From 4b27620e86d388228d9f8cd54843a165f6efec9d Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 14 Jun 2026 02:01:10 +0800 Subject: [PATCH] feat: 完善 IM 群历史消息拉取与历史群前端门控 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java | 12 ++++++++---- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java | 3 +++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java | 17 ++--------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java | 9 ++++----- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java | 4 ++-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java | 8 ++------ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 66 ++++++++++++++++++++++++------------------------------------------ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java | 18 ++++++------------ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 12 +++++------- 10 files changed, 125 insertions(+), 99 deletions(-) diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java index e9a7477..370fdab 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.im.controller.admin.group; import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; @@ -193,13 +194,16 @@ public class ImGroupController { Map messageMap = groupMessageService.getGroupMessageMap(allMessageIds); // 转换输出 return convertList(groups, group -> { - ImGroupRespVO vo = BeanUtils.toBean(group, ImGroupRespVO.class); - if (!activeGroupIds.contains(group.getId()) || CollUtil.isEmpty(group.getPinnedMessageIds())) { - return vo; + ImGroupRespVO groupVO = BeanUtils.toBean(group, ImGroupRespVO.class); + // 标记登录用户在该群的成员状态,供前端区分当前群与历史退群 + boolean joined = activeGroupIds.contains(group.getId()); + groupVO.setJoinStatus(joined ? CommonStatusEnum.ENABLE.getStatus() : CommonStatusEnum.DISABLE.getStatus()); + if (!joined || CollUtil.isEmpty(group.getPinnedMessageIds())) { + return groupVO; } // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 List pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get); - return vo.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class)); + return groupVO.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class)); }); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java index 2dac146..84fc7c4 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java @@ -47,6 +47,9 @@ public class ImGroupRespVO { @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; + @Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)") + private Integer joinStatus; // 参见 CommonStatusEnum 枚举 + @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") private List pinnedMessages; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java index 2ef54da..4a40292 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java @@ -1,7 +1,6 @@ package cn.iocoder.yudao.module.im.dal.mysql.group; import cn.hutool.core.collection.CollUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; @@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX { .eq(ImGroupMemberDO::getStatus, status)); } - /** - * 查询用户已退群的成员记录 - *

- * 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。 - * - * @param userId 用户编号 - * @param minQuitTime 最早退群时间(含),可空 - * @return 已退群成员记录列表 - */ - default List selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) { - return selectList(new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getUserId, userId) - .eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus()) - .geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime)); + default List selectListByUserId(Long userId) { + return selectList(ImGroupMemberDO::getUserId, userId); } @SuppressWarnings("UnusedReturnValue") diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java index d9a8000..a99c0f3 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java @@ -87,13 +87,12 @@ public interface ImGroupMemberService { List getActiveGroupMemberListByUserId(Long userId); /** - * 查询用户已退群的群成员记录(DISABLE 状态) + * 查询用户曾经加入的所有群成员记录(含已退群) * - * @param userId 用户编号,必传 - * @param minQuitTime 最早退群时间(含),可空 - * @return 已退群成员记录列表 + * @param userId 用户编号 + * @return 群成员记录列表 */ - List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime); + List getGroupMemberListByUserId(Long userId); /** * 添加群成员(入群),角色默认 MEMBER diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java index 36bdaa7..c0def09 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java @@ -105,8 +105,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { } @Override - public List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) { - return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime); + public List getGroupMemberListByUserId(Long userId) { + return groupMemberMapper.selectListByUserId(userId); } @Override 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 7880a15..1361710 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 @@ -593,14 +593,10 @@ public class ImGroupServiceImpl implements ImGroupService { return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); } - // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤! @Override public List getMyGroupList(Long userId) { - // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) - List members = groupMemberService.getActiveGroupMemberListByUserId(userId); - // 1.2 再查最近 N 天(与群消息离线拉取窗口一致)内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存) - LocalDateTime minQuitTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); - members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime)); + // 1. 查用户曾经加入的所有群(含退群,前端按需过滤);退群前的离线消息也要能展示 + List members = groupMemberService.getGroupMemberListByUserId(userId); if (CollUtil.isEmpty(members)) { return Collections.emptyList(); } 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 fc85520..7db34f8 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 @@ -97,9 +97,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { sensitiveWordService.validateText(reqVO.getContent()); } - // 2.1 固化发送当时可见成员快照(含发送者),新群消息 receiver_user_ids 不能为空;用户发送均为全员广播 + // 2.1 固化发送当时可见成员快照:用户发送均为全员广播;getReceiverUserIds 兜底纳入发送者,钉死发送者必可见 List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); - // TODO @AI:这里是不是没必要过滤???因为发送者,肯定是群成员呀!!! Set receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds); // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏) reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds)); @@ -135,8 +134,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { String contentString = payload == null || payload instanceof String ? (String) payload : JsonUtils.toJsonString(payload); - // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇? - // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照) + // 1.2 构建并保存消息 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) .setSenderId(senderId).setGroupId(dto.getGroupId()) .setType(dto.getType()).setContent(contentString) @@ -198,19 +196,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); - // 1. 候选群 = 当前在群 ∪ 窗口内退群;退群用户仍可拉到退群前已写入自己 ID 的历史消息 - // TODO @AI:直接查询所有的群(一次性,这样性能也好点呀);然后这里不用过滤 minSendTime 吧; - Set groupIds = new HashSet<>(); - groupIds.addAll(convertList(groupMemberService.getActiveGroupMemberListByUserId(userId), - ImGroupMemberDO::getGroupId)); - groupIds.addAll(convertList(groupMemberService.getQuitGroupMemberListByUserId(userId, minSendTime), - ImGroupMemberDO::getGroupId)); + // 1. 候选群 = 用户曾经加入的所有群(含退群);可见性与时间窗都交给 SQL,退群群最多贡献 0 条 + Set groupIds = convertSet(groupMemberService.getGroupMemberListByUserId(userId), + ImGroupMemberDO::getGroupId); if (CollUtil.isEmpty(groupIds)) { return Collections.emptyList(); } - // TODO @AI:注释是不是优化下,不用按照“单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤” - // 2. 单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤 + // 2. 按 receiver_user_ids 快照过滤可见性,结果按 id 升序 List messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size); // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数 @@ -219,12 +212,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { return messages; } - // TODO @AI:要不要改下,把“回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数”都计算?! /** * 补全消息已读态和回执已读人数 *

- * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD - * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数 + * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD,所有消息都补 + * 2. 回执已读人数(readCount):仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,计算可见成员中的已读人数 */ @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) private void appendMessageStatusAndReceipt(Long userId, List messages) { @@ -254,9 +246,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); } - // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) + // 回执已读人数(readCount):仅补本人发送、且需要回执(非 NO_RECEIPT)的消息 + // 读位置(status)所有消息都补;readCount 只服务「需要回执」的消息,不把已读位置和消息回执混为一谈 if (ObjUtil.notEqual(message.getSenderId(), userId) - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { continue; } Map positions = readPositionsByGroup.computeIfAbsent(groupId, @@ -286,7 +279,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { ImGroupMessageDO message = groupMessageMapper.selectById(messageId); if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId) - || !isMessageVisible(message, userId)) { + || !isMessageReceived(message, userId)) { throw exception(MESSAGE_NOT_IN_GROUP); } @@ -316,7 +309,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { return Collections.emptyList(); } - if (!isMessageVisible(message, userId)) { + if (!isMessageReceived(message, userId)) { return Collections.emptyList(); } // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 @@ -436,7 +429,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { throw exception(MESSAGE_QUOTE_INVALID); } // 校验对发送人可见(按快照) - if (!isMessageVisible(original, senderMember.getUserId())) { + if (!isMessageReceived(original, senderMember.getUserId())) { throw exception(MESSAGE_QUOTE_INVALID); } // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用; @@ -464,24 +457,22 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { return message; } - // TODO @AI:方法名,是不是可以优化下,对齐 ReceiverUserIds; /** - * 判断一条群消息对某个用户是否可见 + * 判断一条群消息是否被某个用户接收到 *

- * 可见性以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 + * 接收范围以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 * * @param message 消息 * @param userId 当前用户编号 - * @return 是否可见 + * @return 是否接收到 */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private boolean isMessageVisible(ImGroupMessageDO message, Long userId) { + private boolean isMessageReceived(ImGroupMessageDO message, Long userId) { return CollUtil.contains(message.getReceiverUserIds(), userId); } - // TODO @AI: ∩ 是啥意思,有点奇怪。 /** - * 计算一条群消息的可见成员集合(快照 ∩ 传入成员,含发送者) + * 计算一条群消息的可见成员集合(快照与传入成员的交集,含发送者) */ private Set getReceiverUserIds(ImGroupMessageDO message, List members) { if (CollUtil.isEmpty(message.getReceiverUserIds())) { @@ -497,20 +488,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集; * 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。 */ - private Set getReceiverUserIds(List receiverUserIds, Long senderId, Collection memberUserIds) { - Set result = new HashSet<>(); - // 无定向接收列表 → 全员可见 - if (CollUtil.isEmpty(receiverUserIds)) { - result.addAll(memberUserIds); - } else { - // 有定向接收列表 → 仅定向用户且仍为成员可见 - Set allowed = new HashSet<>(receiverUserIds); - for (Long userId : memberUserIds) { - if (allowed.contains(userId)) { - result.add(userId); - } - } - } + private Set getReceiverUserIds(List directedUserIds, Long senderId, Collection memberUserIds) { + // 无定向接收列表 → 全员可见;否则取定向用户与当前成员的交集 + Set result = CollUtil.isEmpty(directedUserIds) + ? new HashSet<>(memberUserIds) + : new HashSet<>(CollUtil.intersection(memberUserIds, directedUserIds)); // 发送者始终可见(多端同步),即便成员缓存暂未包含 if (senderId != null) { result.add(senderId); 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 0ca57e4..56bb61d 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 @@ -1100,9 +1100,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @Test public void testGetMyGroupList_noMembers() { - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(new ArrayList<>()); + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); List result = groupService.getMyGroupList(1L); assertTrue(result.isEmpty()); @@ -1111,19 +1109,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @Test public void testGetMyGroupList_success() { - // 活跃群成员 - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( + // 曾经加入的所有群(含退群) + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( ImGroupMemberDO.builder().groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupMemberDO.builder().groupId(20L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(30L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build() ))); - // 最近退群成员(最近 30 天内) - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(new ArrayList<>(List.of( - ImGroupMemberDO.builder().groupId(30L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()).build() - ))); List groups = List.of( ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 9641586..112ea7e 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -122,6 +122,42 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { } @Test + public void testSendMessage_senderAlwaysVisible() { + // 边界:成员缓存漏掉发送者(理论上 validateMemberInGroup 已挡,这里纯防御);发送者仍必须固化进 receiver_user_ids + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + group.setName("测试群"); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + // 成员缓存只返回 {2,3},漏掉发送者 1 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(2L, 3L)); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { + ImGroupMessageDO msg = invocation.getArgument(0); + msg.setId(99L); + return 1; + }); + + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言:固化快照必含发送者,推送目标补回发送者 = {1,2,3} + assertTrue(result.getReceiverUserIds().contains(1L), "发送者必须在 receiver_user_ids 快照内"); + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) && ids.contains(3L)), + any(ImGroupMessageDTO.class)); + } + } + + @Test public void testSendMessage_clientMessageIdIdempotent() { // 准备 ImGroupMessageSendReqVO reqVO = buildSendReqVO(); @@ -224,9 +260,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member)); ImGroupMessageDO low = ImGroupMessageDO.builder() .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) @@ -259,9 +293,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member)); ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(1L) @@ -300,6 +332,37 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(1, result.get(0).getReadCount()); } + @Test + public void testPullMessages_noReceiptMessageSkipsReadCount() { + // 准备:本人发送但不需要回执(NO_RECEIPT)的消息——只补已读位置,不补 readCount + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO noReceiptMsg = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(1L) + .receiverUserIds(List.of(1L, 2L, 3L)) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(noReceiptMsg)); + when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) + .thenReturn(100L); + + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:读位置照常补(status=READ),但 NO_RECEIPT 不补 readCount,也不查 readCount 相关的读位置映射 + assertEquals(1, result.size()); + assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); + assertNull(result.get(0).getReadCount()); + verify(conversationReadService, never()).getUserReadMessageIdMap(anyInt(), anyLong()); + } + // ========== 撤回测试 ========== @Test diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index aa1d606..f3c2635 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -320,17 +320,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { } @Test - public void testReadMessages_noUnread() { - // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息 - when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( - eq(2L), eq(1L), eq(5L), - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class))) - .thenReturn(0); + public void testReadMessages_notAdvanced() { + // 准备:读位置未前进(已读过 / CAS 失败),不下发事件 + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong())) + .thenReturn(false); // 调用 privateMessageService.readPrivateMessages(1L, 2L, 5L); - // 断言:不推送 READ / RECEIPT + // 断言:读位置没前进,不推送 READ / RECEIPT verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); } -- libgit2 0.21.4