Commit 4b27620e86d388228d9f8cd54843a165f6efec9d

Authored by YunaiV
1 parent 6d8ad0c3

feat: 完善 IM 群历史消息拉取与历史群前端门控

- 后端群列表返回历史群成员状态 joinStatus,用于区分当前群和历史退群群
- 群消息拉取支持基于 receiver_user_ids 快照过滤可见消息
- 补充群消息 pull、群成员候选、私聊 pull 相关索引与 SQL 脚本
- 前端接入 joinStatus,并封装历史退群群判断
- 历史退群群禁发、隐藏群操作入口,并从通讯录、转发、推荐名片候选中排除
- 保留历史群会话展示能力,用于查看退群前历史消息
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java
1 1 package cn.iocoder.yudao.module.im.controller.admin.group;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 5 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
5 6 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
6 7 import cn.iocoder.yudao.module.im.controller.admin.group.vo.*;
... ... @@ -193,13 +194,16 @@ public class ImGroupController {
193 194 Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds);
194 195 // 转换输出
195 196 return convertList(groups, group -> {
196   - ImGroupRespVO vo = BeanUtils.toBean(group, ImGroupRespVO.class);
197   - if (!activeGroupIds.contains(group.getId()) || CollUtil.isEmpty(group.getPinnedMessageIds())) {
198   - return vo;
  197 + ImGroupRespVO groupVO = BeanUtils.toBean(group, ImGroupRespVO.class);
  198 + // 标记登录用户在该群的成员状态,供前端区分当前群与历史退群
  199 + boolean joined = activeGroupIds.contains(group.getId());
  200 + groupVO.setJoinStatus(joined ? CommonStatusEnum.ENABLE.getStatus() : CommonStatusEnum.DISABLE.getStatus());
  201 + if (!joined || CollUtil.isEmpty(group.getPinnedMessageIds())) {
  202 + return groupVO;
199 203 }
200 204 // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过
201 205 List<ImGroupMessageDO> pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get);
202   - return vo.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class));
  206 + return groupVO.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class));
203 207 });
204 208 }
205 209  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java
... ... @@ -47,6 +47,9 @@ public class ImGroupRespVO {
47 47 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
48 48 private LocalDateTime createTime;
49 49  
  50 + @Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)")
  51 + private Integer joinStatus; // 参见 CommonStatusEnum 枚举
  52 +
50 53 @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空")
51 54 private List<ImGroupMessageRespVO> pinnedMessages;
52 55  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java
1 1 package cn.iocoder.yudao.module.im.dal.mysql.group;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
4   -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
6 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
7 6 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
... ... @@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
59 58 .eq(ImGroupMemberDO::getStatus, status));
60 59 }
61 60  
62   - /**
63   - * 查询用户已退群的成员记录
64   - * <p>
65   - * 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。
66   - *
67   - * @param userId 用户编号
68   - * @param minQuitTime 最早退群时间(含),可空
69   - * @return 已退群成员记录列表
70   - */
71   - default List<ImGroupMemberDO> selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) {
72   - return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>()
73   - .eq(ImGroupMemberDO::getUserId, userId)
74   - .eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus())
75   - .geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime));
  61 + default List<ImGroupMemberDO> selectListByUserId(Long userId) {
  62 + return selectList(ImGroupMemberDO::getUserId, userId);
76 63 }
77 64  
78 65 @SuppressWarnings("UnusedReturnValue")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java
... ... @@ -87,13 +87,12 @@ public interface ImGroupMemberService {
87 87 List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId);
88 88  
89 89 /**
90   - * 查询用户已退群的群成员记录(DISABLE 状态)
  90 + * 查询用户曾经加入的所有群成员记录(含已退群)
91 91 *
92   - * @param userId 用户编号,必传
93   - * @param minQuitTime 最早退群时间(含),可空
94   - * @return 已退群成员记录列表
  92 + * @param userId 用户编号
  93 + * @return 群成员记录列表
95 94 */
96   - List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime);
  95 + List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId);
97 96  
98 97 /**
99 98 * 添加群成员(入群),角色默认 MEMBER
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java
... ... @@ -105,8 +105,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
105 105 }
106 106  
107 107 @Override
108   - public List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) {
109   - return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime);
  108 + public List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId) {
  109 + return groupMemberMapper.selectListByUserId(userId);
110 110 }
111 111  
112 112 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
... ... @@ -593,14 +593,10 @@ public class ImGroupServiceImpl implements ImGroupService {
593 593 return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId);
594 594 }
595 595  
596   - // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤!
597 596 @Override
598 597 public List<ImGroupDO> getMyGroupList(Long userId) {
599   - // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态)
600   - List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByUserId(userId);
601   - // 1.2 再查最近 N 天(与群消息离线拉取窗口一致)内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存)
602   - LocalDateTime minQuitTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays());
603   - members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime));
  598 + // 1. 查用户曾经加入的所有群(含退群,前端按需过滤);退群前的离线消息也要能展示
  599 + List<ImGroupMemberDO> members = groupMemberService.getGroupMemberListByUserId(userId);
604 600 if (CollUtil.isEmpty(members)) {
605 601 return Collections.emptyList();
606 602 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
... ... @@ -97,9 +97,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
97 97 sensitiveWordService.validateText(reqVO.getContent());
98 98 }
99 99  
100   - // 2.1 固化发送当时可见成员快照(含发送者),新群消息 receiver_user_ids 不能为空;用户发送均为全员广播
  100 + // 2.1 固化发送当时可见成员快照:用户发送均为全员广播;getReceiverUserIds 兜底纳入发送者,钉死发送者必可见
101 101 List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
102   - // TODO @AI:这里是不是没必要过滤???因为发送者,肯定是群成员呀!!!
103 102 Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds);
104 103 // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏)
105 104 reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds));
... ... @@ -135,8 +134,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
135 134 String contentString = payload == null || payload instanceof String
136 135 ? (String) payload
137 136 : JsonUtils.toJsonString(payload);
138   - // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇?
139   - // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照)
  137 + // 1.2 构建并保存消息
140 138 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
141 139 .setSenderId(senderId).setGroupId(dto.getGroupId())
142 140 .setType(dto.getType()).setContent(contentString)
... ... @@ -198,19 +196,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
198 196 // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送
199 197 LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays());
200 198  
201   - // 1. 候选群 = 当前在群 ∪ 窗口内退群;退群用户仍可拉到退群前已写入自己 ID 的历史消息
202   - // TODO @AI:直接查询所有的群(一次性,这样性能也好点呀);然后这里不用过滤 minSendTime 吧;
203   - Set<Long> groupIds = new HashSet<>();
204   - groupIds.addAll(convertList(groupMemberService.getActiveGroupMemberListByUserId(userId),
205   - ImGroupMemberDO::getGroupId));
206   - groupIds.addAll(convertList(groupMemberService.getQuitGroupMemberListByUserId(userId, minSendTime),
207   - ImGroupMemberDO::getGroupId));
  199 + // 1. 候选群 = 用户曾经加入的所有群(含退群);可见性与时间窗都交给 SQL,退群群最多贡献 0 条
  200 + Set<Long> groupIds = convertSet(groupMemberService.getGroupMemberListByUserId(userId),
  201 + ImGroupMemberDO::getGroupId);
208 202 if (CollUtil.isEmpty(groupIds)) {
209 203 return Collections.emptyList();
210 204 }
211 205  
212   - // TODO @AI:注释是不是优化下,不用按照“单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤”
213   - // 2. 单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤
  206 + // 2. 按 receiver_user_ids 快照过滤可见性,结果按 id 升序
214 207 List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size);
215 208  
216 209 // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数
... ... @@ -219,12 +212,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
219 212 return messages;
220 213 }
221 214  
222   - // TODO @AI:要不要改下,把“回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数”都计算?!
223 215 /**
224 216 * 补全消息已读态和回执已读人数
225 217 * <p>
226   - * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD
227   - * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数
  218 + * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD,所有消息都补
  219 + * 2. 回执已读人数(readCount):仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,计算可见成员中的已读人数
228 220 */
229 221 @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
230 222 private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) {
... ... @@ -254,9 +246,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
254 246 message.setStatus(ImMessageStatusEnum.UNREAD.getStatus());
255 247 }
256 248  
257   - // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见)
  249 + // 回执已读人数(readCount):仅补本人发送、且需要回执(非 NO_RECEIPT)的消息
  250 + // 读位置(status)所有消息都补;readCount 只服务「需要回执」的消息,不把已读位置和消息回执混为一谈
258 251 if (ObjUtil.notEqual(message.getSenderId(), userId)
259   - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
  252 + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
260 253 continue;
261 254 }
262 255 Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId,
... ... @@ -286,7 +279,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
286 279 ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
287 280 if (message == null
288 281 || ObjUtil.notEqual(message.getGroupId(), groupId)
289   - || !isMessageVisible(message, userId)) {
  282 + || !isMessageReceived(message, userId)) {
290 283 throw exception(MESSAGE_NOT_IN_GROUP);
291 284 }
292 285  
... ... @@ -316,7 +309,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
316 309 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
317 310 return Collections.emptyList();
318 311 }
319   - if (!isMessageVisible(message, userId)) {
  312 + if (!isMessageReceived(message, userId)) {
320 313 return Collections.emptyList();
321 314 }
322 315 // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空
... ... @@ -436,7 +429,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
436 429 throw exception(MESSAGE_QUOTE_INVALID);
437 430 }
438 431 // 校验对发送人可见(按快照)
439   - if (!isMessageVisible(original, senderMember.getUserId())) {
  432 + if (!isMessageReceived(original, senderMember.getUserId())) {
440 433 throw exception(MESSAGE_QUOTE_INVALID);
441 434 }
442 435 // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用;
... ... @@ -464,24 +457,22 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
464 457 return message;
465 458 }
466 459  
467   - // TODO @AI:方法名,是不是可以优化下,对齐 ReceiverUserIds;
468 460 /**
469   - * 判断一条群消息对某个用户是否可见
  461 + * 判断一条群消息是否被某个用户接收到
470 462 * <p>
471   - * 可见性以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。
  463 + * 接收范围以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。
472 464 *
473 465 * @param message 消息
474 466 * @param userId 当前用户编号
475   - * @return 是否可见
  467 + * @return 是否接收到
476 468 */
477 469 @SuppressWarnings("BooleanMethodIsAlwaysInverted")
478   - private boolean isMessageVisible(ImGroupMessageDO message, Long userId) {
  470 + private boolean isMessageReceived(ImGroupMessageDO message, Long userId) {
479 471 return CollUtil.contains(message.getReceiverUserIds(), userId);
480 472 }
481 473  
482   - // TODO @AI: ∩ 是啥意思,有点奇怪。
483 474 /**
484   - * 计算一条群消息的可见成员集合(快照 ∩ 传入成员,含发送者)
  475 + * 计算一条群消息的可见成员集合(快照与传入成员的交集,含发送者)
485 476 */
486 477 private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
487 478 if (CollUtil.isEmpty(message.getReceiverUserIds())) {
... ... @@ -497,20 +488,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
497 488 * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集;
498 489 * 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。
499 490 */
500   - private Set<Long> getReceiverUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) {
501   - Set<Long> result = new HashSet<>();
502   - // 无定向接收列表 → 全员可见
503   - if (CollUtil.isEmpty(receiverUserIds)) {
504   - result.addAll(memberUserIds);
505   - } else {
506   - // 有定向接收列表 → 仅定向用户且仍为成员可见
507   - Set<Long> allowed = new HashSet<>(receiverUserIds);
508   - for (Long userId : memberUserIds) {
509   - if (allowed.contains(userId)) {
510   - result.add(userId);
511   - }
512   - }
513   - }
  491 + private Set<Long> getReceiverUserIds(List<Long> directedUserIds, Long senderId, Collection<Long> memberUserIds) {
  492 + // 无定向接收列表 → 全员可见;否则取定向用户与当前成员的交集
  493 + Set<Long> result = CollUtil.isEmpty(directedUserIds)
  494 + ? new HashSet<>(memberUserIds)
  495 + : new HashSet<>(CollUtil.intersection(memberUserIds, directedUserIds));
514 496 // 发送者始终可见(多端同步),即便成员缓存暂未包含
515 497 if (senderId != null) {
516 498 result.add(senderId);
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
... ... @@ -1100,9 +1100,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1100 1100  
1101 1101 @Test
1102 1102 public void testGetMyGroupList_noMembers() {
1103   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>());
1104   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
1105   - .thenReturn(new ArrayList<>());
  1103 + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>());
1106 1104  
1107 1105 List<ImGroupDO> result = groupService.getMyGroupList(1L);
1108 1106 assertTrue(result.isEmpty());
... ... @@ -1111,19 +1109,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1111 1109  
1112 1110 @Test
1113 1111 public void testGetMyGroupList_success() {
1114   - // 活跃群成员
1115   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of(
  1112 + // 曾经加入的所有群(含退群)
  1113 + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of(
1116 1114 ImGroupMemberDO.builder().groupId(10L).userId(1L)
1117 1115 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
1118 1116 ImGroupMemberDO.builder().groupId(20L).userId(1L)
1119   - .status(CommonStatusEnum.ENABLE.getStatus()).build()
  1117 + .status(CommonStatusEnum.ENABLE.getStatus()).build(),
  1118 + ImGroupMemberDO.builder().groupId(30L).userId(1L)
  1119 + .status(CommonStatusEnum.DISABLE.getStatus()).build()
1120 1120 )));
1121   - // 最近退群成员(最近 30 天内)
1122   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
1123   - .thenReturn(new ArrayList<>(List.of(
1124   - ImGroupMemberDO.builder().groupId(30L).userId(1L)
1125   - .status(CommonStatusEnum.DISABLE.getStatus()).build()
1126   - )));
1127 1121 List<ImGroupDO> groups = List.of(
1128 1122 ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(),
1129 1123 ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(),
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
... ... @@ -122,6 +122,42 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
122 122 }
123 123  
124 124 @Test
  125 + public void testSendMessage_senderAlwaysVisible() {
  126 + // 边界:成员缓存漏掉发送者(理论上 validateMemberInGroup 已挡,这里纯防御);发送者仍必须固化进 receiver_user_ids
  127 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  128 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class)))
  129 + .thenReturn(groupMessageService);
  130 +
  131 + ImGroupMessageSendReqVO reqVO = buildSendReqVO();
  132 + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001"))
  133 + .thenReturn(null);
  134 + ImGroupDO group = new ImGroupDO();
  135 + group.setId(10L);
  136 + group.setName("测试群");
  137 + when(groupService.validateGroupExists(10L)).thenReturn(group);
  138 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  139 + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build();
  140 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
  141 + // 成员缓存只返回 {2,3},漏掉发送者 1
  142 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
  143 + .thenReturn(List.of(2L, 3L));
  144 + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> {
  145 + ImGroupMessageDO msg = invocation.getArgument(0);
  146 + msg.setId(99L);
  147 + return 1;
  148 + });
  149 +
  150 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO);
  151 +
  152 + // 断言:固化快照必含发送者,推送目标补回发送者 = {1,2,3}
  153 + assertTrue(result.getReceiverUserIds().contains(1L), "发送者必须在 receiver_user_ids 快照内");
  154 + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
  155 + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) && ids.contains(3L)),
  156 + any(ImGroupMessageDTO.class));
  157 + }
  158 + }
  159 +
  160 + @Test
125 161 public void testSendMessage_clientMessageIdIdempotent() {
126 162 // 准备
127 163 ImGroupMessageSendReqVO reqVO = buildSendReqVO();
... ... @@ -224,9 +260,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
224 260 .groupId(10L).userId(1L)
225 261 .status(CommonStatusEnum.ENABLE.getStatus())
226 262 .joinTime(now.minusDays(10)).build();
227   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
228   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
229   - .thenReturn(List.of());
  263 + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member));
230 264  
231 265 ImGroupMessageDO low = ImGroupMessageDO.builder()
232 266 .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
... ... @@ -259,9 +293,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
259 293 .groupId(10L).userId(1L)
260 294 .status(CommonStatusEnum.ENABLE.getStatus())
261 295 .joinTime(now.minusDays(10)).build();
262   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
263   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
264   - .thenReturn(List.of());
  296 + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member));
265 297  
266 298 ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder()
267 299 .id(100L).groupId(10L).senderId(1L)
... ... @@ -300,6 +332,37 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
300 332 assertEquals(1, result.get(0).getReadCount());
301 333 }
302 334  
  335 + @Test
  336 + public void testPullMessages_noReceiptMessageSkipsReadCount() {
  337 + // 准备:本人发送但不需要回执(NO_RECEIPT)的消息——只补已读位置,不补 readCount
  338 + LocalDateTime now = LocalDateTime.now();
  339 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  340 + .groupId(10L).userId(1L)
  341 + .status(CommonStatusEnum.ENABLE.getStatus())
  342 + .joinTime(now.minusDays(10)).build();
  343 + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member));
  344 +
  345 + ImGroupMessageDO noReceiptMsg = ImGroupMessageDO.builder()
  346 + .id(100L).groupId(10L).senderId(1L)
  347 + .receiverUserIds(List.of(1L, 2L, 3L))
  348 + .status(ImMessageStatusEnum.UNREAD.getStatus())
  349 + .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
  350 + .sendTime(now.minusHours(1)).build();
  351 + when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L),
  352 + any(LocalDateTime.class), eq(100)))
  353 + .thenReturn(List.of(noReceiptMsg));
  354 + when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
  355 + .thenReturn(100L);
  356 +
  357 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
  358 +
  359 + // 断言:读位置照常补(status=READ),但 NO_RECEIPT 不补 readCount,也不查 readCount 相关的读位置映射
  360 + assertEquals(1, result.size());
  361 + assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus());
  362 + assertNull(result.get(0).getReadCount());
  363 + verify(conversationReadService, never()).getUserReadMessageIdMap(anyInt(), anyLong());
  364 + }
  365 +
303 366 // ========== 撤回测试 ==========
304 367  
305 368 @Test
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
... ... @@ -320,17 +320,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
320 320 }
321 321  
322 322 @Test
323   - public void testReadMessages_noUnread() {
324   - // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息
325   - when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
326   - eq(2L), eq(1L), eq(5L),
327   - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)))
328   - .thenReturn(0);
  323 + public void testReadMessages_notAdvanced() {
  324 + // 准备:读位置未前进(已读过 / CAS 失败),不下发事件
  325 + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
  326 + .thenReturn(false);
329 327  
330 328 // 调用
331 329 privateMessageService.readPrivateMessages(1L, 2L, 5L);
332 330  
333   - // 断言:不推送 READ / RECEIPT
  331 + // 断言:读位置没前进,不推送 READ / RECEIPT
334 332 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
335 333 }
336 334  
... ...