Commit 210a8d5af65f9bcf4c46d6efe4a7c30784130ba6

Authored by YunaiV
1 parent 927b64fc

feat(im): 增强消息拉取与状态补偿可靠性

- 新增会话读位置持久化接口与前端同步逻辑
- 增加好友、好友申请、加群申请的增量拉取补偿
- 统一前端 pull 编排,增加回扫窗口、落库等待和账号切换守卫
- 调整群成员为按群懒加载缓存,并移除全局成员增量链路
- 修复消息落库、读位置补偿、READ 事件乱序下的未读状态一致性
- 完善群申请红点快照刷新和管理员角色变化补偿
- 更新消息存储设计与修复记录文档
Showing 22 changed files with 351 additions and 19 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/conversation/ImConversationReadController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.conversation;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  5 +import cn.iocoder.yudao.module.im.controller.admin.conversation.vo.ImConversationReadRespVO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
  7 +import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
  8 +import io.swagger.v3.oas.annotations.Operation;
  9 +import io.swagger.v3.oas.annotations.Parameter;
  10 +import io.swagger.v3.oas.annotations.Parameters;
  11 +import io.swagger.v3.oas.annotations.tags.Tag;
  12 +import jakarta.annotation.Resource;
  13 +import jakarta.validation.constraints.Max;
  14 +import jakarta.validation.constraints.Min;
  15 +import org.springframework.validation.annotation.Validated;
  16 +import org.springframework.web.bind.annotation.GetMapping;
  17 +import org.springframework.web.bind.annotation.RequestMapping;
  18 +import org.springframework.web.bind.annotation.RequestParam;
  19 +import org.springframework.web.bind.annotation.RestController;
  20 +
  21 +import java.util.List;
  22 +
  23 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  24 +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
  25 +
  26 +@Tag(name = "管理后台 - IM 会话读位置")
  27 +@RestController
  28 +@RequestMapping("/im/conversation-read")
  29 +@Validated
  30 +public class ImConversationReadController {
  31 +
  32 + @Resource
  33 + private ImConversationReadService conversationReadService;
  34 +
  35 + @GetMapping("/pull")
  36 + @Operation(summary = "增量拉取当前用户的会话读位置(重连 / 离线补偿)")
  37 + @Parameters({
  38 + @Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
  39 + @Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
  40 + @Parameter(name = "limit", description = "单次拉取条数", required = true)
  41 + })
  42 + public CommonResult<List<ImConversationReadRespVO>> pullMyConversationRead(
  43 + @RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
  44 + @RequestParam(value = "lastId", required = false) Long lastId,
  45 + @RequestParam("limit") @Min(1) @Max(200) Integer limit) {
  46 + List<ImConversationReadDO> list = conversationReadService.pullConversationReadList(
  47 + getLoginUserId(), lastUpdateTime, lastId, limit);
  48 + return success(BeanUtils.toBean(list, ImConversationReadRespVO.class));
  49 + }
  50 +
  51 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/conversation/vo/ImConversationReadRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.conversation.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +/**
  9 + * IM 会话读位置 Response VO
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Schema(description = "管理后台 - IM 会话读位置 Response VO")
  14 +@Data
  15 +public class ImConversationReadRespVO {
  16 +
  17 + @Schema(description = "读位置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  18 + private Long id;
  19 +
  20 + @Schema(description = "会话类型", example = "1")
  21 + private Integer conversationType; // 参见 ImConversationTypeEnum 枚举
  22 +
  23 + @Schema(description = "目标编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
  24 + private Long targetId;
  25 +
  26 + @Schema(description = "最大已读消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096")
  27 + private Long messageId;
  28 +
  29 + @Schema(description = "最近更新时间(增量拉取游标用)")
  30 + private LocalDateTime updateTime;
  31 +
  32 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java
... ... @@ -17,6 +17,8 @@ import io.swagger.v3.oas.annotations.Parameters;
17 17 import io.swagger.v3.oas.annotations.tags.Tag;
18 18 import jakarta.annotation.Resource;
19 19 import jakarta.validation.Valid;
  20 +import jakarta.validation.constraints.Max;
  21 +import jakarta.validation.constraints.Min;
20 22 import jakarta.validation.constraints.NotNull;
21 23 import org.springframework.validation.annotation.Validated;
22 24 import org.springframework.web.bind.annotation.*;
... ... @@ -46,11 +48,25 @@ public class ImFriendController {
46 48 @GetMapping("/list")
47 49 @Operation(summary = "获得当前登录用户的好友列表")
48 50 public CommonResult<List<ImFriendRespVO>> getMyFriendList() {
49   - // 含 DISABLE 历史好友:保留给前端展示「已删除好友」的历史对话信息;前端按 status 决定会话级联清理
50 51 List<ImFriendDO> friends = friendService.getFriendList(getLoginUserId());
51 52 return success(buildFriendRespVOList(friends));
52 53 }
53 54  
  55 + @GetMapping("/pull")
  56 + @Operation(summary = "增量拉取当前用户的好友关系(重连 / 离线补偿)")
  57 + @Parameters({
  58 + @Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
  59 + @Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
  60 + @Parameter(name = "limit", description = "单次拉取条数", required = true)
  61 + })
  62 + public CommonResult<List<ImFriendRespVO>> pullMyFriendList(
  63 + @RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
  64 + @RequestParam(value = "lastId", required = false) Long lastId,
  65 + @RequestParam("limit") @Min(1) @Max(200) Integer limit) {
  66 + List<ImFriendDO> list = friendService.pullFriendList(getLoginUserId(), lastUpdateTime, lastId, limit);
  67 + return success(buildFriendRespVOList(list));
  68 + }
  69 +
54 70 @GetMapping("/get")
55 71 @Operation(summary = "获得好友详情")
56 72 @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
... ... @@ -62,8 +78,8 @@ public class ImFriendController {
62 78 @DeleteMapping("/delete")
63 79 @Operation(summary = "删除好友(单向软删除)")
64 80 @Parameters({
65   - @Parameter(description = "好友的用户编号", required = true, example = "2048"),
66   - @Parameter(description = "是否级联清理本端相关数据(如私聊会话)")
  81 + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048"),
  82 + @Parameter(name = "clear", description = "是否级联清理本端相关数据(如私聊会话)")
67 83 })
68 84 public CommonResult<Boolean> deleteFriend(
69 85 @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId,
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendRequestController.java
... ... @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
13 13 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
14 14 import io.swagger.v3.oas.annotations.Operation;
15 15 import io.swagger.v3.oas.annotations.Parameter;
  16 +import io.swagger.v3.oas.annotations.Parameters;
16 17 import io.swagger.v3.oas.annotations.tags.Tag;
17 18 import jakarta.annotation.Resource;
18 19 import jakarta.validation.Valid;
... ... @@ -79,15 +80,32 @@ public class ImFriendRequestController {
79 80  
80 81 @GetMapping("/list")
81 82 @Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)")
  83 + @Parameters({
  84 + @Parameter(name = "maxId", description = "当前列表最旧记录的 id;首页不传"),
  85 + @Parameter(name = "limit", description = "单次拉取条数", required = true)
  86 + })
82 87 public CommonResult<List<ImFriendRequestRespVO>> getMyFriendRequestList(
83   - @Parameter(description = "当前列表最旧记录的 id;首页不传")
84 88 @RequestParam(value = "maxId", required = false) Long maxId,
85   - @Parameter(description = "单次拉取条数", required = true)
86 89 @RequestParam("limit") @Min(1) @Max(200) Integer limit) {
87 90 List<ImFriendRequestDO> list = friendRequestService.getMyFriendRequestList(getLoginUserId(), maxId, limit);
88 91 return success(buildList(list));
89 92 }
90 93  
  94 + @GetMapping("/pull")
  95 + @Operation(summary = "增量拉取「我相关」的好友申请(重连 / 离线补偿)")
  96 + @Parameters({
  97 + @Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
  98 + @Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
  99 + @Parameter(name = "limit", description = "单次拉取条数", required = true)
  100 + })
  101 + public CommonResult<List<ImFriendRequestRespVO>> pullMyFriendRequestList(
  102 + @RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
  103 + @RequestParam(value = "lastId", required = false) Long lastId,
  104 + @RequestParam("limit") @Min(1) @Max(200) Integer limit) {
  105 + List<ImFriendRequestDO> list = friendRequestService.pullFriendRequestList(getLoginUserId(), lastUpdateTime, lastId, limit);
  106 + return success(buildList(list));
  107 + }
  108 +
91 109 @GetMapping("/get")
92 110 @Operation(summary = "按 id 单查「我相关」的申请记录(带越权过滤;WebSocket 通知到达后用)")
93 111 @Parameter(name = "id", description = "申请记录编号", required = true)
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java
... ... @@ -47,6 +47,9 @@ public class ImFriendRespVO {
47 47 @Schema(description = "删除好友时间")
48 48 private LocalDateTime deleteTime;
49 49  
  50 + @Schema(description = "最近更新时间(增量拉取游标用)")
  51 + private LocalDateTime updateTime;
  52 +
50 53 // ========== 下面是聚合字段,方便前端显示 ==========
51 54  
52 55 @Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestRespVO.java
... ... @@ -41,6 +41,9 @@ public class ImFriendRequestRespVO {
41 41 @Schema(description = "申请创建时间")
42 42 private LocalDateTime createTime;
43 43  
  44 + @Schema(description = "最近更新时间(增量拉取游标用)")
  45 + private LocalDateTime updateTime;
  46 +
44 47 // ========== 下面是聚合字段,方便前端显示 ==========
45 48  
46 49 @Schema(description = "发起方昵称(实时聚合自 AdminUser)", example = "芋道")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java
... ... @@ -21,6 +21,7 @@ import jakarta.validation.Valid;
21 21 import org.springframework.validation.annotation.Validated;
22 22 import org.springframework.web.bind.annotation.*;
23 23  
  24 +import java.util.Collections;
24 25 import java.util.List;
25 26 import java.util.Map;
26 27  
... ... @@ -88,7 +89,7 @@ public class ImGroupMemberController {
88 89 }
89 90  
90 91 @GetMapping("/list")
91   - @Operation(summary = "获得指定群的成员列表")
  92 + @Operation(summary = "获得指定群的成员列表(按群全量拉取,作为前端本地成员 cache 的完整基线)")
92 93 @Parameter(name = "groupId", description = "群编号", required = true, example = "1024")
93 94 public CommonResult<List<ImGroupMemberRespVO>> getGroupMemberList(@RequestParam("groupId") Long groupId) {
94 95 // 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤)
... ... @@ -101,16 +102,30 @@ public class ImGroupMemberController {
101 102 throw exception(GROUP_MEMBER_NOT_IN_GROUP);
102 103 }
103 104  
104   - // 2.批量聚合 AdminUser 信息(昵称 / 头像)
  105 + // 2. 批量聚合昵称 / 头像,并对非本人置空私人字段
  106 + return success(buildGroupMemberRespVOList(members));
  107 + }
  108 +
  109 + // ========== 私有方法:VO 组装 ==========
  110 +
  111 + /**
  112 + * 群成员列表批量转 VO + 关联回填昵称 / 头像,并对非本人置空私人字段
  113 + */
  114 + private List<ImGroupMemberRespVO> buildGroupMemberRespVOList(List<ImGroupMemberDO> members) {
  115 + if (CollUtil.isEmpty(members)) {
  116 + return Collections.emptyList();
  117 + }
  118 + Long loginUserId = getLoginUserId();
  119 + // 批量聚合 AdminUser 信息(昵称 / 头像),避免 N+1
105 120 Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
106 121 convertList(members, ImGroupMemberDO::getUserId));
107   - return success(convertList(members, m -> {
108   - ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class);
109   - MapUtils.findAndThen(userMap, m.getUserId(), user ->
  122 + return convertList(members, member -> {
  123 + ImGroupMemberRespVO vo = BeanUtils.toBean(member, ImGroupMemberRespVO.class);
  124 + MapUtils.findAndThen(userMap, member.getUserId(), user ->
110 125 vo.setNickname(user.getNickname()).setAvatar(user.getAvatar()));
111   - hidePrivateFieldsIfNotSelf(vo, m.getUserId(), loginUserId);
  126 + hidePrivateFieldsIfNotSelf(vo, member.getUserId(), loginUserId);
112 127 return vo;
113   - }));
  128 + });
114 129 }
115 130  
116 131 /**
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupRequestController.java
... ... @@ -19,9 +19,12 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
19 19 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
20 20 import io.swagger.v3.oas.annotations.Operation;
21 21 import io.swagger.v3.oas.annotations.Parameter;
  22 +import io.swagger.v3.oas.annotations.Parameters;
22 23 import io.swagger.v3.oas.annotations.tags.Tag;
23 24 import jakarta.annotation.Resource;
24 25 import jakarta.validation.Valid;
  26 +import jakarta.validation.constraints.Max;
  27 +import jakarta.validation.constraints.Min;
25 28 import jakarta.validation.constraints.NotNull;
26 29 import jakarta.validation.constraints.Size;
27 30 import org.springframework.validation.annotation.Validated;
... ... @@ -88,6 +91,22 @@ public class ImGroupRequestController {
88 91 return success(buildVOList(list));
89 92 }
90 93  
  94 + @GetMapping("/pull")
  95 + @Operation(summary = "增量拉取「我管理的群」下的加群申请(重连 / 离线补偿)")
  96 + @Parameters({
  97 + @Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
  98 + @Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
  99 + @Parameter(name = "limit", description = "单次拉取条数", required = true)
  100 + })
  101 + public CommonResult<List<ImGroupRequestRespVO>> pullMyGroupRequestList(
  102 + @RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
  103 + @RequestParam(value = "lastId", required = false) Long lastId,
  104 + @RequestParam("limit") @Min(1) @Max(200) Integer limit) {
  105 + List<ImGroupRequestDO> list = groupRequestService.pullGroupRequestList(
  106 + getLoginUserId(), lastUpdateTime, lastId, limit);
  107 + return success(buildVOList(list));
  108 + }
  109 +
91 110 @GetMapping("/list-by-group")
92 111 @Operation(summary = "查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查")
93 112 @Parameter(name = "groupId", description = "群编号", required = true, example = "1024")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/request/ImGroupRequestRespVO.java
... ... @@ -42,6 +42,9 @@ public class ImGroupRequestRespVO {
42 42 @Schema(description = "申请创建时间")
43 43 private LocalDateTime createTime;
44 44  
  45 + @Schema(description = "申请更新时间;增量拉取游标")
  46 + private LocalDateTime updateTime;
  47 +
45 48 // ========== 下面是聚合字段,方便前端显示 ==========
46 49  
47 50 @Schema(description = "申请人 / 被邀请人昵称(实时聚合自 AdminUser)", example = "芋道")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/conversation/ImConversationReadMapper.java
1 1 package cn.iocoder.yudao.module.im.dal.mysql.conversation;
2 2  
  3 +import cn.hutool.core.date.LocalDateTimeUtil;
3 4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
4 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
5 6 import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
... ... @@ -40,19 +41,41 @@ public interface ImConversationReadMapper extends BaseMapperX&lt;ImConversationRead
40 41 }
41 42  
42 43 /**
  44 + * 增量拉取当前用户的会话读位置(按 update_time + id 正向游标)
  45 + *
  46 + * @param userId 当前用户编号
  47 + * @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
  48 + * @param lastId 上次拉取到的记录编号;首次拉取传 null
  49 + * @param limit 拉取数量
  50 + * @return 会话读位置列表
  51 + */
  52 + default List<ImConversationReadDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
  53 + LambdaQueryWrapperX<ImConversationReadDO> query = new LambdaQueryWrapperX<ImConversationReadDO>()
  54 + .eq(ImConversationReadDO::getUserId, userId);
  55 + if (lastUpdateTime != null && lastId != null) {
  56 + LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
  57 + query.and(w -> w.gt(ImConversationReadDO::getUpdateTime, lastTime)
  58 + .or(n -> n.eq(ImConversationReadDO::getUpdateTime, lastTime).gt(ImConversationReadDO::getId, lastId)));
  59 + }
  60 + return selectList(query.orderByAsc(ImConversationReadDO::getUpdateTime).orderByAsc(ImConversationReadDO::getId)
  61 + .last("LIMIT " + limit));
  62 + }
  63 +
  64 + /**
43 65 * 单调递增更新读位置:仅当新位置更大时才更新
44 66 * <p>
45 67 * 通过 {@code read_message_id < messageId} 的 CAS 条件,保证乱序 / 并发上报时读位置不会回退。
46 68 *
47   - * @param id 记录编号
48   - * @param messageId 新的最大已读消息编号
49   - * @param readTime 已读时间
  69 + * @param id 记录编号
  70 + * @param messageId 新的最大已读消息编号
  71 + * @param readTime 已读时间
50 72 * @return 影响行数;0 表示新位置不大于已有位置,未更新
51 73 */
52 74 default int updateReadMessageIdToLarger(Long id, Long messageId, LocalDateTime readTime) {
53 75 return update(null, Wrappers.<ImConversationReadDO>lambdaUpdate()
54 76 .set(ImConversationReadDO::getMessageId, messageId)
55 77 .set(ImConversationReadDO::getReadTime, readTime)
  78 + .set(ImConversationReadDO::getUpdateTime, readTime)
56 79 .eq(ImConversationReadDO::getId, id)
57 80 .lt(ImConversationReadDO::getMessageId, messageId));
58 81 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java
1 1 package cn.iocoder.yudao.module.im.dal.mysql.friend;
2 2  
  3 +import cn.hutool.core.date.LocalDateTimeUtil;
3 4 import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 5 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 6 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
... ... @@ -31,6 +32,28 @@ public interface ImFriendMapper extends BaseMapperX&lt;ImFriendDO&gt; {
31 32 .eq(ImFriendDO::getUserId, userId)
32 33 .orderByDesc(ImFriendDO::getId));
33 34 }
  35 +
  36 + /**
  37 + * 增量拉取当前用户的好友关系(含已删除,按 update_time + id 正向游标)
  38 + *
  39 + * @param userId 当前用户编号
  40 + * @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
  41 + * @param lastId 上次拉取到的记录编号;首次拉取传 null
  42 + * @param limit 拉取数量
  43 + * @return 好友关系列表
  44 + */
  45 + default List<ImFriendDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
  46 + LambdaQueryWrapperX<ImFriendDO> query = new LambdaQueryWrapperX<ImFriendDO>()
  47 + .eq(ImFriendDO::getUserId, userId);
  48 + if (lastUpdateTime != null && lastId != null) {
  49 + LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
  50 + query.and(w -> w.gt(ImFriendDO::getUpdateTime, lastTime)
  51 + .or(n -> n.eq(ImFriendDO::getUpdateTime, lastTime).gt(ImFriendDO::getId, lastId)));
  52 + }
  53 + return selectList(query.orderByAsc(ImFriendDO::getUpdateTime).orderByAsc(ImFriendDO::getId)
  54 + .last("LIMIT " + limit));
  55 + }
  56 +
34 57 default List<ImFriendDO> selectListByUserIdAndStatus(Long userId, Integer status) {
35 58 return selectList(new LambdaQueryWrapperX<ImFriendDO>()
36 59 .eq(ImFriendDO::getUserId, userId)
... ... @@ -68,12 +91,14 @@ public interface ImFriendMapper extends BaseMapperX&lt;ImFriendDO&gt; {
68 91  
69 92 @SuppressWarnings("UnusedReturnValue")
70 93 default int updateReAddFields(Long id, Integer status, LocalDateTime addTime,
  94 + LocalDateTime updateTime,
71 95 Boolean silent, Boolean pinned, Boolean blocked,
72 96 String displayName, Integer addSource) {
73 97 return update(null, Wrappers.<ImFriendDO>lambdaUpdate()
74 98 .eq(ImFriendDO::getId, id)
75 99 .set(ImFriendDO::getStatus, status)
76 100 .set(ImFriendDO::getAddTime, addTime)
  101 + .set(ImFriendDO::getUpdateTime, updateTime)
77 102 .set(ImFriendDO::getSilent, silent)
78 103 .set(ImFriendDO::getPinned, pinned)
79 104 .set(ImFriendDO::getBlocked, blocked)
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapper.java
1 1 package cn.iocoder.yudao.module.im.dal.mysql.friend;
2 2  
  3 +import cn.hutool.core.date.LocalDateTimeUtil;
3 4 import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 5 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 6 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
... ... @@ -28,6 +29,12 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; {
28 29  
29 30 /**
30 31 * 拉取「我相关」的好友申请列表
  32 + *
  33 + * @param userId 当前用户编号
  34 + * @param maxRequestUpdateTime 起始更新时间;首次拉取传 null
  35 + * @param maxId 起始记录编号;首次拉取传 null
  36 + * @param limit 拉取数量
  37 + * @return 好友申请列表
31 38 */
32 39 default List<ImFriendRequestDO> selectMyList(Long userId, LocalDateTime maxRequestUpdateTime,
33 40 Long maxId, int limit) {
... ... @@ -45,6 +52,28 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; {
45 52 return selectList(wrapper);
46 53 }
47 54  
  55 + /**
  56 + * 增量拉取「我相关」的好友申请(双向 OR,按 update_time + id 正向游标)
  57 + *
  58 + * @param userId 当前用户编号
  59 + * @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
  60 + * @param lastId 上次拉取到的记录编号;首次拉取传 null
  61 + * @param limit 拉取数量
  62 + * @return 好友申请列表
  63 + */
  64 + default List<ImFriendRequestDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
  65 + LambdaQueryWrapperX<ImFriendRequestDO> query = new LambdaQueryWrapperX<>();
  66 + query.and(w -> w.eq(ImFriendRequestDO::getFromUserId, userId)
  67 + .or().eq(ImFriendRequestDO::getToUserId, userId));
  68 + if (lastUpdateTime != null && lastId != null) {
  69 + LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
  70 + query.and(w -> w.gt(ImFriendRequestDO::getUpdateTime, lastTime)
  71 + .or(n -> n.eq(ImFriendRequestDO::getUpdateTime, lastTime).gt(ImFriendRequestDO::getId, lastId)));
  72 + }
  73 + return selectList(query.orderByAsc(ImFriendRequestDO::getUpdateTime).orderByAsc(ImFriendRequestDO::getId)
  74 + .last("LIMIT " + limit));
  75 + }
  76 +
48 77 default int updateByIdAndHandleResult(Long id, Integer handleResult, ImFriendRequestDO updateObj) {
49 78 return update(updateObj, new LambdaUpdateWrapper<ImFriendRequestDO>()
50 79 .eq(ImFriendRequestDO::getId, id).eq(ImFriendRequestDO::getHandleResult, handleResult));
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java
... ... @@ -130,7 +130,8 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
130 130 .set(ImGroupMemberDO::getAddSource, addSource)
131 131 .set(ImGroupMemberDO::getInviterUserId, inviterUserId)
132 132 .set(ImGroupMemberDO::getQuitTime, null)
133   - .set(ImGroupMemberDO::getMuteEndTime, null));
  133 + .set(ImGroupMemberDO::getMuteEndTime, null)
  134 + .set(ImGroupMemberDO::getUpdateTime, joinTime));
134 135 }
135 136  
136 137 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupRequestMapper.java
1 1 package cn.iocoder.yudao.module.im.dal.mysql.group;
2 2  
  3 +import cn.hutool.core.date.LocalDateTimeUtil;
3 4 import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 5 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 6 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
... ... @@ -43,6 +44,28 @@ public interface ImGroupRequestMapper extends BaseMapperX&lt;ImGroupRequestDO&gt; {
43 44 .orderByDesc(ImGroupRequestDO::getId));
44 45 }
45 46  
  47 + /**
  48 + * 增量拉取「我管理的群」下的加群申请(含已处理,按 update_time + id 正向游标)
  49 + *
  50 + * @param groupIds 群编号集合
  51 + * @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
  52 + * @param lastId 上次拉取到的记录编号;首次拉取传 null
  53 + * @param limit 拉取数量
  54 + * @return 加群申请列表
  55 + */
  56 + default List<ImGroupRequestDO> selectPullListByGroupIds(Collection<Long> groupIds, Long lastUpdateTime,
  57 + Long lastId, Integer limit) {
  58 + LambdaQueryWrapperX<ImGroupRequestDO> query = new LambdaQueryWrapperX<ImGroupRequestDO>()
  59 + .in(ImGroupRequestDO::getGroupId, groupIds);
  60 + if (lastUpdateTime != null && lastId != null) {
  61 + LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
  62 + query.and(w -> w.gt(ImGroupRequestDO::getUpdateTime, lastTime)
  63 + .or(n -> n.eq(ImGroupRequestDO::getUpdateTime, lastTime).gt(ImGroupRequestDO::getId, lastId)));
  64 + }
  65 + return selectList(query.orderByAsc(ImGroupRequestDO::getUpdateTime).orderByAsc(ImGroupRequestDO::getId)
  66 + .last("LIMIT " + limit));
  67 + }
  68 +
46 69 default int updateByIdAndHandleResult(Long id, Integer expectedHandleResult, ImGroupRequestDO updateObj) {
47 70 return update(updateObj, new LambdaUpdateWrapper<ImGroupRequestDO>()
48 71 .eq(ImGroupRequestDO::getId, id).eq(ImGroupRequestDO::getHandleResult, expectedHandleResult));
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadService.java
1 1 package cn.iocoder.yudao.module.im.service.conversation;
2 2  
  3 +import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
  4 +
3 5 import java.util.Collection;
  6 +import java.util.List;
4 7 import java.util.Map;
5 8  
6 9 /**
... ... @@ -50,4 +53,15 @@ public interface ImConversationReadService {
50 53 */
51 54 Map<Long, Long> getConversationReadMessageIdMap(Long userId, Integer conversationType, Collection<Long> conversationIds);
52 55  
  56 + /**
  57 + * 增量拉取当前用户的会话读位置(重连 / 离线补偿:按 update_time + id 游标)
  58 + *
  59 + * @param userId 用户编号
  60 + * @param lastUpdateTime 上次拉取到的最新更新时间(毫秒时间戳);首次拉取传 null
  61 + * @param lastId 上次拉取到的最后一条记录 id;首次拉取传 null
  62 + * @param limit 单次拉取条数
  63 + * @return 会话读位置列表,按更新时间、id 正序
  64 + */
  65 + List<ImConversationReadDO> pullConversationReadList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
  66 +
53 67 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/conversation/ImConversationReadServiceImpl.java
... ... @@ -78,4 +78,9 @@ public class ImConversationReadServiceImpl implements ImConversationReadService
78 78 return convertMap(list, ImConversationReadDO::getTargetId, ImConversationReadDO::getMessageId);
79 79 }
80 80  
  81 + @Override
  82 + public List<ImConversationReadDO> pullConversationReadList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
  83 + return conversationReadMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
  84 + }
  85 +
81 86 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestService.java
... ... @@ -51,6 +51,17 @@ public interface ImFriendRequestService {
51 51 List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long maxId, Integer limit);
52 52  
53 53 /**
  54 + * 增量拉取「我相关」的好友申请(重连 / 离线补偿:双向 OR,按 update_time + id 游标)
  55 + *
  56 + * @param userId 用户编号
  57 + * @param lastUpdateTime 上次拉取到的最新更新时间(毫秒时间戳);首次拉取传 null
  58 + * @param lastId 上次拉取到的最后一条记录 id;首次拉取传 null
  59 + * @param limit 单次拉取条数
  60 + * @return 申请记录列表,按更新时间、id 正序
  61 + */
  62 + List<ImFriendRequestDO> pullFriendRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
  63 +
  64 + /**
54 65 * 按 id 单查申请记录;通用读接口,调用方自行做越权过滤
55 66 */
56 67 ImFriendRequestDO getFriendRequest(Long id);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java
... ... @@ -234,6 +234,11 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
234 234 }
235 235  
236 236 @Override
  237 + public List<ImFriendRequestDO> pullFriendRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
  238 + return friendRequestMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
  239 + }
  240 +
  241 + @Override
237 242 public ImFriendRequestDO getFriendRequest(Long id) {
238 243 return friendRequestMapper.selectById(id);
239 244 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java
... ... @@ -43,6 +43,11 @@ public interface ImFriendService {
43 43 List<ImFriendDO> getFriendList(Long userId);
44 44  
45 45 /**
  46 + * 增量拉取当前用户的好友关系(重连 / 离线补偿:含已删除,按 update_time + id 游标)
  47 + */
  48 + List<ImFriendDO> pullFriendList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
  49 +
  50 + /**
46 51 * 获得当前用户的有效好友列表(仅 ENABLE 状态)
47 52 */
48 53 List<ImFriendDO> getEnableFriendList(Long userId);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
... ... @@ -99,6 +99,11 @@ public class ImFriendServiceImpl implements ImFriendService {
99 99 }
100 100  
101 101 @Override
  102 + public List<ImFriendDO> pullFriendList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
  103 + return friendMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
  104 + }
  105 +
  106 + @Override
102 107 public List<ImFriendDO> getEnableFriendList(Long userId) {
103 108 return friendMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus());
104 109 }
... ... @@ -294,7 +299,8 @@ public class ImFriendServiceImpl implements ImFriendService {
294 299 }
295 300 // 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义
296 301 if (exists != null) {
297   - friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.now(),
  302 + LocalDateTime now = LocalDateTime.now();
  303 + friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), now, now,
298 304 false, false, false, displayName, addSource);
299 305 return;
300 306 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestService.java
... ... @@ -64,6 +64,19 @@ public interface ImGroupRequestService {
64 64 List<ImGroupRequestDO> getUnhandledRequestListByOwnerOrAdmin(Long userId);
65 65  
66 66 /**
  67 + * 增量拉取「我管理的群」下的加群申请(重连 / 离线补偿:含已处理,按 update_time + id 游标)
  68 + * <p>
  69 + * 作用域与 {@link #getUnhandledRequestListByOwnerOrAdmin} 一致:按 ImGroupMember.role 取我作为 OWNER / ADMIN 的群
  70 + *
  71 + * @param userId 当前用户编号
  72 + * @param lastUpdateTime 游标:上次拉取的最后一条更新时间戳(毫秒)
  73 + * @param lastId 游标:上次拉取的最后一条申请编号
  74 + * @param limit 单次拉取条数
  75 + * @return 申请记录列表
  76 + */
  77 + List<ImGroupRequestDO> pullGroupRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
  78 +
  79 + /**
67 80 * 拉取指定群下的全部加群申请(含已处理);仅群主 / 管理员可查
68 81 * <p>
69 82 * 用于群「进群申请」子页:最新一条卡片化突出 + 历史申请按 id 倒序
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImpl.java
... ... @@ -212,8 +212,7 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
212 212 // 1. 找出当前用户作为 OWNER / ADMIN 的所有群
213 213 List<ImGroupMemberDO> myMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
214 214 Set<Long> ownerOrAdminGroupIds = convertSet(myMembers,
215   - ImGroupMemberDO::getGroupId,
216   - m -> ImGroupMemberRoleEnum.isOwnerOrAdmin(m.getRole()));
  215 + ImGroupMemberDO::getGroupId, member -> ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()));
217 216 if (CollUtil.isEmpty(ownerOrAdminGroupIds)) {
218 217 return Collections.emptyList();
219 218 }
... ... @@ -223,6 +222,19 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
223 222 }
224 223  
225 224 @Override
  225 + public List<ImGroupRequestDO> pullGroupRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
  226 + // 1. 找出当前用户作为 OWNER / ADMIN 的所有群
  227 + List<ImGroupMemberDO> myMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
  228 + Set<Long> ownerOrAdminGroupIds = convertSet(myMembers,
  229 + ImGroupMemberDO::getGroupId, member -> ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()));
  230 + if (CollUtil.isEmpty(ownerOrAdminGroupIds)) {
  231 + return Collections.emptyList();
  232 + }
  233 + // 2. 按游标增量拉取这些群下的申请
  234 + return groupRequestMapper.selectPullListByGroupIds(ownerOrAdminGroupIds, lastUpdateTime, lastId, limit);
  235 + }
  236 +
  237 + @Override
226 238 public List<ImGroupRequestDO> getGroupRequestListByGroupId(Long userId, Long groupId) {
227 239 // 1. 校验群存在 + 当前用户是群主 / 管理员
228 240 groupService.validateGroupExists(groupId);
... ...