diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java index ae4214d..65e50ee 100644 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java +++ b/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 { */ int MESSAGE_RECALL_TIMEOUT_MINUTES = 5; + /** + * 私聊离线消息最大拉取天数 + *

+ * 客户端通过 pull 接口增量拉取私聊离线消息时,仅返回最近 N 天内产生的消息, + * 超过该窗口的老消息不再主动推送(可通过历史消息接口按需倒翻)。 + */ + int MESSAGE_PRIVATE_PULL_MAX_DAYS = 30; + + /** + * 群聊离线消息最大拉取天数 + *

+ * 客户端通过 pull 接口增量拉取群聊离线消息时,仅返回最近 N 天内产生的消息; + * 退群前消息的补齐也以该窗口为基准(早于窗口的退群群不再扫描),避免老用户首次 + * 拉取时对历史退群群做大量查询。 + */ + int MESSAGE_GROUP_PULL_MAX_DAYS = 30; + } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java index ee7be50..466a803 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java +++ b/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 { @PutMapping("/read") @Operation(summary = "标记群聊消息已读") @Parameter(name = "groupId", description = "群编号", required = true, example = "1") - public CommonResult readGroupMessages(@RequestParam("groupId") Long groupId) { - groupMessageService.readGroupMessages(getLoginUserId(), groupId); + @Parameter(name = "messageId", description = "已读到的消息编号", required = true, example = "100") + public CommonResult readGroupMessages(@RequestParam("groupId") Long groupId, + @RequestParam("messageId") Long messageId) { + groupMessageService.readGroupMessages(getLoginUserId(), groupId, messageId); return success(true); } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java index dfc6ef9..2e493a9 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java +++ b/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 { @Schema(description = "@目标用户编号列表", example = "[1,2,3]") private List atUserIds; - @Schema(description = "定向接收用户编号列表", example = "[1,2]") - private List receiverUserIds; - @Schema(description = "是否需要回执", example = "false") - private Boolean needReceipt; + private Boolean receipt; } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java index f405316..b1f339f 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java +++ b/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; public interface ImGroupMessageMapper extends BaseMapperX { /** - * 根据 minId 增量拉取群聊消息 + * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用) * - * @param groupIds 用户所在群编号列表 - * @param minId 最小消息 id(不含) - * @param size 拉取数量 - * @return 消息列表 + * @param groupIds 用户当前仍在群内的群编号列表 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 + * @param size 拉取数量 + * @return 消息列表(按 id 升序) */ - default List selectListByMinId(List groupIds, Long minId, Integer size) { + default List selectListByMinId(List groupIds, Long minId, + LocalDateTime minSendTime, Integer size) { QueryWrapperX wrapper = new QueryWrapperX<>(); wrapper.in("group_id", groupIds) .gt("id", minId) + .gt("send_time", minSendTime) + .ne("status", ImMessageStatusEnum.RECALL.getStatus()) + .orderByAsc("id"); + wrapper.limitN(size); + return selectList(wrapper); + } + + /** + * 查询"退群前"的离线消息(退群成员使用) + *

+ * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、 + * 不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 + * + * @param groupId 群编号 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含) + * @param quitTime 退群时间(含),仅返回退群当时已存在的消息 + * @param size 拉取数量 + * @return 消息列表(按 id 升序) + */ + default List selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId, + LocalDateTime minSendTime, + LocalDateTime quitTime, + Integer size) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.eq("group_id", groupId) + .gt("id", minId) + .gt("send_time", minSendTime) + .le("send_time", quitTime) .ne("status", ImMessageStatusEnum.RECALL.getStatus()) .orderByAsc("id"); wrapper.limitN(size); diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java index 956da09..46c8630 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java +++ b/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; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import org.apache.ibatis.annotations.Mapper; +import java.time.LocalDateTime; import java.util.List; /** @@ -18,19 +19,22 @@ import java.util.List; public interface ImPrivateMessageMapper extends BaseMapperX { /** - * 根据 minId 增量拉取私聊消息 + * 根据 minId + 时间窗口增量拉取私聊消息 * - * @param userId 当前用户编号 - * @param minId 最小消息 id(不含) - * @param size 拉取数量 + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 + * @param size 拉取数量 * @return 消息列表 */ - default List selectListByMinId(Long userId, Long minId, Integer size) { + default List selectListByMinId(Long userId, Long minId, + LocalDateTime minSendTime, Integer size) { QueryWrapperX wrapper = new QueryWrapperX<>(); wrapper.and(w -> w.eq("sender_id", userId) .or() .eq("receiver_id", userId)) .gt("id", minId) + .gt("send_time", minSendTime) .orderByAsc("id"); wrapper.limitN(size); return selectList(wrapper); diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java index 371bc66..4cac3e8 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java +++ b/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 { /** * 标记群聊消息已读 * - * @param userId 当前用户编号 - * @param groupId 群编号 + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param messageId 已读到的消息编号 */ - void readGroupMessages(Long userId, Long groupId); + void readGroupMessages(Long userId, Long groupId, Long messageId); /** * 撤回群聊消息 diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index 286e37b..72c193c 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.im.service.message; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.extra.spring.SpringUtil; @@ -35,8 +36,10 @@ import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_GROUP_PULL_MAX_DAYS; import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE; import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES; @@ -101,21 +104,44 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { if (size > MESSAGE_MAX_PULL_SIZE) { throw exception(MESSAGE_PULL_SIZE_EXCEEDED); } - // 1. 获取用户所在的所有群 - List members = groupMemberService.getGroupMembersByUserId(userId); - if (CollUtil.isEmpty(members)) { - return Collections.emptyList(); + // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); + + // 1. 主查询:仅用"当前仍在群"的成员记录驱动(已退群的群由步骤 2 补齐) + List activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); + Map memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId); + List messages = new ArrayList<>(); + if (CollUtil.isNotEmpty(activeMembers)) { + List groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); + messages.addAll(groupMessageMapper.selectListByMinId(groupIds, minId, minSendTime, size)); } - List groupIds = convertList(members, ImGroupMemberDO::getGroupId); - // 2. 拉取消息 - List messages = groupMessageMapper.selectListByMinId(groupIds, minId, size); + // 2. 补齐"退群前"的消息: + // - 退群时间早于窗口起点的群直接忽略(消息已不在拉取范围); + // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime, + // 避免把客户端已拥有的老消息再次推送。 + LocalDateTime minQuitTime = minSendTime; + if (minId != null && minId > 0) { + ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId); + if (minMessage != null && minMessage.getSendTime() != null + && minMessage.getSendTime().isAfter(minSendTime)) { + minQuitTime = minMessage.getSendTime(); + } + } + List quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime); + for (ImGroupMemberDO quitMember : quitMembers) { + List quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); + if (CollUtil.isNotEmpty(quitGroupMessages)) { + messages.addAll(quitGroupMessages); + memberMap.put(quitMember.getGroupId(), quitMember); + } + } - // 3. 过滤:入群前不可见、退群后不可见、定向接收过滤 - // TODO @AI:Java 层过滤在 SQL limit 之后,可能导致返回数量不足 size(跨群场景 joinTime 各异,无法下沉 SQL)【后续优化】 - Map memberMap = CollUtil.toMap(members, new HashMap<>(), ImGroupMemberDO::getGroupId); + // 3. 按可见性过滤(入群前不可见、定向消息排除),并按 id 升序重排 List result = messages.stream() .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) + .sorted(Comparator.comparing(ImGroupMessageDO::getId)) .toList(); log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size()); @@ -178,6 +204,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { return recallMessage; } + // TODO @芋艿:这个方法在优化下; @Override public List getGroupReadUsers(Long userId, Long groupId, Long messageId) { // 1.1 校验用户在群中(权限校验) @@ -189,7 +216,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { } // 2. 获取所有成员和已读位置 - List allMembers = groupMemberService.selectByGroupId(groupId); + List allMembers = groupMemberService.getGroupMemberListByGroupId(groupId); Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); // 3. 计算该消息的可见成员集合(排除发送者自己) @@ -225,14 +252,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @Async public void sendGroupMessageEvent(ImGroupMessageDO message) { ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); - // 广播给群内可见成员(含发送方自己,支持多端同步) - List members = groupMemberService.selectByGroupId(message.getGroupId()); + // 广播给群内有效成员(含发送方自己,支持多端同步) + List members = groupMemberService.getActiveGroupMemberListByGroupId(message.getGroupId()); + // 定向消息仍需过滤,非目标用户不应收到推送 List receiverList = message.getReceiverUserIds(); for (ImGroupMemberDO member : members) { - if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { - continue; - } - // 特殊:仅在存在定向接收列表时,才进行过滤;如果没有定向列表,则默认所有成员可见 if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(member.getUserId()) && ObjUtil.notEqual(member.getUserId(), message.getSenderId())) { continue; @@ -263,8 +287,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { if (CollUtil.isEmpty(pendingMessages)) { return; } - // DONE @AI:收敛到 groupMemberService 方法里,会不会更通用;——已收敛为 getActiveMemberListByGroupId - List activeMembers = groupMemberService.getActiveMemberListByGroupId(groupId); + List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); for (ImGroupMessageDO message : pendingMessages) { // 2.1.1 统计可见成员中的已读人数 diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java index 904c7b0..23b1b2f 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ b/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 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_PRIVATE_PULL_MAX_DAYS; import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES; /** @@ -87,7 +88,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { if (size > MESSAGE_MAX_PULL_SIZE) { throw exception(MESSAGE_PULL_SIZE_EXCEEDED); } - List messages = privateMessageMapper.selectListByMinId(userId, minId, size); + // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_PRIVATE_PULL_MAX_DAYS); + + // 根据 minId 和 minSendTime 拉取消息,避免 minId 恰好被发出后才拉取,导致漏消息 + List messages = privateMessageMapper.selectListByMinId(userId, minId, minSendTime, size); log.info("[pullPrivateMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, messages.size()); return messages; diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java index f04459a..c00ebe8 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java +++ b/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 { @Resource private ImPrivateMessageMapper mapper; + /** + * 一个足够早的时间作为 minSendTime,避免把测试数据过滤掉 + */ + private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); + // ========== selectListByMinId ========== @Test @@ -36,7 +41,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { mapper.insert(msg3); // 调用:用户 1 从 id=0 拉取 - List result = mapper.selectListByMinId(1L, 0L, 100); + List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 100); // 断言:只包含与用户 1 相关的 2 条 assertEquals(2, result.size()); @@ -53,7 +58,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { mapper.insert(msg2); // 调用:从 msg1.id 之后拉取 - List result = mapper.selectListByMinId(1L, msg1.getId(), 100); + List result = mapper.selectListByMinId(1L, msg1.getId(), FAR_PAST, 100); // 断言:只有 msg2 assertEquals(1, result.size()); @@ -68,12 +73,31 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); // 调用:limit 2 - List result = mapper.selectListByMinId(1L, 0L, 2); + List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2); // 断言 assertEquals(2, result.size()); } + @Test + public void testSelectListByMinId_sendTimeFilter() { + // 准备:一条落在窗口内,一条落在窗口外 + ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + newMsg.setSendTime(LocalDateTime.now().minusDays(1)); + mapper.insert(newMsg); + ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + oldMsg.setSendTime(LocalDateTime.now().minusDays(40)); + mapper.insert(oldMsg); + + // 调用:窗口起点 = 30 天前 + List result = mapper.selectListByMinId(1L, 0L, + LocalDateTime.now().minusDays(30), 100); + + // 断言:只返回窗口内的消息 + assertEquals(1, result.size()); + assertEquals(newMsg.getId(), result.get(0).getId()); + } + // ========== selectHistoryList ========== @Test diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 58abe5a..e3b2f9f 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/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 { ImGroupMemberDO.builder().groupId(10L).userId(3L) .status(CommonStatusEnum.ENABLE.getStatus()).build() ); - when(groupMemberService.selectByGroupId(10L)).thenReturn(allMembers); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers); when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { ImGroupMessageDO msg = invocation.getArgument(0); msg.setId(99L); @@ -154,23 +154,27 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testPullMessages_joinTimeFilter() { - // 准备 - LocalDateTime joinTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0); + // 准备:消息时间用"最近 1 天内"避免被 30 天窗口过滤 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime joinTime = now.minusHours(2); ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(joinTime).build(); - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - // 模拟一条入群前的消息和一条入群后的消息 + // 入群前 1 条 + 入群后 1 条 ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() .id(1L).groupId(10L).senderId(2L) - .sendTime(LocalDateTime.of(2026, 4, 12, 9, 0, 0)).build(); + .sendTime(now.minusHours(3)).build(); ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() .id(2L).groupId(10L).senderId(2L) - .sendTime(LocalDateTime.of(2026, 4, 12, 11, 0, 0)).build(); - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) .thenReturn(List.of(beforeJoin, afterJoin)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); // 调用 List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); @@ -181,54 +185,95 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { } @Test - public void testPullMessages_quitTimeFilter() { - // 准备 - LocalDateTime joinTime = LocalDateTime.of(2026, 4, 10, 10, 0, 0); - LocalDateTime quitTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0); - ImGroupMemberDO member = ImGroupMemberDO.builder() + public void testPullMessages_quitMemberLoadsPreQuitMessages() { + // 准备:当前群(在群)+ 一个最近退群 + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); + + ImGroupMessageDO activeMsg = ImGroupMessageDO.builder() + .id(5L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(2)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(activeMsg)); + + // 已退群:退群时间在窗口内(minId=0,minQuitTime 直接用 minSendTime) + LocalDateTime quitTime = now.minusDays(3); + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() + .groupId(20L).userId(1L) .status(CommonStatusEnum.DISABLE.getStatus()) - .joinTime(joinTime).quitTime(quitTime).build(); - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); - - // 退群前后各一条消息 - ImGroupMessageDO beforeQuit = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(2L) - .sendTime(LocalDateTime.of(2026, 4, 11, 10, 0, 0)).build(); - ImGroupMessageDO afterQuit = ImGroupMessageDO.builder() - .id(2L).groupId(10L).senderId(2L) - .sendTime(LocalDateTime.of(2026, 4, 13, 10, 0, 0)).build(); - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) - .thenReturn(List.of(beforeQuit, afterQuit)); + .joinTime(now.minusDays(20)) + .quitTime(quitTime).build(); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of(quitMember)); + + ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() + .id(3L).groupId(20L).senderId(99L) + .sendTime(now.minusDays(5)).build(); + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), + any(LocalDateTime.class), eq(quitTime), eq(100))) + .thenReturn(List.of(quitGroupMsg)); // 调用 List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - // 断言:退群后消息不可见 - assertEquals(1, result.size()); - assertEquals(1L, result.get(0).getId()); + // 断言:两条消息均返回,并按 id 升序 + assertEquals(2, result.size()); + assertEquals(3L, result.get(0).getId()); + assertEquals(5L, result.get(1).getId()); + } + + @Test + public void testPullMessages_quitMemberFilteredByBoundarySendTime() { + // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime + LocalDateTime now = LocalDateTime.now(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); + when(groupMessageMapper.selectListByMinId(anyList(), anyLong(), any(LocalDateTime.class), anyInt())) + .thenReturn(List.of()); + + LocalDateTime boundarySendTime = now.minusDays(2); + ImGroupMessageDO boundary = ImGroupMessageDO.builder() + .id(8L).groupId(10L).senderId(2L).sendTime(boundarySendTime).build(); + when(groupMessageMapper.selectById(8L)).thenReturn(boundary); + when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 8L, 100); + + // 断言:使用边界消息 sendTime 抬升 minQuitTime + assertEquals(0, result.size()); + verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); } @Test public void testPullMessages_receiverUserIdsFilter() { // 准备 + LocalDateTime now = LocalDateTime.now(); ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); // 定向接收消息:只给用户 2 和 3 ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() .id(1L).groupId(10L).senderId(5L) .receiverUserIds(List.of(2L, 3L)) - .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); + .sendTime(now.minusHours(2)).build(); // 全员消息 ImGroupMessageDO allMsg = ImGroupMessageDO.builder() .id(2L).groupId(10L).senderId(5L) - .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) .thenReturn(List.of(directedMsg, allMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); // 调用 List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); @@ -241,18 +286,22 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testPullMessages_atFieldCorrectReturn() { // 准备 + LocalDateTime now = LocalDateTime.now(); ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); - when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); ImGroupMessageDO atMsg = ImGroupMessageDO.builder() .id(1L).groupId(10L).senderId(2L) .atUserIds(List.of(1L, 3L)) - .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); - when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) .thenReturn(List.of(atMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); // 调用 List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); @@ -290,7 +339,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO.builder().groupId(10L).userId(3L) .status(CommonStatusEnum.DISABLE.getStatus()).build() ); - when(groupMemberService.selectByGroupId(10L)).thenReturn(members); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); // 调用 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); @@ -396,7 +445,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 ); - when(groupMemberService.selectByGroupId(10L)).thenReturn(allMembers); + when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 Map positions = new HashMap<>(); diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index 598eabd..9ecf8ea 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/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 { ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(), ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build() ); - when(imPrivateMessageMapper.selectListByMinId(1L, 0L, 100)).thenReturn(mockMessages); + when(imPrivateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100))) + .thenReturn(mockMessages); // 调用 List result = privateMessageService.pullPrivateMessageList(1L, 0L, 100);