Commit f0e4639920307a9fbeb8d0b9e15e46244d6f3958
1 parent
f938362b
✨ feat(im): 修一批群消息撤回与群管理治理问题
- 群主 / 管理员可撤回他人违规消息;治理他人消息时不受撤回时间窗限制,撤回自己的仍受限 - banGroup 校验群未解散(已解散抛 GROUP_DISSOLVED)+ banned 幂等,避免重复封禁广播 - 踢人跳过已退群的失效目标,只踢有效成员,不再因混入历史管理员整批失败 - 退群成员离线 pull 去掉撤回消息过滤,与在群成员的 selectListByMinId 对齐 - 补 removeGroupMember 单测 status 字段 + 新增「跳过失效目标」混合用例
Showing
4 changed files
with
87 additions
and
27 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
| ... | ... | @@ -44,8 +44,9 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> { |
| 44 | 44 | /** |
| 45 | 45 | * 查询"退群前"的离线消息(退群成员使用) |
| 46 | 46 | * <p> |
| 47 | - * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、 | |
| 48 | - * 不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 | |
| 47 | + * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 | |
| 48 | + * <p> | |
| 49 | + * 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。 | |
| 49 | 50 | * |
| 50 | 51 | * @param groupId 群编号 |
| 51 | 52 | * @param minId 最小消息 id(不含) |
| ... | ... | @@ -63,7 +64,6 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> { |
| 63 | 64 | .gt("id", minId) |
| 64 | 65 | .gt("send_time", minSendTime) |
| 65 | 66 | .le("send_time", quitTime) |
| 66 | - .ne("status", ImMessageStatusEnum.RECALL.getStatus()) | |
| 67 | 67 | .orderByAsc("id"); |
| 68 | 68 | wrapper.limitN(size); |
| 69 | 69 | return selectList(wrapper); | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
| ... | ... | @@ -50,8 +50,7 @@ import java.util.*; |
| 50 | 50 | import java.util.stream.Collectors; |
| 51 | 51 | |
| 52 | 52 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 53 | -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; | |
| 54 | -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; | |
| 53 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; | |
| 55 | 54 | import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP; |
| 56 | 55 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 57 | 56 | |
| ... | ... | @@ -266,8 +265,15 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 266 | 265 | if (targetUserIds.contains(userId)) { |
| 267 | 266 | throw exception(GROUP_CANNOT_REMOVE_SELF); |
| 268 | 267 | } |
| 269 | - // 1.3 三档权限校验:群主不可被移出;管理员不能移出管理员 | |
| 270 | - List<ImGroupMemberDO> targets = groupMemberService.getGroupMembers(groupId, targetUserIds); | |
| 268 | + // 1.3 仅保留仍有效的成员;已退群(DISABLE)/ 查无记录的目标直接跳过,只踢有效成员,不让整批失败 | |
| 269 | + List<ImGroupMemberDO> targets = filterList(groupMemberService.getGroupMembers(groupId, targetUserIds), | |
| 270 | + target -> CommonStatusEnum.ENABLE.getStatus().equals(target.getStatus())); | |
| 271 | + Set<Long> validTargetUserIds = convertSet(targets, ImGroupMemberDO::getUserId); | |
| 272 | + // 1.4 目标全部已不在群:无人可踢,直接返回 | |
| 273 | + if (CollUtil.isEmpty(validTargetUserIds)) { | |
| 274 | + return; | |
| 275 | + } | |
| 276 | + // 1.5 三档权限校验:群主不可被移出;管理员不能移出管理员 | |
| 271 | 277 | boolean operatorIsAdmin = ImGroupMemberRoleEnum.isAdmin(operator.getRole()); |
| 272 | 278 | for (ImGroupMemberDO target : targets) { |
| 273 | 279 | if (ImGroupMemberRoleEnum.isOwner(target.getRole())) { |
| ... | ... | @@ -280,12 +286,12 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 280 | 286 | |
| 281 | 287 | // 2. 先发 GROUP_MEMBER_KICK 通知:放在被踢者移除前,sendGroupMessage 才能查到全员(含被踢者) |
| 282 | 288 | groupMessageService.sendGroupMessage(userId, |
| 283 | - ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, targetUserIds)); | |
| 289 | + ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds)); | |
| 284 | 290 | |
| 285 | 291 | // 3.1 批量移除群成员 |
| 286 | - groupMemberService.removeGroupMembers(groupId, targetUserIds); | |
| 292 | + groupMemberService.removeGroupMembers(groupId, validTargetUserIds); | |
| 287 | 293 | // 3.2 批量清理已读缓存 |
| 288 | - groupMessageService.deleteReadMaxMessageIds(groupId, targetUserIds); | |
| 294 | + groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds); | |
| 289 | 295 | } |
| 290 | 296 | |
| 291 | 297 | @Override |
| ... | ... | @@ -542,16 +548,24 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 542 | 548 | @Override |
| 543 | 549 | @CacheEvict(cacheNames = GROUP, key = "#banReqVO.id") |
| 544 | 550 | public void banGroup(Long operatorUserId, ImGroupManagerBanReqVO banReqVO) { |
| 545 | - // 1. 校验群存在 | |
| 546 | - if (getSelf().getGroup(banReqVO.getId()) == null) { | |
| 551 | + // 1. 校验群存在且未解散 | |
| 552 | + ImGroupDO group = getSelf().getGroup(banReqVO.getId()); | |
| 553 | + if (group == null) { | |
| 547 | 554 | throw exception(GROUP_NOT_EXISTS); |
| 548 | 555 | } |
| 556 | + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { | |
| 557 | + throw exception(GROUP_DISSOLVED); | |
| 558 | + } | |
| 559 | + // 2. 幂等:已封禁直接返回,避免重复广播封禁通知 | |
| 560 | + if (Boolean.TRUE.equals(group.getBanned())) { | |
| 561 | + return; | |
| 562 | + } | |
| 549 | 563 | |
| 550 | - // 2. 更新封禁状态 | |
| 564 | + // 3. 更新封禁状态 | |
| 551 | 565 | groupMapper.updateById(new ImGroupDO().setId(banReqVO.getId()) |
| 552 | 566 | .setBanned(true).setBannedReason(banReqVO.getReason()).setBannedTime(LocalDateTime.now())); |
| 553 | 567 | |
| 554 | - // 3. 广播通知 | |
| 568 | + // 4. 广播通知 | |
| 555 | 569 | groupMessageService.sendGroupMessage(operatorUserId, |
| 556 | 570 | ImGroupMessageSendDTO.ofGroupBanned(banReqVO.getId(), operatorUserId, true)); |
| 557 | 571 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| ... | ... | @@ -152,21 +152,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 152 | 152 | public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { |
| 153 | 153 | // 1.1 校验消息存在 |
| 154 | 154 | ImGroupMessageDO message = validateGroupMessageExists(messageId); |
| 155 | - // 1.2 只能撤回自己发送的消息 | |
| 156 | - if (ObjUtil.notEqual(message.getSenderId(), userId)) { | |
| 155 | + // 1.2 校验撤回人仍在群中,并取角色用于权限判断 | |
| 156 | + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(message.getGroupId(), userId); | |
| 157 | + boolean isOwnerOrAdmin = ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole()); | |
| 158 | + // 1.3 普通成员只能撤回自己发的消息;群主 / 管理员可撤回他人违规消息 | |
| 159 | + if (ObjUtil.notEqual(message.getSenderId(), userId) && !isOwnerOrAdmin) { | |
| 157 | 160 | throw exception(MESSAGE_RECALL_DENIED); |
| 158 | 161 | } |
| 159 | - // 1.3 不能重复撤回 | |
| 162 | + // 1.4 不能重复撤回 | |
| 160 | 163 | if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { |
| 161 | 164 | throw exception(MESSAGE_ALREADY_RECALLED); |
| 162 | 165 | } |
| 163 | - // 1.4 只允许撤回限定时间内的消息 | |
| 164 | - int recallTimeoutMinutes = imProperties.getMessage().getRecallTimeoutMinutes(); | |
| 165 | - if (message.getSendTime().plusMinutes(recallTimeoutMinutes).isBefore(LocalDateTime.now())) { | |
| 166 | - throw exception(MESSAGE_RECALL_TIMEOUT, recallTimeoutMinutes); | |
| 166 | + // 1.5 撤回时间窗仅约束撤回自己的消息;群主 / 管理员治理他人违规消息不受时间限制 | |
| 167 | + if (ObjUtil.equal(message.getSenderId(), userId)) { | |
| 168 | + int recallTimeoutMinutes = imProperties.getMessage().getRecallTimeoutMinutes(); | |
| 169 | + if (message.getSendTime().plusMinutes(recallTimeoutMinutes).isBefore(LocalDateTime.now())) { | |
| 170 | + throw exception(MESSAGE_RECALL_TIMEOUT, recallTimeoutMinutes); | |
| 171 | + } | |
| 167 | 172 | } |
| 168 | - // 1.5 校验撤回人仍在群中 | |
| 169 | - groupMemberService.validateMemberInGroup(message.getGroupId(), userId); | |
| 170 | 173 | |
| 171 | 174 | // 2. 更新原消息状态为撤回 |
| 172 | 175 | groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
| ... | ... | @@ -35,6 +35,7 @@ import org.mockito.Spy; |
| 35 | 35 | |
| 36 | 36 | import java.time.LocalDateTime; |
| 37 | 37 | import java.util.ArrayList; |
| 38 | +import java.util.Collection; | |
| 38 | 39 | import java.util.List; |
| 39 | 40 | import java.util.Map; |
| 40 | 41 | import java.util.Set; |
| ... | ... | @@ -565,9 +566,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 565 | 566 | // 目标:两个普通成员 |
| 566 | 567 | when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( |
| 567 | 568 | ImGroupMemberDO.builder().groupId(10L).userId(2L) |
| 568 | - .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build(), | |
| 569 | + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) | |
| 570 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 569 | 571 | ImGroupMemberDO.builder().groupId(10L).userId(3L) |
| 570 | - .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); | |
| 572 | + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) | |
| 573 | + .status(CommonStatusEnum.ENABLE.getStatus()).build())); | |
| 571 | 574 | |
| 572 | 575 | ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); |
| 573 | 576 | reqVO.setGroupId(10L); |
| ... | ... | @@ -600,7 +603,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 600 | 603 | // 目标:另一个管理员 |
| 601 | 604 | when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( |
| 602 | 605 | ImGroupMemberDO.builder().groupId(10L).userId(2L) |
| 603 | - .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); | |
| 606 | + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) | |
| 607 | + .status(CommonStatusEnum.ENABLE.getStatus()).build())); | |
| 604 | 608 | |
| 605 | 609 | ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); |
| 606 | 610 | reqVO.setGroupId(10L); |
| ... | ... | @@ -628,7 +632,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 628 | 632 | .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); |
| 629 | 633 | when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( |
| 630 | 634 | ImGroupMemberDO.builder().groupId(10L).userId(99L) |
| 631 | - .role(ImGroupMemberRoleEnum.OWNER.getRole()).build())); | |
| 635 | + .role(ImGroupMemberRoleEnum.OWNER.getRole()) | |
| 636 | + .status(CommonStatusEnum.ENABLE.getStatus()).build())); | |
| 632 | 637 | |
| 633 | 638 | ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); |
| 634 | 639 | reqVO.setGroupId(10L); |
| ... | ... | @@ -640,6 +645,44 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 640 | 645 | } |
| 641 | 646 | } |
| 642 | 647 | |
| 648 | + @Test | |
| 649 | + public void testRemoveGroupMember_skipInactiveTargets() { | |
| 650 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 651 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) | |
| 652 | + .thenReturn(groupService); | |
| 653 | + | |
| 654 | + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) | |
| 655 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 656 | + when(groupMapper.selectById(10L)).thenReturn(group); | |
| 657 | + // 操作者:群主 | |
| 658 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( | |
| 659 | + ImGroupMemberDO.builder().groupId(10L).userId(1L) | |
| 660 | + .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); | |
| 661 | + // 目标:2L 有效普通成员;3L 已退群(DISABLE)的历史管理员,应被跳过而非拦截整批 | |
| 662 | + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( | |
| 663 | + ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 664 | + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) | |
| 665 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 666 | + ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 667 | + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) | |
| 668 | + .status(CommonStatusEnum.DISABLE.getStatus()).build())); | |
| 669 | + | |
| 670 | + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); | |
| 671 | + reqVO.setGroupId(10L); | |
| 672 | + reqVO.setMemberUserIds(List.of(2L, 3L)); | |
| 673 | + | |
| 674 | + groupService.removeGroupMember(1L, reqVO); | |
| 675 | + | |
| 676 | + // 仅有效成员 2L 进入移除 / 已读清理,已退群的 3L 被跳过 | |
| 677 | + ArgumentCaptor<Collection> removeCaptor = ArgumentCaptor.forClass(Collection.class); | |
| 678 | + verify(groupMemberService).removeGroupMembers(eq(10L), removeCaptor.capture()); | |
| 679 | + assertEquals(Set.of(2L), Set.copyOf(removeCaptor.getValue())); | |
| 680 | + ArgumentCaptor<Collection> readCaptor = ArgumentCaptor.forClass(Collection.class); | |
| 681 | + verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), readCaptor.capture()); | |
| 682 | + assertEquals(Set.of(2L), Set.copyOf(readCaptor.getValue())); | |
| 683 | + } | |
| 684 | + } | |
| 685 | + | |
| 643 | 686 | // ========== addGroupAdmin ========== |
| 644 | 687 | |
| 645 | 688 | @Test | ... | ... |