Commit ded120902a0ff67e7c13d0c56ea485500405eaf8

Authored by YunaiV
1 parent 3b0abfb2

✨ feat(im): 初始化群申请 v0.4:第五把 review(多轮 finding 修复 + 通知静默化)

- 邀请路径写 addSource=INVITE;群主 / 管理员邀请绕过审批;inviteGroupMember 入参去重
- getGroupRequest 越权校验加成员有效状态判断;新增 list-by-group 接口
- 申请列表按 update_time 倒序,update(null, wrapper) 路径手动刷 updateTime
- addByRequestId 不再 skip 同 id,复用记录刷新并置顶
- GroupRequestListDialog 单群模式订阅 store 增量同步;GroupMemberAddDialog 审批分支文案区分
- ConversationItem 增加 [X 条进群申请] 红字前缀;MessagePanel 顶部胶囊横幅
- 1505 / 1506 通知改静默:同意走群事件渲染系统提示,拒绝不再打扰;清掉 dead inviterUserId 字段
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupRequestController.java
... ... @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.controller.admin.group;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.util.ObjUtil;
  5 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 6 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
6 7 import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
7 8 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
... ... @@ -117,10 +118,14 @@ public class ImGroupRequestController {
117 118 return success(CollUtil.getFirst(buildVOList(Collections.singletonList(request))));
118 119 }
119 120  
120   - /** 当前用户是否该群的群主 / 管理员 */
  121 + /**
  122 + * 当前用户是否该群的有效群主 / 管理员
  123 + */
121 124 private boolean isGroupOwnerOrAdmin(Long groupId, Long userId) {
122 125 ImGroupMemberDO member = groupMemberService.getGroupMember(groupId, userId);
123   - return member != null && ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole());
  126 + return member != null
  127 + && !CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())
  128 + && ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole());
124 129 }
125 130  
126 131 /** 申请记录列表批量转 VO + 关联回填用户 / 群信息 */
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupRequestMapper.java
... ... @@ -25,16 +25,25 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> {
25 25 .eq(ImGroupRequestDO::getUserId, userId));
26 26 }
27 27  
  28 + default List<ImGroupRequestDO> selectListByGroupIdAndUserIds(Long groupId, Collection<Long> userIds) {
  29 + return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>()
  30 + .eq(ImGroupRequestDO::getGroupId, groupId)
  31 + .in(ImGroupRequestDO::getUserId, userIds));
  32 + }
  33 +
28 34 default List<ImGroupRequestDO> selectListByGroupIdsAndHandleResult(Collection<Long> groupIds, Integer handleResult) {
29 35 return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>()
30 36 .in(ImGroupRequestDO::getGroupId, groupIds)
31 37 .eq(ImGroupRequestDO::getHandleResult, handleResult)
  38 + .orderByDesc(ImGroupRequestDO::getUpdateTime)
32 39 .orderByDesc(ImGroupRequestDO::getId));
33 40 }
34 41  
35 42 default List<ImGroupRequestDO> selectListByGroupId(Long groupId) {
  43 + // 同上,update_time 倒序优先于 id
36 44 return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>()
37 45 .eq(ImGroupRequestDO::getGroupId, groupId)
  46 + .orderByDesc(ImGroupRequestDO::getUpdateTime)
38 47 .orderByDesc(ImGroupRequestDO::getId));
39 48 }
40 49  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImpl.java
... ... @@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
11 11 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
12 12 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO;
13 13 import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper;
  14 +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
14 15 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
15 16 import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum;
16 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
... ... @@ -99,6 +100,9 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
99 100 // TODO @AI:看看是不是在 basemapperx 里,增加一个 updateXXXX;可以根据传递的 DO,深度更新的方法?应该匹配这个场景的对哇?【主要希望 service 不要出现 mapper 相关的类】
100 101 ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId);
101 102 if (request != null) {
  103 + // update_time 显式置当前时间:update(null, wrapper) 不会触发 MetaObjectHandler.updateFill;
  104 + // 列表查询按 update_time 倒序排,复用旧记录时必须刷新这一列才能让本次重新提交排到最前
  105 + LocalDateTime now = LocalDateTime.now();
102 106 groupRequestMapper.update(null, new LambdaUpdateWrapper<ImGroupRequestDO>()
103 107 .eq(ImGroupRequestDO::getId, request.getId())
104 108 .set(ImGroupRequestDO::getApplyContent, reqVO.getApplyContent())
... ... @@ -107,11 +111,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
107 111 .set(ImGroupRequestDO::getInviterUserId, null)
108 112 .set(ImGroupRequestDO::getHandleUserId, null)
109 113 .set(ImGroupRequestDO::getHandleContent, null)
110   - .set(ImGroupRequestDO::getHandleTime, null));
  114 + .set(ImGroupRequestDO::getHandleTime, null)
  115 + .set(ImGroupRequestDO::getUpdateTime, now));
111 116 request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource())
112 117 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
113 118 .setInviterUserId(null).setHandleUserId(null)
114   - .setHandleContent(null).setHandleTime(null);
  119 + .setHandleContent(null).setHandleTime(null).setUpdateTime(now);
115 120 } else {
116 121 request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class)
117 122 .setUserId(userId).setInviterUserId(null)
... ... @@ -206,27 +211,35 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
206 211 ImGroupDO group = groupService.validateGroupExists(groupId);
207 212  
208 213 // 1. 每个被邀请人 upsert 一条 inviter_user_id=邀请人 的记录
209   - // 已有记录(同一对 group_id, user_id 唯一):覆盖 inviter_user_id + 重置 handle_result + 清空旧处理痕迹(走 LambdaUpdateWrapper.set 显式置 null)
  214 + // 已有记录(同一对 group_id, user_id 唯一):覆盖 inviter_user_id / add_source + 重置 handle_result + 清空旧处理痕迹(走 LambdaUpdateWrapper.set 显式置 null)
  215 + // add_source 强制覆写为 INVITE,避免审批通过后把残留的旧来源回写进群成员留痕
  216 + Integer inviteSource = ImGroupAddSourceEnum.INVITE.getSource();
210 217 List<ImGroupRequestDO> requests = new ArrayList<>(invitedUserIds.size());
211 218 // TODO @AI:看看是不是在 basemapperx 里,增加一个 updateXXXX;可以根据传递的 DO,深度更新的方法?应该匹配这个场景的对哇?【主要希望 service 不要出现 mapper 相关的类】
  219 + // 同 applyJoinGroup:update(null, wrapper) 不走 MetaObjectHandler.updateFill,复用旧记录必须显式刷 update_time,否则按 update_time 倒序排不到最前
  220 + LocalDateTime now = LocalDateTime.now();
212 221 for (Long invitedUserId : invitedUserIds) {
213 222 ImGroupRequestDO existing = groupRequestMapper.selectByGroupIdAndUserId(groupId, invitedUserId);
214 223 if (existing != null) {
215 224 groupRequestMapper.update(null, new LambdaUpdateWrapper<ImGroupRequestDO>()
216 225 .eq(ImGroupRequestDO::getId, existing.getId())
217 226 .set(ImGroupRequestDO::getInviterUserId, inviterUserId)
  227 + .set(ImGroupRequestDO::getAddSource, inviteSource)
218 228 .set(ImGroupRequestDO::getHandleResult, ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
219 229 .set(ImGroupRequestDO::getHandleUserId, null)
220 230 .set(ImGroupRequestDO::getHandleContent, null)
221   - .set(ImGroupRequestDO::getHandleTime, null));
222   - existing.setInviterUserId(inviterUserId)
  231 + .set(ImGroupRequestDO::getHandleTime, null)
  232 + .set(ImGroupRequestDO::getUpdateTime, now));
  233 + existing.setInviterUserId(inviterUserId).setAddSource(inviteSource)
223 234 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
224   - .setHandleUserId(null).setHandleContent(null).setHandleTime(null);
  235 + .setHandleUserId(null).setHandleContent(null).setHandleTime(null)
  236 + .setUpdateTime(now);
225 237 requests.add(existing);
226 238 continue;
227 239 }
228 240 ImGroupRequestDO insert = new ImGroupRequestDO()
229 241 .setGroupId(groupId).setUserId(invitedUserId).setInviterUserId(inviterUserId)
  242 + .setAddSource(inviteSource)
230 243 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
231 244 groupRequestMapper.insert(insert);
232 245 requests.add(insert);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
... ... @@ -194,14 +194,15 @@ public class ImGroupServiceImpl implements ImGroupService {
194 194 // 1.1 校验群存在 + 当前用户是群成员;同时拿到 role 供下面审批分支判断
195 195 ImGroupDO group = validateGroupExists(groupId);
196 196 ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId);
197   - // 1.2 排除已在群中的用户
  197 + // 1.2 入参去重 + 排除已在群中的用户
198 198 List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
199   - activeMembers.forEach(member -> inviteReqVO.getMemberUserIds().remove(member.getUserId()));
200   - if (CollUtil.isEmpty(inviteReqVO.getMemberUserIds())) {
  199 + Set<Long> activeMemberUserIds = convertSet(activeMembers, ImGroupMemberDO::getUserId);
  200 + List<Long> memberUserIds = new ArrayList<>(new LinkedHashSet<>(inviteReqVO.getMemberUserIds()));
  201 + memberUserIds.removeAll(activeMemberUserIds);
  202 + if (CollUtil.isEmpty(memberUserIds)) {
201 203 return;
202 204 }
203 205 // 1.3 校验被邀请人都是当前用户的好友
204   - List<Long> memberUserIds = inviteReqVO.getMemberUserIds();
205 206 List<ImFriendDO> friends = friendService.getActiveFriendList(userId, memberUserIds);
206 207 Set<Long> friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId);
207 208 Collection<Long> notFriendUserIds = CollUtil.subtract(memberUserIds, friendUserIds);
... ... @@ -214,7 +215,7 @@ public class ImGroupServiceImpl implements ImGroupService {
214 215 }
215 216  
216 217 // 2. 情况一:群开启审批 + 邀请人是普通成员,落 group_request 等群主 / 管理员处理
217   - // 群主 / 管理员邀请视同已审批通过,绕过审批直接拉人进群(对齐 OpenIM / 微信)
  218 + // 群主 / 管理员邀请,直接拉人进群
218 219 if (Boolean.TRUE.equals(group.getJoinApproval())
219 220 && !ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole())) {
220 221 groupRequestService.createInviteRequestList(groupId, userId, memberUserIds);
... ... @@ -227,7 +228,7 @@ public class ImGroupServiceImpl implements ImGroupService {
227 228  
228 229 // 4. 发 GROUP_MEMBER_INVITE 通知给全员;本地拼 receivers(已查的 active + 新邀请)避免缓存刚 evict 后强制走 DB
229 230 Set<Long> allReceivers = new HashSet<>(memberUserIds);
230   - activeMembers.forEach(member -> allReceivers.add(member.getUserId()));
  231 + allReceivers.addAll(activeMemberUserIds);
231 232 groupMessageService.sendGroupMessage(userId, allReceivers,
232 233 ImGroupMessageSendDTO.ofGroupMemberInvite(groupId, userId, memberUserIds));
233 234 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestApprovedNotification.java
... ... @@ -6,8 +6,7 @@ import lombok.EqualsAndHashCode;
6 6 /**
7 7 * 入群申请被同意通知
8 8 * <p>
9   - * 走私聊通道定向推送给申请人 + 群主 + 全部管理员;
10   - * 申请人侧据此弹 toast、列表项更新;admin 侧据此把 unhandledCount 减 1 并从未处理列表移除
  9 + * 走私聊通道定向推送给申请人 + 群主 + 全部管理员;admin 侧据此把 unhandledCount 减 1 并从未处理列表移除
11 10 */
12 11 @EqualsAndHashCode(callSuper = true)
13 12 @Data
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImplTest.java
... ... @@ -265,8 +265,14 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest {
265 265 groupRequestService.createInviteRequestList(10L, 1L, List.of(2L, 3L));
266 266  
267 267 // 断言:插入 2 条申请记录 + 推 1503 给 owner(每条 1 帧)共 2 帧
268   - verify(groupRequestMapper, times(2)).insert(any(ImGroupRequestDO.class));
  268 + ArgumentCaptor<ImGroupRequestDO> captor = ArgumentCaptor.forClass(ImGroupRequestDO.class);
  269 + verify(groupRequestMapper, times(2)).insert(captor.capture());
269 270 verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
  271 + // 断言:每条记录 inviterUserId=1 + addSource=INVITE,避免审批通过后回写群成员留痕的来源为空 / 脏带旧值
  272 + captor.getAllValues().forEach(insert -> {
  273 + assertEquals(1L, insert.getInviterUserId());
  274 + assertEquals(ImGroupAddSourceEnum.INVITE.getSource(), insert.getAddSource());
  275 + });
270 276 }
271 277  
272 278 private AdminUserRespDTO buildUser(Long id, String nickname) {
... ...