From f0e4639920307a9fbeb8d0b9e15e46244d6f3958 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 22 May 2026 18:09:47 +0800 Subject: [PATCH] ✨ feat(im): 修一批群消息撤回与群管理治理问题 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java | 6 +++--- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java | 36 +++++++++++++++++++++++++----------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 21 ++++++++++++--------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java index 94a55f2..0c6f103 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java +++ b/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 { /** * 查询"退群前"的离线消息(退群成员使用) *

- * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、 - * 不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 + * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 + *

+ * 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。 * * @param groupId 群编号 * @param minId 最小消息 id(不含) @@ -63,7 +64,6 @@ public interface ImGroupMessageMapper extends BaseMapperX { .gt("id", minId) .gt("send_time", minSendTime) .le("send_time", quitTime) - .ne("status", ImMessageStatusEnum.RECALL.getStatus()) .orderByAsc("id"); wrapper.limitN(size); return selectList(wrapper); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java index 863f19a..9d4861b 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java @@ -50,8 +50,7 @@ import java.util.*; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; @@ -266,8 +265,15 @@ public class ImGroupServiceImpl implements ImGroupService { if (targetUserIds.contains(userId)) { throw exception(GROUP_CANNOT_REMOVE_SELF); } - // 1.3 三档权限校验:群主不可被移出;管理员不能移出管理员 - List targets = groupMemberService.getGroupMembers(groupId, targetUserIds); + // 1.3 仅保留仍有效的成员;已退群(DISABLE)/ 查无记录的目标直接跳过,只踢有效成员,不让整批失败 + List targets = filterList(groupMemberService.getGroupMembers(groupId, targetUserIds), + target -> CommonStatusEnum.ENABLE.getStatus().equals(target.getStatus())); + Set validTargetUserIds = convertSet(targets, ImGroupMemberDO::getUserId); + // 1.4 目标全部已不在群:无人可踢,直接返回 + if (CollUtil.isEmpty(validTargetUserIds)) { + return; + } + // 1.5 三档权限校验:群主不可被移出;管理员不能移出管理员 boolean operatorIsAdmin = ImGroupMemberRoleEnum.isAdmin(operator.getRole()); for (ImGroupMemberDO target : targets) { if (ImGroupMemberRoleEnum.isOwner(target.getRole())) { @@ -280,12 +286,12 @@ public class ImGroupServiceImpl implements ImGroupService { // 2. 先发 GROUP_MEMBER_KICK 通知:放在被踢者移除前,sendGroupMessage 才能查到全员(含被踢者) groupMessageService.sendGroupMessage(userId, - ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, targetUserIds)); + ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds)); // 3.1 批量移除群成员 - groupMemberService.removeGroupMembers(groupId, targetUserIds); + groupMemberService.removeGroupMembers(groupId, validTargetUserIds); // 3.2 批量清理已读缓存 - groupMessageService.deleteReadMaxMessageIds(groupId, targetUserIds); + groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds); } @Override @@ -542,16 +548,24 @@ public class ImGroupServiceImpl implements ImGroupService { @Override @CacheEvict(cacheNames = GROUP, key = "#banReqVO.id") public void banGroup(Long operatorUserId, ImGroupManagerBanReqVO banReqVO) { - // 1. 校验群存在 - if (getSelf().getGroup(banReqVO.getId()) == null) { + // 1. 校验群存在且未解散 + ImGroupDO group = getSelf().getGroup(banReqVO.getId()); + if (group == null) { throw exception(GROUP_NOT_EXISTS); } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + // 2. 幂等:已封禁直接返回,避免重复广播封禁通知 + if (Boolean.TRUE.equals(group.getBanned())) { + return; + } - // 2. 更新封禁状态 + // 3. 更新封禁状态 groupMapper.updateById(new ImGroupDO().setId(banReqVO.getId()) .setBanned(true).setBannedReason(banReqVO.getReason()).setBannedTime(LocalDateTime.now())); - // 3. 广播通知 + // 4. 广播通知 groupMessageService.sendGroupMessage(operatorUserId, ImGroupMessageSendDTO.ofGroupBanned(banReqVO.getId(), operatorUserId, true)); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index b25003d..dd5e7d9 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -152,21 +152,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { // 1.1 校验消息存在 ImGroupMessageDO message = validateGroupMessageExists(messageId); - // 1.2 只能撤回自己发送的消息 - if (ObjUtil.notEqual(message.getSenderId(), userId)) { + // 1.2 校验撤回人仍在群中,并取角色用于权限判断 + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(message.getGroupId(), userId); + boolean isOwnerOrAdmin = ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole()); + // 1.3 普通成员只能撤回自己发的消息;群主 / 管理员可撤回他人违规消息 + if (ObjUtil.notEqual(message.getSenderId(), userId) && !isOwnerOrAdmin) { throw exception(MESSAGE_RECALL_DENIED); } - // 1.3 不能重复撤回 + // 1.4 不能重复撤回 if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { throw exception(MESSAGE_ALREADY_RECALLED); } - // 1.4 只允许撤回限定时间内的消息 - int recallTimeoutMinutes = imProperties.getMessage().getRecallTimeoutMinutes(); - if (message.getSendTime().plusMinutes(recallTimeoutMinutes).isBefore(LocalDateTime.now())) { - throw exception(MESSAGE_RECALL_TIMEOUT, recallTimeoutMinutes); + // 1.5 撤回时间窗仅约束撤回自己的消息;群主 / 管理员治理他人违规消息不受时间限制 + if (ObjUtil.equal(message.getSenderId(), userId)) { + int recallTimeoutMinutes = imProperties.getMessage().getRecallTimeoutMinutes(); + if (message.getSendTime().plusMinutes(recallTimeoutMinutes).isBefore(LocalDateTime.now())) { + throw exception(MESSAGE_RECALL_TIMEOUT, recallTimeoutMinutes); + } } - // 1.5 校验撤回人仍在群中 - groupMemberService.validateMemberInGroup(message.getGroupId(), userId); // 2. 更新原消息状态为撤回 groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java index 4bb9c5f..84c9928 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -35,6 +35,7 @@ import org.mockito.Spy; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; @@ -565,9 +566,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { // 目标:两个普通成员 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(2L) - .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build(), + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupMemberDO.builder().groupId(10L).userId(3L) - .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); @@ -600,7 +603,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { // 目标:另一个管理员 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(2L) - .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); @@ -628,7 +632,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(99L) - .role(ImGroupMemberRoleEnum.OWNER.getRole()).build())); + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); @@ -640,6 +645,44 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { } } + @Test + public void testRemoveGroupMember_skipInactiveTargets() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 操作者:群主 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); + // 目标:2L 有效普通成员;3L 已退群(DISABLE)的历史管理员,应被跳过而非拦截整批 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) + .status(CommonStatusEnum.DISABLE.getStatus()).build())); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(2L, 3L)); + + groupService.removeGroupMember(1L, reqVO); + + // 仅有效成员 2L 进入移除 / 已读清理,已退群的 3L 被跳过 + ArgumentCaptor removeCaptor = ArgumentCaptor.forClass(Collection.class); + verify(groupMemberService).removeGroupMembers(eq(10L), removeCaptor.capture()); + assertEquals(Set.of(2L), Set.copyOf(removeCaptor.getValue())); + ArgumentCaptor readCaptor = ArgumentCaptor.forClass(Collection.class); + verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), readCaptor.capture()); + assertEquals(Set.of(2L), Set.copyOf(readCaptor.getValue())); + } + } + // ========== addGroupAdmin ========== @Test -- libgit2 0.21.4