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,6 +2,7 @@ package cn.iocoder.yudao.module.im.controller.admin.group;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
4 import cn.hutool.core.util.ObjUtil; 4 import cn.hutool.core.util.ObjUtil;
  5 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 import cn.iocoder.yudao.framework.common.pojo.CommonResult; 6 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
6 import cn.iocoder.yudao.framework.common.util.collection.MapUtils; 7 import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
7 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 8 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -117,10 +118,14 @@ public class ImGroupRequestController { @@ -117,10 +118,14 @@ public class ImGroupRequestController {
117 return success(CollUtil.getFirst(buildVOList(Collections.singletonList(request)))); 118 return success(CollUtil.getFirst(buildVOList(Collections.singletonList(request))));
118 } 119 }
119 120
120 - /** 当前用户是否该群的群主 / 管理员 */ 121 + /**
  122 + * 当前用户是否该群的有效群主 / 管理员
  123 + */
121 private boolean isGroupOwnerOrAdmin(Long groupId, Long userId) { 124 private boolean isGroupOwnerOrAdmin(Long groupId, Long userId) {
122 ImGroupMemberDO member = groupMemberService.getGroupMember(groupId, userId); 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 /** 申请记录列表批量转 VO + 关联回填用户 / 群信息 */ 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,16 +25,25 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> {
25 .eq(ImGroupRequestDO::getUserId, userId)); 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 default List<ImGroupRequestDO> selectListByGroupIdsAndHandleResult(Collection<Long> groupIds, Integer handleResult) { 34 default List<ImGroupRequestDO> selectListByGroupIdsAndHandleResult(Collection<Long> groupIds, Integer handleResult) {
29 return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>() 35 return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>()
30 .in(ImGroupRequestDO::getGroupId, groupIds) 36 .in(ImGroupRequestDO::getGroupId, groupIds)
31 .eq(ImGroupRequestDO::getHandleResult, handleResult) 37 .eq(ImGroupRequestDO::getHandleResult, handleResult)
  38 + .orderByDesc(ImGroupRequestDO::getUpdateTime)
32 .orderByDesc(ImGroupRequestDO::getId)); 39 .orderByDesc(ImGroupRequestDO::getId));
33 } 40 }
34 41
35 default List<ImGroupRequestDO> selectListByGroupId(Long groupId) { 42 default List<ImGroupRequestDO> selectListByGroupId(Long groupId) {
  43 + // 同上,update_time 倒序优先于 id
36 return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>() 44 return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>()
37 .eq(ImGroupRequestDO::getGroupId, groupId) 45 .eq(ImGroupRequestDO::getGroupId, groupId)
  46 + .orderByDesc(ImGroupRequestDO::getUpdateTime)
38 .orderByDesc(ImGroupRequestDO::getId)); 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,6 +11,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
11 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; 11 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
12 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO; 12 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO;
13 import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper; 13 import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper;
  14 +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
14 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; 15 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
15 import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum; 16 import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum;
16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
@@ -99,6 +100,9 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { @@ -99,6 +100,9 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
99 // TODO @AI:看看是不是在 basemapperx 里,增加一个 updateXXXX;可以根据传递的 DO,深度更新的方法?应该匹配这个场景的对哇?【主要希望 service 不要出现 mapper 相关的类】 100 // TODO @AI:看看是不是在 basemapperx 里,增加一个 updateXXXX;可以根据传递的 DO,深度更新的方法?应该匹配这个场景的对哇?【主要希望 service 不要出现 mapper 相关的类】
100 ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); 101 ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId);
101 if (request != null) { 102 if (request != null) {
  103 + // update_time 显式置当前时间:update(null, wrapper) 不会触发 MetaObjectHandler.updateFill;
  104 + // 列表查询按 update_time 倒序排,复用旧记录时必须刷新这一列才能让本次重新提交排到最前
  105 + LocalDateTime now = LocalDateTime.now();
102 groupRequestMapper.update(null, new LambdaUpdateWrapper<ImGroupRequestDO>() 106 groupRequestMapper.update(null, new LambdaUpdateWrapper<ImGroupRequestDO>()
103 .eq(ImGroupRequestDO::getId, request.getId()) 107 .eq(ImGroupRequestDO::getId, request.getId())
104 .set(ImGroupRequestDO::getApplyContent, reqVO.getApplyContent()) 108 .set(ImGroupRequestDO::getApplyContent, reqVO.getApplyContent())
@@ -107,11 +111,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { @@ -107,11 +111,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
107 .set(ImGroupRequestDO::getInviterUserId, null) 111 .set(ImGroupRequestDO::getInviterUserId, null)
108 .set(ImGroupRequestDO::getHandleUserId, null) 112 .set(ImGroupRequestDO::getHandleUserId, null)
109 .set(ImGroupRequestDO::getHandleContent, null) 113 .set(ImGroupRequestDO::getHandleContent, null)
110 - .set(ImGroupRequestDO::getHandleTime, null)); 114 + .set(ImGroupRequestDO::getHandleTime, null)
  115 + .set(ImGroupRequestDO::getUpdateTime, now));
111 request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource()) 116 request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource())
112 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) 117 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
113 .setInviterUserId(null).setHandleUserId(null) 118 .setInviterUserId(null).setHandleUserId(null)
114 - .setHandleContent(null).setHandleTime(null); 119 + .setHandleContent(null).setHandleTime(null).setUpdateTime(now);
115 } else { 120 } else {
116 request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class) 121 request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class)
117 .setUserId(userId).setInviterUserId(null) 122 .setUserId(userId).setInviterUserId(null)
@@ -206,27 +211,35 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { @@ -206,27 +211,35 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
206 ImGroupDO group = groupService.validateGroupExists(groupId); 211 ImGroupDO group = groupService.validateGroupExists(groupId);
207 212
208 // 1. 每个被邀请人 upsert 一条 inviter_user_id=邀请人 的记录 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 List<ImGroupRequestDO> requests = new ArrayList<>(invitedUserIds.size()); 217 List<ImGroupRequestDO> requests = new ArrayList<>(invitedUserIds.size());
211 // TODO @AI:看看是不是在 basemapperx 里,增加一个 updateXXXX;可以根据传递的 DO,深度更新的方法?应该匹配这个场景的对哇?【主要希望 service 不要出现 mapper 相关的类】 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 for (Long invitedUserId : invitedUserIds) { 221 for (Long invitedUserId : invitedUserIds) {
213 ImGroupRequestDO existing = groupRequestMapper.selectByGroupIdAndUserId(groupId, invitedUserId); 222 ImGroupRequestDO existing = groupRequestMapper.selectByGroupIdAndUserId(groupId, invitedUserId);
214 if (existing != null) { 223 if (existing != null) {
215 groupRequestMapper.update(null, new LambdaUpdateWrapper<ImGroupRequestDO>() 224 groupRequestMapper.update(null, new LambdaUpdateWrapper<ImGroupRequestDO>()
216 .eq(ImGroupRequestDO::getId, existing.getId()) 225 .eq(ImGroupRequestDO::getId, existing.getId())
217 .set(ImGroupRequestDO::getInviterUserId, inviterUserId) 226 .set(ImGroupRequestDO::getInviterUserId, inviterUserId)
  227 + .set(ImGroupRequestDO::getAddSource, inviteSource)
218 .set(ImGroupRequestDO::getHandleResult, ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) 228 .set(ImGroupRequestDO::getHandleResult, ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
219 .set(ImGroupRequestDO::getHandleUserId, null) 229 .set(ImGroupRequestDO::getHandleUserId, null)
220 .set(ImGroupRequestDO::getHandleContent, null) 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 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) 234 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
224 - .setHandleUserId(null).setHandleContent(null).setHandleTime(null); 235 + .setHandleUserId(null).setHandleContent(null).setHandleTime(null)
  236 + .setUpdateTime(now);
225 requests.add(existing); 237 requests.add(existing);
226 continue; 238 continue;
227 } 239 }
228 ImGroupRequestDO insert = new ImGroupRequestDO() 240 ImGroupRequestDO insert = new ImGroupRequestDO()
229 .setGroupId(groupId).setUserId(invitedUserId).setInviterUserId(inviterUserId) 241 .setGroupId(groupId).setUserId(invitedUserId).setInviterUserId(inviterUserId)
  242 + .setAddSource(inviteSource)
230 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); 243 .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
231 groupRequestMapper.insert(insert); 244 groupRequestMapper.insert(insert);
232 requests.add(insert); 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,14 +194,15 @@ public class ImGroupServiceImpl implements ImGroupService {
194 // 1.1 校验群存在 + 当前用户是群成员;同时拿到 role 供下面审批分支判断 194 // 1.1 校验群存在 + 当前用户是群成员;同时拿到 role 供下面审批分支判断
195 ImGroupDO group = validateGroupExists(groupId); 195 ImGroupDO group = validateGroupExists(groupId);
196 ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); 196 ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId);
197 - // 1.2 排除已在群中的用户 197 + // 1.2 入参去重 + 排除已在群中的用户
198 List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); 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 return; 203 return;
202 } 204 }
203 // 1.3 校验被邀请人都是当前用户的好友 205 // 1.3 校验被邀请人都是当前用户的好友
204 - List<Long> memberUserIds = inviteReqVO.getMemberUserIds();  
205 List<ImFriendDO> friends = friendService.getActiveFriendList(userId, memberUserIds); 206 List<ImFriendDO> friends = friendService.getActiveFriendList(userId, memberUserIds);
206 Set<Long> friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId); 207 Set<Long> friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId);
207 Collection<Long> notFriendUserIds = CollUtil.subtract(memberUserIds, friendUserIds); 208 Collection<Long> notFriendUserIds = CollUtil.subtract(memberUserIds, friendUserIds);
@@ -214,7 +215,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -214,7 +215,7 @@ public class ImGroupServiceImpl implements ImGroupService {
214 } 215 }
215 216
216 // 2. 情况一:群开启审批 + 邀请人是普通成员,落 group_request 等群主 / 管理员处理 217 // 2. 情况一:群开启审批 + 邀请人是普通成员,落 group_request 等群主 / 管理员处理
217 - // 群主 / 管理员邀请视同已审批通过,绕过审批直接拉人进群(对齐 OpenIM / 微信) 218 + // 群主 / 管理员邀请,直接拉人进群
218 if (Boolean.TRUE.equals(group.getJoinApproval()) 219 if (Boolean.TRUE.equals(group.getJoinApproval())
219 && !ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole())) { 220 && !ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole())) {
220 groupRequestService.createInviteRequestList(groupId, userId, memberUserIds); 221 groupRequestService.createInviteRequestList(groupId, userId, memberUserIds);
@@ -227,7 +228,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -227,7 +228,7 @@ public class ImGroupServiceImpl implements ImGroupService {
227 228
228 // 4. 发 GROUP_MEMBER_INVITE 通知给全员;本地拼 receivers(已查的 active + 新邀请)避免缓存刚 evict 后强制走 DB 229 // 4. 发 GROUP_MEMBER_INVITE 通知给全员;本地拼 receivers(已查的 active + 新邀请)避免缓存刚 evict 后强制走 DB
229 Set<Long> allReceivers = new HashSet<>(memberUserIds); 230 Set<Long> allReceivers = new HashSet<>(memberUserIds);
230 - activeMembers.forEach(member -> allReceivers.add(member.getUserId())); 231 + allReceivers.addAll(activeMemberUserIds);
231 groupMessageService.sendGroupMessage(userId, allReceivers, 232 groupMessageService.sendGroupMessage(userId, allReceivers,
232 ImGroupMessageSendDTO.ofGroupMemberInvite(groupId, userId, memberUserIds)); 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,8 +6,7 @@ import lombok.EqualsAndHashCode;
6 /** 6 /**
7 * 入群申请被同意通知 7 * 入群申请被同意通知
8 * <p> 8 * <p>
9 - * 走私聊通道定向推送给申请人 + 群主 + 全部管理员;  
10 - * 申请人侧据此弹 toast、列表项更新;admin 侧据此把 unhandledCount 减 1 并从未处理列表移除 9 + * 走私聊通道定向推送给申请人 + 群主 + 全部管理员;admin 侧据此把 unhandledCount 减 1 并从未处理列表移除
11 */ 10 */
12 @EqualsAndHashCode(callSuper = true) 11 @EqualsAndHashCode(callSuper = true)
13 @Data 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,8 +265,14 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest {
265 groupRequestService.createInviteRequestList(10L, 1L, List.of(2L, 3L)); 265 groupRequestService.createInviteRequestList(10L, 1L, List.of(2L, 3L));
266 266
267 // 断言:插入 2 条申请记录 + 推 1503 给 owner(每条 1 帧)共 2 帧 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 verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); 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 private AdminUserRespDTO buildUser(Long id, String nickname) { 278 private AdminUserRespDTO buildUser(Long id, String nickname) {