Commit f0e4639920307a9fbeb8d0b9e15e46244d6f3958

Authored by YunaiV
1 parent f938362b

✨ feat(im): 修一批群消息撤回与群管理治理问题

- 群主 / 管理员可撤回他人违规消息;治理他人消息时不受撤回时间窗限制,撤回自己的仍受限
- banGroup 校验群未解散(已解散抛 GROUP_DISSOLVED)+ banned 幂等,避免重复封禁广播
- 踢人跳过已退群的失效目标,只踢有效成员,不再因混入历史管理员整批失败
- 退群成员离线 pull 去掉撤回消息过滤,与在群成员的 selectListByMinId 对齐
- 补 removeGroupMember 单测 status 字段 + 新增「跳过失效目标」混合用例
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&lt;ImGroupMessageDO&gt; {
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
... ...