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,6 +4,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; 6 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
  7 +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
7 import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO; 8 import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO;
8 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; 9 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
9 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; 10 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
@@ -11,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; @@ -11,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
11 import org.apache.ibatis.annotations.Mapper; 12 import org.apache.ibatis.annotations.Mapper;
12 13
13 import java.time.LocalDateTime; 14 import java.time.LocalDateTime;
  15 +import java.util.Collection;
14 import java.util.List; 16 import java.util.List;
15 17
16 /** 18 /**
@@ -22,48 +24,22 @@ import java.util.List; @@ -22,48 +24,22 @@ import java.util.List;
22 public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> { 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 * @param minId 最小消息 id(不含) 31 * @param minId 最小消息 id(不含)
29 * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 32 * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口
30 * @param size 拉取数量 33 * @param size 拉取数量
31 * @return 消息列表(按 id 升序) 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 LocalDateTime minSendTime, Integer size) { 37 LocalDateTime minSendTime, Integer size) {
35 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>(); 38 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
36 wrapper.in("group_id", groupIds) 39 wrapper.in("group_id", groupIds)
37 .gt("id", minId) 40 .gt("id", minId)
38 .gt("send_time", minSendTime) 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 .orderByAsc("id"); 43 .orderByAsc("id");
68 wrapper.limitN(size); 44 wrapper.limitN(size);
69 return selectList(wrapper); 45 return selectList(wrapper);
@@ -72,17 +48,17 @@ public interface ImGroupMessageMapper extends BaseMapperX&lt;ImGroupMessageDO&gt; { @@ -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 * @return 消息列表(按 id 倒序) 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 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>(); 58 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
83 wrapper.eq("group_id", groupId) 59 wrapper.eq("group_id", groupId)
84 .lt(maxId != null, "id", maxId) 60 .lt(maxId != null, "id", maxId)
85 - .ge(joinTime != null, "send_time", joinTime) 61 + .apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
86 .orderByDesc("id"); 62 .orderByDesc("id");
87 wrapper.limitN(limit); 63 wrapper.limitN(limit);
88 return selectList(wrapper); 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,20 +9,6 @@ package cn.iocoder.yudao.module.im.dal.redis;
9 public interface RedisKeyConstants { 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 * <p> 13 * <p>
28 * KEY 格式:friend_state:{userId}_{friendUserId} 14 * KEY 格式:friend_state:{userId}_{friendUserId}
@@ -44,7 +30,7 @@ public interface RedisKeyConstants { @@ -44,7 +30,7 @@ public interface RedisKeyConstants {
44 * KEY 格式:group_member_ids:{groupId} 30 * KEY 格式:group_member_ids:{groupId}
45 * VALUE 数据类型:List<Long> 31 * VALUE 数据类型:List<Long>
46 * <p> 32 * <p>
47 - * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。 33 + * 说明:只缓存轻量的 userId 列表,适合「群消息推送目标」这类只关心 userId 的场景
48 */ 34 */
49 String GROUP_MEMBER_IDS = "group_member_ids"; 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,8 +193,6 @@ public class ImGroupServiceImpl implements ImGroupService {
193 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now())); 193 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now()));
194 // 2.2 移除全部群成员 194 // 2.2 移除全部群成员
195 groupMemberService.removeGroupMembersByGroupId(id); 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,10 +265,8 @@ public class ImGroupServiceImpl implements ImGroupService {
267 // 2. 先发广播,后移成员(见类 javadoc) 265 // 2. 先发广播,后移成员(见类 javadoc)
268 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId)); 266 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId));
269 267
270 - // 3.1 移除群成员 268 + // 3. 移除群成员
271 groupMemberService.removeGroupMember(groupId, userId); 269 groupMemberService.removeGroupMember(groupId, userId);
272 - // 3.2 清理已读缓存  
273 - groupMessageService.deleteReadMaxMessageId(groupId, userId);  
274 } 270 }
275 271
276 @Override 272 @Override
@@ -307,10 +303,8 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -307,10 +303,8 @@ public class ImGroupServiceImpl implements ImGroupService {
307 groupMessageService.sendGroupMessage(userId, 303 groupMessageService.sendGroupMessage(userId,
308 ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds)); 304 ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds));
309 305
310 - // 3.1 批量移除群成员 306 + // 3. 批量移除群成员;不清理读位置(保留退群前历史已读,供离线补偿)
311 groupMemberService.removeGroupMembers(groupId, validTargetUserIds); 307 groupMemberService.removeGroupMembers(groupId, validTargetUserIds);
312 - // 3.2 批量清理已读缓存  
313 - groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds);  
314 } 308 }
315 309
316 @Override 310 @Override
@@ -599,6 +593,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -599,6 +593,7 @@ public class ImGroupServiceImpl implements ImGroupService {
599 return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); 593 return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId);
600 } 594 }
601 595
  596 + // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤!
602 @Override 597 @Override
603 public List<ImGroupDO> getMyGroupList(Long userId) { 598 public List<ImGroupDO> getMyGroupList(Long userId) {
604 // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) 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,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
4 import cn.hutool.core.lang.Assert; 4 import cn.hutool.core.lang.Assert;
  5 +import cn.hutool.core.util.ObjUtil;
5 import cn.hutool.extra.spring.SpringUtil; 6 import cn.hutool.extra.spring.SpringUtil;
6 import cn.iocoder.yudao.framework.common.pojo.PageResult; 7 import cn.iocoder.yudao.framework.common.pojo.PageResult;
7 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 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,9 +12,10 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.Im
11 import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; 12 import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
12 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; 13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
13 import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper; 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 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
16 import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; 17 import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
  18 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
17 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 19 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
18 import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; 20 import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
19 import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage; 21 import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage;
@@ -25,7 +27,6 @@ import org.springframework.validation.annotation.Validated; @@ -25,7 +27,6 @@ import org.springframework.validation.annotation.Validated;
25 27
26 import java.time.LocalDateTime; 28 import java.time.LocalDateTime;
27 import java.util.Collection; 29 import java.util.Collection;
28 -import java.util.HashMap;  
29 import java.util.List; 30 import java.util.List;
30 import java.util.Map; 31 import java.util.Map;
31 32
@@ -50,7 +51,7 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService { @@ -50,7 +51,7 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
50 private ImWebSocketService webSocketService; 51 private ImWebSocketService webSocketService;
51 52
52 @Resource 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,28 +62,30 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
61 62
62 @Override 63 @Override
63 public Map<Long, Long> getChannelReadMaxMessageIdMap(Long userId, Collection<Long> channelIds) { 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 @Override 69 @Override
75 public void readChannelMessages(Long userId, Long channelId, Long messageId) { 70 public void readChannelMessages(Long userId, Long channelId, Long messageId) {
76 Assert.notNull(channelId, "频道编号不能为空"); 71 Assert.notNull(channelId, "频道编号不能为空");
77 Assert.notNull(messageId, "已读消息编号不能为空"); 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 return; 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 // 3. 异步推 READ 事件给自己多端同步 90 // 3. 异步推 READ 事件给自己多端同步
88 getSelf().readChannelMessageEvent(userId, channelId, messageId); 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,35 +98,6 @@ public interface ImGroupMessageService {
98 */ 98 */
99 List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO); 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,7 +6,6 @@ import cn.hutool.core.util.BooleanUtil;
6 import cn.hutool.core.util.IdUtil; 6 import cn.hutool.core.util.IdUtil;
7 import cn.hutool.core.util.ObjUtil; 7 import cn.hutool.core.util.ObjUtil;
8 import cn.hutool.extra.spring.SpringUtil; 8 import cn.hutool.extra.spring.SpringUtil;
9 -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;  
10 import cn.iocoder.yudao.framework.common.pojo.PageResult; 9 import cn.iocoder.yudao.framework.common.pojo.PageResult;
11 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 10 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
12 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 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,12 +16,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
17 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; 16 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
18 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; 17 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
19 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; 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 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; 20 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
22 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; 21 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
23 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 22 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
24 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 23 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
25 import cn.iocoder.yudao.module.im.framework.config.ImProperties; 24 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  25 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
26 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; 26 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
27 import cn.iocoder.yudao.module.im.service.group.ImGroupService; 27 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
28 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; 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,6 +34,7 @@ import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
34 import cn.iocoder.yudao.module.im.util.ImMessageUtils; 34 import cn.iocoder.yudao.module.im.util.ImMessageUtils;
35 import jakarta.annotation.Resource; 35 import jakarta.annotation.Resource;
36 import lombok.extern.slf4j.Slf4j; 36 import lombok.extern.slf4j.Slf4j;
  37 +import org.springframework.dao.DuplicateKeyException;
37 import org.springframework.scheduling.annotation.Async; 38 import org.springframework.scheduling.annotation.Async;
38 import org.springframework.stereotype.Service; 39 import org.springframework.stereotype.Service;
39 import org.springframework.transaction.annotation.Transactional; 40 import org.springframework.transaction.annotation.Transactional;
@@ -41,7 +42,6 @@ import org.springframework.validation.annotation.Validated; @@ -41,7 +42,6 @@ import org.springframework.validation.annotation.Validated;
41 42
42 import java.time.LocalDateTime; 43 import java.time.LocalDateTime;
43 import java.util.*; 44 import java.util.*;
44 -import java.util.stream.Collectors;  
45 45
46 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 46 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
47 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; 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,17 +57,8 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
57 @Slf4j 57 @Slf4j
58 public class ImGroupMessageServiceImpl implements ImGroupMessageService { 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 @Resource 60 @Resource
68 private ImGroupMessageMapper groupMessageMapper; 61 private ImGroupMessageMapper groupMessageMapper;
69 - @Resource  
70 - private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO;  
71 62
72 @Resource 63 @Resource
73 private ImGroupService groupService; 64 private ImGroupService groupService;
@@ -75,6 +66,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -75,6 +66,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
75 private ImGroupMemberService groupMemberService; 66 private ImGroupMemberService groupMemberService;
76 @Resource 67 @Resource
77 private ImSensitiveWordService sensitiveWordService; 68 private ImSensitiveWordService sensitiveWordService;
  69 + @Resource
  70 + private ImConversationReadService conversationReadService;
78 71
79 @Resource 72 @Resource
80 private ImWebSocketService imWebSocketService; 73 private ImWebSocketService imWebSocketService;
@@ -104,41 +97,51 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -104,41 +97,51 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
104 sensitiveWordService.validateText(reqVO.getContent()); 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 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m 107 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m
111 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) 108 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
  109 + .setReceiverUserIds(new ArrayList<>(receiverUserIds))
112 .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt()))); 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 return message; 121 return message;
120 } 122 }
121 123
122 @Override 124 @Override
123 public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) { 125 public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) {
124 List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId()); 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 @Override 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 // 1.1 content 序列化:null / String 透传,POJO 走 JSON 133 // 1.1 content 序列化:null / String 透传,POJO 走 JSON
132 Object payload = dto.getContent(); 134 Object payload = dto.getContent();
133 String contentString = payload == null || payload instanceof String 135 String contentString = payload == null || payload instanceof String
134 ? (String) payload 136 ? (String) payload
135 : JsonUtils.toJsonString(payload); 137 : JsonUtils.toJsonString(payload);
136 - // 1.2 构建并保存消息 138 + // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇?
  139 + // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照)
137 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) 140 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
138 .setSenderId(senderId).setGroupId(dto.getGroupId()) 141 .setSenderId(senderId).setGroupId(dto.getGroupId())
139 .setType(dto.getType()).setContent(contentString) 142 .setType(dto.getType()).setContent(contentString)
140 .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) 143 .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
141 - .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) 144 + .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(new ArrayList<>(receiverUserIds))
142 .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())); 145 .setReceiptStatus(resolveReceiptStatus(dto.getReceipt()));
143 // 1.3 按 type.persistent 决定是否入库 146 // 1.3 按 type.persistent 决定是否入库
144 if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { 147 if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) {
@@ -146,7 +149,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -146,7 +149,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
146 } 149 }
147 150
148 // 2. WebSocket 异步推送 151 // 2. WebSocket 异步推送
149 - imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); 152 + imWebSocketService.sendGroupMessageAsync(receiverUserIds, ImGroupMessageDTO.ofSend(message));
150 return message; 153 return message;
151 } 154 }
152 155
@@ -195,114 +198,32 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -195,114 +198,32 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
195 // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 198 // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送
196 LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); 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 * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数 227 * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数
307 */ 228 */
308 @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) 229 @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
@@ -310,18 +231,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -310,18 +231,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
310 if (CollUtil.isEmpty(messages)) { 231 if (CollUtil.isEmpty(messages)) {
311 return; 232 return;
312 } 233 }
313 - // 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐 234 + // 群已读关闭:不查读位置,status 保持 DB 原值(含撤回),readCount 不补齐
314 if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { 235 if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
315 return; 236 return;
316 } 237 }
317 - Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 238 + Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 当前用户已读位置
318 Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) 239 Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置)
319 Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 240 Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表
320 for (ImGroupMessageDO message : messages) { 241 for (ImGroupMessageDO message : messages) {
321 // 消息已读态(status):撤回 > 已读 > 未读 242 // 消息已读态(status):撤回 > 已读 > 未读
322 Long groupId = message.getGroupId(); 243 Long groupId = message.getGroupId();
323 long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> { 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 return readMaxMsgId != null ? readMaxMsgId : -1L; 247 return readMaxMsgId != null ? readMaxMsgId : -1L;
326 }); 248 });
327 if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { 249 if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
@@ -338,12 +260,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -338,12 +260,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
338 continue; 260 continue;
339 } 261 }
340 Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId, 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 uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, 270 uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
348 Integer::sum, 0); 271 Integer::sum, 0);
349 message.setReadCount(readCount); 272 message.setReadCount(readCount);
@@ -358,24 +281,25 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -358,24 +281,25 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
358 } 281 }
359 Assert.notNull(messageId, "已读消息编号不能为空"); 282 Assert.notNull(messageId, "已读消息编号不能为空");
360 // 1.1 校验用户在群中(权限校验) 283 // 1.1 校验用户在群中(权限校验)
361 - ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId);  
362 - // 1.2 校验消息属于当前群,且对当前用户可见 284 + groupMemberService.validateMemberInGroup(groupId, userId);
  285 + // 1.2 校验消息属于当前群,且对当前用户可见(按快照)
363 ImGroupMessageDO message = groupMessageMapper.selectById(messageId); 286 ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
364 if (message == null 287 if (message == null
365 || ObjUtil.notEqual(message.getGroupId(), groupId) 288 || ObjUtil.notEqual(message.getGroupId(), groupId)
366 - || !isMessageVisible(message, member, userId)) { 289 + || !isMessageVisible(message, userId)) {
367 throw exception(MESSAGE_NOT_IN_GROUP); 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 return; 299 return;
374 } 300 }
375 - // 3. 更新 Redis 群已读位置  
376 - groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId);  
377 301
378 - // 4. 异步发送 READ 事件 + 刷新范围内的群回执 302 + // 3. 异步发送 READ 事件 + 刷新范围内的群回执
379 getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId); 303 getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId);
380 } 304 }
381 305
@@ -386,13 +310,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -386,13 +310,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
386 throw exception(MESSAGE_GROUP_READ_DISABLED); 310 throw exception(MESSAGE_GROUP_READ_DISABLED);
387 } 311 }
388 // 1.1 校验用户在群中(权限校验) 312 // 1.1 校验用户在群中(权限校验)
389 - ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); 313 + groupMemberService.validateMemberInGroup(groupId, userId);
390 // 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方 314 // 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方
391 ImGroupMessageDO message = groupMessageMapper.selectById(messageId); 315 ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
392 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { 316 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
393 return Collections.emptyList(); 317 return Collections.emptyList();
394 } 318 }
395 - if (!isMessageVisible(message, operator, userId)) { 319 + if (!isMessageVisible(message, userId)) {
396 return Collections.emptyList(); 320 return Collections.emptyList();
397 } 321 }
398 // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 322 // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空
@@ -400,18 +324,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -400,18 +324,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
400 return Collections.emptyList(); 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 // 3. 计算该消息的可见成员集合(排除发送者自己) 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 // 4. 只返回在可见范围内且已读位置 >= messageId 的用户 336 // 4. 只返回在可见范围内且已读位置 >= messageId 的用户
412 List<Long> readUserIds = new ArrayList<>(); 337 List<Long> readUserIds = new ArrayList<>();
413 allPositions.forEach((uid, readMaxMessageId) -> { 338 allPositions.forEach((uid, readMaxMessageId) -> {
414 - if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) { 339 + if (receiverUserIds.contains(uid) && readMaxMessageId >= messageId) {
415 readUserIds.add(uid); 340 readUserIds.add(uid);
416 } 341 }
417 }); 342 });
@@ -420,15 +345,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -420,15 +345,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
420 345
421 @Override 346 @Override
422 public List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) { 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 // ========== 异步 WebSocket 推送 ========== 358 // ========== 异步 WebSocket 推送 ==========
@@ -455,20 +379,21 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -455,20 +379,21 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
455 return; 379 return;
456 } 380 }
457 List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); 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 for (ImGroupMessageDO message : pendingMessages) { 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 continue; 389 continue;
465 } 390 }
466 - int readCount = getSumValue(visibleUserIds, 391 + int readCount = getSumValue(receiverUserIds,
467 uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, 392 uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
468 Integer::sum, 0); 393 Integer::sum, 0);
469 // 2.1.2 全部已读 → 标记回执完成 394 // 2.1.2 全部已读 → 标记回执完成
470 Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus(); 395 Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus();
471 - if (readCount >= visibleUserIds.size()) { 396 + if (readCount >= receiverUserIds.size()) {
472 newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus(); 397 newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus();
473 groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId()) 398 groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId())
474 .setReceiptStatus(newReceiptStatus)); 399 .setReceiptStatus(newReceiptStatus));
@@ -487,9 +412,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -487,9 +412,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
487 * 412 *
488 * @param reqVO 发送请求 413 * @param reqVO 发送请求
489 * @param senderMember 发送人成员 414 * @param senderMember 发送人成员
  415 + * @param newAudience 新消息可见成员集合(发送当时快照)
490 * @return 规范化后的 content 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 // 解析客户端 content 里的 quote.messageId 420 // 解析客户端 content 里的 quote.messageId
494 Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent()); 421 Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent());
495 422
@@ -508,12 +435,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -508,12 +435,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
508 if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) { 435 if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) {
509 throw exception(MESSAGE_QUOTE_INVALID); 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 throw exception(MESSAGE_QUOTE_INVALID); 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 throw exception(MESSAGE_QUOTE_INVALID); 445 throw exception(MESSAGE_QUOTE_INVALID);
518 } 446 }
519 // 构建 quote 对象并注入 content 447 // 构建 quote 对象并注入 content
@@ -536,88 +464,60 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -536,88 +464,60 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
536 return message; 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 * @return 是否可见 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 * <p> 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 if (CollUtil.isEmpty(receiverUserIds)) { 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 if (senderId != null) { 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 return result; 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 @Override 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,9 +12,11 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate
12 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; 12 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; 13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
14 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; 14 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  15 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
15 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
17 import cn.iocoder.yudao.module.im.framework.config.ImProperties; 18 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  19 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
18 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 20 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
19 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; 21 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
20 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 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,6 +27,7 @@ import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
25 import cn.iocoder.yudao.module.im.util.ImMessageUtils; 27 import cn.iocoder.yudao.module.im.util.ImMessageUtils;
26 import jakarta.annotation.Resource; 28 import jakarta.annotation.Resource;
27 import lombok.extern.slf4j.Slf4j; 29 import lombok.extern.slf4j.Slf4j;
  30 +import org.springframework.dao.DuplicateKeyException;
28 import org.springframework.stereotype.Service; 31 import org.springframework.stereotype.Service;
29 import org.springframework.transaction.annotation.Transactional; 32 import org.springframework.transaction.annotation.Transactional;
30 import org.springframework.validation.annotation.Validated; 33 import org.springframework.validation.annotation.Validated;
@@ -52,6 +55,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -52,6 +55,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
52 private ImFriendService friendService; 55 private ImFriendService friendService;
53 @Resource 56 @Resource
54 private ImSensitiveWordService sensitiveWordService; 57 private ImSensitiveWordService sensitiveWordService;
  58 + @Resource
  59 + private ImConversationReadService conversationReadService;
55 60
56 @Resource 61 @Resource
57 private ImWebSocketService imWebSocketService; 62 private ImWebSocketService imWebSocketService;
@@ -80,10 +85,16 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -80,10 +85,16 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
80 85
81 // 2.1 引用 quote 消息规范化 86 // 2.1 引用 quote 消息规范化
82 reqVO.setContent(normalizeQuoteContent(reqVO, senderId)); 87 reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
83 - // 2.2 构建并保存消息 88 + // 2.2 构建并保存消息;唯一键冲突时回查已存在消息返回
84 ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m 89 ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
85 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); 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 // 3. WebSocket 异步推送:接收方 + 发送方多端同步 99 // 3. WebSocket 异步推送:接收方 + 发送方多端同步
89 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); 100 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
@@ -211,16 +222,21 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -211,16 +222,21 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
211 throw exception(MESSAGE_PRIVATE_READ_DISABLED); 222 throw exception(MESSAGE_PRIVATE_READ_DISABLED);
212 } 223 }
213 Assert.notNull(messageId, "已读消息编号不能为空"); 224 Assert.notNull(messageId, "已读消息编号不能为空");
214 - // 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读 225 +
  226 + // 2. status 双写:把 (receiverId → userId) 上 id <= messageId 的未读消息置为已读
215 // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口 227 // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口
216 - int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( 228 + privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
217 receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(), 229 receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(),
218 new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); 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 return; 236 return;
221 } 237 }
222 238
223 - // 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) 239 + // 4. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
224 imWebSocketService.sendPrivateMessageAsync(userId, 240 imWebSocketService.sendPrivateMessageAsync(userId,
225 ImPrivateMessageDTO.ofRead(userId, receiverId, messageId)); 241 ImPrivateMessageDTO.ofRead(userId, receiverId, messageId));
226 imWebSocketService.sendPrivateMessageAsync(receiverId, 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 package cn.iocoder.yudao.module.im.dal.mysql.message; 1 package cn.iocoder.yudao.module.im.dal.mysql.message;
2 2
3 -import cn.hutool.core.collection.ListUtil;  
4 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; 3 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
5 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; 4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
6 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; 5 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
@@ -14,7 +13,7 @@ import java.util.List; @@ -14,7 +13,7 @@ import java.util.List;
14 import static org.junit.jupiter.api.Assertions.*; 13 import static org.junit.jupiter.api.Assertions.*;
15 14
16 /** 15 /**
17 - * IM 群聊消息 Mapper 单元测试 16 + * {@link ImGroupMessageMapper} 的单元测试
18 * 17 *
19 * @author 芋道源码 18 * @author 芋道源码
20 */ 19 */
@@ -28,46 +27,76 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @@ -28,46 +27,76 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
28 // ========== selectListByMinId ========== 27 // ========== selectListByMinId ==========
29 28
30 @Test 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 public void testSelectListByMinId_onlyReturnsGroupsInIdList() { 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 mapper.insert(msg10); 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 mapper.insert(msg20); 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 assertEquals(1, result.size()); 72 assertEquals(1, result.size());
43 assertEquals(msg10.getId(), result.get(0).getId()); 73 assertEquals(msg10.getId(), result.get(0).getId());
44 } 74 }
45 75
46 @Test 76 @Test
47 public void testSelectListByMinId_includesRecall() { 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 mapper.insert(normal); 80 mapper.insert(normal);
51 - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); 81 + ImGroupMessageDO recalled = buildMessage(10L, 2L, ImMessageStatusEnum.RECALL, List.of(1L));
52 mapper.insert(recalled); 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 assertEquals(2, result.size()); 86 assertEquals(2, result.size());
58 } 87 }
59 88
60 @Test 89 @Test
61 public void testSelectListByMinId_sendTimeWindow() { 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 inWindow.setSendTime(LocalDateTime.now().minusDays(1)); 93 inWindow.setSendTime(LocalDateTime.now().minusDays(1));
65 mapper.insert(inWindow); 94 mapper.insert(inWindow);
66 - ImGroupMessageDO outWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); 95 + ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
67 outWindow.setSendTime(LocalDateTime.now().minusDays(40)); 96 outWindow.setSendTime(LocalDateTime.now().minusDays(40));
68 mapper.insert(outWindow); 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 LocalDateTime.now().minusDays(30), 100); 100 LocalDateTime.now().minusDays(30), 100);
72 101
73 assertEquals(1, result.size()); 102 assertEquals(1, result.size());
@@ -76,16 +105,16 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @@ -76,16 +105,16 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
76 105
77 @Test 106 @Test
78 public void testSelectListByMinId_sortAscLimit() { 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 mapper.insert(m1); 110 mapper.insert(m1);
82 - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); 111 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
83 mapper.insert(m2); 112 mapper.insert(m2);
84 - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); 113 + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
85 mapper.insert(m3); 114 mapper.insert(m3);
86 115
87 // 调用:size=2,返回 id 最小的 2 条 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 assertEquals(2, result.size()); 119 assertEquals(2, result.size());
91 assertTrue(result.get(0).getId() < result.get(1).getId()); 120 assertTrue(result.get(0).getId() < result.get(1).getId());
@@ -93,129 +122,63 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @@ -93,129 +122,63 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
93 assertEquals(m2.getId(), result.get(1).getId()); 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 @Test 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 assertEquals(1, result.size()); 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 @Test 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 mapper.insert(m1); 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 mapper.insert(m2); 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 mapper.insert(other); 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 assertEquals(2, result.size()); 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 @Test 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 mapper.insert(m1); 165 mapper.insert(m1);
186 - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); 166 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
187 mapper.insert(m2); 167 mapper.insert(m2);
188 - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); 168 + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
189 mapper.insert(m3); 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 assertEquals(2, result.size()); 174 assertEquals(2, result.size());
195 assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId())); 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 return ImGroupMessageDO.builder() 182 return ImGroupMessageDO.builder()
220 .clientMessageId("uuid-" + System.nanoTime()) 183 .clientMessageId("uuid-" + System.nanoTime())
221 .senderId(senderId) 184 .senderId(senderId)
@@ -224,6 +187,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @@ -224,6 +187,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
224 .content("{\"content\":\"test\"}") 187 .content("{\"content\":\"test\"}")
225 .status(status.getStatus()) 188 .status(status.getStatus())
226 .sendTime(LocalDateTime.now()) 189 .sendTime(LocalDateTime.now())
  190 + .receiverUserIds(receiverUserIds)
227 .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) 191 .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
228 .build(); 192 .build();
229 } 193 }
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
1 package cn.iocoder.yudao.module.im.service.group; 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 import cn.hutool.extra.spring.SpringUtil; 3 import cn.hutool.extra.spring.SpringUtil;
7 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
8 import cn.iocoder.yudao.framework.common.exception.ServiceException; 5 import cn.iocoder.yudao.framework.common.exception.ServiceException;
@@ -111,13 +108,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -111,13 +108,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
111 // 准备:创建者 + 2 个初始成员,都是好友 108 // 准备:创建者 + 2 个初始成员,都是好友
112 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); 109 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO();
113 reqVO.setName("测试群"); 110 reqVO.setName("测试群");
114 - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); 111 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
115 when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> { 112 when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> {
116 ImGroupDO group = invocation.getArgument(0); 113 ImGroupDO group = invocation.getArgument(0);
117 group.setId(100L); 114 group.setId(100L);
118 return 1; 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 ImFriendDO.builder().userId(1L).friendUserId(2L) 118 ImFriendDO.builder().userId(1L).friendUserId(2L)
122 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 119 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
123 ImFriendDO.builder().userId(1L).friendUserId(3L) 120 ImFriendDO.builder().userId(1L).friendUserId(3L)
@@ -143,15 +140,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -143,15 +140,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
143 // 准备:初始成员里有非好友 140 // 准备:初始成员里有非好友
144 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); 141 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO();
145 reqVO.setName("测试群"); 142 reqVO.setName("测试群");
146 - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); 143 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
147 // 只有 2 是好友,3 不是 144 // 只有 2 是好友,3 不是
148 - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( 145 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
149 ImFriendDO.builder().userId(1L).friendUserId(2L) 146 ImFriendDO.builder().userId(1L).friendUserId(2L)
150 .status(CommonStatusEnum.ENABLE.getStatus()).build())); 147 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
151 AdminUserRespDTO u3 = new AdminUserRespDTO(); 148 AdminUserRespDTO u3 = new AdminUserRespDTO();
152 u3.setId(3L); 149 u3.setId(3L);
153 u3.setNickname("李四"); 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 ServiceException exception = assertThrows(ServiceException.class, 154 ServiceException exception = assertThrows(ServiceException.class,
@@ -234,7 +231,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -234,7 +231,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
234 assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); 231 assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus());
235 assertNotNull(captor.getValue().getDissolvedTime()); 232 assertNotNull(captor.getValue().getDissolvedTime());
236 verify(groupMemberService).removeGroupMembersByGroupId(10L); 233 verify(groupMemberService).removeGroupMembersByGroupId(10L);
237 - verify(groupMessageService).deleteReadMaxMessageIdMap(10L);  
238 // 推送 GROUP_DISSOLVE 通知(send-before-remove,sendGroupMessage 内部查 active 自动覆盖全员,含群主多端同步) 234 // 推送 GROUP_DISSOLVE 通知(send-before-remove,sendGroupMessage 内部查 active 自动覆盖全员,含群主多端同步)
239 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); 235 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
240 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); 236 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
@@ -282,7 +278,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -282,7 +278,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
282 verify(groupMessageService).sendGroupMessage(eq(99L), dtoCaptor.capture()); 278 verify(groupMessageService).sendGroupMessage(eq(99L), dtoCaptor.capture());
283 assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType()); 279 assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType());
284 verify(groupMemberService).removeGroupMembersByGroupId(10L); 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,8 +325,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
330 // 被邀请人 2 和 3 都是好友 325 // 被邀请人 2 和 3 都是好友
331 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); 326 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
332 reqVO.setGroupId(10L); 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 ImFriendDO.builder().userId(1L).friendUserId(2L) 330 ImFriendDO.builder().userId(1L).friendUserId(2L)
336 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 331 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
337 ImFriendDO.builder().userId(1L).friendUserId(3L) 332 ImFriendDO.builder().userId(1L).friendUserId(3L)
@@ -369,7 +364,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -369,7 +364,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
369 ImGroupMemberDO.builder().groupId(10L).userId(1L) 364 ImGroupMemberDO.builder().groupId(10L).userId(1L)
370 .role(ImGroupMemberRoleEnum.NORMAL.getRole()) 365 .role(ImGroupMemberRoleEnum.NORMAL.getRole())
371 .status(CommonStatusEnum.ENABLE.getStatus()).build()); 366 .status(CommonStatusEnum.ENABLE.getStatus()).build());
372 - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of( 367 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of(
373 ImGroupMemberDO.builder().groupId(10L).userId(99L) 368 ImGroupMemberDO.builder().groupId(10L).userId(99L)
374 .role(ImGroupMemberRoleEnum.OWNER.getRole()) 369 .role(ImGroupMemberRoleEnum.OWNER.getRole())
375 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 370 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
@@ -379,8 +374,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -379,8 +374,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
379 374
380 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); 375 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
381 reqVO.setGroupId(10L); 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 ImFriendDO.builder().userId(1L).friendUserId(2L) 379 ImFriendDO.builder().userId(1L).friendUserId(2L)
385 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 380 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
386 ImFriendDO.builder().userId(1L).friendUserId(3L) 381 ImFriendDO.builder().userId(1L).friendUserId(3L)
@@ -411,15 +406,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -411,15 +406,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
411 ImGroupMemberDO.builder().groupId(10L).userId(1L) 406 ImGroupMemberDO.builder().groupId(10L).userId(1L)
412 .role(ImGroupMemberRoleEnum.OWNER.getRole()) 407 .role(ImGroupMemberRoleEnum.OWNER.getRole())
413 .status(CommonStatusEnum.ENABLE.getStatus()).build()); 408 .status(CommonStatusEnum.ENABLE.getStatus()).build());
414 - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of( 409 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of(
415 ImGroupMemberDO.builder().groupId(10L).userId(1L) 410 ImGroupMemberDO.builder().groupId(10L).userId(1L)
416 .role(ImGroupMemberRoleEnum.OWNER.getRole()) 411 .role(ImGroupMemberRoleEnum.OWNER.getRole())
417 .status(CommonStatusEnum.ENABLE.getStatus()).build())); 412 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
418 413
419 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); 414 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
420 reqVO.setGroupId(10L); 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 ImFriendDO.builder().userId(1L).friendUserId(2L) 418 ImFriendDO.builder().userId(1L).friendUserId(2L)
424 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 419 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
425 ImFriendDO.builder().userId(1L).friendUserId(3L) 420 ImFriendDO.builder().userId(1L).friendUserId(3L)
@@ -448,18 +443,18 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -448,18 +443,18 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
448 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 443 .status(CommonStatusEnum.ENABLE.getStatus()).build();
449 when(groupMapper.selectById(10L)).thenReturn(group); 444 when(groupMapper.selectById(10L)).thenReturn(group);
450 when(groupMemberService.getActiveGroupMemberListByGroupId(10L)) 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 .status(CommonStatusEnum.ENABLE.getStatus()).build())); 447 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
453 448
454 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); 449 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
455 reqVO.setGroupId(10L); 450 reqVO.setGroupId(10L);
456 - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); 451 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L)));
457 // 只有 2 是好友,3 不是 452 // 只有 2 是好友,3 不是
458 - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( 453 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of(
459 ImFriendDO.builder().userId(1L).friendUserId(2L) 454 ImFriendDO.builder().userId(1L).friendUserId(2L)
460 .status(CommonStatusEnum.ENABLE.getStatus()).build())); 455 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
461 AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); 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 ServiceException exception = assertThrows(ServiceException.class, 459 ServiceException exception = assertThrows(ServiceException.class,
465 () -> groupService.inviteGroupMember(1L, reqVO)); 460 () -> groupService.inviteGroupMember(1L, reqVO));
@@ -487,9 +482,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -487,9 +482,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
487 482
488 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); 483 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
489 reqVO.setGroupId(10L); 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 ImFriendDO.builder().userId(1L).friendUserId(600L) 488 ImFriendDO.builder().userId(1L).friendUserId(600L)
494 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 489 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
495 ImFriendDO.builder().userId(1L).friendUserId(601L) 490 ImFriendDO.builder().userId(1L).friendUserId(601L)
@@ -515,7 +510,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -515,7 +510,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
515 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 510 .status(CommonStatusEnum.ENABLE.getStatus()).build();
516 when(groupMapper.selectById(10L)).thenReturn(group); 511 when(groupMapper.selectById(10L)).thenReturn(group);
517 // 用户 2 已在群中 512 // 用户 2 已在群中
518 - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of( 513 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of(
519 ImGroupMemberDO.builder().groupId(10L).userId(1L) 514 ImGroupMemberDO.builder().groupId(10L).userId(1L)
520 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 515 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
521 ImGroupMemberDO.builder().groupId(10L).userId(2L) 516 ImGroupMemberDO.builder().groupId(10L).userId(2L)
@@ -524,7 +519,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -524,7 +519,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
524 519
525 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); 520 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO();
526 reqVO.setGroupId(10L); 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 groupService.inviteGroupMember(1L, reqVO); 525 groupService.inviteGroupMember(1L, reqVO);
@@ -566,7 +561,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -566,7 +561,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
566 groupService.quitGroup(10L, 1L); 561 groupService.quitGroup(10L, 1L);
567 562
568 verify(groupMemberService).removeGroupMember(10L, 1L); 563 verify(groupMemberService).removeGroupMember(10L, 1L);
569 - verify(groupMessageService).deleteReadMaxMessageId(10L, 1L);  
570 // 推送 GROUP_MEMBER_QUIT 通知给全员(含 quitter,前端自判清群) 564 // 推送 GROUP_MEMBER_QUIT 通知给全员(含 quitter,前端自判清群)
571 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); 565 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
572 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); 566 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
@@ -587,7 +581,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -587,7 +581,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
587 groupService.quitGroup(10L, 1L); 581 groupService.quitGroup(10L, 1L);
588 582
589 verify(groupMemberService).removeGroupMember(10L, 1L); 583 verify(groupMemberService).removeGroupMember(10L, 1L);
590 - verify(groupMessageService).deleteReadMaxMessageId(10L, 1L);  
591 verify(groupMessageService).sendGroupMessage(eq(1L), any(ImGroupMessageSendDTO.class)); 584 verify(groupMessageService).sendGroupMessage(eq(1L), any(ImGroupMessageSendDTO.class));
592 } 585 }
593 } 586 }
@@ -609,7 +602,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -609,7 +602,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
609 602
610 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 603 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
611 reqVO.setGroupId(10L); 604 reqVO.setGroupId(10L);
612 - reqVO.setMemberUserIds(ListUtil.of(1L, 2L)); 605 + reqVO.setMemberUserIds(List.of(1L, 2L));
613 606
614 ServiceException exception = assertThrows(ServiceException.class, 607 ServiceException exception = assertThrows(ServiceException.class,
615 () -> groupService.removeGroupMember(1L, reqVO)); 608 () -> groupService.removeGroupMember(1L, reqVO));
@@ -631,7 +624,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -631,7 +624,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
631 ImGroupMemberDO.builder().groupId(10L).userId(1L) 624 ImGroupMemberDO.builder().groupId(10L).userId(1L)
632 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); 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 ImGroupMemberDO.builder().groupId(10L).userId(2L) 628 ImGroupMemberDO.builder().groupId(10L).userId(2L)
636 .role(ImGroupMemberRoleEnum.NORMAL.getRole()) 629 .role(ImGroupMemberRoleEnum.NORMAL.getRole())
637 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 630 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
@@ -641,12 +634,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -641,12 +634,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
641 634
642 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 635 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
643 reqVO.setGroupId(10L); 636 reqVO.setGroupId(10L);
644 - reqVO.setMemberUserIds(ListUtil.of(2L, 3L)); 637 + reqVO.setMemberUserIds(List.of(2L, 3L));
645 638
646 groupService.removeGroupMember(1L, reqVO); 639 groupService.removeGroupMember(1L, reqVO);
647 640
648 verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection()); 641 verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection());
649 - verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), anyCollection());  
650 // 推送 GROUP_MEMBER_KICK 通知给全员(含被踢者,前端自判清群) 642 // 推送 GROUP_MEMBER_KICK 通知给全员(含被踢者,前端自判清群)
651 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); 643 ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
652 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); 644 verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
@@ -668,14 +660,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -668,14 +660,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
668 ImGroupMemberDO.builder().groupId(10L).userId(1L) 660 ImGroupMemberDO.builder().groupId(10L).userId(1L)
669 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()); 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 ImGroupMemberDO.builder().groupId(10L).userId(2L) 664 ImGroupMemberDO.builder().groupId(10L).userId(2L)
673 .role(ImGroupMemberRoleEnum.ADMIN.getRole()) 665 .role(ImGroupMemberRoleEnum.ADMIN.getRole())
674 .status(CommonStatusEnum.ENABLE.getStatus()).build())); 666 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
675 667
676 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 668 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
677 reqVO.setGroupId(10L); 669 reqVO.setGroupId(10L);
678 - reqVO.setMemberUserIds(ListUtil.of(2L)); 670 + reqVO.setMemberUserIds(List.of(2L));
679 671
680 ServiceException exception = assertThrows(ServiceException.class, 672 ServiceException exception = assertThrows(ServiceException.class,
681 () -> groupService.removeGroupMember(1L, reqVO)); 673 () -> groupService.removeGroupMember(1L, reqVO));
@@ -697,14 +689,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -697,14 +689,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
697 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( 689 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
698 ImGroupMemberDO.builder().groupId(10L).userId(1L) 690 ImGroupMemberDO.builder().groupId(10L).userId(1L)
699 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); 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 ImGroupMemberDO.builder().groupId(10L).userId(99L) 693 ImGroupMemberDO.builder().groupId(10L).userId(99L)
702 .role(ImGroupMemberRoleEnum.OWNER.getRole()) 694 .role(ImGroupMemberRoleEnum.OWNER.getRole())
703 .status(CommonStatusEnum.ENABLE.getStatus()).build())); 695 .status(CommonStatusEnum.ENABLE.getStatus()).build()));
704 696
705 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 697 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
706 reqVO.setGroupId(10L); 698 reqVO.setGroupId(10L);
707 - reqVO.setMemberUserIds(ListUtil.of(99L)); 699 + reqVO.setMemberUserIds(List.of(99L));
708 700
709 ServiceException exception = assertThrows(ServiceException.class, 701 ServiceException exception = assertThrows(ServiceException.class,
710 () -> groupService.removeGroupMember(1L, reqVO)); 702 () -> groupService.removeGroupMember(1L, reqVO));
@@ -726,7 +718,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -726,7 +718,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
726 ImGroupMemberDO.builder().groupId(10L).userId(1L) 718 ImGroupMemberDO.builder().groupId(10L).userId(1L)
727 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); 719 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build());
728 // 目标:2L 有效普通成员;3L 已退群(DISABLE)的历史管理员,应被跳过而非拦截整批 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 ImGroupMemberDO.builder().groupId(10L).userId(2L) 722 ImGroupMemberDO.builder().groupId(10L).userId(2L)
731 .role(ImGroupMemberRoleEnum.NORMAL.getRole()) 723 .role(ImGroupMemberRoleEnum.NORMAL.getRole())
732 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 724 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
@@ -736,17 +728,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -736,17 +728,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
736 728
737 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); 729 ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO();
738 reqVO.setGroupId(10L); 730 reqVO.setGroupId(10L);
739 - reqVO.setMemberUserIds(ListUtil.of(2L, 3L)); 731 + reqVO.setMemberUserIds(List.of(2L, 3L));
740 732
741 groupService.removeGroupMember(1L, reqVO); 733 groupService.removeGroupMember(1L, reqVO);
742 734
743 - // 仅有效成员 2L 进入移除 / 已读清理,已退群的 3L 被跳过 735 + // 仅有效成员 2L 进入移除,已退群的 3L 被跳过
744 ArgumentCaptor<Collection> removeCaptor = ArgumentCaptor.forClass(Collection.class); 736 ArgumentCaptor<Collection> removeCaptor = ArgumentCaptor.forClass(Collection.class);
745 verify(groupMemberService).removeGroupMembers(eq(10L), removeCaptor.capture()); 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,7 +751,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
762 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 751 .status(CommonStatusEnum.ENABLE.getStatus()).build();
763 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); 752 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
764 // 目标 3 是普通成员 753 // 目标 3 是普通成员
765 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( 754 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
766 ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus()) 755 ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus())
767 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); 756 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
768 // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限 757 // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限
@@ -773,7 +762,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -773,7 +762,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
773 762
774 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); 763 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
775 reqVO.setId(10L); 764 reqVO.setId(10L);
776 - reqVO.setUserIds(ListUtil.of(3L)); 765 + reqVO.setUserIds(List.of(3L));
777 766
778 groupService.addGroupAdmin(1L, reqVO); 767 groupService.addGroupAdmin(1L, reqVO);
779 768
@@ -792,7 +781,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -792,7 +781,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
792 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 781 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
793 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 782 .status(CommonStatusEnum.ENABLE.getStatus()).build();
794 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); 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 ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus()) 785 ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus())
797 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); 786 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
798 // 群里已有 3 个 ADMIN(达到上限),再加 1 会超 787 // 群里已有 3 个 ADMIN(达到上限),再加 1 会超
@@ -801,7 +790,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -801,7 +790,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
801 790
802 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); 791 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
803 reqVO.setId(10L); 792 reqVO.setId(10L);
804 - reqVO.setUserIds(ListUtil.of(5L)); 793 + reqVO.setUserIds(List.of(5L));
805 794
806 ServiceException exception = assertThrows(ServiceException.class, 795 ServiceException exception = assertThrows(ServiceException.class,
807 () -> groupService.addGroupAdmin(1L, reqVO)); 796 () -> groupService.addGroupAdmin(1L, reqVO));
@@ -819,13 +808,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -819,13 +808,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
819 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 808 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
820 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 809 .status(CommonStatusEnum.ENABLE.getStatus()).build();
821 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); 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 ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus()) 812 ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus())
824 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build())); 813 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()));
825 814
826 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); 815 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
827 reqVO.setId(10L); 816 reqVO.setId(10L);
828 - reqVO.setUserIds(ListUtil.of(1L)); 817 + reqVO.setUserIds(List.of(1L));
829 818
830 ServiceException exception = assertThrows(ServiceException.class, 819 ServiceException exception = assertThrows(ServiceException.class,
831 () -> groupService.addGroupAdmin(1L, reqVO)); 820 () -> groupService.addGroupAdmin(1L, reqVO));
@@ -843,13 +832,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -843,13 +832,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
843 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 832 .status(CommonStatusEnum.ENABLE.getStatus()).build();
844 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); 833 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
845 // 目标已是 ADMIN:再加无需操作 834 // 目标已是 ADMIN:再加无需操作
846 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( 835 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
847 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) 836 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
848 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); 837 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()));
849 838
850 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); 839 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
851 reqVO.setId(10L); 840 reqVO.setId(10L);
852 - reqVO.setUserIds(ListUtil.of(2L)); 841 + reqVO.setUserIds(List.of(2L));
853 842
854 groupService.addGroupAdmin(1L, reqVO); 843 groupService.addGroupAdmin(1L, reqVO);
855 844
@@ -869,7 +858,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -869,7 +858,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
869 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 858 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
870 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 859 .status(CommonStatusEnum.ENABLE.getStatus()).build();
871 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); 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 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) 862 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
874 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); 863 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()));
875 when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(), 864 when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(),
@@ -877,7 +866,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -877,7 +866,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
877 866
878 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); 867 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
879 reqVO.setId(10L); 868 reqVO.setId(10L);
880 - reqVO.setUserIds(ListUtil.of(2L)); 869 + reqVO.setUserIds(List.of(2L));
881 870
882 groupService.removeGroupAdmin(1L, reqVO); 871 groupService.removeGroupAdmin(1L, reqVO);
883 872
@@ -897,13 +886,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -897,13 +886,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
897 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 886 .status(CommonStatusEnum.ENABLE.getStatus()).build();
898 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); 887 when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
899 // 目标已是 MEMBER:撤销无需操作 888 // 目标已是 MEMBER:撤销无需操作
900 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( 889 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
901 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) 890 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
902 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); 891 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
903 892
904 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); 893 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
905 reqVO.setId(10L); 894 reqVO.setId(10L);
906 - reqVO.setUserIds(ListUtil.of(2L)); 895 + reqVO.setUserIds(List.of(2L));
907 896
908 groupService.removeGroupAdmin(1L, reqVO); 897 groupService.removeGroupAdmin(1L, reqVO);
909 898
@@ -925,9 +914,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -925,9 +914,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
925 when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn( 914 when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn(
926 ImGroupMemberDO.builder().groupId(10L).userId(2L) 915 ImGroupMemberDO.builder().groupId(10L).userId(2L)
927 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); 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 eq(ImGroupMemberRoleEnum.OWNER.getRole()))).thenReturn(1); 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 eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1); 920 eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1);
932 921
933 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); 922 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
@@ -941,9 +930,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -941,9 +930,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
941 verify(groupMapper).updateById(groupCaptor.capture()); 930 verify(groupMapper).updateById(groupCaptor.capture());
942 assertEquals(2L, groupCaptor.getValue().getOwnerUserId()); 931 assertEquals(2L, groupCaptor.getValue().getOwnerUserId());
943 // 旧群主 → MEMBER;新群主 → OWNER 932 // 旧群主 → MEMBER;新群主 → OWNER
944 - verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(1L)), 933 + verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(Set.of(1L)),
945 eq(ImGroupMemberRoleEnum.NORMAL.getRole())); 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 eq(ImGroupMemberRoleEnum.OWNER.getRole())); 936 eq(ImGroupMemberRoleEnum.OWNER.getRole()));
948 } 937 }
949 } 938 }
@@ -1123,7 +1112,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -1123,7 +1112,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1123 @Test 1112 @Test
1124 public void testGetMyGroupList_success() { 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 ImGroupMemberDO.builder().groupId(10L).userId(1L) 1116 ImGroupMemberDO.builder().groupId(10L).userId(1L)
1128 .status(CommonStatusEnum.ENABLE.getStatus()).build(), 1117 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
1129 ImGroupMemberDO.builder().groupId(20L).userId(1L) 1118 ImGroupMemberDO.builder().groupId(20L).userId(1L)
@@ -1131,11 +1120,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -1131,11 +1120,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
1131 ))); 1120 )));
1132 // 最近退群成员(最近 30 天内) 1121 // 最近退群成员(最近 30 天内)
1133 when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) 1122 when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
1134 - .thenReturn(new ArrayList<>(ListUtil.of( 1123 + .thenReturn(new ArrayList<>(List.of(
1135 ImGroupMemberDO.builder().groupId(30L).userId(1L) 1124 ImGroupMemberDO.builder().groupId(30L).userId(1L)
1136 .status(CommonStatusEnum.DISABLE.getStatus()).build() 1125 .status(CommonStatusEnum.DISABLE.getStatus()).build()
1137 ))); 1126 )));
1138 - List<ImGroupDO> groups = ListUtil.of( 1127 + List<ImGroupDO> groups = List.of(
1139 ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), 1128 ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(),
1140 ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), 1129 ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(),
1141 ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build() 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 package cn.iocoder.yudao.module.im.service.message; 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 import cn.hutool.extra.spring.SpringUtil; 3 import cn.hutool.extra.spring.SpringUtil;
6 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
7 import cn.iocoder.yudao.framework.common.exception.ServiceException; 5 import cn.iocoder.yudao.framework.common.exception.ServiceException;
@@ -12,12 +10,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; @@ -12,12 +10,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
12 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; 10 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; 11 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
14 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; 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 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; 14 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
17 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; 15 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
18 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
19 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
20 import cn.iocoder.yudao.module.im.framework.config.ImProperties; 18 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  19 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
21 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; 20 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
22 import cn.iocoder.yudao.module.im.service.group.ImGroupService; 21 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
23 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; 22 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
@@ -59,7 +58,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -59,7 +58,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
59 @Mock 58 @Mock
60 private ImSensitiveWordService sensitiveWordService; 59 private ImSensitiveWordService sensitiveWordService;
61 @Mock 60 @Mock
62 - private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO; 61 + private ImConversationReadService conversationReadService;
63 @Mock 62 @Mock
64 private ImWebSocketService imWebSocketService; 63 private ImWebSocketService imWebSocketService;
65 64
@@ -98,7 +97,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -98,7 +97,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
98 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); 97 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
99 98
100 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 99 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
101 - .thenReturn(ListUtil.of(1L, 2L, 3L)); 100 + .thenReturn(List.of(1L, 2L, 3L));
102 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { 101 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> {
103 ImGroupMessageDO msg = invocation.getArgument(0); 102 ImGroupMessageDO msg = invocation.getArgument(0);
104 msg.setId(99L); 103 msg.setId(99L);
@@ -159,342 +158,94 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -159,342 +158,94 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
159 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); 158 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
160 } 159 }
161 160
162 - // ========== pull 测试 ==========  
163 -  
164 @Test 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 @Test 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 @Test 219 @Test
403 - public void testPullMessages_atFieldCorrectReturn() {  
404 - // 准备 220 + public void testPullMessages_pullSetsReadFromCursor() {
  221 + // 准备:当前在群 10,无退群;拉到两条消息,已读位置为 7
405 LocalDateTime now = LocalDateTime.now(); 222 LocalDateTime now = LocalDateTime.now();
406 ImGroupMemberDO member = ImGroupMemberDO.builder() 223 ImGroupMemberDO member = ImGroupMemberDO.builder()
407 .groupId(10L).userId(1L) 224 .groupId(10L).userId(1L)
408 .status(CommonStatusEnum.ENABLE.getStatus()) 225 .status(CommonStatusEnum.ENABLE.getStatus())
409 .joinTime(now.minusDays(10)).build(); 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 when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) 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 ImGroupMessageDO low = ImGroupMessageDO.builder() 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 .sendTime(now.minusHours(2)).build(); 234 .sendTime(now.minusHours(2)).build();
485 ImGroupMessageDO high = ImGroupMessageDO.builder() 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 .sendTime(now.minusHours(1)).build(); 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 any(LocalDateTime.class), eq(100))) 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 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); 246 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
497 247
  248 + // 断言:已读位置 7 → id<=7 为 READ,id>7 为 UNREAD
498 assertEquals(2, result.size()); 249 assertEquals(2, result.size());
499 assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); 250 assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus());
500 assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus()); 251 assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus());
@@ -502,32 +253,36 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -502,32 +253,36 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
502 253
503 @Test 254 @Test
504 public void testPullMessages_receiptReadCountForSender() { 255 public void testPullMessages_receiptReadCountForSender() {
  256 + // 准备:本人发送的回执消息,可见成员 {1,2,3},分母排除发送者后为 {2,3}
505 LocalDateTime now = LocalDateTime.now(); 257 LocalDateTime now = LocalDateTime.now();
506 ImGroupMemberDO member = ImGroupMemberDO.builder() 258 ImGroupMemberDO member = ImGroupMemberDO.builder()
507 .groupId(10L).userId(1L) 259 .groupId(10L).userId(1L)
508 .status(CommonStatusEnum.ENABLE.getStatus()) 260 .status(CommonStatusEnum.ENABLE.getStatus())
509 .joinTime(now.minusDays(10)).build(); 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 ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() 266 ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder()
513 .id(100L).groupId(10L).senderId(1L) 267 .id(100L).groupId(10L).senderId(1L)
  268 + .receiverUserIds(List.of(1L, 2L, 3L))
514 .status(ImMessageStatusEnum.UNREAD.getStatus()) 269 .status(ImMessageStatusEnum.UNREAD.getStatus())
515 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) 270 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
516 .sendTime(now.minusHours(1)).build(); 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 any(LocalDateTime.class), eq(100))) 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 Map<Long, Long> positions = new HashMap<>(); 278 Map<Long, Long> positions = new HashMap<>();
525 positions.put(1L, 100L); 279 positions.put(1L, 100L);
526 positions.put(2L, 100L); 280 positions.put(2L, 100L);
527 positions.put(3L, 50L); 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 member, 286 member,
532 ImGroupMemberDO.builder().groupId(10L).userId(2L) 287 ImGroupMemberDO.builder().groupId(10L).userId(2L)
533 .status(CommonStatusEnum.ENABLE.getStatus()) 288 .status(CommonStatusEnum.ENABLE.getStatus())
@@ -536,10 +291,11 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -536,10 +291,11 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
536 .status(CommonStatusEnum.ENABLE.getStatus()) 291 .status(CommonStatusEnum.ENABLE.getStatus())
537 .joinTime(now.minusDays(20)).build() 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 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); 296 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
542 297
  298 + // 断言:分母 {2,3},仅用户 2 读到 >=100 → readCount==1
543 assertEquals(1, result.size()); 299 assertEquals(1, result.size());
544 assertEquals(1, result.get(0).getReadCount()); 300 assertEquals(1, result.get(0).getReadCount());
545 } 301 }
@@ -566,7 +322,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -566,7 +322,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
566 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); 322 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1);
567 323
568 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 324 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
569 - .thenReturn(ListUtil.of(1L, 2L)); 325 + .thenReturn(List.of(1L, 2L));
570 326
571 // 调用 327 // 调用
572 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); 328 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L);
@@ -660,18 +416,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -660,18 +416,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
660 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); 416 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();
661 when(groupMemberService.validateMemberInGroup(10L, 5L)).thenReturn(currentMember); 417 when(groupMemberService.validateMemberInGroup(10L, 5L)).thenReturn(currentMember);
662 418
663 - // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 419 + // 准备:消息由用户 5 发,快照可见范围为 {1,2,5}(用户 3 不在快照内 → 不可见)
664 ImGroupMessageDO message = ImGroupMessageDO.builder() 420 ImGroupMessageDO message = ImGroupMessageDO.builder()
665 .id(80L).groupId(10L).senderId(5L) 421 .id(80L).groupId(10L).senderId(5L)
  422 + .receiverUserIds(List.of(1L, 2L, 5L))
666 .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); 423 .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();
667 when(groupMessageMapper.selectById(80L)).thenReturn(message); 424 when(groupMessageMapper.selectById(80L)).thenReturn(message);
668 425
669 // 准备:群成员一览 426 // 准备:群成员一览
670 - // 用户 1: 正常,入群在消息之前  
671 - // 用户 2: 正常,入群在消息之前  
672 - // 用户 3: 正常,但入群在消息之后 → 不可见 427 + // 用户 1: 在快照内
  428 + // 用户 2: 在快照内
  429 + // 用户 3: 不在快照内 → 不可见
673 // 用户 5: 发送者,不计入回执 430 // 用户 5: 发送者,不计入回执
674 - List<ImGroupMemberDO> allMembers = ListUtil.of( 431 + List<ImGroupMemberDO> allMembers = List.of(
675 currentMember, 432 currentMember,
676 ImGroupMemberDO.builder().groupId(10L).userId(1L) 433 ImGroupMemberDO.builder().groupId(10L).userId(1L)
677 .status(CommonStatusEnum.ENABLE.getStatus()) 434 .status(CommonStatusEnum.ENABLE.getStatus())
@@ -681,19 +438,20 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -681,19 +438,20 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
681 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), 438 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(),
682 ImGroupMemberDO.builder().groupId(10L).userId(3L) 439 ImGroupMemberDO.builder().groupId(10L).userId(3L)
683 .status(CommonStatusEnum.ENABLE.getStatus()) 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 ImGroupMemberDO.builder().groupId(10L).userId(5L) 442 ImGroupMemberDO.builder().groupId(10L).userId(5L)
686 .status(CommonStatusEnum.ENABLE.getStatus()) 443 .status(CommonStatusEnum.ENABLE.getStatus())
687 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 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 Map<Long, Long> positions = new HashMap<>(); 449 Map<Long, Long> positions = new HashMap<>();
693 positions.put(1L, 100L); 450 positions.put(1L, 100L);
694 positions.put(2L, 50L); 451 positions.put(2L, 50L);
695 positions.put(3L, 200L); 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 // 调用:查询 messageId=80 的已读用户 456 // 调用:查询 messageId=80 的已读用户
699 List<Long> readUsers = groupMessageService.getGroupReadUserIds(5L, 10L, 80L); 457 List<Long> readUsers = groupMessageService.getGroupReadUserIds(5L, 10L, 80L);
@@ -701,7 +459,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -701,7 +459,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
701 // 断言: 459 // 断言:
702 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ 460 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓
703 // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗ 461 // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗
704 - // 用户 3: 入群在消息之后 → 不可见 → 不算 462 + // 用户 3: 不在快照内 → 不可见 → 不算
705 // 用户 5: 发送者 → 排除 463 // 用户 5: 发送者 → 排除
706 assertEquals(1, readUsers.size()); 464 assertEquals(1, readUsers.size());
707 assertTrue(readUsers.contains(1L)); 465 assertTrue(readUsers.contains(1L));
@@ -741,7 +499,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -741,7 +499,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
741 ImGroupMemberDO member = ImGroupMemberDO.builder() 499 ImGroupMemberDO member = ImGroupMemberDO.builder()
742 .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); 500 .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build();
743 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); 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 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); 503 when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1);
746 504
747 // 调用 505 // 调用
@@ -814,22 +572,26 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -814,22 +572,26 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
814 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) 572 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class)))
815 .thenReturn(groupMessageService); 573 .thenReturn(groupMessageService);
816 574
817 - // 准备:用户在群;Redis 游标从 5 前进到 100 575 + // 准备:用户在群;已读位置从 5 前进到 100
818 ImGroupMemberDO member = ImGroupMemberDO.builder() 576 ImGroupMemberDO member = ImGroupMemberDO.builder()
819 .groupId(10L).userId(1L) 577 .groupId(10L).userId(1L)
820 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 578 .status(CommonStatusEnum.ENABLE.getStatus()).build();
821 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); 579 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
822 when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() 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 // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 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 groupMessageService.readGroupMessages(1L, 10L, 100L); 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 verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); 595 verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class));
834 } 596 }
835 } 597 }
@@ -843,8 +605,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -843,8 +605,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
843 ServiceException exception = assertThrows(ServiceException.class, 605 ServiceException exception = assertThrows(ServiceException.class,
844 () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); 606 () -> groupMessageService.readGroupMessages(1L, 10L, 100L));
845 assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); 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 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); 610 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class));
849 } 611 }
850 612
@@ -877,7 +639,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -877,7 +639,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
877 ImGroupMemberDO member = ImGroupMemberDO.builder() 639 ImGroupMemberDO member = ImGroupMemberDO.builder()
878 .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); 640 .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build();
879 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); 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 ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); 644 ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO);
883 645
@@ -894,14 +656,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -894,14 +656,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
894 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 656 .status(CommonStatusEnum.ENABLE.getStatus()).build();
895 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); 657 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
896 when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() 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 groupMessageService.readGroupMessages(1L, 10L, 100L); 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 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); 672 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class));
906 } 673 }
907 674
@@ -919,7 +686,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -919,7 +686,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
919 ServiceException exception = assertThrows(ServiceException.class, 686 ServiceException exception = assertThrows(ServiceException.class,
920 () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); 687 () -> groupMessageService.readGroupMessages(1L, 10L, 100L));
921 assertEquals(MESSAGE_NOT_IN_GROUP.getCode(), exception.getCode()); 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 // ========== 回执:DONE 状态迁移 ========== 692 // ========== 回执:DONE 状态迁移 ==========
@@ -927,15 +694,16 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -927,15 +694,16 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
927 @Test 694 @Test
928 public void testReadGroupMessageEvent_receiptDoneTransition() { 695 public void testReadGroupMessageEvent_receiptDoneTransition() {
929 // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2} 696 // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2}
930 - // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE 697 + // 已读位置 1→100、2→100 → 全部已读 → 迁移为 DONE
931 ImGroupMessageDO pending = ImGroupMessageDO.builder() 698 ImGroupMessageDO pending = ImGroupMessageDO.builder()
932 .id(100L).groupId(10L).senderId(5L) 699 .id(100L).groupId(10L).senderId(5L)
  700 + .receiverUserIds(List.of(5L, 1L, 2L))
933 .sendTime(LocalDateTime.now().minusMinutes(1)) 701 .sendTime(LocalDateTime.now().minusMinutes(1))
934 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) 702 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
935 .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); 703 .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
936 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) 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 ImGroupMemberDO.builder().groupId(10L).userId(5L) 707 ImGroupMemberDO.builder().groupId(10L).userId(5L)
940 .status(CommonStatusEnum.ENABLE.getStatus()) 708 .status(CommonStatusEnum.ENABLE.getStatus())
941 .joinTime(LocalDateTime.now().minusDays(10)).build(), 709 .joinTime(LocalDateTime.now().minusDays(10)).build(),
@@ -950,7 +718,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -950,7 +718,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
950 Map<Long, Long> positions = new HashMap<>(); 718 Map<Long, Long> positions = new HashMap<>();
951 positions.put(1L, 100L); 719 positions.put(1L, 100L);
952 positions.put(2L, 100L); 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 groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); 725 groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L);
@@ -969,12 +738,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -969,12 +738,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
969 // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB 738 // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB
970 ImGroupMessageDO pending = ImGroupMessageDO.builder() 739 ImGroupMessageDO pending = ImGroupMessageDO.builder()
971 .id(100L).groupId(10L).senderId(5L) 740 .id(100L).groupId(10L).senderId(5L)
  741 + .receiverUserIds(List.of(5L, 1L, 2L))
972 .sendTime(LocalDateTime.now().minusMinutes(1)) 742 .sendTime(LocalDateTime.now().minusMinutes(1))
973 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) 743 .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
974 .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); 744 .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
975 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) 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 ImGroupMemberDO.builder().groupId(10L).userId(5L) 748 ImGroupMemberDO.builder().groupId(10L).userId(5L)
979 .status(CommonStatusEnum.ENABLE.getStatus()) 749 .status(CommonStatusEnum.ENABLE.getStatus())
980 .joinTime(LocalDateTime.now().minusDays(10)).build(), 750 .joinTime(LocalDateTime.now().minusDays(10)).build(),
@@ -989,7 +759,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -989,7 +759,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
989 Map<Long, Long> positions = new HashMap<>(); 759 Map<Long, Long> positions = new HashMap<>();
990 positions.put(1L, 100L); 760 positions.put(1L, 100L);
991 positions.put(2L, 50L); // 还没读到 100 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 groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); 765 groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L);
995 766
@@ -1008,7 +779,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1008,7 +779,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1008 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) 779 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1009 .setContent("{\"messageId\":1}"); 780 .setContent("{\"messageId\":1}");
1010 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 781 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
1011 - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L)); 782 + .thenReturn(List.of(1L, 2L, 3L, 4L));
1012 783
1013 groupMessageService.sendGroupMessage(1L, dto); 784 groupMessageService.sendGroupMessage(1L, dto);
1014 785
@@ -1025,9 +796,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1025,9 +796,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1025 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() 796 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1026 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) 797 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1027 .setContent("{\"messageId\":1}") 798 .setContent("{\"messageId\":1}")
1028 - .setReceiverUserIds(ListUtil.of(2L, 3L)); 799 + .setReceiverUserIds(List.of(2L, 3L));
1029 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 800 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
1030 - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L)); 801 + .thenReturn(List.of(1L, 2L, 3L, 4L));
1031 802
1032 groupMessageService.sendGroupMessage(1L, dto); 803 groupMessageService.sendGroupMessage(1L, dto);
1033 804
@@ -1038,20 +809,21 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1038,20 +809,21 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1038 } 809 }
1039 810
1040 @Test 811 @Test
1041 - public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() {  
1042 - // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御)  
1043 - // 预期:仅定向用户可见 812 + public void testSendGroupMessage_withReceiverUserIds_senderAlwaysIncluded() {
  813 + // 边界:发送者不在群成员缓存里(理论上应先被 validateMemberInGroup 挡住,这里纯防御)
  814 + // 预期:定向用户 + 发送者自己(始终纳入快照保证多端同步);非定向成员 4 被过滤
1044 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() 815 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1045 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) 816 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1046 .setContent("{\"messageId\":1}") 817 .setContent("{\"messageId\":1}")
1047 - .setReceiverUserIds(ListUtil.of(2L, 3L)); 818 + .setReceiverUserIds(List.of(2L, 3L));
1048 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 819 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
1049 - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L)); 820 + .thenReturn(List.of(1L, 2L, 3L, 4L));
1050 821
1051 groupMessageService.sendGroupMessage(99L, dto); 822 groupMessageService.sendGroupMessage(99L, dto);
1052 823
1053 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> 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 any(ImGroupMessageDTO.class)); 827 any(ImGroupMessageDTO.class));
1056 } 828 }
1057 829
@@ -1063,7 +835,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1063,7 +835,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1063 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() 835 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1064 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) 836 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1065 .setContent(new RecallMessage().setMessageId(50L)); 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 groupMessageService.sendGroupMessage(1L, dto); 840 groupMessageService.sendGroupMessage(1L, dto);
1069 841
@@ -1087,7 +859,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1087,7 +859,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1087 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() 859 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1088 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) 860 .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
1089 .setContent("{\"messageId\":50}").setReceipt(true); 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 groupMessageService.sendGroupMessage(1L, dto); 864 groupMessageService.sendGroupMessage(1L, dto);
1093 865
@@ -1101,7 +873,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1101,7 +873,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1101 // 准备:persistent=false 类型(RECEIPT 回执)→ 不入库,仅推送 873 // 准备:persistent=false 类型(RECEIPT 回执)→ 不入库,仅推送
1102 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() 874 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1103 .setGroupId(10L).setType(ImMessageTypeEnum.RECEIPT.getType()); 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 groupMessageService.sendGroupMessage(1L, dto); 878 groupMessageService.sendGroupMessage(1L, dto);
1107 879
@@ -1112,7 +884,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1112,7 +884,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1112 @Test 884 @Test
1113 public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() { 885 public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() {
1114 // 准备:调用方传入显式 targets(解散场景成员已被批量 DISABLE,必须按移除前快照推送) 886 // 准备:调用方传入显式 targets(解散场景成员已被批量 DISABLE,必须按移除前快照推送)
1115 - Set<Long> targets = CollUtil.newHashSet(1L, 2L, 3L); 887 + Set<Long> targets = Set.of(1L, 2L, 3L);
1116 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() 888 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1117 .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent("{}"); 889 .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent("{}");
1118 890
@@ -1126,73 +898,48 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1126,73 +898,48 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1126 // ========== getGroupMessageList ========== 898 // ========== getGroupMessageList ==========
1127 899
1128 @Test 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 ImGroupMemberDO member = ImGroupMemberDO.builder() 903 ImGroupMemberDO member = ImGroupMemberDO.builder()
1133 .id(50L).groupId(10L).userId(1L) 904 .id(50L).groupId(10L).userId(1L)
1134 .status(CommonStatusEnum.ENABLE.getStatus()) 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 ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); 909 ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO();
1139 reqVO.setGroupId(10L); 910 reqVO.setGroupId(10L);
1140 reqVO.setMaxId(100L); 911 reqVO.setMaxId(100L);
1141 reqVO.setLimit(20); 912 reqVO.setLimit(20);
1142 913
1143 - List<ImGroupMessageDO> mockList = ListUtil.of( 914 + List<ImGroupMessageDO> mockList = List.of(
1144 ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L) 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 List<ImGroupMessageDO> result = groupMessageService.getGroupMessageList(1L, reqVO); 921 List<ImGroupMessageDO> result = groupMessageService.getGroupMessageList(1L, reqVO);
1151 922
1152 - // 断言:使用成员的 joinTime 作为历史消息的下限 923 + // 断言:透传到 mapper,参数一致
1153 assertEquals(1, result.size()); 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 @Test 929 @Test
1158 public void testGetGroupMessageList_notInGroup() { 930 public void testGetGroupMessageList_notInGroup() {
1159 - // 准备:用户不在群中 931 + // 准备:用户从未在群中(getGroupMember 返回 null)
1160 ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); 932 ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO();
1161 reqVO.setGroupId(10L); 933 reqVO.setGroupId(10L);
1162 reqVO.setLimit(20); 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 ServiceException exception = assertThrows(ServiceException.class, 938 ServiceException exception = assertThrows(ServiceException.class,
1169 () -> groupMessageService.getGroupMessageList(1L, reqVO)); 939 () -> groupMessageService.getGroupMessageList(1L, reqVO));
1170 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); 940 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
1171 // 断言:不查询 DB 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,6 +10,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
10 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 10 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
11 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 11 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
12 import cn.iocoder.yudao.module.im.framework.config.ImProperties; 12 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  13 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
13 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 14 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
14 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 15 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
15 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; 16 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
@@ -47,6 +48,8 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -47,6 +48,8 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
47 @Mock 48 @Mock
48 private ImSensitiveWordService sensitiveWordService; 49 private ImSensitiveWordService sensitiveWordService;
49 @Mock 50 @Mock
  51 + private ImConversationReadService conversationReadService;
  52 + @Mock
50 private ImWebSocketService imWebSocketService; 53 private ImWebSocketService imWebSocketService;
51 54
52 /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true);个别用例可改字段测分支 */ 55 /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true);个别用例可改字段测分支 */
@@ -167,6 +170,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -167,6 +170,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
167 eq(2L), eq(1L), eq(5L), 170 eq(2L), eq(1L), eq(5L),
168 eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class))) 171 eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)))
169 .thenReturn(2); 172 .thenReturn(2);
  173 + // 读位置前进 → 才下发事件
  174 + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
  175 + .thenReturn(true);
170 176
171 // 调用 177 // 调用
172 privateMessageService.readPrivateMessages(1L, 2L, 5L); 178 privateMessageService.readPrivateMessages(1L, 2L, 5L);
yudao-module-im/src/test/resources/sql/clean.sql
1 DELETE FROM "im_private_message"; 1 DELETE FROM "im_private_message";
2 DELETE FROM "im_group_message"; 2 DELETE FROM "im_group_message";
  3 +DELETE FROM "im_conversation_read";
3 DELETE FROM "im_group"; 4 DELETE FROM "im_group";
4 DELETE FROM "im_group_member"; 5 DELETE FROM "im_group_member";
5 DELETE FROM "im_friend"; 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,6 +292,23 @@ CREATE TABLE IF NOT EXISTS &quot;im_channel_message&quot; (
292 PRIMARY KEY ("id") 292 PRIMARY KEY ("id")
293 ) COMMENT 'IM 频道消息表'; 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 CREATE TABLE IF NOT EXISTS "im_sensitive_word" ( 312 CREATE TABLE IF NOT EXISTS "im_sensitive_word" (
296 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', 313 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
297 "word" varchar(128) NOT NULL COMMENT '敏感词', 314 "word" varchar(128) NOT NULL COMMENT '敏感词',