Commit 3361c757261d621af48206e3010f7e13f426a11d

Authored by YunaiV
1 parent 7057f471

✨ feat(message): 继续优化群聊的实现

yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java
@@ -17,4 +17,21 @@ public interface ImCommonConstants { @@ -17,4 +17,21 @@ public interface ImCommonConstants {
17 */ 17 */
18 int MESSAGE_RECALL_TIMEOUT_MINUTES = 5; 18 int MESSAGE_RECALL_TIMEOUT_MINUTES = 5;
19 19
  20 + /**
  21 + * 私聊离线消息最大拉取天数
  22 + * <p>
  23 + * 客户端通过 pull 接口增量拉取私聊离线消息时,仅返回最近 N 天内产生的消息,
  24 + * 超过该窗口的老消息不再主动推送(可通过历史消息接口按需倒翻)。
  25 + */
  26 + int MESSAGE_PRIVATE_PULL_MAX_DAYS = 30;
  27 +
  28 + /**
  29 + * 群聊离线消息最大拉取天数
  30 + * <p>
  31 + * 客户端通过 pull 接口增量拉取群聊离线消息时,仅返回最近 N 天内产生的消息;
  32 + * 退群前消息的补齐也以该窗口为基准(早于窗口的退群群不再扫描),避免老用户首次
  33 + * 拉取时对历史退群群做大量查询。
  34 + */
  35 + int MESSAGE_GROUP_PULL_MAX_DAYS = 30;
  36 +
20 } 37 }
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java
@@ -52,8 +52,10 @@ public class ImGroupMessageController { @@ -52,8 +52,10 @@ public class ImGroupMessageController {
52 @PutMapping("/read") 52 @PutMapping("/read")
53 @Operation(summary = "标记群聊消息已读") 53 @Operation(summary = "标记群聊消息已读")
54 @Parameter(name = "groupId", description = "群编号", required = true, example = "1") 54 @Parameter(name = "groupId", description = "群编号", required = true, example = "1")
55 - public CommonResult<Boolean> readGroupMessages(@RequestParam("groupId") Long groupId) {  
56 - groupMessageService.readGroupMessages(getLoginUserId(), groupId); 55 + @Parameter(name = "messageId", description = "已读到的消息编号", required = true, example = "100")
  56 + public CommonResult<Boolean> readGroupMessages(@RequestParam("groupId") Long groupId,
  57 + @RequestParam("messageId") Long messageId) {
  58 + groupMessageService.readGroupMessages(getLoginUserId(), groupId, messageId);
57 return success(true); 59 return success(true);
58 } 60 }
59 61
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java
@@ -33,10 +33,7 @@ public class ImGroupMessageSendReqVO { @@ -33,10 +33,7 @@ public class ImGroupMessageSendReqVO {
33 @Schema(description = "@目标用户编号列表", example = "[1,2,3]") 33 @Schema(description = "@目标用户编号列表", example = "[1,2,3]")
34 private List<Long> atUserIds; 34 private List<Long> atUserIds;
35 35
36 - @Schema(description = "定向接收用户编号列表", example = "[1,2]")  
37 - private List<Long> receiverUserIds;  
38 -  
39 @Schema(description = "是否需要回执", example = "false") 36 @Schema(description = "是否需要回执", example = "false")
40 - private Boolean needReceipt; 37 + private Boolean receipt;
41 38
42 } 39 }
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
@@ -20,17 +20,48 @@ import java.util.List; @@ -20,17 +20,48 @@ import java.util.List;
20 public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> { 20 public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
21 21
22 /** 22 /**
23 - * 根据 minId 增量拉取群聊消息 23 + * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用)
24 * 24 *
25 - * @param groupIds 用户所在群编号列表  
26 - * @param minId 最小消息 id(不含)  
27 - * @param size 拉取数量  
28 - * @return 消息列表 25 + * @param groupIds 用户当前仍在群内的群编号列表
  26 + * @param minId 最小消息 id(不含)
  27 + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口
  28 + * @param size 拉取数量
  29 + * @return 消息列表(按 id 升序)
29 */ 30 */
30 - default List<ImGroupMessageDO> selectListByMinId(List<Long> groupIds, Long minId, Integer size) { 31 + default List<ImGroupMessageDO> selectListByMinId(List<Long> groupIds, Long minId,
  32 + LocalDateTime minSendTime, Integer size) {
31 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>(); 33 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
32 wrapper.in("group_id", groupIds) 34 wrapper.in("group_id", groupIds)
33 .gt("id", minId) 35 .gt("id", minId)
  36 + .gt("send_time", minSendTime)
  37 + .ne("status", ImMessageStatusEnum.RECALL.getStatus())
  38 + .orderByAsc("id");
  39 + wrapper.limitN(size);
  40 + return selectList(wrapper);
  41 + }
  42 +
  43 + /**
  44 + * 查询"退群前"的离线消息(退群成员使用)
  45 + * <p>
  46 + * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、
  47 + * 不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。
  48 + *
  49 + * @param groupId 群编号
  50 + * @param minId 最小消息 id(不含)
  51 + * @param minSendTime 最早发送时间(不含)
  52 + * @param quitTime 退群时间(含),仅返回退群当时已存在的消息
  53 + * @param size 拉取数量
  54 + * @return 消息列表(按 id 升序)
  55 + */
  56 + default List<ImGroupMessageDO> selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId,
  57 + LocalDateTime minSendTime,
  58 + LocalDateTime quitTime,
  59 + Integer size) {
  60 + QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
  61 + wrapper.eq("group_id", groupId)
  62 + .gt("id", minId)
  63 + .gt("send_time", minSendTime)
  64 + .le("send_time", quitTime)
34 .ne("status", ImMessageStatusEnum.RECALL.getStatus()) 65 .ne("status", ImMessageStatusEnum.RECALL.getStatus())
35 .orderByAsc("id"); 66 .orderByAsc("id");
36 wrapper.limitN(size); 67 wrapper.limitN(size);
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java
@@ -7,6 +7,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; @@ -7,6 +7,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
7 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 7 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
8 import org.apache.ibatis.annotations.Mapper; 8 import org.apache.ibatis.annotations.Mapper;
9 9
  10 +import java.time.LocalDateTime;
10 import java.util.List; 11 import java.util.List;
11 12
12 /** 13 /**
@@ -18,19 +19,22 @@ import java.util.List; @@ -18,19 +19,22 @@ import java.util.List;
18 public interface ImPrivateMessageMapper extends BaseMapperX<ImPrivateMessageDO> { 19 public interface ImPrivateMessageMapper extends BaseMapperX<ImPrivateMessageDO> {
19 20
20 /** 21 /**
21 - * 根据 minId 增量拉取私聊消息 22 + * 根据 minId + 时间窗口增量拉取私聊消息
22 * 23 *
23 - * @param userId 当前用户编号  
24 - * @param minId 最小消息 id(不含)  
25 - * @param size 拉取数量 24 + * @param userId 当前用户编号
  25 + * @param minId 最小消息 id(不含)
  26 + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口
  27 + * @param size 拉取数量
26 * @return 消息列表 28 * @return 消息列表
27 */ 29 */
28 - default List<ImPrivateMessageDO> selectListByMinId(Long userId, Long minId, Integer size) { 30 + default List<ImPrivateMessageDO> selectListByMinId(Long userId, Long minId,
  31 + LocalDateTime minSendTime, Integer size) {
29 QueryWrapperX<ImPrivateMessageDO> wrapper = new QueryWrapperX<>(); 32 QueryWrapperX<ImPrivateMessageDO> wrapper = new QueryWrapperX<>();
30 wrapper.and(w -> w.eq("sender_id", userId) 33 wrapper.and(w -> w.eq("sender_id", userId)
31 .or() 34 .or()
32 .eq("receiver_id", userId)) 35 .eq("receiver_id", userId))
33 .gt("id", minId) 36 .gt("id", minId)
  37 + .gt("send_time", minSendTime)
34 .orderByAsc("id"); 38 .orderByAsc("id");
35 wrapper.limitN(size); 39 wrapper.limitN(size);
36 return selectList(wrapper); 40 return selectList(wrapper);
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java
@@ -35,10 +35,11 @@ public interface ImGroupMessageService { @@ -35,10 +35,11 @@ public interface ImGroupMessageService {
35 /** 35 /**
36 * 标记群聊消息已读 36 * 标记群聊消息已读
37 * 37 *
38 - * @param userId 当前用户编号  
39 - * @param groupId 群编号 38 + * @param userId 当前用户编号
  39 + * @param groupId 群编号
  40 + * @param messageId 已读到的消息编号
40 */ 41 */
41 - void readGroupMessages(Long userId, Long groupId); 42 + void readGroupMessages(Long userId, Long groupId, Long messageId);
42 43
43 /** 44 /**
44 * 撤回群聊消息 45 * 撤回群聊消息
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
1 package cn.iocoder.yudao.module.im.service.message; 1 package cn.iocoder.yudao.module.im.service.message;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Assert;
4 import cn.hutool.core.util.IdUtil; 5 import cn.hutool.core.util.IdUtil;
5 import cn.hutool.core.util.ObjUtil; 6 import cn.hutool.core.util.ObjUtil;
6 import cn.hutool.extra.spring.SpringUtil; 7 import cn.hutool.extra.spring.SpringUtil;
@@ -35,8 +36,10 @@ import java.util.*; @@ -35,8 +36,10 @@ import java.util.*;
35 36
36 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 37 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
37 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; 38 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  39 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
38 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; 40 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
39 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 41 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  42 +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_GROUP_PULL_MAX_DAYS;
40 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE; 43 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE;
41 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES; 44 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES;
42 45
@@ -101,21 +104,44 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -101,21 +104,44 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
101 if (size > MESSAGE_MAX_PULL_SIZE) { 104 if (size > MESSAGE_MAX_PULL_SIZE) {
102 throw exception(MESSAGE_PULL_SIZE_EXCEEDED); 105 throw exception(MESSAGE_PULL_SIZE_EXCEEDED);
103 } 106 }
104 - // 1. 获取用户所在的所有群  
105 - List<ImGroupMemberDO> members = groupMemberService.getGroupMembersByUserId(userId);  
106 - if (CollUtil.isEmpty(members)) {  
107 - return Collections.emptyList(); 107 + // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送
  108 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS);
  109 +
  110 + // 1. 主查询:仅用"当前仍在群"的成员记录驱动(已退群的群由步骤 2 补齐)
  111 + List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
  112 + Map<Long, ImGroupMemberDO> memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId);
  113 + List<ImGroupMessageDO> messages = new ArrayList<>();
  114 + if (CollUtil.isNotEmpty(activeMembers)) {
  115 + List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId);
  116 + messages.addAll(groupMessageMapper.selectListByMinId(groupIds, minId, minSendTime, size));
108 } 117 }
109 - List<Long> groupIds = convertList(members, ImGroupMemberDO::getGroupId);  
110 118
111 - // 2. 拉取消息  
112 - List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(groupIds, minId, size); 119 + // 2. 补齐"退群前"的消息:
  120 + // - 退群时间早于窗口起点的群直接忽略(消息已不在拉取范围);
  121 + // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,
  122 + // 避免把客户端已拥有的老消息再次推送。
  123 + LocalDateTime minQuitTime = minSendTime;
  124 + if (minId != null && minId > 0) {
  125 + ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId);
  126 + if (minMessage != null && minMessage.getSendTime() != null
  127 + && minMessage.getSendTime().isAfter(minSendTime)) {
  128 + minQuitTime = minMessage.getSendTime();
  129 + }
  130 + }
  131 + List<ImGroupMemberDO> quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime);
  132 + for (ImGroupMemberDO quitMember : quitMembers) {
  133 + List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
  134 + quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size);
  135 + if (CollUtil.isNotEmpty(quitGroupMessages)) {
  136 + messages.addAll(quitGroupMessages);
  137 + memberMap.put(quitMember.getGroupId(), quitMember);
  138 + }
  139 + }
113 140
114 - // 3. 过滤:入群前不可见、退群后不可见、定向接收过滤  
115 - // TODO @AI:Java 层过滤在 SQL limit 之后,可能导致返回数量不足 size(跨群场景 joinTime 各异,无法下沉 SQL)【后续优化】  
116 - Map<Long, ImGroupMemberDO> memberMap = CollUtil.toMap(members, new HashMap<>(), ImGroupMemberDO::getGroupId); 141 + // 3. 按可见性过滤(入群前不可见、定向消息排除),并按 id 升序重排
117 List<ImGroupMessageDO> result = messages.stream() 142 List<ImGroupMessageDO> result = messages.stream()
118 .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) 143 .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId))
  144 + .sorted(Comparator.comparing(ImGroupMessageDO::getId))
119 .toList(); 145 .toList();
120 log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", 146 log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]",
121 userId, minId, size, result.size()); 147 userId, minId, size, result.size());
@@ -178,6 +204,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -178,6 +204,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
178 return recallMessage; 204 return recallMessage;
179 } 205 }
180 206
  207 + // TODO @芋艿:这个方法在优化下;
181 @Override 208 @Override
182 public List<Long> getGroupReadUsers(Long userId, Long groupId, Long messageId) { 209 public List<Long> getGroupReadUsers(Long userId, Long groupId, Long messageId) {
183 // 1.1 校验用户在群中(权限校验) 210 // 1.1 校验用户在群中(权限校验)
@@ -189,7 +216,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -189,7 +216,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
189 } 216 }
190 217
191 // 2. 获取所有成员和已读位置 218 // 2. 获取所有成员和已读位置
192 - List<ImGroupMemberDO> allMembers = groupMemberService.selectByGroupId(groupId); 219 + List<ImGroupMemberDO> allMembers = groupMemberService.getGroupMemberListByGroupId(groupId);
193 Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); 220 Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
194 221
195 // 3. 计算该消息的可见成员集合(排除发送者自己) 222 // 3. 计算该消息的可见成员集合(排除发送者自己)
@@ -225,14 +252,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -225,14 +252,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
225 @Async 252 @Async
226 public void sendGroupMessageEvent(ImGroupMessageDO message) { 253 public void sendGroupMessageEvent(ImGroupMessageDO message) {
227 ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); 254 ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message);
228 - // 广播给群内可见成员(含发送方自己,支持多端同步)  
229 - List<ImGroupMemberDO> members = groupMemberService.selectByGroupId(message.getGroupId()); 255 + // 广播给群内有效成员(含发送方自己,支持多端同步)
  256 + List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByGroupId(message.getGroupId());
  257 + // 定向消息仍需过滤,非目标用户不应收到推送
230 List<Long> receiverList = message.getReceiverUserIds(); 258 List<Long> receiverList = message.getReceiverUserIds();
231 for (ImGroupMemberDO member : members) { 259 for (ImGroupMemberDO member : members) {
232 - if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) {  
233 - continue;  
234 - }  
235 - // 特殊:仅在存在定向接收列表时,才进行过滤;如果没有定向列表,则默认所有成员可见  
236 if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(member.getUserId()) 260 if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(member.getUserId())
237 && ObjUtil.notEqual(member.getUserId(), message.getSenderId())) { 261 && ObjUtil.notEqual(member.getUserId(), message.getSenderId())) {
238 continue; 262 continue;
@@ -263,8 +287,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -263,8 +287,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
263 if (CollUtil.isEmpty(pendingMessages)) { 287 if (CollUtil.isEmpty(pendingMessages)) {
264 return; 288 return;
265 } 289 }
266 - // DONE @AI:收敛到 groupMemberService 方法里,会不会更通用;——已收敛为 getActiveMemberListByGroupId  
267 - List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveMemberListByGroupId(groupId); 290 + List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
268 Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); 291 Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
269 for (ImGroupMessageDO message : pendingMessages) { 292 for (ImGroupMessageDO message : pendingMessages) {
270 // 2.1.1 统计可见成员中的已读人数 293 // 2.1.1 统计可见成员中的已读人数
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
@@ -32,6 +32,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU @@ -32,6 +32,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
32 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; 32 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
33 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 33 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
34 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE; 34 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE;
  35 +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_PRIVATE_PULL_MAX_DAYS;
35 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES; 36 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES;
36 37
37 /** 38 /**
@@ -87,7 +88,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -87,7 +88,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
87 if (size > MESSAGE_MAX_PULL_SIZE) { 88 if (size > MESSAGE_MAX_PULL_SIZE) {
88 throw exception(MESSAGE_PULL_SIZE_EXCEEDED); 89 throw exception(MESSAGE_PULL_SIZE_EXCEEDED);
89 } 90 }
90 - List<ImPrivateMessageDO> messages = privateMessageMapper.selectListByMinId(userId, minId, size); 91 + // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送
  92 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_PRIVATE_PULL_MAX_DAYS);
  93 +
  94 + // 根据 minId 和 minSendTime 拉取消息,避免 minId 恰好被发出后才拉取,导致漏消息
  95 + List<ImPrivateMessageDO> messages = privateMessageMapper.selectListByMinId(userId, minId, minSendTime, size);
91 log.info("[pullPrivateMessageList][userId({}) minId({}) size({}) result({})]", 96 log.info("[pullPrivateMessageList][userId({}) minId({}) size({}) result({})]",
92 userId, minId, size, messages.size()); 97 userId, minId, size, messages.size());
93 return messages; 98 return messages;
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java
@@ -21,6 +21,11 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @@ -21,6 +21,11 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
21 @Resource 21 @Resource
22 private ImPrivateMessageMapper mapper; 22 private ImPrivateMessageMapper mapper;
23 23
  24 + /**
  25 + * 一个足够早的时间作为 minSendTime,避免把测试数据过滤掉
  26 + */
  27 + private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
  28 +
24 // ========== selectListByMinId ========== 29 // ========== selectListByMinId ==========
25 30
26 @Test 31 @Test
@@ -36,7 +41,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @@ -36,7 +41,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
36 mapper.insert(msg3); 41 mapper.insert(msg3);
37 42
38 // 调用:用户 1 从 id=0 拉取 43 // 调用:用户 1 从 id=0 拉取
39 - List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L, 100); 44 + List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 100);
40 45
41 // 断言:只包含与用户 1 相关的 2 条 46 // 断言:只包含与用户 1 相关的 2 条
42 assertEquals(2, result.size()); 47 assertEquals(2, result.size());
@@ -53,7 +58,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @@ -53,7 +58,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
53 mapper.insert(msg2); 58 mapper.insert(msg2);
54 59
55 // 调用:从 msg1.id 之后拉取 60 // 调用:从 msg1.id 之后拉取
56 - List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, msg1.getId(), 100); 61 + List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, msg1.getId(), FAR_PAST, 100);
57 62
58 // 断言:只有 msg2 63 // 断言:只有 msg2
59 assertEquals(1, result.size()); 64 assertEquals(1, result.size());
@@ -68,12 +73,31 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @@ -68,12 +73,31 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
68 mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); 73 mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD));
69 74
70 // 调用:limit 2 75 // 调用:limit 2
71 - List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L, 2); 76 + List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2);
72 77
73 // 断言 78 // 断言
74 assertEquals(2, result.size()); 79 assertEquals(2, result.size());
75 } 80 }
76 81
  82 + @Test
  83 + public void testSelectListByMinId_sendTimeFilter() {
  84 + // 准备:一条落在窗口内,一条落在窗口外
  85 + ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  86 + newMsg.setSendTime(LocalDateTime.now().minusDays(1));
  87 + mapper.insert(newMsg);
  88 + ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  89 + oldMsg.setSendTime(LocalDateTime.now().minusDays(40));
  90 + mapper.insert(oldMsg);
  91 +
  92 + // 调用:窗口起点 = 30 天前
  93 + List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L,
  94 + LocalDateTime.now().minusDays(30), 100);
  95 +
  96 + // 断言:只返回窗口内的消息
  97 + assertEquals(1, result.size());
  98 + assertEquals(newMsg.getId(), result.get(0).getId());
  99 + }
  100 +
77 // ========== selectHistoryList ========== 101 // ========== selectHistoryList ==========
78 102
79 @Test 103 @Test
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
@@ -91,7 +91,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -91,7 +91,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
91 ImGroupMemberDO.builder().groupId(10L).userId(3L) 91 ImGroupMemberDO.builder().groupId(10L).userId(3L)
92 .status(CommonStatusEnum.ENABLE.getStatus()).build() 92 .status(CommonStatusEnum.ENABLE.getStatus()).build()
93 ); 93 );
94 - when(groupMemberService.selectByGroupId(10L)).thenReturn(allMembers); 94 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers);
95 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { 95 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> {
96 ImGroupMessageDO msg = invocation.getArgument(0); 96 ImGroupMessageDO msg = invocation.getArgument(0);
97 msg.setId(99L); 97 msg.setId(99L);
@@ -154,23 +154,27 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -154,23 +154,27 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
154 154
155 @Test 155 @Test
156 public void testPullMessages_joinTimeFilter() { 156 public void testPullMessages_joinTimeFilter() {
157 - // 准备  
158 - LocalDateTime joinTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0); 157 + // 准备:消息时间用"最近 1 天内"避免被 30 天窗口过滤
  158 + LocalDateTime now = LocalDateTime.now();
  159 + LocalDateTime joinTime = now.minusHours(2);
159 ImGroupMemberDO member = ImGroupMemberDO.builder() 160 ImGroupMemberDO member = ImGroupMemberDO.builder()
160 .groupId(10L).userId(1L) 161 .groupId(10L).userId(1L)
161 .status(CommonStatusEnum.ENABLE.getStatus()) 162 .status(CommonStatusEnum.ENABLE.getStatus())
162 .joinTime(joinTime).build(); 163 .joinTime(joinTime).build();
163 - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); 164 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
164 165
165 - // 模拟一条入群前的消息和一条入群后的消息 166 + // 入群前 1 条 + 入群后 1 条
166 ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() 167 ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
167 .id(1L).groupId(10L).senderId(2L) 168 .id(1L).groupId(10L).senderId(2L)
168 - .sendTime(LocalDateTime.of(2026, 4, 12, 9, 0, 0)).build(); 169 + .sendTime(now.minusHours(3)).build();
169 ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() 170 ImGroupMessageDO afterJoin = ImGroupMessageDO.builder()
170 .id(2L).groupId(10L).senderId(2L) 171 .id(2L).groupId(10L).senderId(2L)
171 - .sendTime(LocalDateTime.of(2026, 4, 12, 11, 0, 0)).build();  
172 - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) 172 + .sendTime(now.minusHours(1)).build();
  173 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L),
  174 + any(LocalDateTime.class), eq(100)))
173 .thenReturn(List.of(beforeJoin, afterJoin)); 175 .thenReturn(List.of(beforeJoin, afterJoin));
  176 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  177 + .thenReturn(List.of());
174 178
175 // 调用 179 // 调用
176 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); 180 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
@@ -181,54 +185,95 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -181,54 +185,95 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
181 } 185 }
182 186
183 @Test 187 @Test
184 - public void testPullMessages_quitTimeFilter() {  
185 - // 准备  
186 - LocalDateTime joinTime = LocalDateTime.of(2026, 4, 10, 10, 0, 0);  
187 - LocalDateTime quitTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0);  
188 - ImGroupMemberDO member = ImGroupMemberDO.builder() 188 + public void testPullMessages_quitMemberLoadsPreQuitMessages() {
  189 + // 准备:当前群(在群)+ 一个最近退群
  190 + LocalDateTime now = LocalDateTime.now();
  191 + ImGroupMemberDO activeMember = ImGroupMemberDO.builder()
189 .groupId(10L).userId(1L) 192 .groupId(10L).userId(1L)
  193 + .status(CommonStatusEnum.ENABLE.getStatus())
  194 + .joinTime(now.minusDays(10)).build();
  195 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember));
  196 +
  197 + ImGroupMessageDO activeMsg = ImGroupMessageDO.builder()
  198 + .id(5L).groupId(10L).senderId(2L)
  199 + .sendTime(now.minusHours(2)).build();
  200 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L),
  201 + any(LocalDateTime.class), eq(100)))
  202 + .thenReturn(List.of(activeMsg));
  203 +
  204 + // 已退群:退群时间在窗口内(minId=0,minQuitTime 直接用 minSendTime)
  205 + LocalDateTime quitTime = now.minusDays(3);
  206 + ImGroupMemberDO quitMember = ImGroupMemberDO.builder()
  207 + .groupId(20L).userId(1L)
190 .status(CommonStatusEnum.DISABLE.getStatus()) 208 .status(CommonStatusEnum.DISABLE.getStatus())
191 - .joinTime(joinTime).quitTime(quitTime).build();  
192 - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member));  
193 -  
194 - // 退群前后各一条消息  
195 - ImGroupMessageDO beforeQuit = ImGroupMessageDO.builder()  
196 - .id(1L).groupId(10L).senderId(2L)  
197 - .sendTime(LocalDateTime.of(2026, 4, 11, 10, 0, 0)).build();  
198 - ImGroupMessageDO afterQuit = ImGroupMessageDO.builder()  
199 - .id(2L).groupId(10L).senderId(2L)  
200 - .sendTime(LocalDateTime.of(2026, 4, 13, 10, 0, 0)).build();  
201 - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100))  
202 - .thenReturn(List.of(beforeQuit, afterQuit)); 209 + .joinTime(now.minusDays(20))
  210 + .quitTime(quitTime).build();
  211 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  212 + .thenReturn(List.of(quitMember));
  213 +
  214 + ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder()
  215 + .id(3L).groupId(20L).senderId(99L)
  216 + .sendTime(now.minusDays(5)).build();
  217 + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L),
  218 + any(LocalDateTime.class), eq(quitTime), eq(100)))
  219 + .thenReturn(List.of(quitGroupMsg));
203 220
204 // 调用 221 // 调用
205 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); 222 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
206 223
207 - // 断言:退群后消息不可见  
208 - assertEquals(1, result.size());  
209 - assertEquals(1L, result.get(0).getId()); 224 + // 断言:两条消息均返回,并按 id 升序
  225 + assertEquals(2, result.size());
  226 + assertEquals(3L, result.get(0).getId());
  227 + assertEquals(5L, result.get(1).getId());
  228 + }
  229 +
  230 + @Test
  231 + public void testPullMessages_quitMemberFilteredByBoundarySendTime() {
  232 + // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime
  233 + LocalDateTime now = LocalDateTime.now();
  234 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of());
  235 + when(groupMessageMapper.selectListByMinId(anyList(), anyLong(), any(LocalDateTime.class), anyInt()))
  236 + .thenReturn(List.of());
  237 +
  238 + LocalDateTime boundarySendTime = now.minusDays(2);
  239 + ImGroupMessageDO boundary = ImGroupMessageDO.builder()
  240 + .id(8L).groupId(10L).senderId(2L).sendTime(boundarySendTime).build();
  241 + when(groupMessageMapper.selectById(8L)).thenReturn(boundary);
  242 + when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime))
  243 + .thenReturn(List.of());
  244 +
  245 + // 调用
  246 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 8L, 100);
  247 +
  248 + // 断言:使用边界消息 sendTime 抬升 minQuitTime
  249 + assertEquals(0, result.size());
  250 + verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime);
210 } 251 }
211 252
212 @Test 253 @Test
213 public void testPullMessages_receiverUserIdsFilter() { 254 public void testPullMessages_receiverUserIdsFilter() {
214 // 准备 255 // 准备
  256 + LocalDateTime now = LocalDateTime.now();
215 ImGroupMemberDO member = ImGroupMemberDO.builder() 257 ImGroupMemberDO member = ImGroupMemberDO.builder()
216 .groupId(10L).userId(1L) 258 .groupId(10L).userId(1L)
217 .status(CommonStatusEnum.ENABLE.getStatus()) 259 .status(CommonStatusEnum.ENABLE.getStatus())
218 - .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();  
219 - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); 260 + .joinTime(now.minusDays(10)).build();
  261 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
220 262
221 // 定向接收消息:只给用户 2 和 3 263 // 定向接收消息:只给用户 2 和 3
222 ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() 264 ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
223 .id(1L).groupId(10L).senderId(5L) 265 .id(1L).groupId(10L).senderId(5L)
224 .receiverUserIds(List.of(2L, 3L)) 266 .receiverUserIds(List.of(2L, 3L))
225 - .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); 267 + .sendTime(now.minusHours(2)).build();
226 // 全员消息 268 // 全员消息
227 ImGroupMessageDO allMsg = ImGroupMessageDO.builder() 269 ImGroupMessageDO allMsg = ImGroupMessageDO.builder()
228 .id(2L).groupId(10L).senderId(5L) 270 .id(2L).groupId(10L).senderId(5L)
229 - .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();  
230 - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) 271 + .sendTime(now.minusHours(1)).build();
  272 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L),
  273 + any(LocalDateTime.class), eq(100)))
231 .thenReturn(List.of(directedMsg, allMsg)); 274 .thenReturn(List.of(directedMsg, allMsg));
  275 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  276 + .thenReturn(List.of());
232 277
233 // 调用 278 // 调用
234 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); 279 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
@@ -241,18 +286,22 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -241,18 +286,22 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
241 @Test 286 @Test
242 public void testPullMessages_atFieldCorrectReturn() { 287 public void testPullMessages_atFieldCorrectReturn() {
243 // 准备 288 // 准备
  289 + LocalDateTime now = LocalDateTime.now();
244 ImGroupMemberDO member = ImGroupMemberDO.builder() 290 ImGroupMemberDO member = ImGroupMemberDO.builder()
245 .groupId(10L).userId(1L) 291 .groupId(10L).userId(1L)
246 .status(CommonStatusEnum.ENABLE.getStatus()) 292 .status(CommonStatusEnum.ENABLE.getStatus())
247 - .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();  
248 - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); 293 + .joinTime(now.minusDays(10)).build();
  294 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
249 295
250 ImGroupMessageDO atMsg = ImGroupMessageDO.builder() 296 ImGroupMessageDO atMsg = ImGroupMessageDO.builder()
251 .id(1L).groupId(10L).senderId(2L) 297 .id(1L).groupId(10L).senderId(2L)
252 .atUserIds(List.of(1L, 3L)) 298 .atUserIds(List.of(1L, 3L))
253 - .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();  
254 - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) 299 + .sendTime(now.minusHours(1)).build();
  300 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L),
  301 + any(LocalDateTime.class), eq(100)))
255 .thenReturn(List.of(atMsg)); 302 .thenReturn(List.of(atMsg));
  303 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  304 + .thenReturn(List.of());
256 305
257 // 调用 306 // 调用
258 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); 307 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
@@ -290,7 +339,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -290,7 +339,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
290 ImGroupMemberDO.builder().groupId(10L).userId(3L) 339 ImGroupMemberDO.builder().groupId(10L).userId(3L)
291 .status(CommonStatusEnum.DISABLE.getStatus()).build() 340 .status(CommonStatusEnum.DISABLE.getStatus()).build()
292 ); 341 );
293 - when(groupMemberService.selectByGroupId(10L)).thenReturn(members); 342 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members);
294 343
295 // 调用 344 // 调用
296 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); 345 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L);
@@ -396,7 +445,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -396,7 +445,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
396 .status(CommonStatusEnum.ENABLE.getStatus()) 445 .status(CommonStatusEnum.ENABLE.getStatus())
397 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 446 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者
398 ); 447 );
399 - when(groupMemberService.selectByGroupId(10L)).thenReturn(allMembers); 448 + when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers);
400 449
401 // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 450 // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200
402 Map<Long, Long> positions = new HashMap<>(); 451 Map<Long, Long> positions = new HashMap<>();
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
@@ -132,7 +132,8 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -132,7 +132,8 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
132 ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(), 132 ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(),
133 ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build() 133 ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build()
134 ); 134 );
135 - when(imPrivateMessageMapper.selectListByMinId(1L, 0L, 100)).thenReturn(mockMessages); 135 + when(imPrivateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100)))
  136 + .thenReturn(mockMessages);
136 137
137 // 调用 138 // 调用
138 List<ImPrivateMessageDO> result = privateMessageService.pullPrivateMessageList(1L, 0L, 100); 139 List<ImPrivateMessageDO> result = privateMessageService.pullPrivateMessageList(1L, 0L, 100);