Commit 3361c757261d621af48206e3010f7e13f426a11d
1 parent
7057f471
✨ feat(message): 继续优化群聊的实现
Showing
11 changed files
with
237 additions
and
83 deletions
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); | ... | ... |