diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java
new file mode 100644
index 0000000..bdd9fa0
--- /dev/null
+++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/conversation/ImConversationReadDO.java
@@ -0,0 +1,68 @@
+package cn.iocoder.yudao.module.im.dal.dataobject.conversation;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
+import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
+import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
+import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
+import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
+import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * IM 会话读位置 DO
+ *
+ * 只表达「用户在某个会话的最大已读位置」,私聊 / 群聊 / 频道统一落这张表,是读位置的唯一权威。
+ *
+ * @author 芋道源码
+ */
+@TableName("im_conversation_read")
+@KeySequence("im_conversation_read_seq")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ImConversationReadDO extends BaseDO {
+
+ /**
+ * 编号
+ */
+ @TableId
+ private Long id;
+ /**
+ * 用户编号
+ *
+ * 关联 AdminUserDO 的 id 字段
+ */
+ private Long userId;
+ /**
+ * 会话类型
+ *
+ * 枚举 {@link ImConversationTypeEnum}
+ */
+ private Integer conversationType;
+ /**
+ * 目标编号
+ *
+ * 私聊关联 AdminUserDO 的 id;群聊关联 {@link ImGroupDO#getId()};频道关联 {@link ImChannelDO#getId()}
+ */
+ private Long targetId;
+ /**
+ * 最大已读消息编号
+ *
+ * 关联 {@link ImPrivateMessageDO#getId()}、{@link ImGroupMessageDO#getId()}、{@link ImChannelMessageDO#getId()}
+ */
+ private Long messageId;
+ /**
+ * 最近已读时间
+ */
+ private LocalDateTime readTime;
+
+}
diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java
new file mode 100644
index 0000000..2e6a672
--- /dev/null
+++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java
@@ -0,0 +1,60 @@
+package cn.iocoder.yudao.module.im.dal.mysql.conversation;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * IM 会话读位置 Mapper
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface ImConversationReadMapper extends BaseMapperX {
+
+ default ImConversationReadDO selectByUserIdAndConversation(Long userId, Integer conversationType, Long conversationId) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(ImConversationReadDO::getUserId, userId)
+ .eq(ImConversationReadDO::getConversationType, conversationType)
+ .eq(ImConversationReadDO::getTargetId, conversationId));
+ }
+
+ default List selectListByConversation(Integer conversationType, Long conversationId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(ImConversationReadDO::getConversationType, conversationType)
+ .eq(ImConversationReadDO::getTargetId, conversationId));
+ }
+
+ default List selectListByUserIdAndConversations(Long userId, Integer conversationType,
+ Collection conversationIds) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(ImConversationReadDO::getUserId, userId)
+ .eq(ImConversationReadDO::getConversationType, conversationType)
+ .in(ImConversationReadDO::getTargetId, conversationIds));
+ }
+
+ /**
+ * 单调递增更新读位置:仅当新位置更大时才更新
+ *
+ * 通过 {@code read_message_id < messageId} 的 CAS 条件,保证乱序 / 并发上报时读位置不会回退。
+ *
+ * @param id 记录编号
+ * @param messageId 新的最大已读消息编号
+ * @param readTime 已读时间
+ * @return 影响行数;0 表示新位置不大于已有位置,未更新
+ */
+ default int updateReadMessageIdToLarger(Long id, Long messageId, LocalDateTime readTime) {
+ return update(null, Wrappers.lambdaUpdate()
+ .set(ImConversationReadDO::getMessageId, messageId)
+ .set(ImConversationReadDO::getReadTime, readTime)
+ .eq(ImConversationReadDO::getId, id)
+ .lt(ImConversationReadDO::getMessageId, messageId));
+ }
+
+}
diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
index 84736fd..e37165c 100644
--- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
+++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
@@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
+import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
@@ -11,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
+import java.util.Collection;
import java.util.List;
/**
@@ -22,48 +24,22 @@ import java.util.List;
public interface ImGroupMessageMapper extends BaseMapperX {
/**
- * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用)
+ * 根据 minId + 时间窗口增量拉取群聊消息
*
- * @param groupIds 用户当前仍在群内的群编号列表
+ * @param userId 当前用户编号
+ * @param groupIds 候选群编号集合(当前在群 ∪ 窗口内退群),不能为空
* @param minId 最小消息 id(不含)
* @param minSendTime 最早发送时间(不含),限制离线消息时间窗口
* @param size 拉取数量
* @return 消息列表(按 id 升序)
*/
- default List selectListByMinId(List groupIds, Long minId,
+ default List selectListByMinId(Long userId, Collection groupIds, Long minId,
LocalDateTime minSendTime, Integer size) {
QueryWrapperX wrapper = new QueryWrapperX<>();
wrapper.in("group_id", groupIds)
.gt("id", minId)
.gt("send_time", minSendTime)
- .orderByAsc("id");
- wrapper.limitN(size);
- return selectList(wrapper);
- }
-
- /**
- * 查询"退群前"的离线消息(退群成员使用)
- *
- * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。
- *
- * 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。
- *
- * @param groupId 群编号
- * @param minId 最小消息 id(不含)
- * @param minSendTime 最早发送时间(不含)
- * @param quitTime 退群时间(含),仅返回退群当时已存在的消息
- * @param size 拉取数量
- * @return 消息列表(按 id 升序)
- */
- default List selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId,
- LocalDateTime minSendTime,
- LocalDateTime quitTime,
- Integer size) {
- QueryWrapperX wrapper = new QueryWrapperX<>();
- wrapper.eq("group_id", groupId)
- .gt("id", minId)
- .gt("send_time", minSendTime)
- .le("send_time", quitTime)
+ .apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
.orderByAsc("id");
wrapper.limitN(size);
return selectList(wrapper);
@@ -72,17 +48,17 @@ public interface ImGroupMessageMapper extends BaseMapperX {
/**
* 查询群聊历史消息(游标拉取)
*
- * @param groupId 群编号
- * @param maxId 起始消息 id(不含),为空则从最新开始
- * @param limit 拉取数量
- * @param joinTime 入群时间,仅返回入群之后的消息
+ * @param userId 当前用户编号
+ * @param groupId 群编号
+ * @param maxId 起始消息 id(不含),为空则从最新开始
+ * @param limit 拉取数量
* @return 消息列表(按 id 倒序)
*/
- default List selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) {
+ default List selectHistoryListByUser(Long userId, Long groupId, Long maxId, Integer limit) {
QueryWrapperX wrapper = new QueryWrapperX<>();
wrapper.eq("group_id", groupId)
.lt(maxId != null, "id", maxId)
- .ge(joinTime != null, "send_time", joinTime)
+ .apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
.orderByDesc("id");
wrapper.limitN(limit);
return selectList(wrapper);
diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
index 247ad33..92d3890 100644
--- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
+++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
@@ -9,20 +9,6 @@ package cn.iocoder.yudao.module.im.dal.redis;
public interface RedisKeyConstants {
/**
- * 群消息已读位置
- * KEY 格式: im:group:message:read:{groupId}
- * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
- */
- String GROUP_MESSAGE_READ = "im:group:message:read:%s";
-
- /**
- * 频道消息已读位置
- * KEY 格式: im:channel:message:read:{channelId}
- * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
- */
- String CHANNEL_MESSAGE_READ = "im:channel:message:read:%s";
-
- /**
* 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态)
*
* KEY 格式:friend_state:{userId}_{friendUserId}
@@ -44,7 +30,7 @@ public interface RedisKeyConstants {
* KEY 格式:group_member_ids:{groupId}
* VALUE 数据类型:List
*
- * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。
+ * 说明:只缓存轻量的 userId 列表,适合「群消息推送目标」这类只关心 userId 的场景
*/
String GROUP_MEMBER_IDS = "group_member_ids";
diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java
deleted file mode 100644
index bda4b33..0000000
--- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package cn.iocoder.yudao.module.im.dal.redis.message;
-
-import cn.hutool.core.convert.Convert;
-import jakarta.annotation.Resource;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Repository;
-
-import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.CHANNEL_MESSAGE_READ;
-
-/**
- * IM 频道消息已读位置 Redis DAO
- *
- * @author 芋道源码
- */
-@Repository
-public class ImChannelMessageReadRedisDAO {
-
- @Resource
- private StringRedisTemplate stringRedisTemplate;
-
- /**
- * 更新用户在某频道的最大已读消息编号
- *
- * @param channelId 频道编号
- * @param userId 用户编号
- * @param maxMessageId 最大已读消息编号
- */
- public void updateReadMaxMessageId(Long channelId, Long userId, Long maxMessageId) {
- String key = formatKey(channelId);
- stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
- }
-
- /**
- * 获取用户在某频道的最大已读消息编号
- *
- * @param channelId 频道编号
- * @param userId 用户编号
- * @return 最大已读消息编号;不存在则返回 null
- */
- public Long getReadMaxMessageId(Long channelId, Long userId) {
- String key = formatKey(channelId);
- Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
- return Convert.toLong(val);
- }
-
- private static String formatKey(Long channelId) {
- return String.format(CHANNEL_MESSAGE_READ, channelId);
- }
-
-}
diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java
deleted file mode 100644
index 5c76f79..0000000
--- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java
+++ /dev/null
@@ -1,102 +0,0 @@
-package cn.iocoder.yudao.module.im.dal.redis.message;
-
-import cn.hutool.core.convert.Convert;
-import jakarta.annotation.Resource;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Repository;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-
-import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ;
-
-/**
- * IM 群消息已读位置 Redis DAO
- *
- * @author 芋道源码
- */
-@Repository
-public class ImGroupMessageReadRedisDAO {
-
- @Resource
- private StringRedisTemplate stringRedisTemplate;
-
- /**
- * 更新用户在某群的最大已读消息编号
- *
- * @param groupId 群编号
- * @param userId 用户编号
- * @param maxMessageId 最大已读消息编号
- */
- public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) {
- String key = formatKey(groupId);
- stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
- }
-
- /**
- * 获取用户在某群的最大已读消息编号
- *
- * @param groupId 群编号
- * @param userId 用户编号
- * @return 最大已读消息编号,不存在则返回 null
- */
- public Long getReadMaxMessageId(Long groupId, Long userId) {
- String key = formatKey(groupId);
- Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
- return Convert.toLong(val);
- }
-
- /**
- * 获取某群所有用户的已读位置
- *
- * @param groupId 群编号
- * @return userId → maxReadMessageId 映射
- */
- public Map getReadMaxMessageIdMap(Long groupId) {
- String key = formatKey(groupId);
- Map