Commit 0ce9eddedb339477335bdfcffec793a50ac97e3f

Authored by YunaiV
1 parent bf8eb273

✨ feat(message): 优化群聊的 pullGroupMessageList 方法,解决空分页的风险

yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
@@ -4,12 +4,20 @@ import org.springframework.stereotype.Service; @@ -4,12 +4,20 @@ import org.springframework.stereotype.Service;
4 import jakarta.annotation.Resource; 4 import jakarta.annotation.Resource;
5 import org.springframework.validation.annotation.Validated; 5 import org.springframework.validation.annotation.Validated;
6 6
  7 +
  8 +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
7 import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; 9 import cn.iocoder.yudao.module.im.controller.admin.group.vo.*;
8 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; 10 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
  11 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
9 import cn.iocoder.yudao.framework.common.pojo.PageResult; 12 import cn.iocoder.yudao.framework.common.pojo.PageResult;
10 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 13 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
11 14
12 import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; 15 import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper;
  16 +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper;
  17 +
  18 +import java.util.Collections;
  19 +import java.util.List;
  20 +import java.util.Set;
13 21
14 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 22 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
15 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 23 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
@@ -26,6 +34,9 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -26,6 +34,9 @@ public class ImGroupServiceImpl implements ImGroupService {
26 @Resource 34 @Resource
27 private ImGroupMapper imGroupMapper; 35 private ImGroupMapper imGroupMapper;
28 36
  37 + @Resource
  38 + private ImGroupMemberMapper imGroupMemberMapper;
  39 +
29 @Override 40 @Override
30 public Long createGroup(ImGroupSaveReqVO createReqVO) { 41 public Long createGroup(ImGroupSaveReqVO createReqVO) {
31 // 插入 42 // 插入
@@ -77,4 +88,21 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -77,4 +88,21 @@ public class ImGroupServiceImpl implements ImGroupService {
77 return imGroupMapper.selectPage(pageReqVO); 88 return imGroupMapper.selectPage(pageReqVO);
78 } 89 }
79 90
  91 + @Override
  92 + public List<ImGroupDO> getMyGroupList(Long userId) {
  93 + // 1. 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态)
  94 + List<ImGroupMemberDO> members = imGroupMemberMapper.selectEnabledListByUserId(userId);
  95 + if (members.isEmpty()) {
  96 + return Collections.emptyList();
  97 + }
  98 + Set<Long> groupIds = CollectionUtils.convertSet(members, ImGroupMemberDO::getGroupId);
  99 + if (groupIds.isEmpty()) {
  100 + return Collections.emptyList();
  101 + }
  102 + // 2. 查群并过滤掉已封禁/解散的
  103 + List<ImGroupDO> groups = imGroupMapper.selectByIds(groupIds);
  104 + groups.removeIf(g -> Boolean.TRUE.equals(g.getBanned()) || Boolean.TRUE.equals(g.getDissolved()));
  105 + return groups;
  106 + }
  107 +
80 } 108 }
81 \ No newline at end of file 109 \ No newline at end of file
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
@@ -49,6 +49,13 @@ import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; @@ -49,6 +49,13 @@ import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*;
49 @Slf4j 49 @Slf4j
50 public class ImGroupMessageServiceImpl implements ImGroupMessageService { 50 public class ImGroupMessageServiceImpl implements ImGroupMessageService {
51 51
  52 + /**
  53 + * 仅用于规避群消息 pull 的"假空页":
  54 + * 首批 raw 消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。
  55 + * 因此仅在过滤结果为空时,按本轮 raw 最大 id 向后再试几次。
  56 + */
  57 + private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3;
  58 +
52 @Resource 59 @Resource
53 private ImGroupMessageMapper groupMessageMapper; 60 private ImGroupMessageMapper groupMessageMapper;
54 @Resource 61 @Resource
@@ -100,12 +107,50 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -100,12 +107,50 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
100 if (size > MESSAGE_MAX_PULL_SIZE) { 107 if (size > MESSAGE_MAX_PULL_SIZE) {
101 throw exception(MESSAGE_PULL_SIZE_EXCEEDED); 108 throw exception(MESSAGE_PULL_SIZE_EXCEEDED);
102 } 109 }
  110 +
103 // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 111 // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送
104 LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); 112 LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS);
105 113
  114 + // 1. 拉取一批原始消息(包含当前在群、退群前补齐两部分),并过滤得到当前用户可见的结果
  115 + List<ImGroupMessageDO> result = Collections.emptyList();
  116 + Long currentMinId = minId; // 当前轮次的下限消息编号;初始为用户传入的 minId,后续会根据拉取结果更新
  117 + for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) {
  118 + // 1.1 查询一批原始消息(包含当前在群、退群前补齐两部分)
  119 + Map<Long, ImGroupMemberDO> memberMap = new HashMap<>(); // 群编号 → 群成员记录(包含当前在群、退群前补齐两部分)
  120 + List<ImGroupMessageDO> messages = getGroupMessageList(userId, currentMinId, size, minSendTime, memberMap);
  121 + if (CollUtil.isEmpty(messages)) {
  122 + break;
  123 + }
  124 + // 1.2 过滤得到当前用户可见的结果(按 id 升序)
  125 + result = filterGroupMessageList(messages, memberMap, userId, size);
  126 + // 已拿到可见消息,或原始消息已不足一页(说明没有继续重试的必要)
  127 + if (CollUtil.isNotEmpty(result) || messages.size() < size) {
  128 + break;
  129 + }
  130 +
  131 + // 1.3 更新 currentMinId,进行下一轮查询;如果本轮查询的最大消息编号没有前进,则说明后续没有更多消息了,直接 break
  132 + Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId);
  133 + if (maxMessageId == null || maxMessageId <= currentMinId) {
  134 + break;
  135 + }
  136 + currentMinId = maxMessageId;
  137 + }
  138 +
  139 + // 2. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数
  140 + appendMessageStatusAndReceipt(userId, result);
  141 + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size());
  142 + return result;
  143 + }
  144 +
  145 + /**
  146 + * 查询一批原始群消息(包含当前在群、退群前补齐两部分)
  147 + */
  148 + private List<ImGroupMessageDO> getGroupMessageList(Long userId, Long minId, Integer size,
  149 + LocalDateTime minSendTime,
  150 + Map<Long, ImGroupMemberDO> memberMap) {
106 // 1. 主查询:仅用"当前仍在群"的成员记录驱动(已退群的群由步骤 2 补齐) 151 // 1. 主查询:仅用"当前仍在群"的成员记录驱动(已退群的群由步骤 2 补齐)
107 List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); 152 List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
108 - Map<Long, ImGroupMemberDO> memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId); 153 + memberMap.putAll(convertMap(activeMembers, ImGroupMemberDO::getGroupId));
109 List<ImGroupMessageDO> messages = new ArrayList<>(); 154 List<ImGroupMessageDO> messages = new ArrayList<>();
110 if (CollUtil.isNotEmpty(activeMembers)) { 155 if (CollUtil.isNotEmpty(activeMembers)) {
111 List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); 156 List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId);
@@ -128,25 +173,28 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -128,25 +173,28 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
128 for (ImGroupMemberDO quitMember : quitMembers) { 173 for (ImGroupMemberDO quitMember : quitMembers) {
129 List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( 174 List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
130 quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); 175 quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size);
131 - if (CollUtil.isNotEmpty(quitGroupMessages)) {  
132 - messages.addAll(quitGroupMessages);  
133 - memberMap.put(quitMember.getGroupId(), quitMember); 176 + if (CollUtil.isEmpty(quitGroupMessages)) {
  177 + continue;
134 } 178 }
  179 + messages.addAll(quitGroupMessages);
  180 + memberMap.put(quitMember.getGroupId(), quitMember);
135 } 181 }
  182 + return messages;
  183 + }
136 184
137 - // 3.1 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条,  
138 - // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息  
139 - List<ImGroupMessageDO> result = messages.stream() 185 + /**
  186 + * 过滤一批原始群消息,得到当前用户可见的返回结果
  187 + */
  188 + private List<ImGroupMessageDO> filterGroupMessageList(List<ImGroupMessageDO> messages,
  189 + Map<Long, ImGroupMemberDO> memberMap,
  190 + Long userId, Integer size) {
  191 + // 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条,
  192 + // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息
  193 + return messages.stream()
140 .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) 194 .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId))
141 .sorted(Comparator.comparing(ImGroupMessageDO::getId)) 195 .sorted(Comparator.comparing(ImGroupMessageDO::getId))
142 .limit(size) 196 .limit(size)
143 .toList(); 197 .toList();
144 - // 3.2 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数  
145 - appendMessageStatusAndReceipt(userId, result);  
146 -  
147 - log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]",  
148 - userId, minId, size, result.size());  
149 - return result;  
150 } 198 }
151 199
152 /** 200 /**