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);