Commit 6d8ad0c3740e92d49cd583cba88f466918fe21f8

Authored by YunaiV
1 parent 3fe400a5

feat(im): 优化消息拉取与已读位置存储

- 固化群消息 receiver_user_ids 快照,并按快照过滤离线拉取和历史查询
- 新增 im_conversation_read,统一私聊、群聊、频道的已读位置存储
- 移除群聊、频道已读位置的 Redis 存储
- 补充群消息、群成员、私聊拉取相关索引
- 调整群回执统计、引用消息可见性和退群历史查询口径
- 补充消息拉取与会话已读位置相关测试
Showing 21 changed files with 962 additions and 1082 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.conversation;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  9 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
  10 +import com.baomidou.mybatisplus.annotation.KeySequence;
  11 +import com.baomidou.mybatisplus.annotation.TableId;
  12 +import com.baomidou.mybatisplus.annotation.TableName;
  13 +import lombok.*;
  14 +
  15 +import java.time.LocalDateTime;
  16 +
  17 +/**
  18 + * IM 会话读位置 DO
  19 + * <p>
  20 + * 只表达「用户在某个会话的最大已读位置」,私聊 / 群聊 / 频道统一落这张表,是读位置的唯一权威。
  21 + *
  22 + * @author 芋道源码
  23 + */
  24 +@TableName("im_conversation_read")
  25 +@KeySequence("im_conversation_read_seq")
  26 +@Data
  27 +@EqualsAndHashCode(callSuper = true)
  28 +@ToString(callSuper = true)
  29 +@Builder
  30 +@NoArgsConstructor
  31 +@AllArgsConstructor
  32 +public class ImConversationReadDO extends BaseDO {
  33 +
  34 + /**
  35 + * 编号
  36 + */
  37 + @TableId
  38 + private Long id;
  39 + /**
  40 + * 用户编号
  41 + *
  42 + * 关联 AdminUserDO 的 id 字段
  43 + */
  44 + private Long userId;
  45 + /**
  46 + * 会话类型
  47 + * <p>
  48 + * 枚举 {@link ImConversationTypeEnum}
  49 + */
  50 + private Integer conversationType;
  51 + /**
  52 + * 目标编号
  53 + * <p>
  54 + * 私聊关联 AdminUserDO 的 id;群聊关联 {@link ImGroupDO#getId()};频道关联 {@link ImChannelDO#getId()}
  55 + */
  56 + private Long targetId;
  57 + /**
  58 + * 最大已读消息编号
  59 + * <p>
  60 + * 关联 {@link ImPrivateMessageDO#getId()}、{@link ImGroupMessageDO#getId()}、{@link ImChannelMessageDO#getId()}
  61 + */
  62 + private Long messageId;
  63 + /**
  64 + * 最近已读时间
  65 + */
  66 + private LocalDateTime readTime;
  67 +
  68 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.conversation;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
  6 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  7 +import org.apache.ibatis.annotations.Mapper;
  8 +
  9 +import java.time.LocalDateTime;
  10 +import java.util.Collection;
  11 +import java.util.List;
  12 +
  13 +/**
  14 + * IM 会话读位置 Mapper
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Mapper
  19 +public interface ImConversationReadMapper extends BaseMapperX<ImConversationReadDO> {
  20 +
  21 + default ImConversationReadDO selectByUserIdAndConversation(Long userId, Integer conversationType, Long conversationId) {
  22 + return selectOne(new LambdaQueryWrapperX<ImConversationReadDO>()
  23 + .eq(ImConversationReadDO::getUserId, userId)
  24 + .eq(ImConversationReadDO::getConversationType, conversationType)
  25 + .eq(ImConversationReadDO::getTargetId, conversationId));
  26 + }
  27 +
  28 + default List<ImConversationReadDO> selectListByConversation(Integer conversationType, Long conversationId) {
  29 + return selectList(new LambdaQueryWrapperX<ImConversationReadDO>()
  30 + .eq(ImConversationReadDO::getConversationType, conversationType)
  31 + .eq(ImConversationReadDO::getTargetId, conversationId));
  32 + }
  33 +
  34 + default List<ImConversationReadDO> selectListByUserIdAndConversations(Long userId, Integer conversationType,
  35 + Collection<Long> conversationIds) {
  36 + return selectList(new LambdaQueryWrapperX<ImConversationReadDO>()
  37 + .eq(ImConversationReadDO::getUserId, userId)
  38 + .eq(ImConversationReadDO::getConversationType, conversationType)
  39 + .in(ImConversationReadDO::getTargetId, conversationIds));
  40 + }
  41 +
  42 + /**
  43 + * 单调递增更新读位置:仅当新位置更大时才更新
  44 + * <p>
  45 + * 通过 {@code read_message_id < messageId} 的 CAS 条件,保证乱序 / 并发上报时读位置不会回退。
  46 + *
  47 + * @param id 记录编号
  48 + * @param messageId 新的最大已读消息编号
  49 + * @param readTime 已读时间
  50 + * @return 影响行数;0 表示新位置不大于已有位置,未更新
  51 + */
  52 + default int updateReadMessageIdToLarger(Long id, Long messageId, LocalDateTime readTime) {
  53 + return update(null, Wrappers.<ImConversationReadDO>lambdaUpdate()
  54 + .set(ImConversationReadDO::getMessageId, messageId)
  55 + .set(ImConversationReadDO::getReadTime, readTime)
  56 + .eq(ImConversationReadDO::getId, id)
  57 + .lt(ImConversationReadDO::getMessageId, messageId));
  58 + }
  59 +
  60 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
... ... @@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 6 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
  7 +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
7 8 import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO;
8 9 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
9 10 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
... ... @@ -11,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
11 12 import org.apache.ibatis.annotations.Mapper;
12 13  
13 14 import java.time.LocalDateTime;
  15 +import java.util.Collection;
14 16 import java.util.List;
15 17  
16 18 /**
... ... @@ -22,48 +24,22 @@ import java.util.List;
22 24 public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
23 25  
24 26 /**
25   - * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用)
  27 + * 根据 minId + 时间窗口增量拉取群聊消息
26 28 *
27   - * @param groupIds 用户当前仍在群内的群编号列表
  29 + * @param userId 当前用户编号
  30 + * @param groupIds 候选群编号集合(当前在群 ∪ 窗口内退群),不能为空
28 31 * @param minId 最小消息 id(不含)
29 32 * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口
30 33 * @param size 拉取数量
31 34 * @return 消息列表(按 id 升序)
32 35 */
33   - default List<ImGroupMessageDO> selectListByMinId(List<Long> groupIds, Long minId,
  36 + default List<ImGroupMessageDO> selectListByMinId(Long userId, Collection<Long> groupIds, Long minId,
34 37 LocalDateTime minSendTime, Integer size) {
35 38 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
36 39 wrapper.in("group_id", groupIds)
37 40 .gt("id", minId)
38 41 .gt("send_time", minSendTime)
39   - .orderByAsc("id");
40   - wrapper.limitN(size);
41   - return selectList(wrapper);
42   - }
43   -
44   - /**
45   - * 查询"退群前"的离线消息(退群成员使用)
46   - * <p>
47   - * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。
48   - * <p>
49   - * 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。
50   - *
51   - * @param groupId 群编号
52   - * @param minId 最小消息 id(不含)
53   - * @param minSendTime 最早发送时间(不含)
54   - * @param quitTime 退群时间(含),仅返回退群当时已存在的消息
55   - * @param size 拉取数量
56   - * @return 消息列表(按 id 升序)
57   - */
58   - default List<ImGroupMessageDO> selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId,
59   - LocalDateTime minSendTime,
60   - LocalDateTime quitTime,
61   - Integer size) {
62   - QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
63   - wrapper.eq("group_id", groupId)
64   - .gt("id", minId)
65   - .gt("send_time", minSendTime)
66   - .le("send_time", quitTime)
  42 + .apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
67 43 .orderByAsc("id");
68 44 wrapper.limitN(size);
69 45 return selectList(wrapper);
... ... @@ -72,17 +48,17 @@ public interface ImGroupMessageMapper extends BaseMapperX&lt;ImGroupMessageDO&gt; {
72 48 /**
73 49 * 查询群聊历史消息(游标拉取)
74 50 *
75   - * @param groupId 群编号
76   - * @param maxId 起始消息 id(不含),为空则从最新开始
77   - * @param limit 拉取数量
78   - * @param joinTime 入群时间,仅返回入群之后的消息
  51 + * @param userId 当前用户编号
  52 + * @param groupId 群编号
  53 + * @param maxId 起始消息 id(不含),为空则从最新开始
  54 + * @param limit 拉取数量
79 55 * @return 消息列表(按 id 倒序)
80 56 */
81   - default List<ImGroupMessageDO> selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) {
  57 + default List<ImGroupMessageDO> selectHistoryListByUser(Long userId, Long groupId, Long maxId, Integer limit) {
82 58 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
83 59 wrapper.eq("group_id", groupId)
84 60 .lt(maxId != null, "id", maxId)
85   - .ge(joinTime != null, "send_time", joinTime)
  61 + .apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
86 62 .orderByDesc("id");
87 63 wrapper.limitN(limit);
88 64 return selectList(wrapper);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
... ... @@ -9,20 +9,6 @@ package cn.iocoder.yudao.module.im.dal.redis;
9 9 public interface RedisKeyConstants {
10 10  
11 11 /**
12   - * 群消息已读位置
13   - * KEY 格式: im:group:message:read:{groupId}
14   - * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
15   - */
16   - String GROUP_MESSAGE_READ = "im:group:message:read:%s";
17   -
18   - /**
19   - * 频道消息已读位置
20   - * KEY 格式: im:channel:message:read:{channelId}
21   - * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
22   - */
23   - String CHANNEL_MESSAGE_READ = "im:channel:message:read:%s";
24   -
25   - /**
26 12 * 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态)
27 13 * <p>
28 14 * KEY 格式:friend_state:{userId}_{friendUserId}
... ... @@ -44,7 +30,7 @@ public interface RedisKeyConstants {
44 30 * KEY 格式:group_member_ids:{groupId}
45 31 * VALUE 数据类型:List<Long>
46 32 * <p>
47   - * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。
  33 + * 说明:只缓存轻量的 userId 列表,适合「群消息推送目标」这类只关心 userId 的场景
48 34 */
49 35 String GROUP_MEMBER_IDS = "group_member_ids";
50 36  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java deleted
1   -package cn.iocoder.yudao.module.im.dal.redis.message;
2   -
3   -import cn.hutool.core.convert.Convert;
4   -import jakarta.annotation.Resource;
5   -import org.springframework.data.redis.core.StringRedisTemplate;
6   -import org.springframework.stereotype.Repository;
7   -
8   -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.CHANNEL_MESSAGE_READ;
9   -
10   -/**
11   - * IM 频道消息已读位置 Redis DAO
12   - *
13   - * @author 芋道源码
14   - */
15   -@Repository
16   -public class ImChannelMessageReadRedisDAO {
17   -
18   - @Resource
19   - private StringRedisTemplate stringRedisTemplate;
20   -
21   - /**
22   - * 更新用户在某频道的最大已读消息编号
23   - *
24   - * @param channelId 频道编号
25   - * @param userId 用户编号
26   - * @param maxMessageId 最大已读消息编号
27   - */
28   - public void updateReadMaxMessageId(Long channelId, Long userId, Long maxMessageId) {
29   - String key = formatKey(channelId);
30   - stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
31   - }
32   -
33   - /**
34   - * 获取用户在某频道的最大已读消息编号
35   - *
36   - * @param channelId 频道编号
37   - * @param userId 用户编号
38   - * @return 最大已读消息编号;不存在则返回 null
39   - */
40   - public Long getReadMaxMessageId(Long channelId, Long userId) {
41   - String key = formatKey(channelId);
42   - Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
43   - return Convert.toLong(val);
44   - }
45   -
46   - private static String formatKey(Long channelId) {
47   - return String.format(CHANNEL_MESSAGE_READ, channelId);
48   - }
49   -
50   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java deleted
1   -package cn.iocoder.yudao.module.im.dal.redis.message;
2   -
3   -import cn.hutool.core.convert.Convert;
4   -import jakarta.annotation.Resource;
5   -import org.springframework.data.redis.core.StringRedisTemplate;
6   -import org.springframework.stereotype.Repository;
7   -
8   -import java.util.Collection;
9   -import java.util.HashMap;
10   -import java.util.Map;
11   -
12   -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ;
13   -
14   -/**
15   - * IM 群消息已读位置 Redis DAO
16   - *
17   - * @author 芋道源码
18   - */
19   -@Repository
20   -public class ImGroupMessageReadRedisDAO {
21   -
22   - @Resource
23   - private StringRedisTemplate stringRedisTemplate;
24   -
25   - /**
26   - * 更新用户在某群的最大已读消息编号
27   - *
28   - * @param groupId 群编号
29   - * @param userId 用户编号
30   - * @param maxMessageId 最大已读消息编号
31   - */
32   - public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) {
33   - String key = formatKey(groupId);
34   - stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
35   - }
36   -
37   - /**
38   - * 获取用户在某群的最大已读消息编号
39   - *
40   - * @param groupId 群编号
41   - * @param userId 用户编号
42   - * @return 最大已读消息编号,不存在则返回 null
43   - */
44   - public Long getReadMaxMessageId(Long groupId, Long userId) {
45   - String key = formatKey(groupId);
46   - Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
47   - return Convert.toLong(val);
48   - }
49   -
50   - /**
51   - * 获取某群所有用户的已读位置
52   - *
53   - * @param groupId 群编号
54   - * @return userId → maxReadMessageId 映射
55   - */
56   - public Map<Long, Long> getReadMaxMessageIdMap(Long groupId) {
57   - String key = formatKey(groupId);
58   - Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(key);
59   - // 转换为 Long → Long 的 Map
60   - Map<Long, Long> result = new HashMap<>(entries.size());
61   - entries.forEach((k, v) -> result.put(Long.parseLong(k.toString()), Long.parseLong(v.toString())));
62   - return result;
63   - }
64   -
65   - /**
66   - * 删除用户在某群的已读位置
67   - *
68   - * @param groupId 群编号
69   - * @param userId 用户编号
70   - */
71   - public void deleteReadMaxMessageId(Long groupId, Long userId) {
72   - String key = formatKey(groupId);
73   - stringRedisTemplate.opsForHash().delete(key, userId.toString());
74   - }
75   -
76   - /**
77   - * 批量删除用户在某群的已读位置
78   - *
79   - * @param groupId 群编号
80   - * @param userIds 用户编号集合
81   - */
82   - public void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds) {
83   - String key = formatKey(groupId);
84   - Object[] hashKeys = userIds.stream().map(String::valueOf).toArray();
85   - stringRedisTemplate.opsForHash().delete(key, hashKeys);
86   - }
87   -
88   - /**
89   - * 删除某群所有用户的已读位置(整个 Hash Key)
90   - *
91   - * @param groupId 群编号
92   - */
93   - public void deleteReadMaxMessageIdMap(Long groupId) {
94   - String key = formatKey(groupId);
95   - stringRedisTemplate.delete(key);
96   - }
97   -
98   - private static String formatKey(Long groupId) {
99   - return String.format(GROUP_MESSAGE_READ, groupId);
100   - }
101   -
102   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.conversation;
  2 +
  3 +import java.util.Collection;
  4 +import java.util.Map;
  5 +
  6 +/**
  7 + * IM 会话读位置 Service 接口
  8 + *
  9 + * @author 芋道源码
  10 + */
  11 +public interface ImConversationReadService {
  12 +
  13 + /**
  14 + * 更新用户在某会话的最大已读位置(单调递增,乱序 / 并发上报不会回退)
  15 + *
  16 + * @param userId 用户编号
  17 + * @param conversationType 会话类型
  18 + * @param conversationId 会话编号
  19 + * @param readMessageId 已读到的最大消息编号
  20 + * @return 读位置是否前进;true 时调用方才需要下发已读 / 回执事件
  21 + */
  22 + boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId);
  23 +
  24 + /**
  25 + * 获取用户在某会话的最大已读位置
  26 + *
  27 + * @param userId 用户编号
  28 + * @param conversationType 会话类型
  29 + * @param conversationId 会话编号
  30 + * @return 最大已读消息编号;不存在则返回 null
  31 + */
  32 + Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId);
  33 +
  34 + /**
  35 + * 获取某会话内所有用户的读位置(用于群回执人数聚合)
  36 + *
  37 + * @param conversationType 会话类型
  38 + * @param conversationId 会话编号
  39 + * @return userId → 最大已读消息编号
  40 + */
  41 + Map<Long, Long> getUserReadMessageIdMap(Integer conversationType, Long conversationId);
  42 +
  43 + /**
  44 + * 批量获取某用户在多个会话的读位置(用于频道批量读位置、重连后按活跃会话补偿)
  45 + *
  46 + * @param userId 用户编号
  47 + * @param conversationType 会话类型
  48 + * @param conversationIds 会话编号集合
  49 + * @return conversationId → 最大已读消息编号
  50 + */
  51 + Map<Long, Long> getConversationReadMessageIdMap(Long userId, Integer conversationType, Collection<Long> conversationIds);
  52 +
  53 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.conversation;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
  5 +import cn.iocoder.yudao.module.im.dal.mysql.conversation.ImConversationReadMapper;
  6 +import jakarta.annotation.Resource;
  7 +import lombok.extern.slf4j.Slf4j;
  8 +import org.springframework.dao.DuplicateKeyException;
  9 +import org.springframework.stereotype.Service;
  10 +
  11 +import java.time.LocalDateTime;
  12 +import java.util.Collection;
  13 +import java.util.Collections;
  14 +import java.util.List;
  15 +import java.util.Map;
  16 +
  17 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
  18 +
  19 +/**
  20 + * IM 会话读位置 Service 实现类
  21 + *
  22 + * @author 芋道源码
  23 + */
  24 +@Service
  25 +@Slf4j
  26 +public class ImConversationReadServiceImpl implements ImConversationReadService {
  27 +
  28 + @Resource
  29 + private ImConversationReadMapper conversationReadMapper;
  30 +
  31 + @Override
  32 + public boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId) {
  33 + LocalDateTime now = LocalDateTime.now();
  34 + // 1. 不存在则插入;并发下唯一键冲突,降级为回查 + CAS 更新
  35 + ImConversationReadDO existing = conversationReadMapper.selectByUserIdAndConversation(
  36 + userId, conversationType, conversationId);
  37 + if (existing == null) {
  38 + try {
  39 + conversationReadMapper.insert(new ImConversationReadDO().setUserId(userId)
  40 + .setConversationType(conversationType).setTargetId(conversationId)
  41 + .setMessageId(readMessageId).setReadTime(now));
  42 + return true;
  43 + } catch (DuplicateKeyException e) {
  44 + log.warn("[updateConversationReadPosition][userId({}) type({}) conversationId({}) 并发插入冲突,回查更新]",
  45 + userId, conversationType, conversationId);
  46 + existing = conversationReadMapper.selectByUserIdAndConversation(userId, conversationType, conversationId);
  47 + }
  48 + }
  49 + if (existing == null) {
  50 + return false;
  51 + }
  52 +
  53 + // 2. CAS 单调更新:mapper 内 WHERE message_id < ? 保证乱序 / 并发不回退;影响行数 > 0 即读位置前进
  54 + return conversationReadMapper.updateReadMessageIdToLarger(existing.getId(), readMessageId, now) > 0;
  55 + }
  56 +
  57 + @Override
  58 + public Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId) {
  59 + ImConversationReadDO read = conversationReadMapper.selectByUserIdAndConversation(
  60 + userId, conversationType, conversationId);
  61 + return read != null ? read.getMessageId() : null;
  62 + }
  63 +
  64 + @Override
  65 + public Map<Long, Long> getUserReadMessageIdMap(Integer conversationType, Long conversationId) {
  66 + return convertMap(conversationReadMapper.selectListByConversation(conversationType, conversationId),
  67 + ImConversationReadDO::getUserId, ImConversationReadDO::getMessageId);
  68 + }
  69 +
  70 + @Override
  71 + public Map<Long, Long> getConversationReadMessageIdMap(Long userId, Integer conversationType,
  72 + Collection<Long> conversationIds) {
  73 + if (CollUtil.isEmpty(conversationIds)) {
  74 + return Collections.emptyMap();
  75 + }
  76 + List<ImConversationReadDO> list = conversationReadMapper.selectListByUserIdAndConversations(
  77 + userId, conversationType, conversationIds);
  78 + return convertMap(list, ImConversationReadDO::getTargetId, ImConversationReadDO::getMessageId);
  79 + }
  80 +
  81 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
... ... @@ -193,8 +193,6 @@ public class ImGroupServiceImpl implements ImGroupService {
193 193 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now()));
194 194 // 2.2 移除全部群成员
195 195 groupMemberService.removeGroupMembersByGroupId(id);
196   - // 2.3 清理已读缓存
197   - groupMessageService.deleteReadMaxMessageIdMap(id);
198 196 }
199 197  
200 198 // ==================== 群成员的写操作 ====================
... ... @@ -267,10 +265,8 @@ public class ImGroupServiceImpl implements ImGroupService {
267 265 // 2. 先发广播,后移成员(见类 javadoc)
268 266 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId));
269 267  
270   - // 3.1 移除群成员
  268 + // 3. 移除群成员
271 269 groupMemberService.removeGroupMember(groupId, userId);
272   - // 3.2 清理已读缓存
273   - groupMessageService.deleteReadMaxMessageId(groupId, userId);
274 270 }
275 271  
276 272 @Override
... ... @@ -307,10 +303,8 @@ public class ImGroupServiceImpl implements ImGroupService {
307 303 groupMessageService.sendGroupMessage(userId,
308 304 ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds));
309 305  
310   - // 3.1 批量移除群成员
  306 + // 3. 批量移除群成员;不清理读位置(保留退群前历史已读,供离线补偿)
311 307 groupMemberService.removeGroupMembers(groupId, validTargetUserIds);
312   - // 3.2 批量清理已读缓存
313   - groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds);
314 308 }
315 309  
316 310 @Override
... ... @@ -599,6 +593,7 @@ public class ImGroupServiceImpl implements ImGroupService {
599 593 return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId);
600 594 }
601 595  
  596 + // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤!
602 597 @Override
603 598 public List<ImGroupDO> getMyGroupList(Long userId) {
604 599 // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态)
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java
... ... @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.lang.Assert;
  5 +import cn.hutool.core.util.ObjUtil;
5 6 import cn.hutool.extra.spring.SpringUtil;
6 7 import cn.iocoder.yudao.framework.common.pojo.PageResult;
7 8 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
... ... @@ -11,9 +12,10 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.Im
11 12 import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
12 13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
13 14 import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper;
14   -import cn.iocoder.yudao.module.im.dal.redis.message.ImChannelMessageReadRedisDAO;
  15 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
15 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
16 17 import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
  18 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
17 19 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
18 20 import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
19 21 import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage;
... ... @@ -25,7 +27,6 @@ import org.springframework.validation.annotation.Validated;
25 27  
26 28 import java.time.LocalDateTime;
27 29 import java.util.Collection;
28   -import java.util.HashMap;
29 30 import java.util.List;
30 31 import java.util.Map;
31 32  
... ... @@ -50,7 +51,7 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
50 51 private ImWebSocketService webSocketService;
51 52  
52 53 @Resource
53   - private ImChannelMessageReadRedisDAO channelMessageReadRedisDAO;
  54 + private ImConversationReadService conversationReadService;
54 55  
55 56 // ==================== 用户端 ====================
56 57  
... ... @@ -61,28 +62,30 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
61 62  
62 63 @Override
63 64 public Map<Long, Long> getChannelReadMaxMessageIdMap(Long userId, Collection<Long> channelIds) {
64   - Map<Long, Long> result = new HashMap<>(channelIds.size());
65   - for (Long channelId : channelIds) {
66   - Long max = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
67   - if (max != null) {
68   - result.put(channelId, max);
69   - }
70   - }
71   - return result;
  65 + return conversationReadService.getConversationReadMessageIdMap(
  66 + userId, ImConversationTypeEnum.CHANNEL.getType(), channelIds);
72 67 }
73 68  
74 69 @Override
75 70 public void readChannelMessages(Long userId, Long channelId, Long messageId) {
76 71 Assert.notNull(channelId, "频道编号不能为空");
77 72 Assert.notNull(messageId, "已读消息编号不能为空");
78   - // 1. 已读位置未前进,直接返回
79   - Long prevMaxMessageId = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
80   - if (prevMaxMessageId != null && prevMaxMessageId >= messageId) {
  73 + // 1.1 校验消息真实存在且属于该频道,避免未来 / 伪造 messageId 污染读位置
  74 + ImChannelMessageDO message = channelMessageMapper.selectById(messageId);
  75 + if (message == null || ObjUtil.notEqual(message.getChannelId(), channelId)) {
  76 + return;
  77 + }
  78 + // 1.2 定向消息校验对当前用户可见(receiver_user_ids 为空表示全员可见)
  79 + if (CollUtil.isNotEmpty(message.getReceiverUserIds()) && !message.getReceiverUserIds().contains(userId)) {
81 80 return;
82 81 }
83 82  
84   - // 2. 更新 Redis 频道已读位置
85   - channelMessageReadRedisDAO.updateReadMaxMessageId(channelId, userId, messageId);
  83 + // 2. 更新频道已读位置;读位置未前进则不推
  84 + boolean advanced = conversationReadService.updateConversationReadPosition(
  85 + userId, ImConversationTypeEnum.CHANNEL.getType(), channelId, messageId);
  86 + if (!advanced) {
  87 + return;
  88 + }
86 89  
87 90 // 3. 异步推 READ 事件给自己多端同步
88 91 getSelf().readChannelMessageEvent(userId, channelId, messageId);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java
... ... @@ -98,35 +98,6 @@ public interface ImGroupMessageService {
98 98 */
99 99 List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO);
100 100  
101   - /**
102   - * 清理用户在某群的已读位置缓存
103   - * <p>
104   - * 用于成员退群场景
105   - *
106   - * @param groupId 群编号
107   - * @param userId 用户编号
108   - */
109   - void deleteReadMaxMessageId(Long groupId, Long userId);
110   -
111   - /**
112   - * 批量清理用户在某群的已读位置缓存
113   - * <p>
114   - * 用于批量踢出场景
115   - *
116   - * @param groupId 群编号
117   - * @param userIds 用户编号集合
118   - */
119   - void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds);
120   -
121   - /**
122   - * 清理某群所有用户的已读位置缓存
123   - * <p>
124   - * 用于群解散场景
125   - *
126   - * @param groupId 群编号
127   - */
128   - void deleteReadMaxMessageIdMap(Long groupId);
129   -
130 101 // ==================== 管理后台 ====================
131 102  
132 103 /**
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
... ... @@ -6,7 +6,6 @@ import cn.hutool.core.util.BooleanUtil;
6 6 import cn.hutool.core.util.IdUtil;
7 7 import cn.hutool.core.util.ObjUtil;
8 8 import cn.hutool.extra.spring.SpringUtil;
9   -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
10 9 import cn.iocoder.yudao.framework.common.pojo.PageResult;
11 10 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
12 11 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
... ... @@ -17,12 +16,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
17 16 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
18 17 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
19 18 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
20   -import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
  19 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
21 20 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
22 21 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
23 22 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
24 23 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
25 24 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  25 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
26 26 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
27 27 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
28 28 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
... ... @@ -34,6 +34,7 @@ import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
34 34 import cn.iocoder.yudao.module.im.util.ImMessageUtils;
35 35 import jakarta.annotation.Resource;
36 36 import lombok.extern.slf4j.Slf4j;
  37 +import org.springframework.dao.DuplicateKeyException;
37 38 import org.springframework.scheduling.annotation.Async;
38 39 import org.springframework.stereotype.Service;
39 40 import org.springframework.transaction.annotation.Transactional;
... ... @@ -41,7 +42,6 @@ import org.springframework.validation.annotation.Validated;
41 42  
42 43 import java.time.LocalDateTime;
43 44 import java.util.*;
44   -import java.util.stream.Collectors;
45 45  
46 46 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
47 47 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
... ... @@ -57,17 +57,8 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
57 57 @Slf4j
58 58 public class ImGroupMessageServiceImpl implements ImGroupMessageService {
59 59  
60   - /**
61   - * 仅用于规避群消息 pull 的"假空页":
62   - * 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。
63   - * 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。
64   - */
65   - private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3;
66   -
67 60 @Resource
68 61 private ImGroupMessageMapper groupMessageMapper;
69   - @Resource
70   - private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO;
71 62  
72 63 @Resource
73 64 private ImGroupService groupService;
... ... @@ -75,6 +66,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
75 66 private ImGroupMemberService groupMemberService;
76 67 @Resource
77 68 private ImSensitiveWordService sensitiveWordService;
  69 + @Resource
  70 + private ImConversationReadService conversationReadService;
78 71  
79 72 @Resource
80 73 private ImWebSocketService imWebSocketService;
... ... @@ -104,41 +97,51 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
104 97 sensitiveWordService.validateText(reqVO.getContent());
105 98 }
106 99  
107   - // 2.1 引用 quote 消息规范化
108   - reqVO.setContent(normalizeQuoteContent(reqVO, senderMember));
109   - // 2.2 构建并保存消息
  100 + // 2.1 固化发送当时可见成员快照(含发送者),新群消息 receiver_user_ids 不能为空;用户发送均为全员广播
  101 + List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
  102 + // TODO @AI:这里是不是没必要过滤???因为发送者,肯定是群成员呀!!!
  103 + Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds);
  104 + // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏)
  105 + reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds));
  106 + // 2.3 构建并保存消息;唯一键冲突时回查已存在消息返回
110 107 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m
111 108 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
  109 + .setReceiverUserIds(new ArrayList<>(receiverUserIds))
112 110 .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt())));
113   - groupMessageMapper.insert(message);
  111 + try {
  112 + groupMessageMapper.insert(message);
  113 + } catch (DuplicateKeyException e) {
  114 + log.warn("[sendGroupMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]",
  115 + senderId, reqVO.getClientMessageId());
  116 + return groupMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId());
  117 + }
114 118  
115   - // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步)
116   - List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId());
117   - Set<Long> targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds);
118   - imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
  119 + // 3. WebSocket 异步推送(快照内可见成员 + 发送方多端同步)
  120 + imWebSocketService.sendGroupMessageAsync(receiverUserIds, ImGroupMessageDTO.ofSend(message));
119 121 return message;
120 122 }
121 123  
122 124 @Override
123 125 public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) {
124 126 List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId());
125   - Set<Long> targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds);
126   - return sendGroupMessage(senderId, targetUserIds, dto);
  127 + Set<Long> receiverUserIds = getReceiverUserIds(dto.getReceiverUserIds(), senderId, memberUserIds);
  128 + return sendGroupMessage(senderId, receiverUserIds, dto);
127 129 }
128 130  
129 131 @Override
130   - public ImGroupMessageDO sendGroupMessage(Long senderId, Collection<Long> targetUserIds, ImGroupMessageSendDTO dto) {
  132 + public ImGroupMessageDO sendGroupMessage(Long senderId, Collection<Long> receiverUserIds, ImGroupMessageSendDTO dto) {
131 133 // 1.1 content 序列化:null / String 透传,POJO 走 JSON
132 134 Object payload = dto.getContent();
133 135 String contentString = payload == null || payload instanceof String
134 136 ? (String) payload
135 137 : JsonUtils.toJsonString(payload);
136   - // 1.2 构建并保存消息
  138 + // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇?
  139 + // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照)
137 140 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
138 141 .setSenderId(senderId).setGroupId(dto.getGroupId())
139 142 .setType(dto.getType()).setContent(contentString)
140 143 .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
141   - .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds())
  144 + .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(new ArrayList<>(receiverUserIds))
142 145 .setReceiptStatus(resolveReceiptStatus(dto.getReceipt()));
143 146 // 1.3 按 type.persistent 决定是否入库
144 147 if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) {
... ... @@ -146,7 +149,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
146 149 }
147 150  
148 151 // 2. WebSocket 异步推送
149   - imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
  152 + imWebSocketService.sendGroupMessageAsync(receiverUserIds, ImGroupMessageDTO.ofSend(message));
150 153 return message;
151 154 }
152 155  
... ... @@ -195,114 +198,32 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
195 198 // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送
196 199 LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays());
197 200  
198   - // 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试
199   - List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
200   - Map<Long, ImGroupMemberDO> memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId);
201   - List<ImGroupMessageDO> messages = new ArrayList<>();
202   - if (CollUtil.isNotEmpty(activeMembers)) {
203   - List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId);
204   - messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap));
205   - }
206   - // 1.2 补齐"退群前"的消息:
207   - // - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进;
208   - // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,避免把客户端已拥有的老消息再次推送。
209   - messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap));
210   -
211   - // 2. 合并后统一过滤,得到当前用户可见的结果
212   - List<ImGroupMessageDO> result = filterGroupMessageList(messages, memberMap, userId, size);
213   -
214   - // 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数
215   - appendMessageStatusAndReceipt(userId, result);
216   - log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size());
217   - return result;
218   - }
219   -
220   - /**
221   - * 拉取当前仍在群的主路径消息。
222   - *
223   - * 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测,
224   - * 直到命中可见消息或确认该来源已耗尽。
225   - */
226   - private List<ImGroupMessageDO> pullActiveGroupMessageList(Long userId, List<Long> groupIds, Long minId,
227   - Integer size, LocalDateTime minSendTime,
228   - Map<Long, ImGroupMemberDO> memberMap) {
229   - // 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界
230   - Long activeMinId = minId;
231   - for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) {
232   - // 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束
233   - List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId,
234   - minSendTime, size);
235   - if (CollUtil.isEmpty(messages)) {
236   - return Collections.emptyList();
237   - }
238   - boolean hasVisibleMessage = CollUtil.anyMatch(messages,
239   - message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId));
240   - boolean sourceExhausted = messages.size() < size;
241   - // 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息
242   - if (hasVisibleMessage || sourceExhausted) {
243   - return messages;
244   - }
245   -
246   - // 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止
247   - Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId);
248   - if (maxMessageId == null || maxMessageId <= activeMinId) {
249   - return Collections.emptyList();
250   - }
251   - activeMinId = maxMessageId;
  201 + // 1. 候选群 = 当前在群 ∪ 窗口内退群;退群用户仍可拉到退群前已写入自己 ID 的历史消息
  202 + // TODO @AI:直接查询所有的群(一次性,这样性能也好点呀);然后这里不用过滤 minSendTime 吧;
  203 + Set<Long> groupIds = new HashSet<>();
  204 + groupIds.addAll(convertList(groupMemberService.getActiveGroupMemberListByUserId(userId),
  205 + ImGroupMemberDO::getGroupId));
  206 + groupIds.addAll(convertList(groupMemberService.getQuitGroupMemberListByUserId(userId, minSendTime),
  207 + ImGroupMemberDO::getGroupId));
  208 + if (CollUtil.isEmpty(groupIds)) {
  209 + return Collections.emptyList();
252 210 }
253   - return Collections.emptyList();
254   - }
255 211  
256   - /**
257   - * 拉取已离开群路径的消息(退群前消息)
258   - */
259   - private List<ImGroupMessageDO> pullQuitGroupMessageList(Long userId, Long minId, Integer size,
260   - LocalDateTime minSendTime,
261   - Map<Long, ImGroupMemberDO> memberMap) {
262   - // 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤
263   - LocalDateTime minQuitTime = minSendTime;
264   - if (minId != null && minId > 0) {
265   - ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId);
266   - if (minMessage != null && minMessage.getSendTime() != null
267   - && minMessage.getSendTime().isAfter(minSendTime)) {
268   - minQuitTime = minMessage.getSendTime();
269   - }
270   - }
271   - // 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限
272   - List<ImGroupMessageDO> messages = new ArrayList<>();
273   - List<ImGroupMemberDO> quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime);
274   - for (ImGroupMemberDO quitMember : quitMembers) {
275   - // 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用
276   - List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
277   - quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size);
278   - if (CollUtil.isEmpty(quitGroupMessages)) {
279   - continue;
280   - }
281   - messages.addAll(quitGroupMessages);
282   - memberMap.put(quitMember.getGroupId(), quitMember);
283   - }
284   - return messages;
285   - }
  212 + // TODO @AI:注释是不是优化下,不用按照“单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤”
  213 + // 2. 单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤
  214 + List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size);
286 215  
287   - /**
288   - * 过滤一批原始群消息,得到当前用户可见的返回结果
289   - */
290   - private List<ImGroupMessageDO> filterGroupMessageList(List<ImGroupMessageDO> messages,
291   - Map<Long, ImGroupMemberDO> memberMap,
292   - Long userId, Integer size) {
293   - // 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条,
294   - // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息
295   - return messages.stream()
296   - .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId))
297   - .sorted(Comparator.comparing(ImGroupMessageDO::getId))
298   - .limit(size)
299   - .collect(Collectors.toList());
  216 + // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数
  217 + appendMessageStatusAndReceipt(userId, messages);
  218 + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, messages.size());
  219 + return messages;
300 220 }
301 221  
  222 + // TODO @AI:要不要改下,把“回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数”都计算?!
302 223 /**
303 224 * 补全消息已读态和回执已读人数
304   - *
305   - * 1. 消息已读态(status):根据 Redis 已读游标判断 READ / UNREAD
  225 + * <p>
  226 + * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD
306 227 * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数
307 228 */
308 229 @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
... ... @@ -310,18 +231,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
310 231 if (CollUtil.isEmpty(messages)) {
311 232 return;
312 233 }
313   - // 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐
  234 + // 群已读关闭:不查读位置,status 保持 DB 原值(含撤回),readCount 不补齐
314 235 if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
315 236 return;
316 237 }
317   - Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置
  238 + Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 当前用户已读位置
318 239 Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置)
319 240 Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表
320 241 for (ImGroupMessageDO message : messages) {
321 242 // 消息已读态(status):撤回 > 已读 > 未读
322 243 Long groupId = message.getGroupId();
323 244 long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> {
324   - Long readMaxMsgId = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId);
  245 + Long readMaxMsgId = conversationReadService.getConversationReadMessageId(
  246 + userId, ImConversationTypeEnum.GROUP.getType(), gid);
325 247 return readMaxMsgId != null ? readMaxMsgId : -1L;
326 248 });
327 249 if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
... ... @@ -338,12 +260,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
338 260 continue;
339 261 }
340 262 Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId,
341   - gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid));
342   - List<ImGroupMemberDO> allMembers = membersByGroup.computeIfAbsent(groupId,
343   - groupMemberService::getGroupMemberListByGroupId);
344   - Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers);
345   - visibleUserIds.remove(message.getSenderId());
346   - int readCount = getSumValue(visibleUserIds,
  263 + gid -> conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), gid));
  264 + // 应读分母取当前有效成员(剔除已退群),与异步回执刷新、前端已读弹层口径一致
  265 + List<ImGroupMemberDO> activeMembers = membersByGroup.computeIfAbsent(groupId,
  266 + groupMemberService::getActiveGroupMemberListByGroupId);
  267 + Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
  268 + receiverUserIds.remove(message.getSenderId());
  269 + int readCount = getSumValue(receiverUserIds,
347 270 uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
348 271 Integer::sum, 0);
349 272 message.setReadCount(readCount);
... ... @@ -358,24 +281,25 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
358 281 }
359 282 Assert.notNull(messageId, "已读消息编号不能为空");
360 283 // 1.1 校验用户在群中(权限校验)
361   - ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId);
362   - // 1.2 校验消息属于当前群,且对当前用户可见
  284 + groupMemberService.validateMemberInGroup(groupId, userId);
  285 + // 1.2 校验消息属于当前群,且对当前用户可见(按快照)
363 286 ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
364 287 if (message == null
365 288 || ObjUtil.notEqual(message.getGroupId(), groupId)
366   - || !isMessageVisible(message, member, userId)) {
  289 + || !isMessageVisible(message, userId)) {
367 290 throw exception(MESSAGE_NOT_IN_GROUP);
368 291 }
369 292  
370   - // 2. 已读位置未前进,直接返回
371   - Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId);
372   - if (prevMaxMessageId != null && prevMaxMessageId >= messageId) {
  293 + // 2. 取旧读位置(用于回执刷新区间),再更新群已读位置;读位置未前进则不推
  294 + Long prevMaxMessageId = conversationReadService.getConversationReadMessageId(
  295 + userId, ImConversationTypeEnum.GROUP.getType(), groupId);
  296 + boolean advanced = conversationReadService.updateConversationReadPosition(
  297 + userId, ImConversationTypeEnum.GROUP.getType(), groupId, messageId);
  298 + if (!advanced) {
373 299 return;
374 300 }
375   - // 3. 更新 Redis 群已读位置
376   - groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId);
377 301  
378   - // 4. 异步发送 READ 事件 + 刷新范围内的群回执
  302 + // 3. 异步发送 READ 事件 + 刷新范围内的群回执
379 303 getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId);
380 304 }
381 305  
... ... @@ -386,13 +310,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
386 310 throw exception(MESSAGE_GROUP_READ_DISABLED);
387 311 }
388 312 // 1.1 校验用户在群中(权限校验)
389   - ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId);
  313 + groupMemberService.validateMemberInGroup(groupId, userId);
390 314 // 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方
391 315 ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
392 316 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
393 317 return Collections.emptyList();
394 318 }
395   - if (!isMessageVisible(message, operator, userId)) {
  319 + if (!isMessageVisible(message, userId)) {
396 320 return Collections.emptyList();
397 321 }
398 322 // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空
... ... @@ -400,18 +324,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
400 324 return Collections.emptyList();
401 325 }
402 326  
403   - // 2. 获取所有成员和已读位置
404   - List<ImGroupMemberDO> allMembers = groupMemberService.getGroupMemberListByGroupId(groupId);
405   - Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
  327 + // 2. 获取当前有效成员和已读位置(剔除已退群,与回执分母口径一致)
  328 + List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
  329 + Map<Long, Long> allPositions = conversationReadService.getUserReadMessageIdMap(
  330 + ImConversationTypeEnum.GROUP.getType(), groupId);
406 331  
407 332 // 3. 计算该消息的可见成员集合(排除发送者自己)
408   - Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers);
409   - visibleUserIds.remove(message.getSenderId());
  333 + Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
  334 + receiverUserIds.remove(message.getSenderId());
410 335  
411 336 // 4. 只返回在可见范围内且已读位置 >= messageId 的用户
412 337 List<Long> readUserIds = new ArrayList<>();
413 338 allPositions.forEach((uid, readMaxMessageId) -> {
414   - if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) {
  339 + if (receiverUserIds.contains(uid) && readMaxMessageId >= messageId) {
415 340 readUserIds.add(uid);
416 341 }
417 342 });
... ... @@ -420,15 +345,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
420 345  
421 346 @Override
422 347 public List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) {
423   - // 1. 校验用户在群中
424   - ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId);
425   -
426   - // 2. 查询历史消息(仅入群之后)
427   - List<ImGroupMessageDO> messages = groupMessageMapper.selectHistoryList(
428   - reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime());
  348 + // 1. 校验用户曾经在群(当前在群或已退群),与 pull 退群窗口口径一致;内容仍由 receiver_user_ids 快照过滤
  349 + if (groupMemberService.getGroupMember(reqVO.getGroupId(), userId) == null) {
  350 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  351 + }
429 352  
430   - // 3. 过滤定向消息:仅保留当前用户可见的(receiverUserIds 为空 / 含当前用户 / 本人发送)
431   - return filterList(messages, message -> isMessageVisible(message, member, userId));
  353 + // 2. 查询历史消息:SQL 已按 receiver_user_ids 快照过滤当前用户可见
  354 + return groupMessageMapper.selectHistoryListByUser(
  355 + userId, reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit());
432 356 }
433 357  
434 358 // ========== 异步 WebSocket 推送 ==========
... ... @@ -455,20 +379,21 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
455 379 return;
456 380 }
457 381 List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
458   - Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
  382 + Map<Long, Long> allPositions = conversationReadService.getUserReadMessageIdMap(
  383 + ImConversationTypeEnum.GROUP.getType(), groupId);
459 384 for (ImGroupMessageDO message : pendingMessages) {
460   - // 2.1.1 统计可见成员中的已读人数
461   - Set<Long> visibleUserIds = getVisibleUserIds(message, activeMembers);
462   - visibleUserIds.remove(message.getSenderId()); // 发送者自己不算已读
463   - if (CollUtil.isEmpty(visibleUserIds)) {
  385 + // 2.1.1 统计可见成员中的已读人数(应读分母 = 快照 ∩ 当前有效成员 - sender)
  386 + Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
  387 + receiverUserIds.remove(message.getSenderId()); // 发送者自己不算已读
  388 + if (CollUtil.isEmpty(receiverUserIds)) {
464 389 continue;
465 390 }
466   - int readCount = getSumValue(visibleUserIds,
  391 + int readCount = getSumValue(receiverUserIds,
467 392 uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
468 393 Integer::sum, 0);
469 394 // 2.1.2 全部已读 → 标记回执完成
470 395 Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus();
471   - if (readCount >= visibleUserIds.size()) {
  396 + if (readCount >= receiverUserIds.size()) {
472 397 newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus();
473 398 groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId())
474 399 .setReceiptStatus(newReceiptStatus));
... ... @@ -487,9 +412,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
487 412 *
488 413 * @param reqVO 发送请求
489 414 * @param senderMember 发送人成员
  415 + * @param newAudience 新消息可见成员集合(发送当时快照)
490 416 * @return 规范化后的 content
491 417 */
492   - private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember) {
  418 + private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember,
  419 + Set<Long> newAudience) {
493 420 // 解析客户端 content 里的 quote.messageId
494 421 Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent());
495 422  
... ... @@ -508,12 +435,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
508 435 if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) {
509 436 throw exception(MESSAGE_QUOTE_INVALID);
510 437 }
511   - // 拒绝定向消息(仅发送人可见的内容若被全员广播 quote.content,会泄漏给原本看不到的成员)
512   - if (CollUtil.isNotEmpty(original.getReceiverUserIds())) {
  438 + // 校验对发送人可见(按快照)
  439 + if (!isMessageVisible(original, senderMember.getUserId())) {
513 440 throw exception(MESSAGE_QUOTE_INVALID);
514 441 }
515   - // 校验对发送人可见(入群时间 / 退群时间)
516   - if (!isMessageVisible(original, senderMember, senderMember.getUserId())) {
  442 + // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用;
  443 + // 否则只对部分成员可见的定向消息内容,会随广播引用泄漏给原本看不到的成员
  444 + if (!new HashSet<>(original.getReceiverUserIds()).containsAll(newAudience)) {
517 445 throw exception(MESSAGE_QUOTE_INVALID);
518 446 }
519 447 // 构建 quote 对象并注入 content
... ... @@ -536,88 +464,60 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
536 464 return message;
537 465 }
538 466  
  467 + // TODO @AI:方法名,是不是可以优化下,对齐 ReceiverUserIds;
539 468 /**
540   - * 判断一条群消息对某个群成员是否可见
  469 + * 判断一条群消息对某个用户是否可见
  470 + * <p>
  471 + * 可见性以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。
541 472 *
542   - * @param msg 消息
543   - * @param member 群成员
544   - * @param userId 当前用户编号(用于定向消息过滤)
  473 + * @param message 消息
  474 + * @param userId 当前用户编号
545 475 * @return 是否可见
546 476 */
547   - private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) {
548   - if (member == null) {
549   - return false;
550   - }
551   - // 1. 入群时间晚于消息发送时间 → 不可见
552   - if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) {
553   - return false;
554   - }
555   - // 2. 已退群且退群时间早于消息发送时间 → 不可见
556   - if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())
557   - && member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) {
558   - return false;
559   - }
560   - // 3.1 无定向接收列表 → 全员可见
561   - if (CollUtil.isEmpty(msg.getReceiverUserIds())) {
562   - return true;
563   - }
564   - // 3.2 当前用户在定向列表中,或本人即发送者 → 可见
565   - return msg.getReceiverUserIds().contains(userId)
566   - || ObjUtil.equal(msg.getSenderId(), userId);
  477 + @SuppressWarnings("BooleanMethodIsAlwaysInverted")
  478 + private boolean isMessageVisible(ImGroupMessageDO message, Long userId) {
  479 + return CollUtil.contains(message.getReceiverUserIds(), userId);
567 480 }
568 481  
  482 + // TODO @AI: ∩ 是啥意思,有点奇怪。
569 483 /**
570   - * 计算一条群消息的可见成员集合(含发送者)
  484 + * 计算一条群消息的可见成员集合(快照 ∩ 传入成员,含发送者)
571 485 */
572   - private Set<Long> getVisibleUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
573   - return convertSet(members, ImGroupMemberDO::getUserId,
574   - member -> isMessageVisible(message, member, member.getUserId()));
  486 + private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
  487 + if (CollUtil.isEmpty(message.getReceiverUserIds())) {
  488 + return new HashSet<>();
  489 + }
  490 + Set<Long> snapshot = new HashSet<>(message.getReceiverUserIds());
  491 + return convertSet(members, ImGroupMemberDO::getUserId, member -> snapshot.contains(member.getUserId()));
575 492 }
576 493  
577 494 /**
578   - * 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。
  495 + * 基于群成员 userId 列表,计算一条新消息的可见成员快照(含发送者)。
579 496 * <p>
580   - * 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定,
581   - * 只应用 {@code receiverUserIds} 定向过滤;语义与
582   - * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。
  497 + * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集;
  498 + * 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。
583 499 */
584   - private Set<Long> getVisibleUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) {
585   - if (CollUtil.isEmpty(memberUserIds)) {
586   - return new HashSet<>();
587   - }
  500 + private Set<Long> getReceiverUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) {
  501 + Set<Long> result = new HashSet<>();
588 502 // 无定向接收列表 → 全员可见
589 503 if (CollUtil.isEmpty(receiverUserIds)) {
590   - return new HashSet<>(memberUserIds);
  504 + result.addAll(memberUserIds);
  505 + } else {
  506 + // 有定向接收列表 → 仅定向用户且仍为成员可见
  507 + Set<Long> allowed = new HashSet<>(receiverUserIds);
  508 + for (Long userId : memberUserIds) {
  509 + if (allowed.contains(userId)) {
  510 + result.add(userId);
  511 + }
  512 + }
591 513 }
592   - // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步)
593   - Set<Long> allowed = new HashSet<>(receiverUserIds);
  514 + // 发送者始终可见(多端同步),即便成员缓存暂未包含
594 515 if (senderId != null) {
595   - allowed.add(senderId);
596   - }
597   - Set<Long> result = new HashSet<>();
598   - for (Long userId : memberUserIds) {
599   - if (allowed.contains(userId)) {
600   - result.add(userId);
601   - }
  516 + result.add(senderId);
602 517 }
603 518 return result;
604 519 }
605 520  
606   - @Override
607   - public void deleteReadMaxMessageId(Long groupId, Long userId) {
608   - groupMessageReadRedisDAO.deleteReadMaxMessageId(groupId, userId);
609   - }
610   -
611   - @Override
612   - public void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds) {
613   - groupMessageReadRedisDAO.deleteReadMaxMessageIds(groupId, userIds);
614   - }
615   -
616   - @Override
617   - public void deleteReadMaxMessageIdMap(Long groupId) {
618   - groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId);
619   - }
620   -
621 521 // ==================== 管理后台 ====================
622 522  
623 523 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
... ... @@ -12,9 +12,11 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate
12 12 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
13 13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
14 14 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  15 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
15 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
16 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
17 18 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  19 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
18 20 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
19 21 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
20 22 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
... ... @@ -25,6 +27,7 @@ import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
25 27 import cn.iocoder.yudao.module.im.util.ImMessageUtils;
26 28 import jakarta.annotation.Resource;
27 29 import lombok.extern.slf4j.Slf4j;
  30 +import org.springframework.dao.DuplicateKeyException;
28 31 import org.springframework.stereotype.Service;
29 32 import org.springframework.transaction.annotation.Transactional;
30 33 import org.springframework.validation.annotation.Validated;
... ... @@ -52,6 +55,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
52 55 private ImFriendService friendService;
53 56 @Resource
54 57 private ImSensitiveWordService sensitiveWordService;
  58 + @Resource
  59 + private ImConversationReadService conversationReadService;
55 60  
56 61 @Resource
57 62 private ImWebSocketService imWebSocketService;
... ... @@ -80,10 +85,16 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
80 85  
81 86 // 2.1 引用 quote 消息规范化
82 87 reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
83   - // 2.2 构建并保存消息
  88 + // 2.2 构建并保存消息;唯一键冲突时回查已存在消息返回
84 89 ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
85 90 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
86   - privateMessageMapper.insert(message);
  91 + try {
  92 + privateMessageMapper.insert(message);
  93 + } catch (DuplicateKeyException e) {
  94 + log.warn("[sendPrivateMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]",
  95 + senderId, reqVO.getClientMessageId());
  96 + return privateMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId());
  97 + }
87 98  
88 99 // 3. WebSocket 异步推送:接收方 + 发送方多端同步
89 100 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
... ... @@ -211,16 +222,21 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
211 222 throw exception(MESSAGE_PRIVATE_READ_DISABLED);
212 223 }
213 224 Assert.notNull(messageId, "已读消息编号不能为空");
214   - // 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读
  225 +
  226 + // 2. status 双写:把 (receiverId → userId) 上 id <= messageId 的未读消息置为已读
215 227 // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口
216   - int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  228 + privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
217 229 receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(),
218 230 new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));
219   - if (updated == 0) {
  231 +
  232 + // 3. 同步写 im_conversation_read(读位置唯一权威,单调递增);读位置前进才下发事件
  233 + boolean advanced = conversationReadService.updateConversationReadPosition(
  234 + userId, ImConversationTypeEnum.PRIVATE.getType(), receiverId, messageId);
  235 + if (!advanced) {
220 236 return;
221 237 }
222 238  
223   - // 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
  239 + // 4. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
224 240 imWebSocketService.sendPrivateMessageAsync(userId,
225 241 ImPrivateMessageDTO.ofRead(userId, receiverId, messageId));
226 242 imWebSocketService.sendPrivateMessageAsync(receiverId,
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapperTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.conversation;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
  5 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
  6 +import jakarta.annotation.Resource;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.time.LocalDateTime;
  10 +import java.util.List;
  11 +import java.util.Map;
  12 +
  13 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
  14 +import static org.junit.jupiter.api.Assertions.*;
  15 +
  16 +/**
  17 + * {@link ImConversationReadMapper} 的单元测试
  18 + *
  19 + * @author 芋道源码
  20 + */
  21 +public class ImConversationReadMapperTest extends BaseDbUnitTest {
  22 +
  23 + @Resource
  24 + private ImConversationReadMapper mapper;
  25 +
  26 + @Test
  27 + public void testUpdateReadMessageIdToLarger_onlyAdvancesWhenLarger() {
  28 + // 准备:用户 1 在群 10 的读位置 = 50
  29 + ImConversationReadDO read = buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L);
  30 + mapper.insert(read);
  31 +
  32 + // 1. 新位置更大 → 前进
  33 + int advanced = mapper.updateReadMessageIdToLarger(read.getId(), 100L, LocalDateTime.now());
  34 + assertEquals(1, advanced);
  35 + assertEquals(100L, mapper.selectById(read.getId()).getMessageId());
  36 +
  37 + // 2. 新位置相等 → 不更新
  38 + int equalUpdate = mapper.updateReadMessageIdToLarger(read.getId(), 100L, LocalDateTime.now());
  39 + assertEquals(0, equalUpdate);
  40 +
  41 + // 3. 新位置更小(乱序上报)→ 不回退
  42 + int smaller = mapper.updateReadMessageIdToLarger(read.getId(), 80L, LocalDateTime.now());
  43 + assertEquals(0, smaller);
  44 + assertEquals(100L, mapper.selectById(read.getId()).getMessageId());
  45 + }
  46 +
  47 + @Test
  48 + public void testSelectByUserIdAndConversation() {
  49 + ImConversationReadDO read = buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L);
  50 + mapper.insert(read);
  51 + // 同用户不同会话类型,不应命中
  52 + mapper.insert(buildRead(1L, ImConversationTypeEnum.PRIVATE.getType(), 10L, 99L));
  53 +
  54 + ImConversationReadDO result = mapper.selectByUserIdAndConversation(
  55 + 1L, ImConversationTypeEnum.GROUP.getType(), 10L);
  56 +
  57 + assertNotNull(result);
  58 + assertEquals(50L, result.getMessageId());
  59 + }
  60 +
  61 + @Test
  62 + public void testSelectListByConversation_aggregatesAllUsers() {
  63 + // 群 10 内三个用户的读位置,用于群回执人数聚合
  64 + mapper.insert(buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L));
  65 + mapper.insert(buildRead(2L, ImConversationTypeEnum.GROUP.getType(), 10L, 80L));
  66 + mapper.insert(buildRead(3L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L));
  67 + // 别的群,不应混入
  68 + mapper.insert(buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 20L, 999L));
  69 +
  70 + List<ImConversationReadDO> list = mapper.selectListByConversation(
  71 + ImConversationTypeEnum.GROUP.getType(), 10L);
  72 + Map<Long, Long> positions = convertMap(list,
  73 + ImConversationReadDO::getUserId, ImConversationReadDO::getMessageId);
  74 +
  75 + assertEquals(3, positions.size());
  76 + assertEquals(100L, positions.get(1L));
  77 + assertEquals(80L, positions.get(2L));
  78 + assertEquals(50L, positions.get(3L));
  79 + }
  80 +
  81 + @Test
  82 + public void testSelectListByUserIdAndConversations_batch() {
  83 + // 用户 1 在频道 10 / 20 / 30 的读位置
  84 + mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 10L, 5L));
  85 + mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 20L, 8L));
  86 + mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 30L, 3L));
  87 +
  88 + List<ImConversationReadDO> list = mapper.selectListByUserIdAndConversations(
  89 + 1L, ImConversationTypeEnum.CHANNEL.getType(), List.of(10L, 20L));
  90 +
  91 + assertEquals(2, list.size());
  92 + }
  93 +
  94 + private ImConversationReadDO buildRead(Long userId, Integer conversationType, Long conversationId,
  95 + Long readMessageId) {
  96 + return ImConversationReadDO.builder()
  97 + .userId(userId)
  98 + .conversationType(conversationType)
  99 + .targetId(conversationId)
  100 + .messageId(readMessageId)
  101 + .readTime(LocalDateTime.now())
  102 + .build();
  103 + }
  104 +
  105 +}
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java
1 1 package cn.iocoder.yudao.module.im.dal.mysql.message;
2 2  
3   -import cn.hutool.core.collection.ListUtil;
4 3 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
5 4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
6 5 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
... ... @@ -14,7 +13,7 @@ import java.util.List;
14 13 import static org.junit.jupiter.api.Assertions.*;
15 14  
16 15 /**
17   - * IM 群聊消息 Mapper 单元测试
  16 + * {@link ImGroupMessageMapper} 的单元测试
18 17 *
19 18 * @author 芋道源码
20 19 */
... ... @@ -28,46 +27,76 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
28 27 // ========== selectListByMinId ==========
29 28  
30 29 @Test
  30 + public void testSelectListByMinId_filterByReceiverSnapshot() {
  31 + // 准备:群 10 三条消息,快照分别命中 / 不命中用户 1
  32 + ImGroupMessageDO visible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  33 + mapper.insert(visible);
  34 + ImGroupMessageDO invisible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L));
  35 + mapper.insert(invisible);
  36 + ImGroupMessageDO selfSend = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  37 + mapper.insert(selfSend);
  38 +
  39 + // 调用:用户 1 拉取群 10
  40 + List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
  41 +
  42 + // 断言:仅返回快照包含用户 1 的消息
  43 + assertEquals(2, result.size());
  44 + assertEquals(visible.getId(), result.get(0).getId());
  45 + assertEquals(selfSend.getId(), result.get(1).getId());
  46 + }
  47 +
  48 + @Test
  49 + public void testSelectListByMinId_excludeNullAndEmptySnapshot() {
  50 + // 准备:快照为 null / 空字符串的老数据,不应被任何用户拉到
  51 + ImGroupMessageDO nullSnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, null);
  52 + mapper.insert(nullSnapshot);
  53 + ImGroupMessageDO emptySnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of());
  54 + mapper.insert(emptySnapshot);
  55 +
  56 + List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
  57 +
  58 + assertTrue(result.isEmpty());
  59 + }
  60 +
  61 + @Test
31 62 public void testSelectListByMinId_onlyReturnsGroupsInIdList() {
32   - // 准备:群 10 的消息 + 群 20 的消息
33   - ImGroupMessageDO msg10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  63 + // 准备:群 10 + 群 20 各一条,用户 1 都在快照内
  64 + ImGroupMessageDO msg10 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
34 65 mapper.insert(msg10);
35   - ImGroupMessageDO msg20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD);
  66 + ImGroupMessageDO msg20 = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
36 67 mapper.insert(msg20);
37 68  
38   - // 调用:只查群 10
39   - List<ImGroupMessageDO> result = mapper.selectListByMinId(ListUtil.of(10L), 0L, FAR_PAST, 100);
  69 + // 调用:候选群只含群 10
  70 + List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
40 71  
41   - // 断言:只有 msg10
42 72 assertEquals(1, result.size());
43 73 assertEquals(msg10.getId(), result.get(0).getId());
44 74 }
45 75  
46 76 @Test
47 77 public void testSelectListByMinId_includesRecall() {
48   - // 准备:一条正常 + 一条撤回;客户端拉离线消息需要拿到撤回状态以渲染「此消息已撤回」占位
49   - ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  78 + // 准备:撤回消息也要返回,由客户端按 status 渲染「此消息已撤回」占位
  79 + ImGroupMessageDO normal = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
50 80 mapper.insert(normal);
51   - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL);
  81 + ImGroupMessageDO recalled = buildMessage(10L, 2L, ImMessageStatusEnum.RECALL, List.of(1L));
52 82 mapper.insert(recalled);
53 83  
54   - List<ImGroupMessageDO> result = mapper.selectListByMinId(ListUtil.of(10L), 0L, FAR_PAST, 100);
  84 + List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
55 85  
56   - // 断言:撤回消息一并返回,由客户端按 status 切换渲染
57 86 assertEquals(2, result.size());
58 87 }
59 88  
60 89 @Test
61 90 public void testSelectListByMinId_sendTimeWindow() {
62 91 // 准备:一条在窗口内,一条在窗口外
63   - ImGroupMessageDO inWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  92 + ImGroupMessageDO inWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
64 93 inWindow.setSendTime(LocalDateTime.now().minusDays(1));
65 94 mapper.insert(inWindow);
66   - ImGroupMessageDO outWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  95 + ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
67 96 outWindow.setSendTime(LocalDateTime.now().minusDays(40));
68 97 mapper.insert(outWindow);
69 98  
70   - List<ImGroupMessageDO> result = mapper.selectListByMinId(ListUtil.of(10L), 0L,
  99 + List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L,
71 100 LocalDateTime.now().minusDays(30), 100);
72 101  
73 102 assertEquals(1, result.size());
... ... @@ -76,16 +105,16 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
76 105  
77 106 @Test
78 107 public void testSelectListByMinId_sortAscLimit() {
79   - // 准备:插入 3 条
80   - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  108 + // 准备:插入 3 条可见消息
  109 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
81 110 mapper.insert(m1);
82   - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  111 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
83 112 mapper.insert(m2);
84   - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  113 + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
85 114 mapper.insert(m3);
86 115  
87 116 // 调用:size=2,返回 id 最小的 2 条
88   - List<ImGroupMessageDO> result = mapper.selectListByMinId(ListUtil.of(10L), 0L, FAR_PAST, 2);
  117 + List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 2);
89 118  
90 119 assertEquals(2, result.size());
91 120 assertTrue(result.get(0).getId() < result.get(1).getId());
... ... @@ -93,129 +122,63 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
93 122 assertEquals(m2.getId(), result.get(1).getId());
94 123 }
95 124  
96   - // ========== selectListByGroupIdAndMinIdAndQuitTimeBefore ==========
97   -
98   - @Test
99   - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_onlyReturnsBeforeQuit() {
100   - // 准备:退群时间 = now-1 day,插入一条"退群前",一条"退群后"
101   - LocalDateTime quitTime = LocalDateTime.now().minusDays(1);
102   - ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
103   - before.setSendTime(LocalDateTime.now().minusDays(2));
104   - mapper.insert(before);
105   - ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
106   - after.setSendTime(LocalDateTime.now().minusHours(1));
107   - mapper.insert(after);
108   -
109   - List<ImGroupMessageDO> result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
110   - 10L, 0L, FAR_PAST, quitTime, 100);
111   -
112   - // 断言:只返回退群前消息
113   - assertEquals(1, result.size());
114   - assertEquals(before.getId(), result.get(0).getId());
115   - }
116   -
117 125 @Test
118   - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_boundaryEqualsQuitTime() {
119   - // 准备:消息发送时间恰好等于退群时间(le 语义应包含该条)
120   - LocalDateTime quitTime = LocalDateTime.now().minusDays(1).withNano(0);
121   - ImGroupMessageDO boundary = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
122   - boundary.setSendTime(quitTime);
123   - mapper.insert(boundary);
124   -
125   - List<ImGroupMessageDO> result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
126   - 10L, 0L, FAR_PAST, quitTime, 100);
127   -
128   - assertEquals(1, result.size());
129   - }
  126 + public void testSelectListByMinId_minIdExclusive() {
  127 + // 准备:游标之前的消息不返回
  128 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  129 + mapper.insert(m1);
  130 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  131 + mapper.insert(m2);
130 132  
131   - @Test
132   - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_otherGroupExcluded() {
133   - // 准备:群 10 和群 20 各一条消息
134   - LocalDateTime quitTime = LocalDateTime.now();
135   - ImGroupMessageDO in10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
136   - in10.setSendTime(LocalDateTime.now().minusHours(2));
137   - mapper.insert(in10);
138   - ImGroupMessageDO in20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD);
139   - in20.setSendTime(LocalDateTime.now().minusHours(2));
140   - mapper.insert(in20);
141   -
142   - List<ImGroupMessageDO> result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
143   - 10L, 0L, FAR_PAST, quitTime, 100);
  133 + List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), m1.getId(), FAR_PAST, 100);
144 134  
145 135 assertEquals(1, result.size());
146   - assertEquals(in10.getId(), result.get(0).getId());
  136 + assertEquals(m2.getId(), result.get(0).getId());
147 137 }
148 138  
149   - // ========== selectHistoryList ==========
  139 + // ========== selectHistoryListByUser ==========
150 140  
151 141 @Test
152   - public void testSelectHistoryList_basic() {
153   - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  142 + public void testSelectHistoryListByUser_filterByReceiverSnapshot() {
  143 + // 准备:群 10 三条,用户 1 在前两条快照内、第三条定向给别人
  144 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
154 145 mapper.insert(m1);
155   - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD);
  146 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
156 147 mapper.insert(m2);
  148 + ImGroupMessageDO directedOther = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L));
  149 + mapper.insert(directedOther);
157 150 // 别的群
158   - ImGroupMessageDO other = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD);
  151 + ImGroupMessageDO other = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
159 152 mapper.insert(other);
160 153  
161   - List<ImGroupMessageDO> result = mapper.selectHistoryList(10L, null, 100, null);
162   -
163   - // 断言:只返回 10 群的,按 id 倒序
164   - assertEquals(2, result.size());
165   - assertTrue(result.get(0).getId() > result.get(1).getId());
166   - }
167   -
168   - @Test
169   - public void testSelectHistoryList_includeRecall() {
170   - ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
171   - mapper.insert(normal);
172   - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL);
173   - mapper.insert(recalled);
174   -
175   - List<ImGroupMessageDO> result = mapper.selectHistoryList(10L, null, 100, null);
  154 + List<ImGroupMessageDO> result = mapper.selectHistoryListByUser(1L, 10L, null, 100);
176 155  
  156 + // 断言:只返回群 10 中用户 1 可见的,按 id 倒序
177 157 assertEquals(2, result.size());
178   - assertEquals(recalled.getId(), result.get(0).getId());
179   - assertEquals(normal.getId(), result.get(1).getId());
  158 + assertEquals(m2.getId(), result.get(0).getId());
  159 + assertEquals(m1.getId(), result.get(1).getId());
180 160 }
181 161  
182 162 @Test
183   - public void testSelectHistoryList_maxIdCursor() {
184   - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  163 + public void testSelectHistoryListByUser_maxIdCursor() {
  164 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
185 165 mapper.insert(m1);
186   - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  166 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
187 167 mapper.insert(m2);
188   - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
  168 + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
189 169 mapper.insert(m3);
190 170  
191   - List<ImGroupMessageDO> result = mapper.selectHistoryList(10L, m3.getId(), 100, null);
  171 + List<ImGroupMessageDO> result = mapper.selectHistoryListByUser(1L, 10L, m3.getId(), 100);
192 172  
193   - // 断言:只返回 id < m3.id 的,即 m1、m2
  173 + // 断言:只返回 id < m3.id 的 m1、m2
194 174 assertEquals(2, result.size());
195 175 assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId()));
196 176 }
197 177  
198   - @Test
199   - public void testSelectHistoryList_filterByJoinTime() {
200   - // 准备:一条在入群前,一条在入群后
201   - LocalDateTime joinTime = LocalDateTime.now().minusHours(1);
202   - ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
203   - before.setSendTime(LocalDateTime.now().minusHours(2));
204   - mapper.insert(before);
205   - ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
206   - after.setSendTime(LocalDateTime.now().minusMinutes(30));
207   - mapper.insert(after);
208   -
209   - List<ImGroupMessageDO> result = mapper.selectHistoryList(10L, null, 100, joinTime);
210   -
211   - // 断言:入群前消息不可见
212   - assertEquals(1, result.size());
213   - assertEquals(after.getId(), result.get(0).getId());
214   - }
215   -
216 178 // ========== 工具方法 ==========
217 179  
218   - private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) {
  180 + private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status,
  181 + List<Long> receiverUserIds) {
219 182 return ImGroupMessageDO.builder()
220 183 .clientMessageId("uuid-" + System.nanoTime())
221 184 .senderId(senderId)
... ... @@ -224,6 +187,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
224 187 .content("{\"content\":\"test\"}")
225 188 .status(status.getStatus())
226 189 .sendTime(LocalDateTime.now())
  190 + .receiverUserIds(receiverUserIds)
227 191 .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
228 192 .build();
229 193 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
1 1 package cn.iocoder.yudao.module.im.service.group;
2 2  
3   -import cn.hutool.core.collection.CollUtil;
4   -import cn.hutool.core.collection.ListUtil;
5   -import cn.hutool.core.map.MapUtil;
6 3 import cn.hutool.extra.spring.SpringUtil;
7 4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
8 5 import cn.iocoder.yudao.framework.common.exception.ServiceException;
... ... @@ -111,13 +108,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
111 108 // 准备:创建者 + 2 个初始成员,都是好友
112 109 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO();
113 110 reqVO.setName("测试群");
114   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L)));
  111 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
115 112 when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> {
116 113 ImGroupDO group = invocation.getArgument(0);
117 114 group.setId(100L);
118 115 return 1;
119 116 });
120   - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of(
  117 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
121 118 ImFriendDO.builder().userId(1L).friendUserId(2L)
122 119 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
123 120 ImFriendDO.builder().userId(1L).friendUserId(3L)
... ... @@ -143,15 +140,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
143 140 // 准备:初始成员里有非好友
144 141 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO();
145 142 reqVO.setName("测试群");
146   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L)));
  143 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
147 144 // 只有 2 是好友,3 不是
148   - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of(
  145 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
149 146 ImFriendDO.builder().userId(1L).friendUserId(2L)
150 147 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
151 148 AdminUserRespDTO u3 = new AdminUserRespDTO();
152 149 u3.setId(3L);
153 150 u3.setNickname("李四");
154   - when(adminUserApi.getUserMap(anyCollection())).thenReturn(MapUtil.of(3L, u3));
  151 + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3));
155 152  
156 153 // 调用并断言
157 154 ServiceException exception = assertThrows(ServiceException.class,
... ... @@ -234,7 +231,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
234 231 assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus());
235 232 assertNotNull(captor.getValue().getDissolvedTime());
236 233 verify(groupMemberService).removeGroupMembersByGroupId(10L);
237   - verify(groupMessageService).deleteReadMaxMessageIdMap(10L);
238 234 // 推送 GROUP_DISSOLVE 通知(send-before-remove,sendGroupMessage 内部查 active 自动覆盖全员,含群主多端同步)
239 235 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
240 236 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
... ... @@ -282,7 +278,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
282 278 verify(groupMessageService).sendGroupMessage(eq(99L), dtoCaptor.capture());
283 279 assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType());
284 280 verify(groupMemberService).removeGroupMembersByGroupId(10L);
285   - verify(groupMessageService).deleteReadMaxMessageIdMap(10L);
286 281 }
287 282 }
288 283  
... ... @@ -330,8 +325,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
330 325 // 被邀请人 2 和 3 都是好友
331 326 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
332 327 reqVO.setGroupId(10L);
333   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L)));
334   - List<ImFriendDO> friends = ListUtil.of(
  328 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
  329 + List<ImFriendDO> friends = List.of(
335 330 ImFriendDO.builder().userId(1L).friendUserId(2L)
336 331 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
337 332 ImFriendDO.builder().userId(1L).friendUserId(3L)
... ... @@ -369,7 +364,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
369 364 ImGroupMemberDO.builder().groupId(10L).userId(1L)
370 365 .role(ImGroupMemberRoleEnum.NORMAL.getRole())
371 366 .status(CommonStatusEnum.ENABLE.getStatus()).build());
372   - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of(
  367 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of(
373 368 ImGroupMemberDO.builder().groupId(10L).userId(99L)
374 369 .role(ImGroupMemberRoleEnum.OWNER.getRole())
375 370 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
... ... @@ -379,8 +374,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
379 374  
380 375 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
381 376 reqVO.setGroupId(10L);
382   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L)));
383   - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of(
  377 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
  378 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
384 379 ImFriendDO.builder().userId(1L).friendUserId(2L)
385 380 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
386 381 ImFriendDO.builder().userId(1L).friendUserId(3L)
... ... @@ -411,15 +406,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
411 406 ImGroupMemberDO.builder().groupId(10L).userId(1L)
412 407 .role(ImGroupMemberRoleEnum.OWNER.getRole())
413 408 .status(CommonStatusEnum.ENABLE.getStatus()).build());
414   - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of(
  409 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of(
415 410 ImGroupMemberDO.builder().groupId(10L).userId(1L)
416 411 .role(ImGroupMemberRoleEnum.OWNER.getRole())
417 412 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
418 413  
419 414 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
420 415 reqVO.setGroupId(10L);
421   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L)));
422   - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of(
  416 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
  417 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
423 418 ImFriendDO.builder().userId(1L).friendUserId(2L)
424 419 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
425 420 ImFriendDO.builder().userId(1L).friendUserId(3L)
... ... @@ -448,18 +443,18 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
448 443 .status(CommonStatusEnum.ENABLE.getStatus()).build();
449 444 when(groupMapper.selectById(10L)).thenReturn(group);
450 445 when(groupMemberService.getActiveGroupMemberListByGroupId(10L))
451   - .thenReturn(ListUtil.of(ImGroupMemberDO.builder().groupId(10L).userId(1L)
  446 + .thenReturn(List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L)
452 447 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
453 448  
454 449 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
455 450 reqVO.setGroupId(10L);
456   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L)));
  451 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
457 452 // 只有 2 是好友,3 不是
458   - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of(
  453 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
459 454 ImFriendDO.builder().userId(1L).friendUserId(2L)
460 455 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
461 456 AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四");
462   - when(adminUserApi.getUserMap(anyCollection())).thenReturn(MapUtil.of(3L, u3));
  457 + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3));
463 458  
464 459 ServiceException exception = assertThrows(ServiceException.class,
465 460 () -> groupService.inviteGroupMember(1L, reqVO));
... ... @@ -487,9 +482,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
487 482  
488 483 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
489 484 reqVO.setGroupId(10L);
490   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(600L, 601L)));
  485 + reqVO.setMemberUserIds(new ArrayList<>(List.of(600L, 601L)));
491 486 // 被邀请人都是好友
492   - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of(
  487 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
493 488 ImFriendDO.builder().userId(1L).friendUserId(600L)
494 489 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
495 490 ImFriendDO.builder().userId(1L).friendUserId(601L)
... ... @@ -515,7 +510,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
515 510 .status(CommonStatusEnum.ENABLE.getStatus()).build();
516 511 when(groupMapper.selectById(10L)).thenReturn(group);
517 512 // 用户 2 已在群中
518   - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of(
  513 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of(
519 514 ImGroupMemberDO.builder().groupId(10L).userId(1L)
520 515 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
521 516 ImGroupMemberDO.builder().groupId(10L).userId(2L)
... ... @@ -524,7 +519,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
524 519  
525 520 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
526 521 reqVO.setGroupId(10L);
527   - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L))); // 只邀请 2,他已在群中
  522 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L))); // 只邀请 2,他已在群中
528 523  
529 524 // 调用
530 525 groupService.inviteGroupMember(1L, reqVO);
... ... @@ -566,7 +561,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
566 561 groupService.quitGroup(10L, 1L);
567 562  
568 563 verify(groupMemberService).removeGroupMember(10L, 1L);
569   - verify(groupMessageService).deleteReadMaxMessageId(10L, 1L);
570 564 // 推送 GROUP_MEMBER_QUIT 通知给全员(含 quitter,前端自判清群)
571 565 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
572 566 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
... ... @@ -587,7 +581,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
587 581 groupService.quitGroup(10L, 1L);
588 582  
589 583 verify(groupMemberService).removeGroupMember(10L, 1L);
590   - verify(groupMessageService).deleteReadMaxMessageId(10L, 1L);
591 584 verify(groupMessageService).sendGroupMessage(eq(1L), any(ImGroupMessageSendDTO.class));
592 585 }
593 586 }
... ... @@ -609,7 +602,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
609 602  
610 603 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
611 604 reqVO.setGroupId(10L);
612   - reqVO.setMemberUserIds(ListUtil.of(1L, 2L));
  605 + reqVO.setMemberUserIds(List.of(1L, 2L));
613 606  
614 607 ServiceException exception = assertThrows(ServiceException.class,
615 608 () -> groupService.removeGroupMember(1L, reqVO));
... ... @@ -631,7 +624,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
631 624 ImGroupMemberDO.builder().groupId(10L).userId(1L)
632 625 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build());
633 626 // 目标:两个普通成员
634   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  627 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
635 628 ImGroupMemberDO.builder().groupId(10L).userId(2L)
636 629 .role(ImGroupMemberRoleEnum.NORMAL.getRole())
637 630 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
... ... @@ -641,12 +634,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
641 634  
642 635 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
643 636 reqVO.setGroupId(10L);
644   - reqVO.setMemberUserIds(ListUtil.of(2L, 3L));
  637 + reqVO.setMemberUserIds(List.of(2L, 3L));
645 638  
646 639 groupService.removeGroupMember(1L, reqVO);
647 640  
648 641 verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection());
649   - verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), anyCollection());
650 642 // 推送 GROUP_MEMBER_KICK 通知给全员(含被踢者,前端自判清群)
651 643 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
652 644 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
... ... @@ -668,14 +660,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
668 660 ImGroupMemberDO.builder().groupId(10L).userId(1L)
669 661 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build());
670 662 // 目标:另一个管理员
671   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  663 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
672 664 ImGroupMemberDO.builder().groupId(10L).userId(2L)
673 665 .role(ImGroupMemberRoleEnum.ADMIN.getRole())
674 666 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
675 667  
676 668 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
677 669 reqVO.setGroupId(10L);
678   - reqVO.setMemberUserIds(ListUtil.of(2L));
  670 + reqVO.setMemberUserIds(List.of(2L));
679 671  
680 672 ServiceException exception = assertThrows(ServiceException.class,
681 673 () -> groupService.removeGroupMember(1L, reqVO));
... ... @@ -697,14 +689,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
697 689 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
698 690 ImGroupMemberDO.builder().groupId(10L).userId(1L)
699 691 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build());
700   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  692 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
701 693 ImGroupMemberDO.builder().groupId(10L).userId(99L)
702 694 .role(ImGroupMemberRoleEnum.OWNER.getRole())
703 695 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
704 696  
705 697 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
706 698 reqVO.setGroupId(10L);
707   - reqVO.setMemberUserIds(ListUtil.of(99L));
  699 + reqVO.setMemberUserIds(List.of(99L));
708 700  
709 701 ServiceException exception = assertThrows(ServiceException.class,
710 702 () -> groupService.removeGroupMember(1L, reqVO));
... ... @@ -726,7 +718,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
726 718 ImGroupMemberDO.builder().groupId(10L).userId(1L)
727 719 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build());
728 720 // 目标:2L 有效普通成员;3L 已退群(DISABLE)的历史管理员,应被跳过而非拦截整批
729   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  721 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
730 722 ImGroupMemberDO.builder().groupId(10L).userId(2L)
731 723 .role(ImGroupMemberRoleEnum.NORMAL.getRole())
732 724 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
... ... @@ -736,17 +728,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
736 728  
737 729 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
738 730 reqVO.setGroupId(10L);
739   - reqVO.setMemberUserIds(ListUtil.of(2L, 3L));
  731 + reqVO.setMemberUserIds(List.of(2L, 3L));
740 732  
741 733 groupService.removeGroupMember(1L, reqVO);
742 734  
743   - // 仅有效成员 2L 进入移除 / 已读清理,已退群的 3L 被跳过
  735 + // 仅有效成员 2L 进入移除,已退群的 3L 被跳过
744 736 ArgumentCaptor<Collection> removeCaptor = ArgumentCaptor.forClass(Collection.class);
745 737 verify(groupMemberService).removeGroupMembers(eq(10L), removeCaptor.capture());
746   - assertEquals(CollUtil.newHashSet(2L), CollUtil.newHashSet(removeCaptor.getValue()));
747   - ArgumentCaptor<Collection> readCaptor = ArgumentCaptor.forClass(Collection.class);
748   - verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), readCaptor.capture());
749   - assertEquals(CollUtil.newHashSet(2L), CollUtil.newHashSet(readCaptor.getValue()));
  738 + assertEquals(Set.of(2L), Set.copyOf(removeCaptor.getValue()));
750 739 }
751 740 }
752 741  
... ... @@ -762,7 +751,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
762 751 .status(CommonStatusEnum.ENABLE.getStatus()).build();
763 752 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
764 753 // 目标 3 是普通成员
765   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  754 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
766 755 ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus())
767 756 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
768 757 // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限
... ... @@ -773,7 +762,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
773 762  
774 763 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
775 764 reqVO.setId(10L);
776   - reqVO.setUserIds(ListUtil.of(3L));
  765 + reqVO.setUserIds(List.of(3L));
777 766  
778 767 groupService.addGroupAdmin(1L, reqVO);
779 768  
... ... @@ -792,7 +781,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
792 781 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
793 782 .status(CommonStatusEnum.ENABLE.getStatus()).build();
794 783 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
795   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  784 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
796 785 ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus())
797 786 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
798 787 // 群里已有 3 个 ADMIN(达到上限),再加 1 会超
... ... @@ -801,7 +790,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
801 790  
802 791 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
803 792 reqVO.setId(10L);
804   - reqVO.setUserIds(ListUtil.of(5L));
  793 + reqVO.setUserIds(List.of(5L));
805 794  
806 795 ServiceException exception = assertThrows(ServiceException.class,
807 796 () -> groupService.addGroupAdmin(1L, reqVO));
... ... @@ -819,13 +808,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
819 808 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
820 809 .status(CommonStatusEnum.ENABLE.getStatus()).build();
821 810 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
822   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  811 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
823 812 ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus())
824 813 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()));
825 814  
826 815 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
827 816 reqVO.setId(10L);
828   - reqVO.setUserIds(ListUtil.of(1L));
  817 + reqVO.setUserIds(List.of(1L));
829 818  
830 819 ServiceException exception = assertThrows(ServiceException.class,
831 820 () -> groupService.addGroupAdmin(1L, reqVO));
... ... @@ -843,13 +832,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
843 832 .status(CommonStatusEnum.ENABLE.getStatus()).build();
844 833 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
845 834 // 目标已是 ADMIN:再加无需操作
846   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  835 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
847 836 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
848 837 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()));
849 838  
850 839 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
851 840 reqVO.setId(10L);
852   - reqVO.setUserIds(ListUtil.of(2L));
  841 + reqVO.setUserIds(List.of(2L));
853 842  
854 843 groupService.addGroupAdmin(1L, reqVO);
855 844  
... ... @@ -869,7 +858,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
869 858 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
870 859 .status(CommonStatusEnum.ENABLE.getStatus()).build();
871 860 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
872   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  861 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
873 862 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
874 863 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()));
875 864 when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(),
... ... @@ -877,7 +866,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
877 866  
878 867 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
879 868 reqVO.setId(10L);
880   - reqVO.setUserIds(ListUtil.of(2L));
  869 + reqVO.setUserIds(List.of(2L));
881 870  
882 871 groupService.removeGroupAdmin(1L, reqVO);
883 872  
... ... @@ -897,13 +886,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
897 886 .status(CommonStatusEnum.ENABLE.getStatus()).build();
898 887 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
899 888 // 目标已是 MEMBER:撤销无需操作
900   - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of(
  889 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
901 890 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
902 891 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
903 892  
904 893 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
905 894 reqVO.setId(10L);
906   - reqVO.setUserIds(ListUtil.of(2L));
  895 + reqVO.setUserIds(List.of(2L));
907 896  
908 897 groupService.removeGroupAdmin(1L, reqVO);
909 898  
... ... @@ -925,9 +914,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
925 914 when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn(
926 915 ImGroupMemberDO.builder().groupId(10L).userId(2L)
927 916 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build());
928   - when(groupMemberService.updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(2L)),
  917 + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(2L)),
929 918 eq(ImGroupMemberRoleEnum.OWNER.getRole()))).thenReturn(1);
930   - when(groupMemberService.updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(1L)),
  919 + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(1L)),
931 920 eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1);
932 921  
933 922 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
... ... @@ -941,9 +930,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
941 930 verify(groupMapper).updateById(groupCaptor.capture());
942 931 assertEquals(2L, groupCaptor.getValue().getOwnerUserId());
943 932 // 旧群主 → MEMBER;新群主 → OWNER
944   - verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(1L)),
  933 + verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(Set.of(1L)),
945 934 eq(ImGroupMemberRoleEnum.NORMAL.getRole()));
946   - verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(2L)),
  935 + verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(Set.of(2L)),
947 936 eq(ImGroupMemberRoleEnum.OWNER.getRole()));
948 937 }
949 938 }
... ... @@ -1123,7 +1112,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1123 1112 @Test
1124 1113 public void testGetMyGroupList_success() {
1125 1114 // 活跃群成员
1126   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(ListUtil.of(
  1115 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of(
1127 1116 ImGroupMemberDO.builder().groupId(10L).userId(1L)
1128 1117 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
1129 1118 ImGroupMemberDO.builder().groupId(20L).userId(1L)
... ... @@ -1131,11 +1120,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1131 1120 )));
1132 1121 // 最近退群成员(最近 30 天内)
1133 1122 when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
1134   - .thenReturn(new ArrayList<>(ListUtil.of(
  1123 + .thenReturn(new ArrayList<>(List.of(
1135 1124 ImGroupMemberDO.builder().groupId(30L).userId(1L)
1136 1125 .status(CommonStatusEnum.DISABLE.getStatus()).build()
1137 1126 )));
1138   - List<ImGroupDO> groups = ListUtil.of(
  1127 + List<ImGroupDO> groups = List.of(
1139 1128 ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(),
1140 1129 ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(),
1141 1130 ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build()
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
  5 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper;
  6 +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
  7 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
  8 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  9 +import org.junit.jupiter.api.Test;
  10 +import org.mockito.InjectMocks;
  11 +import org.mockito.Mock;
  12 +
  13 +import java.time.LocalDateTime;
  14 +import java.util.List;
  15 +
  16 +import static org.mockito.ArgumentMatchers.*;
  17 +import static org.mockito.Mockito.*;
  18 +
  19 +/**
  20 + * {@link ImChannelMessageServiceImpl} 的单元测试
  21 + *
  22 + * @author 芋道源码
  23 + */
  24 +public class ImChannelMessageServiceImplTest extends BaseMockitoUnitTest {
  25 +
  26 + @InjectMocks
  27 + private ImChannelMessageServiceImpl channelMessageService;
  28 +
  29 + @Mock
  30 + private ImChannelMessageMapper channelMessageMapper;
  31 + @Mock
  32 + private ImChannelMaterialService channelMaterialService;
  33 + @Mock
  34 + private ImWebSocketService webSocketService;
  35 + @Mock
  36 + private ImConversationReadService conversationReadService;
  37 +
  38 + @Test
  39 + public void testReadChannelMessages_messageNotExists() {
  40 + // 准备:messageId 不存在(伪造 / 未来 id)
  41 + when(channelMessageMapper.selectById(999L)).thenReturn(null);
  42 +
  43 + // 调用
  44 + channelMessageService.readChannelMessages(1L, 10L, 999L);
  45 +
  46 + // 断言:不推进读位置
  47 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
  48 + }
  49 +
  50 + @Test
  51 + public void testReadChannelMessages_wrongChannel() {
  52 + // 准备:消息属于别的频道
  53 + ImChannelMessageDO message = ImChannelMessageDO.builder()
  54 + .id(100L).channelId(20L).sendTime(LocalDateTime.now()).build();
  55 + when(channelMessageMapper.selectById(100L)).thenReturn(message);
  56 +
  57 + // 调用:声称读的是频道 10
  58 + channelMessageService.readChannelMessages(1L, 10L, 100L);
  59 +
  60 + // 断言:不推进读位置
  61 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
  62 + }
  63 +
  64 + @Test
  65 + public void testReadChannelMessages_notVisible() {
  66 + // 准备:定向消息,接收人不含当前用户
  67 + ImChannelMessageDO message = ImChannelMessageDO.builder()
  68 + .id(100L).channelId(10L).receiverUserIds(List.of(2L, 3L)).sendTime(LocalDateTime.now()).build();
  69 + when(channelMessageMapper.selectById(100L)).thenReturn(message);
  70 +
  71 + // 调用
  72 + channelMessageService.readChannelMessages(1L, 10L, 100L);
  73 +
  74 + // 断言:不推进读位置
  75 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
  76 + }
  77 +
  78 + @Test
  79 + public void testReadChannelMessages_notAdvanced() {
  80 + // 准备:消息真实可见,但读位置未前进(updateConversationReadPosition 返回 false)
  81 + ImChannelMessageDO message = ImChannelMessageDO.builder()
  82 + .id(100L).channelId(10L).sendTime(LocalDateTime.now()).build();
  83 + when(channelMessageMapper.selectById(100L)).thenReturn(message);
  84 + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
  85 + .thenReturn(false);
  86 +
  87 + // 调用
  88 + channelMessageService.readChannelMessages(1L, 10L, 100L);
  89 +
  90 + // 断言:读位置未前进 → 不推 READ 事件
  91 + verify(webSocketService, never()).sendChannelMessageAsync(anyLong(), any());
  92 + }
  93 +
  94 +}
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
1 1 package cn.iocoder.yudao.module.im.service.message;
2 2  
3   -import cn.hutool.core.collection.CollUtil;
4   -import cn.hutool.core.collection.ListUtil;
5 3 import cn.hutool.extra.spring.SpringUtil;
6 4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
7 5 import cn.iocoder.yudao.framework.common.exception.ServiceException;
... ... @@ -12,12 +10,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
12 10 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
13 11 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
14 12 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
15   -import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
  13 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
16 14 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
17 15 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
18 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
19 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
20 18 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  19 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
21 20 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
22 21 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
23 22 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
... ... @@ -59,7 +58,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
59 58 @Mock
60 59 private ImSensitiveWordService sensitiveWordService;
61 60 @Mock
62   - private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO;
  61 + private ImConversationReadService conversationReadService;
63 62 @Mock
64 63 private ImWebSocketService imWebSocketService;
65 64  
... ... @@ -98,7 +97,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
98 97 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
99 98  
100 99 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
101   - .thenReturn(ListUtil.of(1L, 2L, 3L));
  100 + .thenReturn(List.of(1L, 2L, 3L));
102 101 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> {
103 102 ImGroupMessageDO msg = invocation.getArgument(0);
104 103 msg.setId(99L);
... ... @@ -159,342 +158,94 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
159 158 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
160 159 }
161 160  
162   - // ========== pull 测试 ==========
163   -
164 161 @Test
165   - public void testPullMessages_joinTimeFilter() {
166   - // 准备:消息时间用"最近 1 天内"避免被 30 天窗口过滤
167   - LocalDateTime now = LocalDateTime.now();
168   - LocalDateTime joinTime = now.minusHours(2);
169   - ImGroupMemberDO member = ImGroupMemberDO.builder()
170   - .groupId(10L).userId(1L)
171   - .status(CommonStatusEnum.ENABLE.getStatus())
172   - .joinTime(joinTime).build();
173   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member));
174   -
175   - // 入群前 1 条 + 入群后 1 条
176   - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
177   - .id(1L).groupId(10L).senderId(2L)
178   - .sendTime(now.minusHours(3)).build();
179   - ImGroupMessageDO afterJoin = ImGroupMessageDO.builder()
180   - .id(2L).groupId(10L).senderId(2L)
181   - .sendTime(now.minusHours(1)).build();
182   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
183   - any(LocalDateTime.class), eq(100)))
184   - .thenReturn(ListUtil.of(beforeJoin, afterJoin));
185   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
186   - .thenReturn(ListUtil.of());
187   -
188   - // 调用
189   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
190   -
191   - // 断言:入群前消息不可见
192   - assertEquals(1, result.size());
193   - assertEquals(2L, result.get(0).getId());
194   - }
195   -
196   - @Test
197   - public void testPullMessages_activeQueryRetriesWhenFirstBatchInvisible() {
198   - // 准备:首批 2 条(入群前 + 定向给别人)全部不可见,第二批命中可见消息
199   - LocalDateTime now = LocalDateTime.now();
200   - LocalDateTime joinTime = now.minusHours(1);
201   - ImGroupMemberDO member = ImGroupMemberDO.builder()
202   - .groupId(10L).userId(1L)
203   - .status(CommonStatusEnum.ENABLE.getStatus())
204   - .joinTime(joinTime).build();
205   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member));
206   -
207   - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
208   - .id(1L).groupId(10L).senderId(2L)
209   - .sendTime(now.minusHours(3)).build();
210   - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
211   - .id(2L).groupId(10L).senderId(2L)
212   - .receiverUserIds(ListUtil.of(2L, 3L))
213   - .sendTime(now.minusHours(2)).build();
214   - ImGroupMessageDO visibleMsg = ImGroupMessageDO.builder()
215   - .id(3L).groupId(10L).senderId(2L)
216   - .sendTime(now.minusMinutes(30)).build();
217   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
218   - any(LocalDateTime.class), eq(2)))
219   - .thenReturn(ListUtil.of(beforeJoin, directedMsg));
220   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(2L),
221   - any(LocalDateTime.class), eq(2)))
222   - .thenReturn(ListUtil.of(visibleMsg));
223   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
224   - .thenReturn(ListUtil.of());
225   -
226   - // 调用
227   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 2);
228   -
229   - // 断言:仅返回第二批的可见消息;两批 selectListByMinId 各被调用一次
230   - assertEquals(1, result.size());
231   - assertEquals(3L, result.get(0).getId());
232   - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
233   - any(LocalDateTime.class), eq(2));
234   - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(2L),
235   - any(LocalDateTime.class), eq(2));
236   - }
237   -
238   - @Test
239   - public void testPullMessages_quitMemberLoadsPreQuitMessages() {
240   - // 准备:当前群(在群)+ 一个最近退群
241   - LocalDateTime now = LocalDateTime.now();
242   - ImGroupMemberDO activeMember = ImGroupMemberDO.builder()
243   - .groupId(10L).userId(1L)
244   - .status(CommonStatusEnum.ENABLE.getStatus())
245   - .joinTime(now.minusDays(10)).build();
246   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(activeMember));
247   -
248   - ImGroupMessageDO activeMsg = ImGroupMessageDO.builder()
249   - .id(5L).groupId(10L).senderId(2L)
250   - .sendTime(now.minusHours(2)).build();
251   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
252   - any(LocalDateTime.class), eq(100)))
253   - .thenReturn(ListUtil.of(activeMsg));
254   -
255   - // 已退群:退群时间在窗口内(minId=0,minQuitTime 直接用 minSendTime)
256   - LocalDateTime quitTime = now.minusDays(3);
257   - ImGroupMemberDO quitMember = ImGroupMemberDO.builder()
258   - .groupId(20L).userId(1L)
259   - .status(CommonStatusEnum.DISABLE.getStatus())
260   - .joinTime(now.minusDays(20))
261   - .quitTime(quitTime).build();
262   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
263   - .thenReturn(ListUtil.of(quitMember));
264   -
265   - ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder()
266   - .id(3L).groupId(20L).senderId(99L)
267   - .sendTime(now.minusDays(5)).build();
268   - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L),
269   - any(LocalDateTime.class), eq(quitTime), eq(100)))
270   - .thenReturn(ListUtil.of(quitGroupMsg));
271   -
272   - // 调用
273   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
274   -
275   - // 断言:两条消息均返回,并按 id 升序
276   - assertEquals(2, result.size());
277   - assertEquals(3L, result.get(0).getId());
278   - assertEquals(5L, result.get(1).getId());
279   - }
280   -
281   - @Test
282   - public void testPullMessages_quitMemberFilteredByBoundarySendTime() {
283   - // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime
284   - LocalDateTime now = LocalDateTime.now();
285   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of());
286   -
287   - LocalDateTime boundarySendTime = now.minusDays(2);
288   - ImGroupMessageDO boundary = ImGroupMessageDO.builder()
289   - .id(8L).groupId(10L).senderId(2L).sendTime(boundarySendTime).build();
290   - when(groupMessageMapper.selectById(8L)).thenReturn(boundary);
291   - when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime))
292   - .thenReturn(ListUtil.of());
293   -
294   - // 调用
295   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 8L, 100);
  162 + public void testSendMessage_quoteDirectedMessageRejected() {
  163 + // 准备:发送人 1 在群 10,当前有效成员(广播受众)= {1,2,3}
  164 + ImGroupMessageSendReqVO reqVO = buildSendReqVO();
  165 + reqVO.setContent("{\"content\":\"引用\",\"quote\":{\"messageId\":500}}");
  166 + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")).thenReturn(null);
  167 + ImGroupDO group = new ImGroupDO();
  168 + group.setId(10L);
  169 + when(groupService.validateGroupExists(10L)).thenReturn(group);
  170 + ImGroupMemberDO member = ImGroupMemberDO.builder().groupId(10L).userId(1L)
  171 + .status(CommonStatusEnum.ENABLE.getStatus()).build();
  172 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
  173 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L));
  174 + // 被引用原消息:定向给 {1,2}(对发送人 1 可见,但不覆盖广播受众 {1,2,3})
  175 + ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L)
  176 + .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L))
  177 + .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"密\"}")
  178 + .sendTime(LocalDateTime.now()).build();
  179 + when(groupMessageMapper.selectById(500L)).thenReturn(original);
296 180  
297   - // 断言:使用边界消息 sendTime 抬升 minQuitTime
298   - assertEquals(0, result.size());
299   - verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime);
  181 + // 调用并断言:引用定向消息被拒绝,避免原文随广播泄漏给全群
  182 + ServiceException exception = assertThrows(ServiceException.class,
  183 + () -> groupMessageService.sendGroupMessage(1L, reqVO));
  184 + assertEquals(MESSAGE_QUOTE_INVALID.getCode(), exception.getCode());
  185 + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class));
300 186 }
301 187  
302 188 @Test
303   - public void testPullMessages_quitQueryKeepsOriginalMinIdWhenActiveQueryRetries() {
304   - // 准备:主查询首批不可见触发重试(游标从 8→101),退群补齐仍使用原始 minId=8
305   - LocalDateTime now = LocalDateTime.now();
306   - LocalDateTime joinTime = now.minusHours(1);
307   - ImGroupMemberDO activeMember = ImGroupMemberDO.builder()
308   - .groupId(10L).userId(1L)
309   - .status(CommonStatusEnum.ENABLE.getStatus())
310   - .joinTime(joinTime).build();
311   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(activeMember));
312   -
313   - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
314   - .id(100L).groupId(10L).senderId(2L)
315   - .sendTime(now.minusHours(3)).build();
316   - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
317   - .id(101L).groupId(10L).senderId(2L)
318   - .receiverUserIds(ListUtil.of(2L, 3L))
319   - .sendTime(now.minusMinutes(50)).build();
320   - ImGroupMessageDO visibleActiveMsg = ImGroupMessageDO.builder()
321   - .id(102L).groupId(10L).senderId(2L)
322   - .sendTime(now.minusMinutes(30)).build();
323   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(8L),
324   - any(LocalDateTime.class), eq(2)))
325   - .thenReturn(ListUtil.of(beforeJoin, directedMsg));
326   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(101L),
327   - any(LocalDateTime.class), eq(2)))
328   - .thenReturn(ListUtil.of(visibleActiveMsg));
329   -
330   - LocalDateTime boundarySendTime = now.minusDays(2);
331   - ImGroupMessageDO boundary = ImGroupMessageDO.builder()
332   - .id(8L).groupId(99L).senderId(2L).sendTime(boundarySendTime).build();
333   - when(groupMessageMapper.selectById(8L)).thenReturn(boundary);
334   -
335   - LocalDateTime quitTime = now.minusHours(12);
336   - ImGroupMemberDO quitMember = ImGroupMemberDO.builder()
337   - .groupId(20L).userId(1L)
338   - .status(CommonStatusEnum.DISABLE.getStatus())
339   - .joinTime(now.minusDays(10))
340   - .quitTime(quitTime).build();
341   - when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime))
342   - .thenReturn(ListUtil.of(quitMember));
343   -
344   - ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder()
345   - .id(50L).groupId(20L).senderId(99L)
346   - .sendTime(now.minusDays(1)).build();
347   - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L),
348   - any(LocalDateTime.class), eq(quitTime), eq(2)))
349   - .thenReturn(ListUtil.of(quitGroupMsg));
350   -
351   - // 调用
352   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 8L, 2);
  189 + public void testSendMessage_quoteBroadcastAccepted() {
  190 + // 准备:广播受众 {1,2,3},被引用原消息快照覆盖全部受众 → 允许引用
  191 + ImGroupMessageSendReqVO reqVO = buildSendReqVO();
  192 + reqVO.setContent("{\"content\":\"引用\",\"quote\":{\"messageId\":500}}");
  193 + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")).thenReturn(null);
  194 + ImGroupDO group = new ImGroupDO();
  195 + group.setId(10L);
  196 + when(groupService.validateGroupExists(10L)).thenReturn(group);
  197 + ImGroupMemberDO member = ImGroupMemberDO.builder().groupId(10L).userId(1L)
  198 + .status(CommonStatusEnum.ENABLE.getStatus()).build();
  199 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
  200 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L));
  201 + ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L)
  202 + .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L, 3L))
  203 + .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"公开\"}")
  204 + .sendTime(LocalDateTime.now()).build();
  205 + when(groupMessageMapper.selectById(500L)).thenReturn(original);
  206 + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> {
  207 + invocation.<ImGroupMessageDO>getArgument(0).setId(99L);
  208 + return 1;
  209 + });
353 210  
354   - // 断言:退群补齐使用原始 minId=8 而非主查询重试后的 101
355   - assertEquals(2, result.size());
356   - assertEquals(50L, result.get(0).getId());
357   - assertEquals(102L, result.get(1).getId());
358   - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(8L),
359   - any(LocalDateTime.class), eq(2));
360   - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(101L),
361   - any(LocalDateTime.class), eq(2));
362   - verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime);
363   - verify(groupMessageMapper).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L),
364   - any(LocalDateTime.class), eq(quitTime), eq(2));
365   - verify(groupMessageMapper, never()).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(101L),
366   - any(LocalDateTime.class), eq(quitTime), eq(2));
  211 + // 调用并断言:正常入库
  212 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO);
  213 + assertEquals(99L, result.getId());
  214 + verify(groupMessageMapper).insert(any(ImGroupMessageDO.class));
367 215 }
368 216  
369   - @Test
370   - public void testPullMessages_receiverUserIdsFilter() {
371   - // 准备
372   - LocalDateTime now = LocalDateTime.now();
373   - ImGroupMemberDO member = ImGroupMemberDO.builder()
374   - .groupId(10L).userId(1L)
375   - .status(CommonStatusEnum.ENABLE.getStatus())
376   - .joinTime(now.minusDays(10)).build();
377   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member));
378   -
379   - // 定向接收消息:只给用户 2 和 3
380   - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
381   - .id(1L).groupId(10L).senderId(5L)
382   - .receiverUserIds(ListUtil.of(2L, 3L))
383   - .sendTime(now.minusHours(2)).build();
384   - // 全员消息
385   - ImGroupMessageDO allMsg = ImGroupMessageDO.builder()
386   - .id(2L).groupId(10L).senderId(5L)
387   - .sendTime(now.minusHours(1)).build();
388   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
389   - any(LocalDateTime.class), eq(100)))
390   - .thenReturn(ListUtil.of(directedMsg, allMsg));
391   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
392   - .thenReturn(ListUtil.of());
393   -
394   - // 调用
395   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
396   -
397   - // 断言:定向接收的消息用户 1 看不到,只能看到全员消息
398   - assertEquals(1, result.size());
399   - assertEquals(2L, result.get(0).getId());
400   - }
  217 + // ========== pull 测试 ==========
401 218  
402 219 @Test
403   - public void testPullMessages_atFieldCorrectReturn() {
404   - // 准备
  220 + public void testPullMessages_pullSetsReadFromCursor() {
  221 + // 准备:当前在群 10,无退群;拉到两条消息,已读位置为 7
405 222 LocalDateTime now = LocalDateTime.now();
406 223 ImGroupMemberDO member = ImGroupMemberDO.builder()
407 224 .groupId(10L).userId(1L)
408 225 .status(CommonStatusEnum.ENABLE.getStatus())
409 226 .joinTime(now.minusDays(10)).build();
410   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member));
411   -
412   - ImGroupMessageDO atMsg = ImGroupMessageDO.builder()
413   - .id(1L).groupId(10L).senderId(2L)
414   - .atUserIds(ListUtil.of(1L, 3L))
415   - .sendTime(now.minusHours(1)).build();
416   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
417   - any(LocalDateTime.class), eq(100)))
418   - .thenReturn(ListUtil.of(atMsg));
  227 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
419 228 when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
420   - .thenReturn(ListUtil.of());
421   -
422   - // 调用
423   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
424   -
425   - // 断言:@ 字段正确返回
426   - assertEquals(1, result.size());
427   - assertEquals(ListUtil.of(1L, 3L), result.get(0).getAtUserIds());
428   - }
429   -
430   - @Test
431   - public void testPullMessages_pageTrimmedToSize() {
432   - LocalDateTime now = LocalDateTime.now();
433   - ImGroupMemberDO activeMember = ImGroupMemberDO.builder()
434   - .groupId(10L).userId(1L)
435   - .status(CommonStatusEnum.ENABLE.getStatus())
436   - .joinTime(now.minusDays(10)).build();
437   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(activeMember));
438   -
439   - ImGroupMessageDO activeMsg1 = ImGroupMessageDO.builder()
440   - .id(1000L).groupId(10L).senderId(2L).sendTime(now.minusHours(2)).build();
441   - ImGroupMessageDO activeMsg2 = ImGroupMessageDO.builder()
442   - .id(2000L).groupId(10L).senderId(2L).sendTime(now.minusHours(1)).build();
443   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
444   - any(LocalDateTime.class), eq(2)))
445   - .thenReturn(ListUtil.of(activeMsg1, activeMsg2));
446   -
447   - LocalDateTime quitTime = now.minusDays(1);
448   - ImGroupMemberDO quitMember = ImGroupMemberDO.builder()
449   - .groupId(20L).userId(1L)
450   - .status(CommonStatusEnum.DISABLE.getStatus())
451   - .joinTime(now.minusDays(20))
452   - .quitTime(quitTime).build();
453   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
454   - .thenReturn(ListUtil.of(quitMember));
455   -
456   - ImGroupMessageDO quit1 = ImGroupMessageDO.builder()
457   - .id(150L).groupId(20L).senderId(99L).sendTime(now.minusDays(5)).build();
458   - ImGroupMessageDO quit2 = ImGroupMessageDO.builder()
459   - .id(160L).groupId(20L).senderId(99L).sendTime(now.minusDays(4)).build();
460   - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L),
461   - any(LocalDateTime.class), eq(quitTime), eq(2)))
462   - .thenReturn(ListUtil.of(quit1, quit2));
463   -
464   - when(groupMessageReadRedisDAO.getReadMaxMessageId(anyLong(), eq(1L))).thenReturn(null);
465   -
466   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 2);
467   -
468   - assertEquals(2, result.size());
469   - assertEquals(150L, result.get(0).getId());
470   - assertEquals(160L, result.get(1).getId());
471   - }
472   -
473   - @Test
474   - public void testPullMessages_pullSetsReadFromRedisCursor() {
475   - LocalDateTime now = LocalDateTime.now();
476   - ImGroupMemberDO member = ImGroupMemberDO.builder()
477   - .groupId(10L).userId(1L)
478   - .status(CommonStatusEnum.ENABLE.getStatus())
479   - .joinTime(now.minusDays(10)).build();
480   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member));
  229 + .thenReturn(List.of());
481 230  
482 231 ImGroupMessageDO low = ImGroupMessageDO.builder()
483   - .id(5L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus())
  232 + .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
  233 + .status(ImMessageStatusEnum.UNREAD.getStatus())
484 234 .sendTime(now.minusHours(2)).build();
485 235 ImGroupMessageDO high = ImGroupMessageDO.builder()
486   - .id(10L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus())
  236 + .id(10L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
  237 + .status(ImMessageStatusEnum.UNREAD.getStatus())
487 238 .sendTime(now.minusHours(1)).build();
488   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
  239 + when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L),
489 240 any(LocalDateTime.class), eq(100)))
490   - .thenReturn(ListUtil.of(low, high));
491   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
492   - .thenReturn(ListUtil.of());
  241 + .thenReturn(List.of(low, high));
493 242  
494   - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(7L);
  243 + when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
  244 + .thenReturn(7L);
495 245  
496 246 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
497 247  
  248 + // 断言:已读位置 7 → id<=7 为 READ,id>7 为 UNREAD
498 249 assertEquals(2, result.size());
499 250 assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus());
500 251 assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus());
... ... @@ -502,32 +253,36 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
502 253  
503 254 @Test
504 255 public void testPullMessages_receiptReadCountForSender() {
  256 + // 准备:本人发送的回执消息,可见成员 {1,2,3},分母排除发送者后为 {2,3}
505 257 LocalDateTime now = LocalDateTime.now();
506 258 ImGroupMemberDO member = ImGroupMemberDO.builder()
507 259 .groupId(10L).userId(1L)
508 260 .status(CommonStatusEnum.ENABLE.getStatus())
509 261 .joinTime(now.minusDays(10)).build();
510   - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member));
  262 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
  263 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  264 + .thenReturn(List.of());
511 265  
512 266 ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder()
513 267 .id(100L).groupId(10L).senderId(1L)
  268 + .receiverUserIds(List.of(1L, 2L, 3L))
514 269 .status(ImMessageStatusEnum.UNREAD.getStatus())
515 270 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
516 271 .sendTime(now.minusHours(1)).build();
517   - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L),
  272 + when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L),
518 273 any(LocalDateTime.class), eq(100)))
519   - .thenReturn(ListUtil.of(receiptMsg));
520   - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
521   - .thenReturn(ListUtil.of());
  274 + .thenReturn(List.of(receiptMsg));
522 275  
523   - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(100L);
  276 + when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
  277 + .thenReturn(100L);
524 278 Map<Long, Long> positions = new HashMap<>();
525 279 positions.put(1L, 100L);
526 280 positions.put(2L, 100L);
527 281 positions.put(3L, 50L);
528   - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions);
  282 + when(conversationReadService.getUserReadMessageIdMap(eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
  283 + .thenReturn(positions);
529 284  
530   - List<ImGroupMemberDO> allMembers = ListUtil.of(
  285 + List<ImGroupMemberDO> allMembers = List.of(
531 286 member,
532 287 ImGroupMemberDO.builder().groupId(10L).userId(2L)
533 288 .status(CommonStatusEnum.ENABLE.getStatus())
... ... @@ -536,10 +291,11 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
536 291 .status(CommonStatusEnum.ENABLE.getStatus())
537 292 .joinTime(now.minusDays(20)).build()
538 293 );
539   - when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers);
  294 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers);
540 295  
541 296 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
542 297  
  298 + // 断言:分母 {2,3},仅用户 2 读到 >=100 → readCount==1
543 299 assertEquals(1, result.size());
544 300 assertEquals(1, result.get(0).getReadCount());
545 301 }
... ... @@ -566,7 +322,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
566 322 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1);
567 323  
568 324 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
569   - .thenReturn(ListUtil.of(1L, 2L));
  325 + .thenReturn(List.of(1L, 2L));
570 326  
571 327 // 调用
572 328 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L);
... ... @@ -660,18 +416,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
660 416 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();
661 417 when(groupMemberService.validateMemberInGroup(10L, 5L)).thenReturn(currentMember);
662 418  
663   - // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00
  419 + // 准备:消息由用户 5 发,快照可见范围为 {1,2,5}(用户 3 不在快照内 → 不可见)
664 420 ImGroupMessageDO message = ImGroupMessageDO.builder()
665 421 .id(80L).groupId(10L).senderId(5L)
  422 + .receiverUserIds(List.of(1L, 2L, 5L))
666 423 .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();
667 424 when(groupMessageMapper.selectById(80L)).thenReturn(message);
668 425  
669 426 // 准备:群成员一览
670   - // 用户 1: 正常,入群在消息之前
671   - // 用户 2: 正常,入群在消息之前
672   - // 用户 3: 正常,但入群在消息之后 → 不可见
  427 + // 用户 1: 在快照内
  428 + // 用户 2: 在快照内
  429 + // 用户 3: 不在快照内 → 不可见
673 430 // 用户 5: 发送者,不计入回执
674   - List<ImGroupMemberDO> allMembers = ListUtil.of(
  431 + List<ImGroupMemberDO> allMembers = List.of(
675 432 currentMember,
676 433 ImGroupMemberDO.builder().groupId(10L).userId(1L)
677 434 .status(CommonStatusEnum.ENABLE.getStatus())
... ... @@ -681,19 +438,20 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
681 438 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(),
682 439 ImGroupMemberDO.builder().groupId(10L).userId(3L)
683 440 .status(CommonStatusEnum.ENABLE.getStatus())
684   - .joinTime(LocalDateTime.of(2026, 4, 13, 0, 0, 0)).build(), // 消息之后才入群
  441 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(),
685 442 ImGroupMemberDO.builder().groupId(10L).userId(5L)
686 443 .status(CommonStatusEnum.ENABLE.getStatus())
687 444 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者
688 445 );
689   - when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers);
  446 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers);
690 447  
691   - // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200
  448 + // 准备:已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200
692 449 Map<Long, Long> positions = new HashMap<>();
693 450 positions.put(1L, 100L);
694 451 positions.put(2L, 50L);
695 452 positions.put(3L, 200L);
696   - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions);
  453 + when(conversationReadService.getUserReadMessageIdMap(eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
  454 + .thenReturn(positions);
697 455  
698 456 // 调用:查询 messageId=80 的已读用户
699 457 List<Long> readUsers = groupMessageService.getGroupReadUserIds(5L, 10L, 80L);
... ... @@ -701,7 +459,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
701 459 // 断言:
702 460 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓
703 461 // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗
704   - // 用户 3: 入群在消息之后 → 不可见 → 不算
  462 + // 用户 3: 不在快照内 → 不可见 → 不算
705 463 // 用户 5: 发送者 → 排除
706 464 assertEquals(1, readUsers.size());
707 465 assertTrue(readUsers.contains(1L));
... ... @@ -741,7 +499,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
741 499 ImGroupMemberDO member = ImGroupMemberDO.builder()
742 500 .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build();
743 501 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
744   - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L));
  502 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L));
745 503 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1);
746 504  
747 505 // 调用
... ... @@ -814,22 +572,26 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
814 572 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class)))
815 573 .thenReturn(groupMessageService);
816 574  
817   - // 准备:用户在群;Redis 游标从 5 前进到 100
  575 + // 准备:用户在群;已读位置从 5 前进到 100
818 576 ImGroupMemberDO member = ImGroupMemberDO.builder()
819 577 .groupId(10L).userId(1L)
820 578 .status(CommonStatusEnum.ENABLE.getStatus()).build();
821 579 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
822 580 when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder()
823   - .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build());
824   - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L);
  581 + .id(100L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
  582 + .sendTime(LocalDateTime.now()).build());
  583 + when(conversationReadService.getConversationReadMessageId(1L, ImConversationTypeEnum.GROUP.getType(), 10L))
  584 + .thenReturn(5L);
  585 + when(conversationReadService.updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L))
  586 + .thenReturn(true);
825 587 // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程
826   - when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(ListUtil.of());
  588 + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of());
827 589  
828 590 // 调用
829 591 groupMessageService.readGroupMessages(1L, 10L, 100L);
830 592  
831   - // 断言:Redis 游标更新 + READ 事件
832   - verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L);
  593 + // 断言:已读位置更新 + READ 事件
  594 + verify(conversationReadService).updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L);
833 595 verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class));
834 596 }
835 597 }
... ... @@ -843,8 +605,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
843 605 ServiceException exception = assertThrows(ServiceException.class,
844 606 () -> groupMessageService.readGroupMessages(1L, 10L, 100L));
845 607 assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode());
846   - // 断言:Redis 不写、不推送
847   - verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong());
  608 + // 断言:已读位置不写、不推送
  609 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
848 610 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class));
849 611 }
850 612  
... ... @@ -877,7 +639,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
877 639 ImGroupMemberDO member = ImGroupMemberDO.builder()
878 640 .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build();
879 641 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
880   - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L, 2L));
  642 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L));
881 643  
882 644 ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO);
883 645  
... ... @@ -894,14 +656,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
894 656 .status(CommonStatusEnum.ENABLE.getStatus()).build();
895 657 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
896 658 when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder()
897   - .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build());
898   - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L);
  659 + .id(100L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
  660 + .sendTime(LocalDateTime.now()).build());
  661 + when(conversationReadService.getConversationReadMessageId(1L, ImConversationTypeEnum.GROUP.getType(), 10L))
  662 + .thenReturn(200L);
  663 + // CAS 命中旧位置 200 > 100,单调更新不前进,返回 false
  664 + when(conversationReadService.updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L))
  665 + .thenReturn(false);
899 666  
900 667 // 调用
901 668 groupMessageService.readGroupMessages(1L, 10L, 100L);
902 669  
903   - // 断言:不更新、不推送
904   - verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong());
  670 + // 断言:调用了 CAS 更新但读位置未前进 → 不推送
  671 + verify(conversationReadService).updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L);
905 672 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class));
906 673 }
907 674  
... ... @@ -919,7 +686,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
919 686 ServiceException exception = assertThrows(ServiceException.class,
920 687 () -> groupMessageService.readGroupMessages(1L, 10L, 100L));
921 688 assertEquals(MESSAGE_NOT_IN_GROUP.getCode(), exception.getCode());
922   - verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong());
  689 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
923 690 }
924 691  
925 692 // ========== 回执:DONE 状态迁移 ==========
... ... @@ -927,15 +694,16 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
927 694 @Test
928 695 public void testReadGroupMessageEvent_receiptDoneTransition() {
929 696 // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2}
930   - // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE
  697 + // 已读位置 1→100、2→100 → 全部已读 → 迁移为 DONE
931 698 ImGroupMessageDO pending = ImGroupMessageDO.builder()
932 699 .id(100L).groupId(10L).senderId(5L)
  700 + .receiverUserIds(List.of(5L, 1L, 2L))
933 701 .sendTime(LocalDateTime.now().minusMinutes(1))
934 702 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
935 703 .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
936 704 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L))
937   - .thenReturn(ListUtil.of(pending));
938   - List<ImGroupMemberDO> activeMembers = ListUtil.of(
  705 + .thenReturn(List.of(pending));
  706 + List<ImGroupMemberDO> activeMembers = List.of(
939 707 ImGroupMemberDO.builder().groupId(10L).userId(5L)
940 708 .status(CommonStatusEnum.ENABLE.getStatus())
941 709 .joinTime(LocalDateTime.now().minusDays(10)).build(),
... ... @@ -950,7 +718,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
950 718 Map<Long, Long> positions = new HashMap<>();
951 719 positions.put(1L, 100L);
952 720 positions.put(2L, 100L);
953   - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions);
  721 + when(conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), 10L))
  722 + .thenReturn(positions);
954 723  
955 724 // 调用
956 725 groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L);
... ... @@ -969,12 +738,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
969 738 // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB
970 739 ImGroupMessageDO pending = ImGroupMessageDO.builder()
971 740 .id(100L).groupId(10L).senderId(5L)
  741 + .receiverUserIds(List.of(5L, 1L, 2L))
972 742 .sendTime(LocalDateTime.now().minusMinutes(1))
973 743 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
974 744 .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
975 745 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L))
976   - .thenReturn(ListUtil.of(pending));
977   - List<ImGroupMemberDO> activeMembers = ListUtil.of(
  746 + .thenReturn(List.of(pending));
  747 + List<ImGroupMemberDO> activeMembers = List.of(
978 748 ImGroupMemberDO.builder().groupId(10L).userId(5L)
979 749 .status(CommonStatusEnum.ENABLE.getStatus())
980 750 .joinTime(LocalDateTime.now().minusDays(10)).build(),
... ... @@ -989,7 +759,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
989 759 Map<Long, Long> positions = new HashMap<>();
990 760 positions.put(1L, 100L);
991 761 positions.put(2L, 50L); // 还没读到 100
992   - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions);
  762 + when(conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), 10L))
  763 + .thenReturn(positions);
993 764  
994 765 groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L);
995 766  
... ... @@ -1008,7 +779,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1008 779 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1009 780 .setContent("{\"messageId\":1}");
1010 781 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
1011   - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L));
  782 + .thenReturn(List.of(1L, 2L, 3L, 4L));
1012 783  
1013 784 groupMessageService.sendGroupMessage(1L, dto);
1014 785  
... ... @@ -1025,9 +796,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1025 796 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1026 797 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1027 798 .setContent("{\"messageId\":1}")
1028   - .setReceiverUserIds(ListUtil.of(2L, 3L));
  799 + .setReceiverUserIds(List.of(2L, 3L));
1029 800 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
1030   - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L));
  801 + .thenReturn(List.of(1L, 2L, 3L, 4L));
1031 802  
1032 803 groupMessageService.sendGroupMessage(1L, dto);
1033 804  
... ... @@ -1038,20 +809,21 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1038 809 }
1039 810  
1040 811 @Test
1041   - public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() {
1042   - // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御)
1043   - // 预期:仅定向用户可见
  812 + public void testSendGroupMessage_withReceiverUserIds_senderAlwaysIncluded() {
  813 + // 边界:发送者不在群成员缓存里(理论上应先被 validateMemberInGroup 挡住,这里纯防御)
  814 + // 预期:定向用户 + 发送者自己(始终纳入快照保证多端同步);非定向成员 4 被过滤
1044 815 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1045 816 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1046 817 .setContent("{\"messageId\":1}")
1047   - .setReceiverUserIds(ListUtil.of(2L, 3L));
  818 + .setReceiverUserIds(List.of(2L, 3L));
1048 819 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
1049   - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L));
  820 + .thenReturn(List.of(1L, 2L, 3L, 4L));
1050 821  
1051 822 groupMessageService.sendGroupMessage(99L, dto);
1052 823  
1053 824 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
1054   - ids.size() == 2 && ids.contains(2L) && ids.contains(3L)),
  825 + ids.size() == 3 && ids.contains(2L) && ids.contains(3L)
  826 + && ids.contains(99L) && !ids.contains(4L)),
1055 827 any(ImGroupMessageDTO.class));
1056 828 }
1057 829  
... ... @@ -1063,7 +835,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1063 835 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1064 836 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1065 837 .setContent(new RecallMessage().setMessageId(50L));
1066   - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L));
  838 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L));
1067 839  
1068 840 groupMessageService.sendGroupMessage(1L, dto);
1069 841  
... ... @@ -1087,7 +859,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1087 859 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1088 860 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1089 861 .setContent("{\"messageId\":50}").setReceipt(true);
1090   - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L));
  862 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L));
1091 863  
1092 864 groupMessageService.sendGroupMessage(1L, dto);
1093 865  
... ... @@ -1101,7 +873,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1101 873 // 准备:persistent=false 类型(RECEIPT 回执)→ 不入库,仅推送
1102 874 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1103 875 .setGroupId(10L).setType(ImMessageTypeEnum.RECEIPT.getType());
1104   - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L, 2L));
  876 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L));
1105 877  
1106 878 groupMessageService.sendGroupMessage(1L, dto);
1107 879  
... ... @@ -1112,7 +884,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1112 884 @Test
1113 885 public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() {
1114 886 // 准备:调用方传入显式 targets(解散场景成员已被批量 DISABLE,必须按移除前快照推送)
1115   - Set<Long> targets = CollUtil.newHashSet(1L, 2L, 3L);
  887 + Set<Long> targets = Set.of(1L, 2L, 3L);
1116 888 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1117 889 .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent("{}");
1118 890  
... ... @@ -1126,73 +898,48 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1126 898 // ========== getGroupMessageList ==========
1127 899  
1128 900 @Test
1129   - public void testGetGroupMessageList_delegatesToMapperWithJoinTime() {
1130   - // 准备
1131   - LocalDateTime joinTime = LocalDateTime.now().minusDays(5);
  901 + public void testGetGroupMessageList_delegatesToMapper() {
  902 + // 准备:可见性由 SQL 按 receiver_user_ids 快照过滤,Service 直接透传 mapper 结果
1132 903 ImGroupMemberDO member = ImGroupMemberDO.builder()
1133 904 .id(50L).groupId(10L).userId(1L)
1134 905 .status(CommonStatusEnum.ENABLE.getStatus())
1135   - .joinTime(joinTime).build();
1136   - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
  906 + .joinTime(LocalDateTime.now().minusDays(5)).build();
  907 + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member);
1137 908  
1138 909 ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO();
1139 910 reqVO.setGroupId(10L);
1140 911 reqVO.setMaxId(100L);
1141 912 reqVO.setLimit(20);
1142 913  
1143   - List<ImGroupMessageDO> mockList = ListUtil.of(
  914 + List<ImGroupMessageDO> mockList = List.of(
1144 915 ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L)
1145   - .sendTime(joinTime.plusMinutes(1)).build()
  916 + .receiverUserIds(List.of(1L)).sendTime(LocalDateTime.now()).build()
1146 917 );
1147   - when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList);
  918 + when(groupMessageMapper.selectHistoryListByUser(1L, 10L, 100L, 20)).thenReturn(mockList);
1148 919  
1149 920 // 调用
1150 921 List<ImGroupMessageDO> result = groupMessageService.getGroupMessageList(1L, reqVO);
1151 922  
1152   - // 断言:使用成员的 joinTime 作为历史消息的下限
  923 + // 断言:透传到 mapper,参数一致
1153 924 assertEquals(1, result.size());
1154   - verify(groupMessageMapper).selectHistoryList(10L, 100L, 20, joinTime);
  925 + assertEquals(99L, result.get(0).getId());
  926 + verify(groupMessageMapper).selectHistoryListByUser(1L, 10L, 100L, 20);
1155 927 }
1156 928  
1157 929 @Test
1158 930 public void testGetGroupMessageList_notInGroup() {
1159   - // 准备:用户不在群中
  931 + // 准备:用户从未在群中(getGroupMember 返回 null)
1160 932 ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO();
1161 933 reqVO.setGroupId(10L);
1162 934 reqVO.setLimit(20);
1163   - when(groupMemberService.validateMemberInGroup(10L, 1L))
1164   - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(),
1165   - GROUP_MEMBER_NOT_IN_GROUP.getMsg()));
  935 + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(null);
1166 936  
1167 937 // 调用并断言:鉴权失败
1168 938 ServiceException exception = assertThrows(ServiceException.class,
1169 939 () -> groupMessageService.getGroupMessageList(1L, reqVO));
1170 940 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
1171 941 // 断言:不查询 DB
1172   - verify(groupMessageMapper, never()).selectHistoryList(anyLong(), anyLong(), anyInt(), any());
1173   - }
1174   -
1175   - // ========== delete read cursor 委托 ==========
1176   -
1177   - @Test
1178   - public void testDeleteReadMaxMessageId_delegatesToRedis() {
1179   - groupMessageService.deleteReadMaxMessageId(10L, 1L);
1180   -
1181   - verify(groupMessageReadRedisDAO).deleteReadMaxMessageId(10L, 1L);
1182   - }
1183   -
1184   - @Test
1185   - public void testDeleteReadMaxMessageIds_delegatesToRedis() {
1186   - groupMessageService.deleteReadMaxMessageIds(10L, ListUtil.of(1L, 2L));
1187   -
1188   - verify(groupMessageReadRedisDAO).deleteReadMaxMessageIds(10L, ListUtil.of(1L, 2L));
1189   - }
1190   -
1191   - @Test
1192   - public void testDeleteReadMaxMessageIdMap_delegatesToRedis() {
1193   - groupMessageService.deleteReadMaxMessageIdMap(10L);
1194   -
1195   - verify(groupMessageReadRedisDAO).deleteReadMaxMessageIdMap(10L);
  942 + verify(groupMessageMapper, never()).selectHistoryListByUser(anyLong(), anyLong(), anyLong(), anyInt());
1196 943 }
1197 944  
1198 945 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
... ... @@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
10 10 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
11 11 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
12 12 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  13 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
13 14 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
14 15 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
15 16 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
... ... @@ -47,6 +48,8 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
47 48 @Mock
48 49 private ImSensitiveWordService sensitiveWordService;
49 50 @Mock
  51 + private ImConversationReadService conversationReadService;
  52 + @Mock
50 53 private ImWebSocketService imWebSocketService;
51 54  
52 55 /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true);个别用例可改字段测分支 */
... ... @@ -167,6 +170,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
167 170 eq(2L), eq(1L), eq(5L),
168 171 eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)))
169 172 .thenReturn(2);
  173 + // 读位置前进 → 才下发事件
  174 + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
  175 + .thenReturn(true);
170 176  
171 177 // 调用
172 178 privateMessageService.readPrivateMessages(1L, 2L, 5L);
... ...
yudao-module-im/src/test/resources/sql/clean.sql
1 1 DELETE FROM "im_private_message";
2 2 DELETE FROM "im_group_message";
  3 +DELETE FROM "im_conversation_read";
3 4 DELETE FROM "im_group";
4 5 DELETE FROM "im_group_member";
5 6 DELETE FROM "im_friend";
... ...
yudao-module-im/src/test/resources/sql/create_tables.sql
... ... @@ -292,6 +292,23 @@ CREATE TABLE IF NOT EXISTS &quot;im_channel_message&quot; (
292 292 PRIMARY KEY ("id")
293 293 ) COMMENT 'IM 频道消息表';
294 294  
  295 +CREATE TABLE IF NOT EXISTS "im_conversation_read" (
  296 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
  297 + "user_id" bigint NOT NULL COMMENT '用户编号',
  298 + "conversation_type" tinyint NOT NULL COMMENT '会话类型',
  299 + "target_id" bigint NOT NULL COMMENT '目标编号',
  300 + "message_id" bigint NOT NULL COMMENT '最大已读消息编号',
  301 + "read_time" timestamp NOT NULL COMMENT '最近已读时间',
  302 + "creator" varchar(64) DEFAULT '',
  303 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  304 + "updater" varchar(64) DEFAULT '',
  305 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  306 + "deleted" bit NOT NULL DEFAULT FALSE,
  307 + "tenant_id" bigint NOT NULL DEFAULT 0,
  308 + PRIMARY KEY ("id"),
  309 + CONSTRAINT "uk_im_conversation_read_user_target" UNIQUE ("user_id", "conversation_type", "target_id", "tenant_id")
  310 +) COMMENT 'IM 会话读位置表';
  311 +
295 312 CREATE TABLE IF NOT EXISTS "im_sensitive_word" (
296 313 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
297 314 "word" varchar(128) NOT NULL COMMENT '敏感词',
... ...