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