From 6d8ad0c3740e92d49cd583cba88f466918fe21f8 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 13 Jun 2026 23:25:46 +0800 Subject: [PATCH] feat(im): 优化消息拉取与已读位置存储 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java | 50 +++++++++++++------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java | 16 +--------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java | 50 -------------------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java | 102 ------------------------------------------------------------------------------------------------------ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadService.java | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadServiceImpl.java | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java | 11 +++-------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java | 35 +++++++++++++++++++---------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java | 29 ----------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java | 28 ++++++++++++++++++++++------ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapperTest.java | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImplTest.java | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 563 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 6 ++++++ yudao-module-im/src/test/resources/sql/clean.sql | 1 + yudao-module-im/src/test/resources/sql/create_tables.sql | 17 +++++++++++++++++ 21 files changed, 962 insertions(+), 1082 deletions(-) create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadServiceImpl.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapperTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImplTest.java diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java new file mode 100644 index 0000000..bdd9fa0 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.conversation; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 会话读位置 DO + *

+ * 只表达「用户在某个会话的最大已读位置」,私聊 / 群聊 / 频道统一落这张表,是读位置的唯一权威。 + * + * @author 芋道源码 + */ +@TableName("im_conversation_read") +@KeySequence("im_conversation_read_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImConversationReadDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 会话类型 + *

+ * 枚举 {@link ImConversationTypeEnum} + */ + private Integer conversationType; + /** + * 目标编号 + *

+ * 私聊关联 AdminUserDO 的 id;群聊关联 {@link ImGroupDO#getId()};频道关联 {@link ImChannelDO#getId()} + */ + private Long targetId; + /** + * 最大已读消息编号 + *

+ * 关联 {@link ImPrivateMessageDO#getId()}、{@link ImGroupMessageDO#getId()}、{@link ImChannelMessageDO#getId()} + */ + private Long messageId; + /** + * 最近已读时间 + */ + private LocalDateTime readTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java new file mode 100644 index 0000000..2e6a672 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.im.dal.mysql.conversation; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * IM 会话读位置 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImConversationReadMapper extends BaseMapperX { + + default ImConversationReadDO selectByUserIdAndConversation(Long userId, Integer conversationType, Long conversationId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImConversationReadDO::getUserId, userId) + .eq(ImConversationReadDO::getConversationType, conversationType) + .eq(ImConversationReadDO::getTargetId, conversationId)); + } + + default List selectListByConversation(Integer conversationType, Long conversationId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImConversationReadDO::getConversationType, conversationType) + .eq(ImConversationReadDO::getTargetId, conversationId)); + } + + default List selectListByUserIdAndConversations(Long userId, Integer conversationType, + Collection conversationIds) { + return selectList(new LambdaQueryWrapperX() + .eq(ImConversationReadDO::getUserId, userId) + .eq(ImConversationReadDO::getConversationType, conversationType) + .in(ImConversationReadDO::getTargetId, conversationIds)); + } + + /** + * 单调递增更新读位置:仅当新位置更大时才更新 + *

+ * 通过 {@code read_message_id < messageId} 的 CAS 条件,保证乱序 / 并发上报时读位置不会回退。 + * + * @param id 记录编号 + * @param messageId 新的最大已读消息编号 + * @param readTime 已读时间 + * @return 影响行数;0 表示新位置不大于已有位置,未更新 + */ + default int updateReadMessageIdToLarger(Long id, Long messageId, LocalDateTime readTime) { + return update(null, Wrappers.lambdaUpdate() + .set(ImConversationReadDO::getMessageId, messageId) + .set(ImConversationReadDO::getReadTime, readTime) + .eq(ImConversationReadDO::getId, id) + .lt(ImConversationReadDO::getMessageId, messageId)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java index 84736fd..e37165c 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java +++ b/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; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; @@ -11,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import org.apache.ibatis.annotations.Mapper; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; /** @@ -22,48 +24,22 @@ import java.util.List; public interface ImGroupMessageMapper extends BaseMapperX { /** - * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用) + * 根据 minId + 时间窗口增量拉取群聊消息 * - * @param groupIds 用户当前仍在群内的群编号列表 + * @param userId 当前用户编号 + * @param groupIds 候选群编号集合(当前在群 ∪ 窗口内退群),不能为空 * @param minId 最小消息 id(不含) * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 * @param size 拉取数量 * @return 消息列表(按 id 升序) */ - default List selectListByMinId(List groupIds, Long minId, + default List selectListByMinId(Long userId, Collection groupIds, Long minId, LocalDateTime minSendTime, Integer size) { QueryWrapperX wrapper = new QueryWrapperX<>(); wrapper.in("group_id", groupIds) .gt("id", minId) .gt("send_time", minSendTime) - .orderByAsc("id"); - wrapper.limitN(size); - return selectList(wrapper); - } - - /** - * 查询"退群前"的离线消息(退群成员使用) - *

- * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 - *

- * 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。 - * - * @param groupId 群编号 - * @param minId 最小消息 id(不含) - * @param minSendTime 最早发送时间(不含) - * @param quitTime 退群时间(含),仅返回退群当时已存在的消息 - * @param size 拉取数量 - * @return 消息列表(按 id 升序) - */ - default List selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId, - LocalDateTime minSendTime, - LocalDateTime quitTime, - Integer size) { - QueryWrapperX wrapper = new QueryWrapperX<>(); - wrapper.eq("group_id", groupId) - .gt("id", minId) - .gt("send_time", minSendTime) - .le("send_time", quitTime) + .apply(MyBatisUtils.findInSet("receiver_user_ids"), userId) .orderByAsc("id"); wrapper.limitN(size); return selectList(wrapper); @@ -72,17 +48,17 @@ public interface ImGroupMessageMapper extends BaseMapperX { /** * 查询群聊历史消息(游标拉取) * - * @param groupId 群编号 - * @param maxId 起始消息 id(不含),为空则从最新开始 - * @param limit 拉取数量 - * @param joinTime 入群时间,仅返回入群之后的消息 + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param maxId 起始消息 id(不含),为空则从最新开始 + * @param limit 拉取数量 * @return 消息列表(按 id 倒序) */ - default List selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) { + default List selectHistoryListByUser(Long userId, Long groupId, Long maxId, Integer limit) { QueryWrapperX wrapper = new QueryWrapperX<>(); wrapper.eq("group_id", groupId) .lt(maxId != null, "id", maxId) - .ge(joinTime != null, "send_time", joinTime) + .apply(MyBatisUtils.findInSet("receiver_user_ids"), userId) .orderByDesc("id"); wrapper.limitN(limit); return selectList(wrapper); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java index 247ad33..92d3890 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java +++ b/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; public interface RedisKeyConstants { /** - * 群消息已读位置 - * KEY 格式: im:group:message:read:{groupId} - * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId) - */ - String GROUP_MESSAGE_READ = "im:group:message:read:%s"; - - /** - * 频道消息已读位置 - * KEY 格式: im:channel:message:read:{channelId} - * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId) - */ - String CHANNEL_MESSAGE_READ = "im:channel:message:read:%s"; - - /** * 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态) *

* KEY 格式:friend_state:{userId}_{friendUserId} @@ -44,7 +30,7 @@ public interface RedisKeyConstants { * KEY 格式:group_member_ids:{groupId} * VALUE 数据类型:List *

- * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。 + * 说明:只缓存轻量的 userId 列表,适合「群消息推送目标」这类只关心 userId 的场景 */ String GROUP_MEMBER_IDS = "group_member_ids"; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java deleted file mode 100644 index bda4b33..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java +++ /dev/null @@ -1,50 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.redis.message; - -import cn.hutool.core.convert.Convert; -import jakarta.annotation.Resource; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Repository; - -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.CHANNEL_MESSAGE_READ; - -/** - * IM 频道消息已读位置 Redis DAO - * - * @author 芋道源码 - */ -@Repository -public class ImChannelMessageReadRedisDAO { - - @Resource - private StringRedisTemplate stringRedisTemplate; - - /** - * 更新用户在某频道的最大已读消息编号 - * - * @param channelId 频道编号 - * @param userId 用户编号 - * @param maxMessageId 最大已读消息编号 - */ - public void updateReadMaxMessageId(Long channelId, Long userId, Long maxMessageId) { - String key = formatKey(channelId); - stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); - } - - /** - * 获取用户在某频道的最大已读消息编号 - * - * @param channelId 频道编号 - * @param userId 用户编号 - * @return 最大已读消息编号;不存在则返回 null - */ - public Long getReadMaxMessageId(Long channelId, Long userId) { - String key = formatKey(channelId); - Object val = stringRedisTemplate.opsForHash().get(key, userId.toString()); - return Convert.toLong(val); - } - - private static String formatKey(Long channelId) { - return String.format(CHANNEL_MESSAGE_READ, channelId); - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java deleted file mode 100644 index 5c76f79..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java +++ /dev/null @@ -1,102 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.redis.message; - -import cn.hutool.core.convert.Convert; -import jakarta.annotation.Resource; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Repository; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ; - -/** - * IM 群消息已读位置 Redis DAO - * - * @author 芋道源码 - */ -@Repository -public class ImGroupMessageReadRedisDAO { - - @Resource - private StringRedisTemplate stringRedisTemplate; - - /** - * 更新用户在某群的最大已读消息编号 - * - * @param groupId 群编号 - * @param userId 用户编号 - * @param maxMessageId 最大已读消息编号 - */ - public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) { - String key = formatKey(groupId); - stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); - } - - /** - * 获取用户在某群的最大已读消息编号 - * - * @param groupId 群编号 - * @param userId 用户编号 - * @return 最大已读消息编号,不存在则返回 null - */ - public Long getReadMaxMessageId(Long groupId, Long userId) { - String key = formatKey(groupId); - Object val = stringRedisTemplate.opsForHash().get(key, userId.toString()); - return Convert.toLong(val); - } - - /** - * 获取某群所有用户的已读位置 - * - * @param groupId 群编号 - * @return userId → maxReadMessageId 映射 - */ - public Map getReadMaxMessageIdMap(Long groupId) { - String key = formatKey(groupId); - Map entries = stringRedisTemplate.opsForHash().entries(key); - // 转换为 Long → Long 的 Map - Map result = new HashMap<>(entries.size()); - entries.forEach((k, v) -> result.put(Long.parseLong(k.toString()), Long.parseLong(v.toString()))); - return result; - } - - /** - * 删除用户在某群的已读位置 - * - * @param groupId 群编号 - * @param userId 用户编号 - */ - public void deleteReadMaxMessageId(Long groupId, Long userId) { - String key = formatKey(groupId); - stringRedisTemplate.opsForHash().delete(key, userId.toString()); - } - - /** - * 批量删除用户在某群的已读位置 - * - * @param groupId 群编号 - * @param userIds 用户编号集合 - */ - public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { - String key = formatKey(groupId); - Object[] hashKeys = userIds.stream().map(String::valueOf).toArray(); - stringRedisTemplate.opsForHash().delete(key, hashKeys); - } - - /** - * 删除某群所有用户的已读位置(整个 Hash Key) - * - * @param groupId 群编号 - */ - public void deleteReadMaxMessageIdMap(Long groupId) { - String key = formatKey(groupId); - stringRedisTemplate.delete(key); - } - - private static String formatKey(Long groupId) { - return String.format(GROUP_MESSAGE_READ, groupId); - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadService.java new file mode 100644 index 0000000..fd843ff --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadService.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.im.service.conversation; + +import java.util.Collection; +import java.util.Map; + +/** + * IM 会话读位置 Service 接口 + * + * @author 芋道源码 + */ +public interface ImConversationReadService { + + /** + * 更新用户在某会话的最大已读位置(单调递增,乱序 / 并发上报不会回退) + * + * @param userId 用户编号 + * @param conversationType 会话类型 + * @param conversationId 会话编号 + * @param readMessageId 已读到的最大消息编号 + * @return 读位置是否前进;true 时调用方才需要下发已读 / 回执事件 + */ + boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId); + + /** + * 获取用户在某会话的最大已读位置 + * + * @param userId 用户编号 + * @param conversationType 会话类型 + * @param conversationId 会话编号 + * @return 最大已读消息编号;不存在则返回 null + */ + Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId); + + /** + * 获取某会话内所有用户的读位置(用于群回执人数聚合) + * + * @param conversationType 会话类型 + * @param conversationId 会话编号 + * @return userId → 最大已读消息编号 + */ + Map getUserReadMessageIdMap(Integer conversationType, Long conversationId); + + /** + * 批量获取某用户在多个会话的读位置(用于频道批量读位置、重连后按活跃会话补偿) + * + * @param userId 用户编号 + * @param conversationType 会话类型 + * @param conversationIds 会话编号集合 + * @return conversationId → 最大已读消息编号 + */ + Map getConversationReadMessageIdMap(Long userId, Integer conversationType, Collection conversationIds); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadServiceImpl.java new file mode 100644 index 0000000..bcfb945 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadServiceImpl.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.im.service.conversation; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO; +import cn.iocoder.yudao.module.im.dal.mysql.conversation.ImConversationReadMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * IM 会话读位置 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class ImConversationReadServiceImpl implements ImConversationReadService { + + @Resource + private ImConversationReadMapper conversationReadMapper; + + @Override + public boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId) { + LocalDateTime now = LocalDateTime.now(); + // 1. 不存在则插入;并发下唯一键冲突,降级为回查 + CAS 更新 + ImConversationReadDO existing = conversationReadMapper.selectByUserIdAndConversation( + userId, conversationType, conversationId); + if (existing == null) { + try { + conversationReadMapper.insert(new ImConversationReadDO().setUserId(userId) + .setConversationType(conversationType).setTargetId(conversationId) + .setMessageId(readMessageId).setReadTime(now)); + return true; + } catch (DuplicateKeyException e) { + log.warn("[updateConversationReadPosition][userId({}) type({}) conversationId({}) 并发插入冲突,回查更新]", + userId, conversationType, conversationId); + existing = conversationReadMapper.selectByUserIdAndConversation(userId, conversationType, conversationId); + } + } + if (existing == null) { + return false; + } + + // 2. CAS 单调更新:mapper 内 WHERE message_id < ? 保证乱序 / 并发不回退;影响行数 > 0 即读位置前进 + return conversationReadMapper.updateReadMessageIdToLarger(existing.getId(), readMessageId, now) > 0; + } + + @Override + public Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId) { + ImConversationReadDO read = conversationReadMapper.selectByUserIdAndConversation( + userId, conversationType, conversationId); + return read != null ? read.getMessageId() : null; + } + + @Override + public Map getUserReadMessageIdMap(Integer conversationType, Long conversationId) { + return convertMap(conversationReadMapper.selectListByConversation(conversationType, conversationId), + ImConversationReadDO::getUserId, ImConversationReadDO::getMessageId); + } + + @Override + public Map getConversationReadMessageIdMap(Long userId, Integer conversationType, + Collection conversationIds) { + if (CollUtil.isEmpty(conversationIds)) { + return Collections.emptyMap(); + } + List list = conversationReadMapper.selectListByUserIdAndConversations( + userId, conversationType, conversationIds); + return convertMap(list, ImConversationReadDO::getTargetId, ImConversationReadDO::getMessageId); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java index 61f0273..7880a15 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java @@ -193,8 +193,6 @@ public class ImGroupServiceImpl implements ImGroupService { .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now())); // 2.2 移除全部群成员 groupMemberService.removeGroupMembersByGroupId(id); - // 2.3 清理已读缓存 - groupMessageService.deleteReadMaxMessageIdMap(id); } // ==================== 群成员的写操作 ==================== @@ -267,10 +265,8 @@ public class ImGroupServiceImpl implements ImGroupService { // 2. 先发广播,后移成员(见类 javadoc) groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId)); - // 3.1 移除群成员 + // 3. 移除群成员 groupMemberService.removeGroupMember(groupId, userId); - // 3.2 清理已读缓存 - groupMessageService.deleteReadMaxMessageId(groupId, userId); } @Override @@ -307,10 +303,8 @@ public class ImGroupServiceImpl implements ImGroupService { groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds)); - // 3.1 批量移除群成员 + // 3. 批量移除群成员;不清理读位置(保留退群前历史已读,供离线补偿) groupMemberService.removeGroupMembers(groupId, validTargetUserIds); - // 3.2 批量清理已读缓存 - groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds); } @Override @@ -599,6 +593,7 @@ public class ImGroupServiceImpl implements ImGroupService { return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); } + // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤! @Override public List getMyGroupList(Long userId) { // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java index cbdf710..ab6edf5 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java +++ b/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; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; 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 import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper; -import cn.iocoder.yudao.module.im.dal.redis.message.ImChannelMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage; @@ -25,7 +27,6 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.Collection; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -50,7 +51,7 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService { private ImWebSocketService webSocketService; @Resource - private ImChannelMessageReadRedisDAO channelMessageReadRedisDAO; + private ImConversationReadService conversationReadService; // ==================== 用户端 ==================== @@ -61,28 +62,30 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService { @Override public Map getChannelReadMaxMessageIdMap(Long userId, Collection channelIds) { - Map result = new HashMap<>(channelIds.size()); - for (Long channelId : channelIds) { - Long max = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId); - if (max != null) { - result.put(channelId, max); - } - } - return result; + return conversationReadService.getConversationReadMessageIdMap( + userId, ImConversationTypeEnum.CHANNEL.getType(), channelIds); } @Override public void readChannelMessages(Long userId, Long channelId, Long messageId) { Assert.notNull(channelId, "频道编号不能为空"); Assert.notNull(messageId, "已读消息编号不能为空"); - // 1. 已读位置未前进,直接返回 - Long prevMaxMessageId = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId); - if (prevMaxMessageId != null && prevMaxMessageId >= messageId) { + // 1.1 校验消息真实存在且属于该频道,避免未来 / 伪造 messageId 污染读位置 + ImChannelMessageDO message = channelMessageMapper.selectById(messageId); + if (message == null || ObjUtil.notEqual(message.getChannelId(), channelId)) { + return; + } + // 1.2 定向消息校验对当前用户可见(receiver_user_ids 为空表示全员可见) + if (CollUtil.isNotEmpty(message.getReceiverUserIds()) && !message.getReceiverUserIds().contains(userId)) { return; } - // 2. 更新 Redis 频道已读位置 - channelMessageReadRedisDAO.updateReadMaxMessageId(channelId, userId, messageId); + // 2. 更新频道已读位置;读位置未前进则不推 + boolean advanced = conversationReadService.updateConversationReadPosition( + userId, ImConversationTypeEnum.CHANNEL.getType(), channelId, messageId); + if (!advanced) { + return; + } // 3. 异步推 READ 事件给自己多端同步 getSelf().readChannelMessageEvent(userId, channelId, messageId); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java index 51a28f1..77426d1 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java @@ -98,35 +98,6 @@ public interface ImGroupMessageService { */ List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO); - /** - * 清理用户在某群的已读位置缓存 - *

- * 用于成员退群场景 - * - * @param groupId 群编号 - * @param userId 用户编号 - */ - void deleteReadMaxMessageId(Long groupId, Long userId); - - /** - * 批量清理用户在某群的已读位置缓存 - *

- * 用于批量踢出场景 - * - * @param groupId 群编号 - * @param userIds 用户编号集合 - */ - void deleteReadMaxMessageIds(Long groupId, Collection userIds); - - /** - * 清理某群所有用户的已读位置缓存 - *

- * 用于群解散场景 - * - * @param groupId 群编号 - */ - void deleteReadMaxMessageIdMap(Long groupId); - // ==================== 管理后台 ==================== /** diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index bfdefc9..fc85520 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/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; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; @@ -17,12 +16,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; -import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService; import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.group.ImGroupService; 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; import cn.iocoder.yudao.module.im.util.ImMessageUtils; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,7 +42,6 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.*; -import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; @@ -57,17 +57,8 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; @Slf4j public class ImGroupMessageServiceImpl implements ImGroupMessageService { - /** - * 仅用于规避群消息 pull 的"假空页": - * 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。 - * 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。 - */ - private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3; - @Resource private ImGroupMessageMapper groupMessageMapper; - @Resource - private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO; @Resource private ImGroupService groupService; @@ -75,6 +66,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { private ImGroupMemberService groupMemberService; @Resource private ImSensitiveWordService sensitiveWordService; + @Resource + private ImConversationReadService conversationReadService; @Resource private ImWebSocketService imWebSocketService; @@ -104,41 +97,51 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { sensitiveWordService.validateText(reqVO.getContent()); } - // 2.1 引用 quote 消息规范化 - reqVO.setContent(normalizeQuoteContent(reqVO, senderMember)); - // 2.2 构建并保存消息 + // 2.1 固化发送当时可见成员快照(含发送者),新群消息 receiver_user_ids 不能为空;用户发送均为全员广播 + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); + // TODO @AI:这里是不是没必要过滤???因为发送者,肯定是群成员呀!!! + Set receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds); + // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏) + reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds)); + // 2.3 构建并保存消息;唯一键冲突时回查已存在消息返回 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setReceiverUserIds(new ArrayList<>(receiverUserIds)) .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt()))); - groupMessageMapper.insert(message); + try { + groupMessageMapper.insert(message); + } catch (DuplicateKeyException e) { + log.warn("[sendGroupMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]", + senderId, reqVO.getClientMessageId()); + return groupMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId()); + } - // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步) - List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); - Set targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds); - imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); + // 3. WebSocket 异步推送(快照内可见成员 + 发送方多端同步) + imWebSocketService.sendGroupMessageAsync(receiverUserIds, ImGroupMessageDTO.ofSend(message)); return message; } @Override public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) { List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId()); - Set targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds); - return sendGroupMessage(senderId, targetUserIds, dto); + Set receiverUserIds = getReceiverUserIds(dto.getReceiverUserIds(), senderId, memberUserIds); + return sendGroupMessage(senderId, receiverUserIds, dto); } @Override - public ImGroupMessageDO sendGroupMessage(Long senderId, Collection targetUserIds, ImGroupMessageSendDTO dto) { + public ImGroupMessageDO sendGroupMessage(Long senderId, Collection receiverUserIds, ImGroupMessageSendDTO dto) { // 1.1 content 序列化:null / String 透传,POJO 走 JSON Object payload = dto.getContent(); String contentString = payload == null || payload instanceof String ? (String) payload : JsonUtils.toJsonString(payload); - // 1.2 构建并保存消息 + // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇? + // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照) ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) .setSenderId(senderId).setGroupId(dto.getGroupId()) .setType(dto.getType()).setContent(contentString) .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) - .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) + .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(new ArrayList<>(receiverUserIds)) .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())); // 1.3 按 type.persistent 决定是否入库 if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { @@ -146,7 +149,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { } // 2. WebSocket 异步推送 - imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); + imWebSocketService.sendGroupMessageAsync(receiverUserIds, ImGroupMessageDTO.ofSend(message)); return message; } @@ -195,114 +198,32 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); - // 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试 - List activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); - Map memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId); - List messages = new ArrayList<>(); - if (CollUtil.isNotEmpty(activeMembers)) { - List groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); - messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap)); - } - // 1.2 补齐"退群前"的消息: - // - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进; - // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,避免把客户端已拥有的老消息再次推送。 - messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap)); - - // 2. 合并后统一过滤,得到当前用户可见的结果 - List result = filterGroupMessageList(messages, memberMap, userId, size); - - // 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 - appendMessageStatusAndReceipt(userId, result); - log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size()); - return result; - } - - /** - * 拉取当前仍在群的主路径消息。 - * - * 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测, - * 直到命中可见消息或确认该来源已耗尽。 - */ - private List pullActiveGroupMessageList(Long userId, List groupIds, Long minId, - Integer size, LocalDateTime minSendTime, - Map memberMap) { - // 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界 - Long activeMinId = minId; - for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) { - // 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束 - List messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId, - minSendTime, size); - if (CollUtil.isEmpty(messages)) { - return Collections.emptyList(); - } - boolean hasVisibleMessage = CollUtil.anyMatch(messages, - message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId)); - boolean sourceExhausted = messages.size() < size; - // 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息 - if (hasVisibleMessage || sourceExhausted) { - return messages; - } - - // 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止 - Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId); - if (maxMessageId == null || maxMessageId <= activeMinId) { - return Collections.emptyList(); - } - activeMinId = maxMessageId; + // 1. 候选群 = 当前在群 ∪ 窗口内退群;退群用户仍可拉到退群前已写入自己 ID 的历史消息 + // TODO @AI:直接查询所有的群(一次性,这样性能也好点呀);然后这里不用过滤 minSendTime 吧; + Set groupIds = new HashSet<>(); + groupIds.addAll(convertList(groupMemberService.getActiveGroupMemberListByUserId(userId), + ImGroupMemberDO::getGroupId)); + groupIds.addAll(convertList(groupMemberService.getQuitGroupMemberListByUserId(userId, minSendTime), + ImGroupMemberDO::getGroupId)); + if (CollUtil.isEmpty(groupIds)) { + return Collections.emptyList(); } - return Collections.emptyList(); - } - /** - * 拉取已离开群路径的消息(退群前消息) - */ - private List pullQuitGroupMessageList(Long userId, Long minId, Integer size, - LocalDateTime minSendTime, - Map memberMap) { - // 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤 - LocalDateTime minQuitTime = minSendTime; - if (minId != null && minId > 0) { - ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId); - if (minMessage != null && minMessage.getSendTime() != null - && minMessage.getSendTime().isAfter(minSendTime)) { - minQuitTime = minMessage.getSendTime(); - } - } - // 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限 - List messages = new ArrayList<>(); - List quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime); - for (ImGroupMemberDO quitMember : quitMembers) { - // 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用 - List quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); - if (CollUtil.isEmpty(quitGroupMessages)) { - continue; - } - messages.addAll(quitGroupMessages); - memberMap.put(quitMember.getGroupId(), quitMember); - } - return messages; - } + // TODO @AI:注释是不是优化下,不用按照“单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤” + // 2. 单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤 + List messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size); - /** - * 过滤一批原始群消息,得到当前用户可见的返回结果 - */ - private List filterGroupMessageList(List messages, - Map memberMap, - Long userId, Integer size) { - // 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条, - // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息 - return messages.stream() - .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) - .sorted(Comparator.comparing(ImGroupMessageDO::getId)) - .limit(size) - .collect(Collectors.toList()); + // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数 + appendMessageStatusAndReceipt(userId, messages); + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, messages.size()); + return messages; } + // TODO @AI:要不要改下,把“回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数”都计算?! /** * 补全消息已读态和回执已读人数 - * - * 1. 消息已读态(status):根据 Redis 已读游标判断 READ / UNREAD + *

+ * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数 */ @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) @@ -310,18 +231,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { if (CollUtil.isEmpty(messages)) { return; } - // 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐 + // 群已读关闭:不查读位置,status 保持 DB 原值(含撤回),readCount 不补齐 if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { return; } - Map readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 + Map readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 当前用户已读位置 Map> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) Map> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 for (ImGroupMessageDO message : messages) { // 消息已读态(status):撤回 > 已读 > 未读 Long groupId = message.getGroupId(); long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> { - Long readMaxMsgId = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId); + Long readMaxMsgId = conversationReadService.getConversationReadMessageId( + userId, ImConversationTypeEnum.GROUP.getType(), gid); return readMaxMsgId != null ? readMaxMsgId : -1L; }); if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { @@ -338,12 +260,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { continue; } Map positions = readPositionsByGroup.computeIfAbsent(groupId, - gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid)); - List allMembers = membersByGroup.computeIfAbsent(groupId, - groupMemberService::getGroupMemberListByGroupId); - Set visibleUserIds = getVisibleUserIds(message, allMembers); - visibleUserIds.remove(message.getSenderId()); - int readCount = getSumValue(visibleUserIds, + gid -> conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), gid)); + // 应读分母取当前有效成员(剔除已退群),与异步回执刷新、前端已读弹层口径一致 + List activeMembers = membersByGroup.computeIfAbsent(groupId, + groupMemberService::getActiveGroupMemberListByGroupId); + Set receiverUserIds = getReceiverUserIds(message, activeMembers); + receiverUserIds.remove(message.getSenderId()); + int readCount = getSumValue(receiverUserIds, uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, Integer::sum, 0); message.setReadCount(readCount); @@ -358,24 +281,25 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { } Assert.notNull(messageId, "已读消息编号不能为空"); // 1.1 校验用户在群中(权限校验) - ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); - // 1.2 校验消息属于当前群,且对当前用户可见 + groupMemberService.validateMemberInGroup(groupId, userId); + // 1.2 校验消息属于当前群,且对当前用户可见(按快照) ImGroupMessageDO message = groupMessageMapper.selectById(messageId); if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId) - || !isMessageVisible(message, member, userId)) { + || !isMessageVisible(message, userId)) { throw exception(MESSAGE_NOT_IN_GROUP); } - // 2. 已读位置未前进,直接返回 - Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId); - if (prevMaxMessageId != null && prevMaxMessageId >= messageId) { + // 2. 取旧读位置(用于回执刷新区间),再更新群已读位置;读位置未前进则不推 + Long prevMaxMessageId = conversationReadService.getConversationReadMessageId( + userId, ImConversationTypeEnum.GROUP.getType(), groupId); + boolean advanced = conversationReadService.updateConversationReadPosition( + userId, ImConversationTypeEnum.GROUP.getType(), groupId, messageId); + if (!advanced) { return; } - // 3. 更新 Redis 群已读位置 - groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId); - // 4. 异步发送 READ 事件 + 刷新范围内的群回执 + // 3. 异步发送 READ 事件 + 刷新范围内的群回执 getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId); } @@ -386,13 +310,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { throw exception(MESSAGE_GROUP_READ_DISABLED); } // 1.1 校验用户在群中(权限校验) - ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); + groupMemberService.validateMemberInGroup(groupId, userId); // 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方 ImGroupMessageDO message = groupMessageMapper.selectById(messageId); if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { return Collections.emptyList(); } - if (!isMessageVisible(message, operator, userId)) { + if (!isMessageVisible(message, userId)) { return Collections.emptyList(); } // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 @@ -400,18 +324,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { return Collections.emptyList(); } - // 2. 获取所有成员和已读位置 - List allMembers = groupMemberService.getGroupMemberListByGroupId(groupId); - Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); + // 2. 获取当前有效成员和已读位置(剔除已退群,与回执分母口径一致) + List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); + Map allPositions = conversationReadService.getUserReadMessageIdMap( + ImConversationTypeEnum.GROUP.getType(), groupId); // 3. 计算该消息的可见成员集合(排除发送者自己) - Set visibleUserIds = getVisibleUserIds(message, allMembers); - visibleUserIds.remove(message.getSenderId()); + Set receiverUserIds = getReceiverUserIds(message, activeMembers); + receiverUserIds.remove(message.getSenderId()); // 4. 只返回在可见范围内且已读位置 >= messageId 的用户 List readUserIds = new ArrayList<>(); allPositions.forEach((uid, readMaxMessageId) -> { - if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) { + if (receiverUserIds.contains(uid) && readMaxMessageId >= messageId) { readUserIds.add(uid); } }); @@ -420,15 +345,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @Override public List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) { - // 1. 校验用户在群中 - ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); - - // 2. 查询历史消息(仅入群之后) - List messages = groupMessageMapper.selectHistoryList( - reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime()); + // 1. 校验用户曾经在群(当前在群或已退群),与 pull 退群窗口口径一致;内容仍由 receiver_user_ids 快照过滤 + if (groupMemberService.getGroupMember(reqVO.getGroupId(), userId) == null) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } - // 3. 过滤定向消息:仅保留当前用户可见的(receiverUserIds 为空 / 含当前用户 / 本人发送) - return filterList(messages, message -> isMessageVisible(message, member, userId)); + // 2. 查询历史消息:SQL 已按 receiver_user_ids 快照过滤当前用户可见 + return groupMessageMapper.selectHistoryListByUser( + userId, reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit()); } // ========== 异步 WebSocket 推送 ========== @@ -455,20 +379,21 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { return; } List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); - Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); + Map allPositions = conversationReadService.getUserReadMessageIdMap( + ImConversationTypeEnum.GROUP.getType(), groupId); for (ImGroupMessageDO message : pendingMessages) { - // 2.1.1 统计可见成员中的已读人数 - Set visibleUserIds = getVisibleUserIds(message, activeMembers); - visibleUserIds.remove(message.getSenderId()); // 发送者自己不算已读 - if (CollUtil.isEmpty(visibleUserIds)) { + // 2.1.1 统计可见成员中的已读人数(应读分母 = 快照 ∩ 当前有效成员 - sender) + Set receiverUserIds = getReceiverUserIds(message, activeMembers); + receiverUserIds.remove(message.getSenderId()); // 发送者自己不算已读 + if (CollUtil.isEmpty(receiverUserIds)) { continue; } - int readCount = getSumValue(visibleUserIds, + int readCount = getSumValue(receiverUserIds, uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, Integer::sum, 0); // 2.1.2 全部已读 → 标记回执完成 Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus(); - if (readCount >= visibleUserIds.size()) { + if (readCount >= receiverUserIds.size()) { newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus(); groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId()) .setReceiptStatus(newReceiptStatus)); @@ -487,9 +412,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { * * @param reqVO 发送请求 * @param senderMember 发送人成员 + * @param newAudience 新消息可见成员集合(发送当时快照) * @return 规范化后的 content */ - private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember) { + private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember, + Set newAudience) { // 解析客户端 content 里的 quote.messageId Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent()); @@ -508,12 +435,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) { throw exception(MESSAGE_QUOTE_INVALID); } - // 拒绝定向消息(仅发送人可见的内容若被全员广播 quote.content,会泄漏给原本看不到的成员) - if (CollUtil.isNotEmpty(original.getReceiverUserIds())) { + // 校验对发送人可见(按快照) + if (!isMessageVisible(original, senderMember.getUserId())) { throw exception(MESSAGE_QUOTE_INVALID); } - // 校验对发送人可见(入群时间 / 退群时间) - if (!isMessageVisible(original, senderMember, senderMember.getUserId())) { + // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用; + // 否则只对部分成员可见的定向消息内容,会随广播引用泄漏给原本看不到的成员 + if (!new HashSet<>(original.getReceiverUserIds()).containsAll(newAudience)) { throw exception(MESSAGE_QUOTE_INVALID); } // 构建 quote 对象并注入 content @@ -536,88 +464,60 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { return message; } + // TODO @AI:方法名,是不是可以优化下,对齐 ReceiverUserIds; /** - * 判断一条群消息对某个群成员是否可见 + * 判断一条群消息对某个用户是否可见 + *

+ * 可见性以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 * - * @param msg 消息 - * @param member 群成员 - * @param userId 当前用户编号(用于定向消息过滤) + * @param message 消息 + * @param userId 当前用户编号 * @return 是否可见 */ - private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) { - if (member == null) { - return false; - } - // 1. 入群时间晚于消息发送时间 → 不可见 - if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) { - return false; - } - // 2. 已退群且退群时间早于消息发送时间 → 不可见 - if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus()) - && member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) { - return false; - } - // 3.1 无定向接收列表 → 全员可见 - if (CollUtil.isEmpty(msg.getReceiverUserIds())) { - return true; - } - // 3.2 当前用户在定向列表中,或本人即发送者 → 可见 - return msg.getReceiverUserIds().contains(userId) - || ObjUtil.equal(msg.getSenderId(), userId); + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean isMessageVisible(ImGroupMessageDO message, Long userId) { + return CollUtil.contains(message.getReceiverUserIds(), userId); } + // TODO @AI: ∩ 是啥意思,有点奇怪。 /** - * 计算一条群消息的可见成员集合(含发送者) + * 计算一条群消息的可见成员集合(快照 ∩ 传入成员,含发送者) */ - private Set getVisibleUserIds(ImGroupMessageDO message, List members) { - return convertSet(members, ImGroupMemberDO::getUserId, - member -> isMessageVisible(message, member, member.getUserId())); + private Set getReceiverUserIds(ImGroupMessageDO message, List members) { + if (CollUtil.isEmpty(message.getReceiverUserIds())) { + return new HashSet<>(); + } + Set snapshot = new HashSet<>(message.getReceiverUserIds()); + return convertSet(members, ImGroupMemberDO::getUserId, member -> snapshot.contains(member.getUserId())); } /** - * 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。 + * 基于群成员 userId 列表,计算一条新消息的可见成员快照(含发送者)。 *

- * 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定, - * 只应用 {@code receiverUserIds} 定向过滤;语义与 - * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 + * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集; + * 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。 */ - private Set getVisibleUserIds(List receiverUserIds, Long senderId, Collection memberUserIds) { - if (CollUtil.isEmpty(memberUserIds)) { - return new HashSet<>(); - } + private Set getReceiverUserIds(List receiverUserIds, Long senderId, Collection memberUserIds) { + Set result = new HashSet<>(); // 无定向接收列表 → 全员可见 if (CollUtil.isEmpty(receiverUserIds)) { - return new HashSet<>(memberUserIds); + result.addAll(memberUserIds); + } else { + // 有定向接收列表 → 仅定向用户且仍为成员可见 + Set allowed = new HashSet<>(receiverUserIds); + for (Long userId : memberUserIds) { + if (allowed.contains(userId)) { + result.add(userId); + } + } } - // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) - Set allowed = new HashSet<>(receiverUserIds); + // 发送者始终可见(多端同步),即便成员缓存暂未包含 if (senderId != null) { - allowed.add(senderId); - } - Set result = new HashSet<>(); - for (Long userId : memberUserIds) { - if (allowed.contains(userId)) { - result.add(userId); - } + result.add(senderId); } return result; } - @Override - public void deleteReadMaxMessageId(Long groupId, Long userId) { - groupMessageReadRedisDAO.deleteReadMaxMessageId(groupId, userId); - } - - @Override - public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { - groupMessageReadRedisDAO.deleteReadMaxMessageIds(groupId, userIds); - } - - @Override - public void deleteReadMaxMessageIdMap(Long groupId) { - groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId); - } - // ==================== 管理后台 ==================== @Override diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java index cd83a2d..d04f8ba 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ b/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 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; @@ -25,6 +27,7 @@ import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; import cn.iocoder.yudao.module.im.util.ImMessageUtils; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -52,6 +55,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { private ImFriendService friendService; @Resource private ImSensitiveWordService sensitiveWordService; + @Resource + private ImConversationReadService conversationReadService; @Resource private ImWebSocketService imWebSocketService; @@ -80,10 +85,16 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { // 2.1 引用 quote 消息规范化 reqVO.setContent(normalizeQuoteContent(reqVO, senderId)); - // 2.2 构建并保存消息 + // 2.2 构建并保存消息;唯一键冲突时回查已存在消息返回 ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); - privateMessageMapper.insert(message); + try { + privateMessageMapper.insert(message); + } catch (DuplicateKeyException e) { + log.warn("[sendPrivateMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]", + senderId, reqVO.getClientMessageId()); + return privateMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId()); + } // 3. WebSocket 异步推送:接收方 + 发送方多端同步 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); @@ -211,16 +222,21 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { throw exception(MESSAGE_PRIVATE_READ_DISABLED); } Assert.notNull(messageId, "已读消息编号不能为空"); - // 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读 + + // 2. status 双写:把 (receiverId → userId) 上 id <= messageId 的未读消息置为已读 // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口 - int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( + privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(), new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); - if (updated == 0) { + + // 3. 同步写 im_conversation_read(读位置唯一权威,单调递增);读位置前进才下发事件 + boolean advanced = conversationReadService.updateConversationReadPosition( + userId, ImConversationTypeEnum.PRIVATE.getType(), receiverId, messageId); + if (!advanced) { return; } - // 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) + // 4. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) imWebSocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofRead(userId, receiverId, messageId)); imWebSocketService.sendPrivateMessageAsync(receiverId, diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapperTest.java new file mode 100644 index 0000000..b56845f --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapperTest.java @@ -0,0 +1,105 @@ +package cn.iocoder.yudao.module.im.dal.mysql.conversation; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link ImConversationReadMapper} 的单元测试 + * + * @author 芋道源码 + */ +public class ImConversationReadMapperTest extends BaseDbUnitTest { + + @Resource + private ImConversationReadMapper mapper; + + @Test + public void testUpdateReadMessageIdToLarger_onlyAdvancesWhenLarger() { + // 准备:用户 1 在群 10 的读位置 = 50 + ImConversationReadDO read = buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L); + mapper.insert(read); + + // 1. 新位置更大 → 前进 + int advanced = mapper.updateReadMessageIdToLarger(read.getId(), 100L, LocalDateTime.now()); + assertEquals(1, advanced); + assertEquals(100L, mapper.selectById(read.getId()).getMessageId()); + + // 2. 新位置相等 → 不更新 + int equalUpdate = mapper.updateReadMessageIdToLarger(read.getId(), 100L, LocalDateTime.now()); + assertEquals(0, equalUpdate); + + // 3. 新位置更小(乱序上报)→ 不回退 + int smaller = mapper.updateReadMessageIdToLarger(read.getId(), 80L, LocalDateTime.now()); + assertEquals(0, smaller); + assertEquals(100L, mapper.selectById(read.getId()).getMessageId()); + } + + @Test + public void testSelectByUserIdAndConversation() { + ImConversationReadDO read = buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L); + mapper.insert(read); + // 同用户不同会话类型,不应命中 + mapper.insert(buildRead(1L, ImConversationTypeEnum.PRIVATE.getType(), 10L, 99L)); + + ImConversationReadDO result = mapper.selectByUserIdAndConversation( + 1L, ImConversationTypeEnum.GROUP.getType(), 10L); + + assertNotNull(result); + assertEquals(50L, result.getMessageId()); + } + + @Test + public void testSelectListByConversation_aggregatesAllUsers() { + // 群 10 内三个用户的读位置,用于群回执人数聚合 + mapper.insert(buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L)); + mapper.insert(buildRead(2L, ImConversationTypeEnum.GROUP.getType(), 10L, 80L)); + mapper.insert(buildRead(3L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L)); + // 别的群,不应混入 + mapper.insert(buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 20L, 999L)); + + List list = mapper.selectListByConversation( + ImConversationTypeEnum.GROUP.getType(), 10L); + Map positions = convertMap(list, + ImConversationReadDO::getUserId, ImConversationReadDO::getMessageId); + + assertEquals(3, positions.size()); + assertEquals(100L, positions.get(1L)); + assertEquals(80L, positions.get(2L)); + assertEquals(50L, positions.get(3L)); + } + + @Test + public void testSelectListByUserIdAndConversations_batch() { + // 用户 1 在频道 10 / 20 / 30 的读位置 + mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 10L, 5L)); + mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 20L, 8L)); + mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 30L, 3L)); + + List list = mapper.selectListByUserIdAndConversations( + 1L, ImConversationTypeEnum.CHANNEL.getType(), List.of(10L, 20L)); + + assertEquals(2, list.size()); + } + + private ImConversationReadDO buildRead(Long userId, Integer conversationType, Long conversationId, + Long readMessageId) { + return ImConversationReadDO.builder() + .userId(userId) + .conversationType(conversationType) + .targetId(conversationId) + .messageId(readMessageId) + .readTime(LocalDateTime.now()) + .build(); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java index 2f1d093..3fd7554 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java @@ -1,6 +1,5 @@ package cn.iocoder.yudao.module.im.dal.mysql.message; -import cn.hutool.core.collection.ListUtil; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; @@ -14,7 +13,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; /** - * IM 群聊消息 Mapper 单元测试 + * {@link ImGroupMessageMapper} 的单元测试 * * @author 芋道源码 */ @@ -28,46 +27,76 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { // ========== selectListByMinId ========== @Test + public void testSelectListByMinId_filterByReceiverSnapshot() { + // 准备:群 10 三条消息,快照分别命中 / 不命中用户 1 + ImGroupMessageDO visible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + mapper.insert(visible); + ImGroupMessageDO invisible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L)); + mapper.insert(invisible); + ImGroupMessageDO selfSend = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + mapper.insert(selfSend); + + // 调用:用户 1 拉取群 10 + List result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100); + + // 断言:仅返回快照包含用户 1 的消息 + assertEquals(2, result.size()); + assertEquals(visible.getId(), result.get(0).getId()); + assertEquals(selfSend.getId(), result.get(1).getId()); + } + + @Test + public void testSelectListByMinId_excludeNullAndEmptySnapshot() { + // 准备:快照为 null / 空字符串的老数据,不应被任何用户拉到 + ImGroupMessageDO nullSnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, null); + mapper.insert(nullSnapshot); + ImGroupMessageDO emptySnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of()); + mapper.insert(emptySnapshot); + + List result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100); + + assertTrue(result.isEmpty()); + } + + @Test public void testSelectListByMinId_onlyReturnsGroupsInIdList() { - // 准备:群 10 的消息 + 群 20 的消息 - ImGroupMessageDO msg10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + // 准备:群 10 + 群 20 各一条,用户 1 都在快照内 + ImGroupMessageDO msg10 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); mapper.insert(msg10); - ImGroupMessageDO msg20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO msg20 = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); mapper.insert(msg20); - // 调用:只查群 10 - List result = mapper.selectListByMinId(ListUtil.of(10L), 0L, FAR_PAST, 100); + // 调用:候选群只含群 10 + List result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100); - // 断言:只有 msg10 assertEquals(1, result.size()); assertEquals(msg10.getId(), result.get(0).getId()); } @Test public void testSelectListByMinId_includesRecall() { - // 准备:一条正常 + 一条撤回;客户端拉离线消息需要拿到撤回状态以渲染「此消息已撤回」占位 - ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + // 准备:撤回消息也要返回,由客户端按 status 渲染「此消息已撤回」占位 + ImGroupMessageDO normal = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(normal); - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + ImGroupMessageDO recalled = buildMessage(10L, 2L, ImMessageStatusEnum.RECALL, List.of(1L)); mapper.insert(recalled); - List result = mapper.selectListByMinId(ListUtil.of(10L), 0L, FAR_PAST, 100); + List result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100); - // 断言:撤回消息一并返回,由客户端按 status 切换渲染 assertEquals(2, result.size()); } @Test public void testSelectListByMinId_sendTimeWindow() { // 准备:一条在窗口内,一条在窗口外 - ImGroupMessageDO inWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO inWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); inWindow.setSendTime(LocalDateTime.now().minusDays(1)); mapper.insert(inWindow); - ImGroupMessageDO outWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); outWindow.setSendTime(LocalDateTime.now().minusDays(40)); mapper.insert(outWindow); - List result = mapper.selectListByMinId(ListUtil.of(10L), 0L, + List result = mapper.selectListByMinId(1L, List.of(10L), 0L, LocalDateTime.now().minusDays(30), 100); assertEquals(1, result.size()); @@ -76,16 +105,16 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_sortAscLimit() { - // 准备:插入 3 条 - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + // 准备:插入 3 条可见消息 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(m2); - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(m3); // 调用:size=2,返回 id 最小的 2 条 - List result = mapper.selectListByMinId(ListUtil.of(10L), 0L, FAR_PAST, 2); + List result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 2); assertEquals(2, result.size()); assertTrue(result.get(0).getId() < result.get(1).getId()); @@ -93,129 +122,63 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { assertEquals(m2.getId(), result.get(1).getId()); } - // ========== selectListByGroupIdAndMinIdAndQuitTimeBefore ========== - - @Test - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_onlyReturnsBeforeQuit() { - // 准备:退群时间 = now-1 day,插入一条"退群前",一条"退群后" - LocalDateTime quitTime = LocalDateTime.now().minusDays(1); - ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - before.setSendTime(LocalDateTime.now().minusDays(2)); - mapper.insert(before); - ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - after.setSendTime(LocalDateTime.now().minusHours(1)); - mapper.insert(after); - - List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - 10L, 0L, FAR_PAST, quitTime, 100); - - // 断言:只返回退群前消息 - assertEquals(1, result.size()); - assertEquals(before.getId(), result.get(0).getId()); - } - @Test - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_boundaryEqualsQuitTime() { - // 准备:消息发送时间恰好等于退群时间(le 语义应包含该条) - LocalDateTime quitTime = LocalDateTime.now().minusDays(1).withNano(0); - ImGroupMessageDO boundary = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - boundary.setSendTime(quitTime); - mapper.insert(boundary); - - List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - 10L, 0L, FAR_PAST, quitTime, 100); - - assertEquals(1, result.size()); - } + public void testSelectListByMinId_minIdExclusive() { + // 准备:游标之前的消息不返回 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + mapper.insert(m2); - @Test - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_otherGroupExcluded() { - // 准备:群 10 和群 20 各一条消息 - LocalDateTime quitTime = LocalDateTime.now(); - ImGroupMessageDO in10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - in10.setSendTime(LocalDateTime.now().minusHours(2)); - mapper.insert(in10); - ImGroupMessageDO in20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); - in20.setSendTime(LocalDateTime.now().minusHours(2)); - mapper.insert(in20); - - List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - 10L, 0L, FAR_PAST, quitTime, 100); + List result = mapper.selectListByMinId(1L, List.of(10L), m1.getId(), FAR_PAST, 100); assertEquals(1, result.size()); - assertEquals(in10.getId(), result.get(0).getId()); + assertEquals(m2.getId(), result.get(0).getId()); } - // ========== selectHistoryList ========== + // ========== selectHistoryListByUser ========== @Test - public void testSelectHistoryList_basic() { - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + public void testSelectHistoryListByUser_filterByReceiverSnapshot() { + // 准备:群 10 三条,用户 1 在前两条快照内、第三条定向给别人 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); mapper.insert(m2); + ImGroupMessageDO directedOther = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L)); + mapper.insert(directedOther); // 别的群 - ImGroupMessageDO other = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO other = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(other); - List result = mapper.selectHistoryList(10L, null, 100, null); - - // 断言:只返回 10 群的,按 id 倒序 - assertEquals(2, result.size()); - assertTrue(result.get(0).getId() > result.get(1).getId()); - } - - @Test - public void testSelectHistoryList_includeRecall() { - ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(normal); - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); - mapper.insert(recalled); - - List result = mapper.selectHistoryList(10L, null, 100, null); + List result = mapper.selectHistoryListByUser(1L, 10L, null, 100); + // 断言:只返回群 10 中用户 1 可见的,按 id 倒序 assertEquals(2, result.size()); - assertEquals(recalled.getId(), result.get(0).getId()); - assertEquals(normal.getId(), result.get(1).getId()); + assertEquals(m2.getId(), result.get(0).getId()); + assertEquals(m1.getId(), result.get(1).getId()); } @Test - public void testSelectHistoryList_maxIdCursor() { - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + public void testSelectHistoryListByUser_maxIdCursor() { + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(m2); - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); mapper.insert(m3); - List result = mapper.selectHistoryList(10L, m3.getId(), 100, null); + List result = mapper.selectHistoryListByUser(1L, 10L, m3.getId(), 100); - // 断言:只返回 id < m3.id 的,即 m1、m2 + // 断言:只返回 id < m3.id 的 m1、m2 assertEquals(2, result.size()); assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId())); } - @Test - public void testSelectHistoryList_filterByJoinTime() { - // 准备:一条在入群前,一条在入群后 - LocalDateTime joinTime = LocalDateTime.now().minusHours(1); - ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - before.setSendTime(LocalDateTime.now().minusHours(2)); - mapper.insert(before); - ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - after.setSendTime(LocalDateTime.now().minusMinutes(30)); - mapper.insert(after); - - List result = mapper.selectHistoryList(10L, null, 100, joinTime); - - // 断言:入群前消息不可见 - assertEquals(1, result.size()); - assertEquals(after.getId(), result.get(0).getId()); - } - // ========== 工具方法 ========== - private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) { + private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status, + List receiverUserIds) { return ImGroupMessageDO.builder() .clientMessageId("uuid-" + System.nanoTime()) .senderId(senderId) @@ -224,6 +187,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { .content("{\"content\":\"test\"}") .status(status.getStatus()) .sendTime(LocalDateTime.now()) + .receiverUserIds(receiverUserIds) .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) .build(); } diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java index 044a91d..0ca57e4 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -1,8 +1,5 @@ package cn.iocoder.yudao.module.im.service.group; -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.collection.ListUtil; -import cn.hutool.core.map.MapUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.exception.ServiceException; @@ -111,13 +108,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { // 准备:创建者 + 2 个初始成员,都是好友 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); reqVO.setName("测试群"); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> { ImGroupDO group = invocation.getArgument(0); group.setId(100L); return 1; }); - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( ImFriendDO.builder().userId(1L).friendUserId(2L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImFriendDO.builder().userId(1L).friendUserId(3L) @@ -143,15 +140,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { // 准备:初始成员里有非好友 ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); reqVO.setName("测试群"); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); // 只有 2 是好友,3 不是 - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( ImFriendDO.builder().userId(1L).friendUserId(2L) .status(CommonStatusEnum.ENABLE.getStatus()).build())); AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); - when(adminUserApi.getUserMap(anyCollection())).thenReturn(MapUtil.of(3L, u3)); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3)); // 调用并断言 ServiceException exception = assertThrows(ServiceException.class, @@ -234,7 +231,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); assertNotNull(captor.getValue().getDissolvedTime()); verify(groupMemberService).removeGroupMembersByGroupId(10L); - verify(groupMessageService).deleteReadMaxMessageIdMap(10L); // 推送 GROUP_DISSOLVE 通知(send-before-remove,sendGroupMessage 内部查 active 自动覆盖全员,含群主多端同步) ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); @@ -282,7 +278,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { verify(groupMessageService).sendGroupMessage(eq(99L), dtoCaptor.capture()); assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType()); verify(groupMemberService).removeGroupMembersByGroupId(10L); - verify(groupMessageService).deleteReadMaxMessageIdMap(10L); } } @@ -330,8 +325,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { // 被邀请人 2 和 3 都是好友 ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); - List friends = ListUtil.of( + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + List friends = List.of( ImFriendDO.builder().userId(1L).friendUserId(2L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImFriendDO.builder().userId(1L).friendUserId(3L) @@ -369,7 +364,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO.builder().groupId(10L).userId(1L) .role(ImGroupMemberRoleEnum.NORMAL.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build()); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of( + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(99L) .role(ImGroupMemberRoleEnum.OWNER.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build(), @@ -379,8 +374,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( ImFriendDO.builder().userId(1L).friendUserId(2L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImFriendDO.builder().userId(1L).friendUserId(3L) @@ -411,15 +406,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO.builder().groupId(10L).userId(1L) .role(ImGroupMemberRoleEnum.OWNER.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build()); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of( + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(1L) .role(ImGroupMemberRoleEnum.OWNER.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build())); ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( ImFriendDO.builder().userId(1L).friendUserId(2L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImFriendDO.builder().userId(1L).friendUserId(3L) @@ -448,18 +443,18 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectById(10L)).thenReturn(group); when(groupMemberService.getActiveGroupMemberListByGroupId(10L)) - .thenReturn(ListUtil.of(ImGroupMemberDO.builder().groupId(10L).userId(1L) + .thenReturn(List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build())); ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L, 3L))); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); // 只有 2 是好友,3 不是 - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( ImFriendDO.builder().userId(1L).friendUserId(2L) .status(CommonStatusEnum.ENABLE.getStatus()).build())); AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); - when(adminUserApi.getUserMap(anyCollection())).thenReturn(MapUtil.of(3L, u3)); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3)); ServiceException exception = assertThrows(ServiceException.class, () -> groupService.inviteGroupMember(1L, reqVO)); @@ -487,9 +482,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(600L, 601L))); + reqVO.setMemberUserIds(new ArrayList<>(List.of(600L, 601L))); // 被邀请人都是好友 - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(ListUtil.of( + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( ImFriendDO.builder().userId(1L).friendUserId(600L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImFriendDO.builder().userId(1L).friendUserId(601L) @@ -515,7 +510,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectById(10L)).thenReturn(group); // 用户 2 已在群中 - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(ListUtil.of( + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupMemberDO.builder().groupId(10L).userId(2L) @@ -524,7 +519,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(ListUtil.of(2L))); // 只邀请 2,他已在群中 + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L))); // 只邀请 2,他已在群中 // 调用 groupService.inviteGroupMember(1L, reqVO); @@ -566,7 +561,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { groupService.quitGroup(10L, 1L); verify(groupMemberService).removeGroupMember(10L, 1L); - verify(groupMessageService).deleteReadMaxMessageId(10L, 1L); // 推送 GROUP_MEMBER_QUIT 通知给全员(含 quitter,前端自判清群) ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); @@ -587,7 +581,6 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { groupService.quitGroup(10L, 1L); verify(groupMemberService).removeGroupMember(10L, 1L); - verify(groupMessageService).deleteReadMaxMessageId(10L, 1L); verify(groupMessageService).sendGroupMessage(eq(1L), any(ImGroupMessageSendDTO.class)); } } @@ -609,7 +602,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(ListUtil.of(1L, 2L)); + reqVO.setMemberUserIds(List.of(1L, 2L)); ServiceException exception = assertThrows(ServiceException.class, () -> groupService.removeGroupMember(1L, reqVO)); @@ -631,7 +624,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO.builder().groupId(10L).userId(1L) .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); // 目标:两个普通成员 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(2L) .role(ImGroupMemberRoleEnum.NORMAL.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build(), @@ -641,12 +634,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(ListUtil.of(2L, 3L)); + reqVO.setMemberUserIds(List.of(2L, 3L)); groupService.removeGroupMember(1L, reqVO); verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection()); - verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), anyCollection()); // 推送 GROUP_MEMBER_KICK 通知给全员(含被踢者,前端自判清群) ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); @@ -668,14 +660,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO.builder().groupId(10L).userId(1L) .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()); // 目标:另一个管理员 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(2L) .role(ImGroupMemberRoleEnum.ADMIN.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build())); ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(ListUtil.of(2L)); + reqVO.setMemberUserIds(List.of(2L)); ServiceException exception = assertThrows(ServiceException.class, () -> groupService.removeGroupMember(1L, reqVO)); @@ -697,14 +689,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( ImGroupMemberDO.builder().groupId(10L).userId(1L) .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(99L) .role(ImGroupMemberRoleEnum.OWNER.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build())); ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(ListUtil.of(99L)); + reqVO.setMemberUserIds(List.of(99L)); ServiceException exception = assertThrows(ServiceException.class, () -> groupService.removeGroupMember(1L, reqVO)); @@ -726,7 +718,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO.builder().groupId(10L).userId(1L) .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); // 目标:2L 有效普通成员;3L 已退群(DISABLE)的历史管理员,应被跳过而非拦截整批 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().groupId(10L).userId(2L) .role(ImGroupMemberRoleEnum.NORMAL.getRole()) .status(CommonStatusEnum.ENABLE.getStatus()).build(), @@ -736,17 +728,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); reqVO.setGroupId(10L); - reqVO.setMemberUserIds(ListUtil.of(2L, 3L)); + reqVO.setMemberUserIds(List.of(2L, 3L)); groupService.removeGroupMember(1L, reqVO); - // 仅有效成员 2L 进入移除 / 已读清理,已退群的 3L 被跳过 + // 仅有效成员 2L 进入移除,已退群的 3L 被跳过 ArgumentCaptor removeCaptor = ArgumentCaptor.forClass(Collection.class); verify(groupMemberService).removeGroupMembers(eq(10L), removeCaptor.capture()); - assertEquals(CollUtil.newHashSet(2L), CollUtil.newHashSet(removeCaptor.getValue())); - ArgumentCaptor readCaptor = ArgumentCaptor.forClass(Collection.class); - verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), readCaptor.capture()); - assertEquals(CollUtil.newHashSet(2L), CollUtil.newHashSet(readCaptor.getValue())); + assertEquals(Set.of(2L), Set.copyOf(removeCaptor.getValue())); } } @@ -762,7 +751,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); // 目标 3 是普通成员 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus()) .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限 @@ -773,7 +762,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); reqVO.setId(10L); - reqVO.setUserIds(ListUtil.of(3L)); + reqVO.setUserIds(List.of(3L)); groupService.addGroupAdmin(1L, reqVO); @@ -792,7 +781,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus()) .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); // 群里已有 3 个 ADMIN(达到上限),再加 1 会超 @@ -801,7 +790,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); reqVO.setId(10L); - reqVO.setUserIds(ListUtil.of(5L)); + reqVO.setUserIds(List.of(5L)); ServiceException exception = assertThrows(ServiceException.class, () -> groupService.addGroupAdmin(1L, reqVO)); @@ -819,13 +808,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus()) .role(ImGroupMemberRoleEnum.OWNER.getRole()).build())); ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); reqVO.setId(10L); - reqVO.setUserIds(ListUtil.of(1L)); + reqVO.setUserIds(List.of(1L)); ServiceException exception = assertThrows(ServiceException.class, () -> groupService.addGroupAdmin(1L, reqVO)); @@ -843,13 +832,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); // 目标已是 ADMIN:再加无需操作 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); reqVO.setId(10L); - reqVO.setUserIds(ListUtil.of(2L)); + reqVO.setUserIds(List.of(2L)); groupService.addGroupAdmin(1L, reqVO); @@ -869,7 +858,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(), @@ -877,7 +866,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); reqVO.setId(10L); - reqVO.setUserIds(ListUtil.of(2L)); + reqVO.setUserIds(List.of(2L)); groupService.removeGroupAdmin(1L, reqVO); @@ -897,13 +886,13 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); // 目标已是 MEMBER:撤销无需操作 - when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(ListUtil.of( + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); reqVO.setId(10L); - reqVO.setUserIds(ListUtil.of(2L)); + reqVO.setUserIds(List.of(2L)); groupService.removeGroupAdmin(1L, reqVO); @@ -925,9 +914,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn( ImGroupMemberDO.builder().groupId(10L).userId(2L) .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); - when(groupMemberService.updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(2L)), + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(2L)), eq(ImGroupMemberRoleEnum.OWNER.getRole()))).thenReturn(1); - when(groupMemberService.updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(1L)), + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(1L)), eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1); ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); @@ -941,9 +930,9 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { verify(groupMapper).updateById(groupCaptor.capture()); assertEquals(2L, groupCaptor.getValue().getOwnerUserId()); // 旧群主 → MEMBER;新群主 → OWNER - verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(1L)), + verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(Set.of(1L)), eq(ImGroupMemberRoleEnum.NORMAL.getRole())); - verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(CollUtil.newHashSet(2L)), + verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(Set.of(2L)), eq(ImGroupMemberRoleEnum.OWNER.getRole())); } } @@ -1123,7 +1112,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @Test public void testGetMyGroupList_success() { // 活跃群成员 - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(ListUtil.of( + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( ImGroupMemberDO.builder().groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupMemberDO.builder().groupId(20L).userId(1L) @@ -1131,11 +1120,11 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { ))); // 最近退群成员(最近 30 天内) when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(new ArrayList<>(ListUtil.of( + .thenReturn(new ArrayList<>(List.of( ImGroupMemberDO.builder().groupId(30L).userId(1L) .status(CommonStatusEnum.DISABLE.getStatus()).build() ))); - List groups = ListUtil.of( + List groups = List.of( ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build() diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImplTest.java new file mode 100644 index 0000000..77a69c4 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImplTest.java @@ -0,0 +1,94 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link ImChannelMessageServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +public class ImChannelMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImChannelMessageServiceImpl channelMessageService; + + @Mock + private ImChannelMessageMapper channelMessageMapper; + @Mock + private ImChannelMaterialService channelMaterialService; + @Mock + private ImWebSocketService webSocketService; + @Mock + private ImConversationReadService conversationReadService; + + @Test + public void testReadChannelMessages_messageNotExists() { + // 准备:messageId 不存在(伪造 / 未来 id) + when(channelMessageMapper.selectById(999L)).thenReturn(null); + + // 调用 + channelMessageService.readChannelMessages(1L, 10L, 999L); + + // 断言:不推进读位置 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()); + } + + @Test + public void testReadChannelMessages_wrongChannel() { + // 准备:消息属于别的频道 + ImChannelMessageDO message = ImChannelMessageDO.builder() + .id(100L).channelId(20L).sendTime(LocalDateTime.now()).build(); + when(channelMessageMapper.selectById(100L)).thenReturn(message); + + // 调用:声称读的是频道 10 + channelMessageService.readChannelMessages(1L, 10L, 100L); + + // 断言:不推进读位置 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()); + } + + @Test + public void testReadChannelMessages_notVisible() { + // 准备:定向消息,接收人不含当前用户 + ImChannelMessageDO message = ImChannelMessageDO.builder() + .id(100L).channelId(10L).receiverUserIds(List.of(2L, 3L)).sendTime(LocalDateTime.now()).build(); + when(channelMessageMapper.selectById(100L)).thenReturn(message); + + // 调用 + channelMessageService.readChannelMessages(1L, 10L, 100L); + + // 断言:不推进读位置 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()); + } + + @Test + public void testReadChannelMessages_notAdvanced() { + // 准备:消息真实可见,但读位置未前进(updateConversationReadPosition 返回 false) + ImChannelMessageDO message = ImChannelMessageDO.builder() + .id(100L).channelId(10L).sendTime(LocalDateTime.now()).build(); + when(channelMessageMapper.selectById(100L)).thenReturn(message); + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong())) + .thenReturn(false); + + // 调用 + channelMessageService.readChannelMessages(1L, 10L, 100L); + + // 断言:读位置未前进 → 不推 READ 事件 + verify(webSocketService, never()).sendChannelMessageAsync(anyLong(), any()); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 5ccce80..9641586 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -1,7 +1,5 @@ package cn.iocoder.yudao.module.im.service.message; -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.collection.ListUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.exception.ServiceException; @@ -12,12 +10,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; -import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService; import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.group.ImGroupService; import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; @@ -59,7 +58,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Mock private ImSensitiveWordService sensitiveWordService; @Mock - private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO; + private ImConversationReadService conversationReadService; @Mock private ImWebSocketService imWebSocketService; @@ -98,7 +97,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(ListUtil.of(1L, 2L, 3L)); + .thenReturn(List.of(1L, 2L, 3L)); when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { ImGroupMessageDO msg = invocation.getArgument(0); msg.setId(99L); @@ -159,342 +158,94 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); } - // ========== pull 测试 ========== - @Test - public void testPullMessages_joinTimeFilter() { - // 准备:消息时间用"最近 1 天内"避免被 30 天窗口过滤 - LocalDateTime now = LocalDateTime.now(); - LocalDateTime joinTime = now.minusHours(2); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member)); - - // 入群前 1 条 + 入群后 1 条 - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(3)).build(); - ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() - .id(2L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(ListUtil.of(beforeJoin, afterJoin)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:入群前消息不可见 - assertEquals(1, result.size()); - assertEquals(2L, result.get(0).getId()); - } - - @Test - public void testPullMessages_activeQueryRetriesWhenFirstBatchInvisible() { - // 准备:首批 2 条(入群前 + 定向给别人)全部不可见,第二批命中可见消息 - LocalDateTime now = LocalDateTime.now(); - LocalDateTime joinTime = now.minusHours(1); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member)); - - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(3)).build(); - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() - .id(2L).groupId(10L).senderId(2L) - .receiverUserIds(ListUtil.of(2L, 3L)) - .sendTime(now.minusHours(2)).build(); - ImGroupMessageDO visibleMsg = ImGroupMessageDO.builder() - .id(3L).groupId(10L).senderId(2L) - .sendTime(now.minusMinutes(30)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), - any(LocalDateTime.class), eq(2))) - .thenReturn(ListUtil.of(beforeJoin, directedMsg)); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(2L), - any(LocalDateTime.class), eq(2))) - .thenReturn(ListUtil.of(visibleMsg)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); - - // 断言:仅返回第二批的可见消息;两批 selectListByMinId 各被调用一次 - assertEquals(1, result.size()); - assertEquals(3L, result.get(0).getId()); - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(0L), - any(LocalDateTime.class), eq(2)); - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(2L), - any(LocalDateTime.class), eq(2)); - } - - @Test - public void testPullMessages_quitMemberLoadsPreQuitMessages() { - // 准备:当前群(在群)+ 一个最近退群 - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO activeMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(activeMember)); - - ImGroupMessageDO activeMsg = ImGroupMessageDO.builder() - .id(5L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(2)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(ListUtil.of(activeMsg)); - - // 已退群:退群时间在窗口内(minId=0,minQuitTime 直接用 minSendTime) - LocalDateTime quitTime = now.minusDays(3); - ImGroupMemberDO quitMember = ImGroupMemberDO.builder() - .groupId(20L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()) - .joinTime(now.minusDays(20)) - .quitTime(quitTime).build(); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of(quitMember)); - - ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() - .id(3L).groupId(20L).senderId(99L) - .sendTime(now.minusDays(5)).build(); - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), - any(LocalDateTime.class), eq(quitTime), eq(100))) - .thenReturn(ListUtil.of(quitGroupMsg)); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:两条消息均返回,并按 id 升序 - assertEquals(2, result.size()); - assertEquals(3L, result.get(0).getId()); - assertEquals(5L, result.get(1).getId()); - } - - @Test - public void testPullMessages_quitMemberFilteredByBoundarySendTime() { - // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime - LocalDateTime now = LocalDateTime.now(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of()); - - LocalDateTime boundarySendTime = now.minusDays(2); - ImGroupMessageDO boundary = ImGroupMessageDO.builder() - .id(8L).groupId(10L).senderId(2L).sendTime(boundarySendTime).build(); - when(groupMessageMapper.selectById(8L)).thenReturn(boundary); - when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) - .thenReturn(ListUtil.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 8L, 100); + public void testSendMessage_quoteDirectedMessageRejected() { + // 准备:发送人 1 在群 10,当前有效成员(广播受众)= {1,2,3} + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + reqVO.setContent("{\"content\":\"引用\",\"quote\":{\"messageId\":500}}"); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")).thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L)); + // 被引用原消息:定向给 {1,2}(对发送人 1 可见,但不覆盖广播受众 {1,2,3}) + ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L)) + .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"密\"}") + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(500L)).thenReturn(original); - // 断言:使用边界消息 sendTime 抬升 minQuitTime - assertEquals(0, result.size()); - verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); + // 调用并断言:引用定向消息被拒绝,避免原文随广播泄漏给全群 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.sendGroupMessage(1L, reqVO)); + assertEquals(MESSAGE_QUOTE_INVALID.getCode(), exception.getCode()); + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); } @Test - public void testPullMessages_quitQueryKeepsOriginalMinIdWhenActiveQueryRetries() { - // 准备:主查询首批不可见触发重试(游标从 8→101),退群补齐仍使用原始 minId=8 - LocalDateTime now = LocalDateTime.now(); - LocalDateTime joinTime = now.minusHours(1); - ImGroupMemberDO activeMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(activeMember)); - - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(3)).build(); - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() - .id(101L).groupId(10L).senderId(2L) - .receiverUserIds(ListUtil.of(2L, 3L)) - .sendTime(now.minusMinutes(50)).build(); - ImGroupMessageDO visibleActiveMsg = ImGroupMessageDO.builder() - .id(102L).groupId(10L).senderId(2L) - .sendTime(now.minusMinutes(30)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(8L), - any(LocalDateTime.class), eq(2))) - .thenReturn(ListUtil.of(beforeJoin, directedMsg)); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(101L), - any(LocalDateTime.class), eq(2))) - .thenReturn(ListUtil.of(visibleActiveMsg)); - - LocalDateTime boundarySendTime = now.minusDays(2); - ImGroupMessageDO boundary = ImGroupMessageDO.builder() - .id(8L).groupId(99L).senderId(2L).sendTime(boundarySendTime).build(); - when(groupMessageMapper.selectById(8L)).thenReturn(boundary); - - LocalDateTime quitTime = now.minusHours(12); - ImGroupMemberDO quitMember = ImGroupMemberDO.builder() - .groupId(20L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()) - .joinTime(now.minusDays(10)) - .quitTime(quitTime).build(); - when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) - .thenReturn(ListUtil.of(quitMember)); - - ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() - .id(50L).groupId(20L).senderId(99L) - .sendTime(now.minusDays(1)).build(); - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), - any(LocalDateTime.class), eq(quitTime), eq(2))) - .thenReturn(ListUtil.of(quitGroupMsg)); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 8L, 2); + public void testSendMessage_quoteBroadcastAccepted() { + // 准备:广播受众 {1,2,3},被引用原消息快照覆盖全部受众 → 允许引用 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + reqVO.setContent("{\"content\":\"引用\",\"quote\":{\"messageId\":500}}"); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")).thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L)); + ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L, 3L)) + .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"公开\"}") + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(500L)).thenReturn(original); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { + invocation.getArgument(0).setId(99L); + return 1; + }); - // 断言:退群补齐使用原始 minId=8 而非主查询重试后的 101 - assertEquals(2, result.size()); - assertEquals(50L, result.get(0).getId()); - assertEquals(102L, result.get(1).getId()); - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(8L), - any(LocalDateTime.class), eq(2)); - verify(groupMessageMapper).selectListByMinId(eq(ListUtil.of(10L)), eq(101L), - any(LocalDateTime.class), eq(2)); - verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); - verify(groupMessageMapper).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), - any(LocalDateTime.class), eq(quitTime), eq(2)); - verify(groupMessageMapper, never()).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(101L), - any(LocalDateTime.class), eq(quitTime), eq(2)); + // 调用并断言:正常入库 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + assertEquals(99L, result.getId()); + verify(groupMessageMapper).insert(any(ImGroupMessageDO.class)); } - @Test - public void testPullMessages_receiverUserIdsFilter() { - // 准备 - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member)); - - // 定向接收消息:只给用户 2 和 3 - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(5L) - .receiverUserIds(ListUtil.of(2L, 3L)) - .sendTime(now.minusHours(2)).build(); - // 全员消息 - ImGroupMessageDO allMsg = ImGroupMessageDO.builder() - .id(2L).groupId(10L).senderId(5L) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(ListUtil.of(directedMsg, allMsg)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:定向接收的消息用户 1 看不到,只能看到全员消息 - assertEquals(1, result.size()); - assertEquals(2L, result.get(0).getId()); - } + // ========== pull 测试 ========== @Test - public void testPullMessages_atFieldCorrectReturn() { - // 准备 + public void testPullMessages_pullSetsReadFromCursor() { + // 准备:当前在群 10,无退群;拉到两条消息,已读位置为 7 LocalDateTime now = LocalDateTime.now(); ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member)); - - ImGroupMessageDO atMsg = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(2L) - .atUserIds(ListUtil.of(1L, 3L)) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(ListUtil.of(atMsg)); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:@ 字段正确返回 - assertEquals(1, result.size()); - assertEquals(ListUtil.of(1L, 3L), result.get(0).getAtUserIds()); - } - - @Test - public void testPullMessages_pageTrimmedToSize() { - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO activeMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(activeMember)); - - ImGroupMessageDO activeMsg1 = ImGroupMessageDO.builder() - .id(1000L).groupId(10L).senderId(2L).sendTime(now.minusHours(2)).build(); - ImGroupMessageDO activeMsg2 = ImGroupMessageDO.builder() - .id(2000L).groupId(10L).senderId(2L).sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), - any(LocalDateTime.class), eq(2))) - .thenReturn(ListUtil.of(activeMsg1, activeMsg2)); - - LocalDateTime quitTime = now.minusDays(1); - ImGroupMemberDO quitMember = ImGroupMemberDO.builder() - .groupId(20L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()) - .joinTime(now.minusDays(20)) - .quitTime(quitTime).build(); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of(quitMember)); - - ImGroupMessageDO quit1 = ImGroupMessageDO.builder() - .id(150L).groupId(20L).senderId(99L).sendTime(now.minusDays(5)).build(); - ImGroupMessageDO quit2 = ImGroupMessageDO.builder() - .id(160L).groupId(20L).senderId(99L).sendTime(now.minusDays(4)).build(); - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), - any(LocalDateTime.class), eq(quitTime), eq(2))) - .thenReturn(ListUtil.of(quit1, quit2)); - - when(groupMessageReadRedisDAO.getReadMaxMessageId(anyLong(), eq(1L))).thenReturn(null); - - List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); - - assertEquals(2, result.size()); - assertEquals(150L, result.get(0).getId()); - assertEquals(160L, result.get(1).getId()); - } - - @Test - public void testPullMessages_pullSetsReadFromRedisCursor() { - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member)); + .thenReturn(List.of()); ImGroupMessageDO low = ImGroupMessageDO.builder() - .id(5L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) + .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) + .status(ImMessageStatusEnum.UNREAD.getStatus()) .sendTime(now.minusHours(2)).build(); ImGroupMessageDO high = ImGroupMessageDO.builder() - .id(10L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) + .id(10L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) + .status(ImMessageStatusEnum.UNREAD.getStatus()) .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), + when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L), any(LocalDateTime.class), eq(100))) - .thenReturn(ListUtil.of(low, high)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of()); + .thenReturn(List.of(low, high)); - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(7L); + when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) + .thenReturn(7L); List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + // 断言:已读位置 7 → id<=7 为 READ,id>7 为 UNREAD assertEquals(2, result.size()); assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus()); @@ -502,32 +253,36 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testPullMessages_receiptReadCountForSender() { + // 准备:本人发送的回执消息,可见成员 {1,2,3},分母排除发送者后为 {2,3} LocalDateTime now = LocalDateTime.now(); ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(ListUtil.of(member)); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(1L) + .receiverUserIds(List.of(1L, 2L, 3L)) .status(ImMessageStatusEnum.UNREAD.getStatus()) .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(ListUtil.of(10L)), eq(0L), + when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L), any(LocalDateTime.class), eq(100))) - .thenReturn(ListUtil.of(receiptMsg)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(ListUtil.of()); + .thenReturn(List.of(receiptMsg)); - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(100L); + when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) + .thenReturn(100L); Map positions = new HashMap<>(); positions.put(1L, 100L); positions.put(2L, 100L); positions.put(3L, 50L); - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + when(conversationReadService.getUserReadMessageIdMap(eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) + .thenReturn(positions); - List allMembers = ListUtil.of( + List allMembers = List.of( member, ImGroupMemberDO.builder().groupId(10L).userId(2L) .status(CommonStatusEnum.ENABLE.getStatus()) @@ -536,10 +291,11 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(now.minusDays(20)).build() ); - when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers); List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + // 断言:分母 {2,3},仅用户 2 读到 >=100 → readCount==1 assertEquals(1, result.size()); assertEquals(1, result.get(0).getReadCount()); } @@ -566,7 +322,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(ListUtil.of(1L, 2L)); + .thenReturn(List.of(1L, 2L)); // 调用 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); @@ -660,18 +416,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); when(groupMemberService.validateMemberInGroup(10L, 5L)).thenReturn(currentMember); - // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 + // 准备:消息由用户 5 发,快照可见范围为 {1,2,5}(用户 3 不在快照内 → 不可见) ImGroupMessageDO message = ImGroupMessageDO.builder() .id(80L).groupId(10L).senderId(5L) + .receiverUserIds(List.of(1L, 2L, 5L)) .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); when(groupMessageMapper.selectById(80L)).thenReturn(message); // 准备:群成员一览 - // 用户 1: 正常,入群在消息之前 - // 用户 2: 正常,入群在消息之前 - // 用户 3: 正常,但入群在消息之后 → 不可见 + // 用户 1: 在快照内 + // 用户 2: 在快照内 + // 用户 3: 不在快照内 → 不可见 // 用户 5: 发送者,不计入回执 - List allMembers = ListUtil.of( + List allMembers = List.of( currentMember, ImGroupMemberDO.builder().groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) @@ -681,19 +438,20 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), ImGroupMemberDO.builder().groupId(10L).userId(3L) .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.of(2026, 4, 13, 0, 0, 0)).build(), // 消息之后才入群 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), ImGroupMemberDO.builder().groupId(10L).userId(5L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 ); - when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers); - // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 + // 准备:已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 Map positions = new HashMap<>(); positions.put(1L, 100L); positions.put(2L, 50L); positions.put(3L, 200L); - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + when(conversationReadService.getUserReadMessageIdMap(eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) + .thenReturn(positions); // 调用:查询 messageId=80 的已读用户 List readUsers = groupMessageService.getGroupReadUserIds(5L, 10L, 80L); @@ -701,7 +459,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 断言: // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗ - // 用户 3: 入群在消息之后 → 不可见 → 不算 + // 用户 3: 不在快照内 → 不可见 → 不算 // 用户 5: 发送者 → 排除 assertEquals(1, readUsers.size()); assertTrue(readUsers.contains(1L)); @@ -741,7 +499,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); // 调用 @@ -814,22 +572,26 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) .thenReturn(groupMessageService); - // 准备:用户在群;Redis 游标从 5 前进到 100 + // 准备:用户在群;已读位置从 5 前进到 100 ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build()); - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); + .id(100L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) + .sendTime(LocalDateTime.now()).build()); + when(conversationReadService.getConversationReadMessageId(1L, ImConversationTypeEnum.GROUP.getType(), 10L)) + .thenReturn(5L); + when(conversationReadService.updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L)) + .thenReturn(true); // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 - when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(ListUtil.of()); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of()); // 调用 groupMessageService.readGroupMessages(1L, 10L, 100L); - // 断言:Redis 游标更新 + READ 事件 - verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L); + // 断言:已读位置更新 + READ 事件 + verify(conversationReadService).updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L); verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); } } @@ -843,8 +605,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ServiceException exception = assertThrows(ServiceException.class, () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); - // 断言:Redis 不写、不推送 - verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + // 断言:已读位置不写、不推送 + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()); verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); } @@ -877,7 +639,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L, 2L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); @@ -894,14 +656,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build()); - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); + .id(100L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) + .sendTime(LocalDateTime.now()).build()); + when(conversationReadService.getConversationReadMessageId(1L, ImConversationTypeEnum.GROUP.getType(), 10L)) + .thenReturn(200L); + // CAS 命中旧位置 200 > 100,单调更新不前进,返回 false + when(conversationReadService.updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L)) + .thenReturn(false); // 调用 groupMessageService.readGroupMessages(1L, 10L, 100L); - // 断言:不更新、不推送 - verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + // 断言:调用了 CAS 更新但读位置未前进 → 不推送 + verify(conversationReadService).updateConversationReadPosition(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L); verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); } @@ -919,7 +686,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ServiceException exception = assertThrows(ServiceException.class, () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); assertEquals(MESSAGE_NOT_IN_GROUP.getCode(), exception.getCode()); - verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()); } // ========== 回执:DONE 状态迁移 ========== @@ -927,15 +694,16 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testReadGroupMessageEvent_receiptDoneTransition() { // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2} - // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE + // 已读位置 1→100、2→100 → 全部已读 → 迁移为 DONE ImGroupMessageDO pending = ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(5L) + .receiverUserIds(List.of(5L, 1L, 2L)) .sendTime(LocalDateTime.now().minusMinutes(1)) .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) - .thenReturn(ListUtil.of(pending)); - List activeMembers = ListUtil.of( + .thenReturn(List.of(pending)); + List activeMembers = List.of( ImGroupMemberDO.builder().groupId(10L).userId(5L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(LocalDateTime.now().minusDays(10)).build(), @@ -950,7 +718,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { Map positions = new HashMap<>(); positions.put(1L, 100L); positions.put(2L, 100L); - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + when(conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), 10L)) + .thenReturn(positions); // 调用 groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); @@ -969,12 +738,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB ImGroupMessageDO pending = ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(5L) + .receiverUserIds(List.of(5L, 1L, 2L)) .sendTime(LocalDateTime.now().minusMinutes(1)) .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) - .thenReturn(ListUtil.of(pending)); - List activeMembers = ListUtil.of( + .thenReturn(List.of(pending)); + List activeMembers = List.of( ImGroupMemberDO.builder().groupId(10L).userId(5L) .status(CommonStatusEnum.ENABLE.getStatus()) .joinTime(LocalDateTime.now().minusDays(10)).build(), @@ -989,7 +759,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { Map positions = new HashMap<>(); positions.put(1L, 100L); positions.put(2L, 50L); // 还没读到 100 - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + when(conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), 10L)) + .thenReturn(positions); groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); @@ -1008,7 +779,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) .setContent("{\"messageId\":1}"); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L)); + .thenReturn(List.of(1L, 2L, 3L, 4L)); groupMessageService.sendGroupMessage(1L, dto); @@ -1025,9 +796,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) .setContent("{\"messageId\":1}") - .setReceiverUserIds(ListUtil.of(2L, 3L)); + .setReceiverUserIds(List.of(2L, 3L)); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L)); + .thenReturn(List.of(1L, 2L, 3L, 4L)); groupMessageService.sendGroupMessage(1L, dto); @@ -1038,20 +809,21 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { } @Test - public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { - // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) - // 预期:仅定向用户可见 + public void testSendGroupMessage_withReceiverUserIds_senderAlwaysIncluded() { + // 边界:发送者不在群成员缓存里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) + // 预期:定向用户 + 发送者自己(始终纳入快照保证多端同步);非定向成员 4 被过滤 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) .setContent("{\"messageId\":1}") - .setReceiverUserIds(ListUtil.of(2L, 3L)); + .setReceiverUserIds(List.of(2L, 3L)); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(ListUtil.of(1L, 2L, 3L, 4L)); + .thenReturn(List.of(1L, 2L, 3L, 4L)); groupMessageService.sendGroupMessage(99L, dto); verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> - ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), + ids.size() == 3 && ids.contains(2L) && ids.contains(3L) + && ids.contains(99L) && !ids.contains(4L)), any(ImGroupMessageDTO.class)); } @@ -1063,7 +835,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) .setContent(new RecallMessage().setMessageId(50L)); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); groupMessageService.sendGroupMessage(1L, dto); @@ -1087,7 +859,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) .setContent("{\"messageId\":50}").setReceipt(true); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); groupMessageService.sendGroupMessage(1L, dto); @@ -1101,7 +873,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:persistent=false 类型(RECEIPT 回执)→ 不入库,仅推送 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() .setGroupId(10L).setType(ImMessageTypeEnum.RECEIPT.getType()); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(ListUtil.of(1L, 2L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); groupMessageService.sendGroupMessage(1L, dto); @@ -1112,7 +884,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() { // 准备:调用方传入显式 targets(解散场景成员已被批量 DISABLE,必须按移除前快照推送) - Set targets = CollUtil.newHashSet(1L, 2L, 3L); + Set targets = Set.of(1L, 2L, 3L); ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent("{}"); @@ -1126,73 +898,48 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // ========== getGroupMessageList ========== @Test - public void testGetGroupMessageList_delegatesToMapperWithJoinTime() { - // 准备 - LocalDateTime joinTime = LocalDateTime.now().minusDays(5); + public void testGetGroupMessageList_delegatesToMapper() { + // 准备:可见性由 SQL 按 receiver_user_ids 快照过滤,Service 直接透传 mapper 结果 ImGroupMemberDO member = ImGroupMemberDO.builder() .id(50L).groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + .joinTime(LocalDateTime.now().minusDays(5)).build(); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member); ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); reqVO.setGroupId(10L); reqVO.setMaxId(100L); reqVO.setLimit(20); - List mockList = ListUtil.of( + List mockList = List.of( ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L) - .sendTime(joinTime.plusMinutes(1)).build() + .receiverUserIds(List.of(1L)).sendTime(LocalDateTime.now()).build() ); - when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList); + when(groupMessageMapper.selectHistoryListByUser(1L, 10L, 100L, 20)).thenReturn(mockList); // 调用 List result = groupMessageService.getGroupMessageList(1L, reqVO); - // 断言:使用成员的 joinTime 作为历史消息的下限 + // 断言:透传到 mapper,参数一致 assertEquals(1, result.size()); - verify(groupMessageMapper).selectHistoryList(10L, 100L, 20, joinTime); + assertEquals(99L, result.get(0).getId()); + verify(groupMessageMapper).selectHistoryListByUser(1L, 10L, 100L, 20); } @Test public void testGetGroupMessageList_notInGroup() { - // 准备:用户不在群中 + // 准备:用户从未在群中(getGroupMember 返回 null) ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); reqVO.setGroupId(10L); reqVO.setLimit(20); - when(groupMemberService.validateMemberInGroup(10L, 1L)) - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), - GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(null); // 调用并断言:鉴权失败 ServiceException exception = assertThrows(ServiceException.class, () -> groupMessageService.getGroupMessageList(1L, reqVO)); assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); // 断言:不查询 DB - verify(groupMessageMapper, never()).selectHistoryList(anyLong(), anyLong(), anyInt(), any()); - } - - // ========== delete read cursor 委托 ========== - - @Test - public void testDeleteReadMaxMessageId_delegatesToRedis() { - groupMessageService.deleteReadMaxMessageId(10L, 1L); - - verify(groupMessageReadRedisDAO).deleteReadMaxMessageId(10L, 1L); - } - - @Test - public void testDeleteReadMaxMessageIds_delegatesToRedis() { - groupMessageService.deleteReadMaxMessageIds(10L, ListUtil.of(1L, 2L)); - - verify(groupMessageReadRedisDAO).deleteReadMaxMessageIds(10L, ListUtil.of(1L, 2L)); - } - - @Test - public void testDeleteReadMaxMessageIdMap_delegatesToRedis() { - groupMessageService.deleteReadMaxMessageIdMap(10L); - - verify(groupMessageReadRedisDAO).deleteReadMaxMessageIdMap(10L); + verify(groupMessageMapper, never()).selectHistoryListByUser(anyLong(), anyLong(), anyLong(), anyInt()); } } diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index dd64cac..aa1d606 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/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; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; @@ -47,6 +48,8 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Mock private ImSensitiveWordService sensitiveWordService; @Mock + private ImConversationReadService conversationReadService; + @Mock private ImWebSocketService imWebSocketService; /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true);个别用例可改字段测分支 */ @@ -167,6 +170,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { eq(2L), eq(1L), eq(5L), eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class))) .thenReturn(2); + // 读位置前进 → 才下发事件 + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong())) + .thenReturn(true); // 调用 privateMessageService.readPrivateMessages(1L, 2L, 5L); diff --git a/yudao-module-im/src/test/resources/sql/clean.sql b/yudao-module-im/src/test/resources/sql/clean.sql index 64ff0dd..c25a453 100644 --- a/yudao-module-im/src/test/resources/sql/clean.sql +++ b/yudao-module-im/src/test/resources/sql/clean.sql @@ -1,5 +1,6 @@ DELETE FROM "im_private_message"; DELETE FROM "im_group_message"; +DELETE FROM "im_conversation_read"; DELETE FROM "im_group"; DELETE FROM "im_group_member"; DELETE FROM "im_friend"; diff --git a/yudao-module-im/src/test/resources/sql/create_tables.sql b/yudao-module-im/src/test/resources/sql/create_tables.sql index 2a5f27e..6e33f96 100644 --- a/yudao-module-im/src/test/resources/sql/create_tables.sql +++ b/yudao-module-im/src/test/resources/sql/create_tables.sql @@ -292,6 +292,23 @@ CREATE TABLE IF NOT EXISTS "im_channel_message" ( PRIMARY KEY ("id") ) COMMENT 'IM 频道消息表'; +CREATE TABLE IF NOT EXISTS "im_conversation_read" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "user_id" bigint NOT NULL COMMENT '用户编号', + "conversation_type" tinyint NOT NULL COMMENT '会话类型', + "target_id" bigint NOT NULL COMMENT '目标编号', + "message_id" bigint NOT NULL COMMENT '最大已读消息编号', + "read_time" timestamp NOT NULL COMMENT '最近已读时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_conversation_read_user_target" UNIQUE ("user_id", "conversation_type", "target_id", "tenant_id") +) COMMENT 'IM 会话读位置表'; + CREATE TABLE IF NOT EXISTS "im_sensitive_word" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', "word" varchar(128) NOT NULL COMMENT '敏感词', -- libgit2 0.21.4