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,8 +44,9 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
44 /** 44 /**
45 * 查询"退群前"的离线消息(退群成员使用) 45 * 查询"退群前"的离线消息(退群成员使用)
46 * <p> 46 * <p>
47 - * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、  
48 - * 不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 47 + * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。
  48 + * <p>
  49 + * 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。
49 * 50 *
50 * @param groupId 群编号 51 * @param groupId 群编号
51 * @param minId 最小消息 id(不含) 52 * @param minId 最小消息 id(不含)
@@ -63,7 +64,6 @@ public interface ImGroupMessageMapper extends BaseMapperX&lt;ImGroupMessageDO&gt; { @@ -63,7 +64,6 @@ public interface ImGroupMessageMapper extends BaseMapperX&lt;ImGroupMessageDO&gt; {
63 .gt("id", minId) 64 .gt("id", minId)
64 .gt("send_time", minSendTime) 65 .gt("send_time", minSendTime)
65 .le("send_time", quitTime) 66 .le("send_time", quitTime)
66 - .ne("status", ImMessageStatusEnum.RECALL.getStatus())  
67 .orderByAsc("id"); 67 .orderByAsc("id");
68 wrapper.limitN(size); 68 wrapper.limitN(size);
69 return selectList(wrapper); 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,8 +50,7 @@ import java.util.*;
50 import java.util.stream.Collectors; 50 import java.util.stream.Collectors;
51 51
52 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 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 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP; 54 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP;
56 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 55 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
57 56
@@ -266,8 +265,15 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -266,8 +265,15 @@ public class ImGroupServiceImpl implements ImGroupService {
266 if (targetUserIds.contains(userId)) { 265 if (targetUserIds.contains(userId)) {
267 throw exception(GROUP_CANNOT_REMOVE_SELF); 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 boolean operatorIsAdmin = ImGroupMemberRoleEnum.isAdmin(operator.getRole()); 277 boolean operatorIsAdmin = ImGroupMemberRoleEnum.isAdmin(operator.getRole());
272 for (ImGroupMemberDO target : targets) { 278 for (ImGroupMemberDO target : targets) {
273 if (ImGroupMemberRoleEnum.isOwner(target.getRole())) { 279 if (ImGroupMemberRoleEnum.isOwner(target.getRole())) {
@@ -280,12 +286,12 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -280,12 +286,12 @@ public class ImGroupServiceImpl implements ImGroupService {
280 286
281 // 2. 先发 GROUP_MEMBER_KICK 通知:放在被踢者移除前,sendGroupMessage 才能查到全员(含被踢者) 287 // 2. 先发 GROUP_MEMBER_KICK 通知:放在被踢者移除前,sendGroupMessage 才能查到全员(含被踢者)
282 groupMessageService.sendGroupMessage(userId, 288 groupMessageService.sendGroupMessage(userId,
283 - ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, targetUserIds)); 289 + ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds));
284 290
285 // 3.1 批量移除群成员 291 // 3.1 批量移除群成员
286 - groupMemberService.removeGroupMembers(groupId, targetUserIds); 292 + groupMemberService.removeGroupMembers(groupId, validTargetUserIds);
287 // 3.2 批量清理已读缓存 293 // 3.2 批量清理已读缓存
288 - groupMessageService.deleteReadMaxMessageIds(groupId, targetUserIds); 294 + groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds);
289 } 295 }
290 296
291 @Override 297 @Override
@@ -542,16 +548,24 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -542,16 +548,24 @@ public class ImGroupServiceImpl implements ImGroupService {
542 @Override 548 @Override
543 @CacheEvict(cacheNames = GROUP, key = "#banReqVO.id") 549 @CacheEvict(cacheNames = GROUP, key = "#banReqVO.id")
544 public void banGroup(Long operatorUserId, ImGroupManagerBanReqVO banReqVO) { 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 throw exception(GROUP_NOT_EXISTS); 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 groupMapper.updateById(new ImGroupDO().setId(banReqVO.getId()) 565 groupMapper.updateById(new ImGroupDO().setId(banReqVO.getId())
552 .setBanned(true).setBannedReason(banReqVO.getReason()).setBannedTime(LocalDateTime.now())); 566 .setBanned(true).setBannedReason(banReqVO.getReason()).setBannedTime(LocalDateTime.now()));
553 567
554 - // 3. 广播通知 568 + // 4. 广播通知
555 groupMessageService.sendGroupMessage(operatorUserId, 569 groupMessageService.sendGroupMessage(operatorUserId,
556 ImGroupMessageSendDTO.ofGroupBanned(banReqVO.getId(), operatorUserId, true)); 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,21 +152,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
152 public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { 152 public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) {
153 // 1.1 校验消息存在 153 // 1.1 校验消息存在
154 ImGroupMessageDO message = validateGroupMessageExists(messageId); 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 throw exception(MESSAGE_RECALL_DENIED); 160 throw exception(MESSAGE_RECALL_DENIED);
158 } 161 }
159 - // 1.3 不能重复撤回 162 + // 1.4 不能重复撤回
160 if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { 163 if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
161 throw exception(MESSAGE_ALREADY_RECALLED); 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 // 2. 更新原消息状态为撤回 174 // 2. 更新原消息状态为撤回
172 groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) 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,6 +35,7 @@ import org.mockito.Spy;
35 35
36 import java.time.LocalDateTime; 36 import java.time.LocalDateTime;
37 import java.util.ArrayList; 37 import java.util.ArrayList;
  38 +import java.util.Collection;
38 import java.util.List; 39 import java.util.List;
39 import java.util.Map; 40 import java.util.Map;
40 import java.util.Set; 41 import java.util.Set;
@@ -565,9 +566,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -565,9 +566,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
565 // 目标:两个普通成员 566 // 目标:两个普通成员
566 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 567 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
567 ImGroupMemberDO.builder().groupId(10L).userId(2L) 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 ImGroupMemberDO.builder().groupId(10L).userId(3L) 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 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 575 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
573 reqVO.setGroupId(10L); 576 reqVO.setGroupId(10L);
@@ -600,7 +603,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -600,7 +603,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
600 // 目标:另一个管理员 603 // 目标:另一个管理员
601 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 604 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
602 ImGroupMemberDO.builder().groupId(10L).userId(2L) 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 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 609 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
606 reqVO.setGroupId(10L); 610 reqVO.setGroupId(10L);
@@ -628,7 +632,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -628,7 +632,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
628 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); 632 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build());
629 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 633 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
630 ImGroupMemberDO.builder().groupId(10L).userId(99L) 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 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 638 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
634 reqVO.setGroupId(10L); 639 reqVO.setGroupId(10L);
@@ -640,6 +645,44 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -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 // ========== addGroupAdmin ========== 686 // ========== addGroupAdmin ==========
644 687
645 @Test 688 @Test