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 package cn.iocoder.yudao.module.im.controller.admin.group; 1 package cn.iocoder.yudao.module.im.controller.admin.group;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 import cn.iocoder.yudao.framework.common.pojo.CommonResult; 5 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 6 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
6 import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; 7 import cn.iocoder.yudao.module.im.controller.admin.group.vo.*;
@@ -193,13 +194,16 @@ public class ImGroupController { @@ -193,13 +194,16 @@ public class ImGroupController {
193 Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds); 194 Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds);
194 // 转换输出 195 // 转换输出
195 return convertList(groups, group -> { 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 // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 204 // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过
201 List<ImGroupMessageDO> pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get); 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,6 +47,9 @@ public class ImGroupRespVO {
47 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) 47 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
48 private LocalDateTime createTime; 48 private LocalDateTime createTime;
49 49
  50 + @Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)")
  51 + private Integer joinStatus; // 参见 CommonStatusEnum 枚举
  52 +
50 @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") 53 @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空")
51 private List<ImGroupMessageRespVO> pinnedMessages; 54 private List<ImGroupMessageRespVO> pinnedMessages;
52 55
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java
1 package cn.iocoder.yudao.module.im.dal.mysql.group; 1 package cn.iocoder.yudao.module.im.dal.mysql.group;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
4 -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;  
5 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
6 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
7 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
@@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; { @@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
59 .eq(ImGroupMemberDO::getStatus, status)); 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 @SuppressWarnings("UnusedReturnValue") 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,13 +87,12 @@ public interface ImGroupMemberService {
87 List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId); 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 * 添加群成员(入群),角色默认 MEMBER 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,8 +105,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
105 } 105 }
106 106
107 @Override 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 @Override 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,14 +593,10 @@ public class ImGroupServiceImpl implements ImGroupService {
593 return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); 593 return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId);
594 } 594 }
595 595
596 - // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤!  
597 @Override 596 @Override
598 public List<ImGroupDO> getMyGroupList(Long userId) { 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 if (CollUtil.isEmpty(members)) { 600 if (CollUtil.isEmpty(members)) {
605 return Collections.emptyList(); 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,9 +97,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
97 sensitiveWordService.validateText(reqVO.getContent()); 97 sensitiveWordService.validateText(reqVO.getContent());
98 } 98 }
99 99
100 - // 2.1 固化发送当时可见成员快照(含发送者),新群消息 receiver_user_ids 不能为空;用户发送均为全员广播 100 + // 2.1 固化发送当时可见成员快照:用户发送均为全员广播;getReceiverUserIds 兜底纳入发送者,钉死发送者必可见
101 List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); 101 List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
102 - // TODO @AI:这里是不是没必要过滤???因为发送者,肯定是群成员呀!!!  
103 Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds); 102 Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds);
104 // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏) 103 // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏)
105 reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds)); 104 reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds));
@@ -135,8 +134,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -135,8 +134,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
135 String contentString = payload == null || payload instanceof String 134 String contentString = payload == null || payload instanceof String
136 ? (String) payload 135 ? (String) payload
137 : JsonUtils.toJsonString(payload); 136 : JsonUtils.toJsonString(payload);
138 - // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇?  
139 - // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 137 + // 1.2 构建并保存消息
140 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) 138 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
141 .setSenderId(senderId).setGroupId(dto.getGroupId()) 139 .setSenderId(senderId).setGroupId(dto.getGroupId())
142 .setType(dto.getType()).setContent(contentString) 140 .setType(dto.getType()).setContent(contentString)
@@ -198,19 +196,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -198,19 +196,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
198 // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 196 // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送
199 LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); 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 if (CollUtil.isEmpty(groupIds)) { 202 if (CollUtil.isEmpty(groupIds)) {
209 return Collections.emptyList(); 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 List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size); 207 List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size);
215 208
216 // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数 209 // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数
@@ -219,12 +212,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -219,12 +212,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
219 return messages; 212 return messages;
220 } 213 }
221 214
222 - // TODO @AI:要不要改下,把“回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数”都计算?!  
223 /** 215 /**
224 * 补全消息已读态和回执已读人数 216 * 补全消息已读态和回执已读人数
225 * <p> 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 @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) 221 @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
230 private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) { 222 private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) {
@@ -254,9 +246,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -254,9 +246,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
254 message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); 246 message.setStatus(ImMessageStatusEnum.UNREAD.getStatus());
255 } 247 }
256 248
257 - // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) 249 + // 回执已读人数(readCount):仅补本人发送、且需要回执(非 NO_RECEIPT)的消息
  250 + // 读位置(status)所有消息都补;readCount 只服务「需要回执」的消息,不把已读位置和消息回执混为一谈
258 if (ObjUtil.notEqual(message.getSenderId(), userId) 251 if (ObjUtil.notEqual(message.getSenderId(), userId)
259 - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { 252 + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
260 continue; 253 continue;
261 } 254 }
262 Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId, 255 Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId,
@@ -286,7 +279,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -286,7 +279,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
286 ImGroupMessageDO message = groupMessageMapper.selectById(messageId); 279 ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
287 if (message == null 280 if (message == null
288 || ObjUtil.notEqual(message.getGroupId(), groupId) 281 || ObjUtil.notEqual(message.getGroupId(), groupId)
289 - || !isMessageVisible(message, userId)) { 282 + || !isMessageReceived(message, userId)) {
290 throw exception(MESSAGE_NOT_IN_GROUP); 283 throw exception(MESSAGE_NOT_IN_GROUP);
291 } 284 }
292 285
@@ -316,7 +309,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -316,7 +309,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
316 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { 309 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
317 return Collections.emptyList(); 310 return Collections.emptyList();
318 } 311 }
319 - if (!isMessageVisible(message, userId)) { 312 + if (!isMessageReceived(message, userId)) {
320 return Collections.emptyList(); 313 return Collections.emptyList();
321 } 314 }
322 // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 315 // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空
@@ -436,7 +429,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -436,7 +429,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
436 throw exception(MESSAGE_QUOTE_INVALID); 429 throw exception(MESSAGE_QUOTE_INVALID);
437 } 430 }
438 // 校验对发送人可见(按快照) 431 // 校验对发送人可见(按快照)
439 - if (!isMessageVisible(original, senderMember.getUserId())) { 432 + if (!isMessageReceived(original, senderMember.getUserId())) {
440 throw exception(MESSAGE_QUOTE_INVALID); 433 throw exception(MESSAGE_QUOTE_INVALID);
441 } 434 }
442 // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用; 435 // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用;
@@ -464,24 +457,22 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -464,24 +457,22 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
464 return message; 457 return message;
465 } 458 }
466 459
467 - // TODO @AI:方法名,是不是可以优化下,对齐 ReceiverUserIds;  
468 /** 460 /**
469 - * 判断一条群消息对某个用户是否可见 461 + * 判断一条群消息是否被某个用户接收到
470 * <p> 462 * <p>
471 - * 可见性以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 463 + * 接收范围以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。
472 * 464 *
473 * @param message 消息 465 * @param message 消息
474 * @param userId 当前用户编号 466 * @param userId 当前用户编号
475 - * @return 是否可见 467 + * @return 是否接收到
476 */ 468 */
477 @SuppressWarnings("BooleanMethodIsAlwaysInverted") 469 @SuppressWarnings("BooleanMethodIsAlwaysInverted")
478 - private boolean isMessageVisible(ImGroupMessageDO message, Long userId) { 470 + private boolean isMessageReceived(ImGroupMessageDO message, Long userId) {
479 return CollUtil.contains(message.getReceiverUserIds(), userId); 471 return CollUtil.contains(message.getReceiverUserIds(), userId);
480 } 472 }
481 473
482 - // TODO @AI: ∩ 是啥意思,有点奇怪。  
483 /** 474 /**
484 - * 计算一条群消息的可见成员集合(快照 ∩ 传入成员,含发送者) 475 + * 计算一条群消息的可见成员集合(快照与传入成员的交集,含发送者)
485 */ 476 */
486 private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) { 477 private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
487 if (CollUtil.isEmpty(message.getReceiverUserIds())) { 478 if (CollUtil.isEmpty(message.getReceiverUserIds())) {
@@ -497,20 +488,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -497,20 +488,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
497 * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集; 488 * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集;
498 * 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。 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 if (senderId != null) { 497 if (senderId != null) {
516 result.add(senderId); 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,9 +1100,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1100 1100
1101 @Test 1101 @Test
1102 public void testGetMyGroupList_noMembers() { 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 List<ImGroupDO> result = groupService.getMyGroupList(1L); 1105 List<ImGroupDO> result = groupService.getMyGroupList(1L);
1108 assertTrue(result.isEmpty()); 1106 assertTrue(result.isEmpty());
@@ -1111,19 +1109,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -1111,19 +1109,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1111 1109
1112 @Test 1110 @Test
1113 public void testGetMyGroupList_success() { 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 ImGroupMemberDO.builder().groupId(10L).userId(1L) 1114 ImGroupMemberDO.builder().groupId(10L).userId(1L)
1117 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 1115 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
1118 ImGroupMemberDO.builder().groupId(20L).userId(1L) 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 List<ImGroupDO> groups = List.of( 1121 List<ImGroupDO> groups = List.of(
1128 ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), 1122 ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(),
1129 ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), 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,6 +122,42 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
122 } 122 }
123 123
124 @Test 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 public void testSendMessage_clientMessageIdIdempotent() { 161 public void testSendMessage_clientMessageIdIdempotent() {
126 // 准备 162 // 准备
127 ImGroupMessageSendReqVO reqVO = buildSendReqVO(); 163 ImGroupMessageSendReqVO reqVO = buildSendReqVO();
@@ -224,9 +260,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -224,9 +260,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
224 .groupId(10L).userId(1L) 260 .groupId(10L).userId(1L)
225 .status(CommonStatusEnum.ENABLE.getStatus()) 261 .status(CommonStatusEnum.ENABLE.getStatus())
226 .joinTime(now.minusDays(10)).build(); 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 ImGroupMessageDO low = ImGroupMessageDO.builder() 265 ImGroupMessageDO low = ImGroupMessageDO.builder()
232 .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) 266 .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
@@ -259,9 +293,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -259,9 +293,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
259 .groupId(10L).userId(1L) 293 .groupId(10L).userId(1L)
260 .status(CommonStatusEnum.ENABLE.getStatus()) 294 .status(CommonStatusEnum.ENABLE.getStatus())
261 .joinTime(now.minusDays(10)).build(); 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 ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() 298 ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder()
267 .id(100L).groupId(10L).senderId(1L) 299 .id(100L).groupId(10L).senderId(1L)
@@ -300,6 +332,37 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -300,6 +332,37 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
300 assertEquals(1, result.get(0).getReadCount()); 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 @Test 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,17 +320,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
320 } 320 }
321 321
322 @Test 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 privateMessageService.readPrivateMessages(1L, 2L, 5L); 329 privateMessageService.readPrivateMessages(1L, 2L, 5L);
332 330
333 - // 断言:不推送 READ / RECEIPT 331 + // 断言:读位置没前进,不推送 READ / RECEIPT
334 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); 332 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
335 } 333 }
336 334