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 17 */
18 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 52 @PutMapping("/read")
53 53 @Operation(summary = "标记群聊消息已读")
54 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 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 33 @Schema(description = "@目标用户编号列表", example = "[1,2,3]")
34 34 private List<Long> atUserIds;
35 35  
36   - @Schema(description = "定向接收用户编号列表", example = "[1,2]")
37   - private List<Long> receiverUserIds;
38   -
39 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 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 33 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
32 34 wrapper.in("group_id", groupIds)
33 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 65 .ne("status", ImMessageStatusEnum.RECALL.getStatus())
35 66 .orderByAsc("id");
36 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 7 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
8 8 import org.apache.ibatis.annotations.Mapper;
9 9  
  10 +import java.time.LocalDateTime;
10 11 import java.util.List;
11 12  
12 13 /**
... ... @@ -18,19 +19,22 @@ import java.util.List;
18 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 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 32 QueryWrapperX<ImPrivateMessageDO> wrapper = new QueryWrapperX<>();
30 33 wrapper.and(w -> w.eq("sender_id", userId)
31 34 .or()
32 35 .eq("receiver_id", userId))
33 36 .gt("id", minId)
  37 + .gt("send_time", minSendTime)
34 38 .orderByAsc("id");
35 39 wrapper.limitN(size);
36 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 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 1 package cn.iocoder.yudao.module.im.service.message;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Assert;
4 5 import cn.hutool.core.util.IdUtil;
5 6 import cn.hutool.core.util.ObjUtil;
6 7 import cn.hutool.extra.spring.SpringUtil;
... ... @@ -35,8 +36,10 @@ import java.util.*;
35 36  
36 37 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
37 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 40 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
39 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 43 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE;
41 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 104 if (size > MESSAGE_MAX_PULL_SIZE) {
102 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 142 List<ImGroupMessageDO> result = messages.stream()
118 143 .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId))
  144 + .sorted(Comparator.comparing(ImGroupMessageDO::getId))
119 145 .toList();
120 146 log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]",
121 147 userId, minId, size, result.size());
... ... @@ -178,6 +204,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
178 204 return recallMessage;
179 205 }
180 206  
  207 + // TODO @芋艿:这个方法在优化下;
181 208 @Override
182 209 public List<Long> getGroupReadUsers(Long userId, Long groupId, Long messageId) {
183 210 // 1.1 校验用户在群中(权限校验)
... ... @@ -189,7 +216,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
189 216 }
190 217  
191 218 // 2. 获取所有成员和已读位置
192   - List<ImGroupMemberDO> allMembers = groupMemberService.selectByGroupId(groupId);
  219 + List<ImGroupMemberDO> allMembers = groupMemberService.getGroupMemberListByGroupId(groupId);
193 220 Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
194 221  
195 222 // 3. 计算该消息的可见成员集合(排除发送者自己)
... ... @@ -225,14 +252,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
225 252 @Async
226 253 public void sendGroupMessageEvent(ImGroupMessageDO message) {
227 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 258 List<Long> receiverList = message.getReceiverUserIds();
231 259 for (ImGroupMemberDO member : members) {
232   - if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) {
233   - continue;
234   - }
235   - // 特殊:仅在存在定向接收列表时,才进行过滤;如果没有定向列表,则默认所有成员可见
236 260 if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(member.getUserId())
237 261 && ObjUtil.notEqual(member.getUserId(), message.getSenderId())) {
238 262 continue;
... ... @@ -263,8 +287,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
263 287 if (CollUtil.isEmpty(pendingMessages)) {
264 288 return;
265 289 }
266   - // DONE @AI:收敛到 groupMemberService 方法里,会不会更通用;——已收敛为 getActiveMemberListByGroupId
267   - List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveMemberListByGroupId(groupId);
  290 + List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
268 291 Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
269 292 for (ImGroupMessageDO message : pendingMessages) {
270 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 32 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
33 33 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
34 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 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 88 if (size > MESSAGE_MAX_PULL_SIZE) {
88 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 96 log.info("[pullPrivateMessageList][userId({}) minId({}) size({}) result({})]",
92 97 userId, minId, size, messages.size());
93 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 21 @Resource
22 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 29 // ========== selectListByMinId ==========
25 30  
26 31 @Test
... ... @@ -36,7 +41,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
36 41 mapper.insert(msg3);
37 42  
38 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 46 // 断言:只包含与用户 1 相关的 2 条
42 47 assertEquals(2, result.size());
... ... @@ -53,7 +58,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
53 58 mapper.insert(msg2);
54 59  
55 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 63 // 断言:只有 msg2
59 64 assertEquals(1, result.size());
... ... @@ -68,12 +73,31 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
68 73 mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD));
69 74  
70 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 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 101 // ========== selectHistoryList ==========
78 102  
79 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 91 ImGroupMemberDO.builder().groupId(10L).userId(3L)
92 92 .status(CommonStatusEnum.ENABLE.getStatus()).build()
93 93 );
94   - when(groupMemberService.selectByGroupId(10L)).thenReturn(allMembers);
  94 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers);
95 95 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> {
96 96 ImGroupMessageDO msg = invocation.getArgument(0);
97 97 msg.setId(99L);
... ... @@ -154,23 +154,27 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
154 154  
155 155 @Test
156 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 160 ImGroupMemberDO member = ImGroupMemberDO.builder()
160 161 .groupId(10L).userId(1L)
161 162 .status(CommonStatusEnum.ENABLE.getStatus())
162 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 167 ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
167 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 170 ImGroupMessageDO afterJoin = ImGroupMessageDO.builder()
170 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 175 .thenReturn(List.of(beforeJoin, afterJoin));
  176 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  177 + .thenReturn(List.of());
174 178  
175 179 // 调用
176 180 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
... ... @@ -181,54 +185,95 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
181 185 }
182 186  
183 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 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 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 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 253 @Test
213 254 public void testPullMessages_receiverUserIdsFilter() {
214 255 // 准备
  256 + LocalDateTime now = LocalDateTime.now();
215 257 ImGroupMemberDO member = ImGroupMemberDO.builder()
216 258 .groupId(10L).userId(1L)
217 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 263 // 定向接收消息:只给用户 2 和 3
222 264 ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
223 265 .id(1L).groupId(10L).senderId(5L)
224 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 269 ImGroupMessageDO allMsg = ImGroupMessageDO.builder()
228 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 274 .thenReturn(List.of(directedMsg, allMsg));
  275 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  276 + .thenReturn(List.of());
232 277  
233 278 // 调用
234 279 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
... ... @@ -241,18 +286,22 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
241 286 @Test
242 287 public void testPullMessages_atFieldCorrectReturn() {
243 288 // 准备
  289 + LocalDateTime now = LocalDateTime.now();
244 290 ImGroupMemberDO member = ImGroupMemberDO.builder()
245 291 .groupId(10L).userId(1L)
246 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 296 ImGroupMessageDO atMsg = ImGroupMessageDO.builder()
251 297 .id(1L).groupId(10L).senderId(2L)
252 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 302 .thenReturn(List.of(atMsg));
  303 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  304 + .thenReturn(List.of());
256 305  
257 306 // 调用
258 307 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
... ... @@ -290,7 +339,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
290 339 ImGroupMemberDO.builder().groupId(10L).userId(3L)
291 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 345 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L);
... ... @@ -396,7 +445,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
396 445 .status(CommonStatusEnum.ENABLE.getStatus())
397 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 450 // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200
402 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 132 ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(),
133 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 139 List<ImPrivateMessageDO> result = privateMessageService.pullPrivateMessageList(1L, 0L, 100);
... ...