Commit 99121a25e0ea9e526a3db42dba1455c7075600e0

Authored by YunaiV
1 parent 0ce9edde

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

yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
@@ -51,8 +51,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -51,8 +51,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
51 51
52 /** 52 /**
53 * 仅用于规避群消息 pull 的"假空页": 53 * 仅用于规避群消息 pull 的"假空页":
54 - * 首批 raw 消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。  
55 - * 因此仅在过滤结果为空时,按本轮 raw 最大 id 向后再试几次。 54 + * 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。
  55 + * 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。
56 */ 56 */
57 private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3; 57 private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3;
58 58
@@ -111,56 +111,71 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -111,56 +111,71 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
111 // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 111 // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送
112 LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); 112 LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS);
113 113
114 - // 1. 拉取一批原始消息(包含当前在群、退群前补齐两部分),并过滤得到当前用户可见的结果  
115 - List<ImGroupMessageDO> result = Collections.emptyList();  
116 - Long currentMinId = minId; // 当前轮次的下限消息编号;初始为用户传入的 minId,后续会根据拉取结果更新 114 + // 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试
  115 + List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
  116 + Map<Long, ImGroupMemberDO> memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId);
  117 + List<ImGroupMessageDO> messages = new ArrayList<>();
  118 + if (CollUtil.isNotEmpty(activeMembers)) {
  119 + List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId);
  120 + messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap));
  121 + }
  122 + // 1.2 补齐"退群前"的消息:
  123 + // - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进;
  124 + // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,避免把客户端已拥有的老消息再次推送。
  125 + messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap));
  126 +
  127 + // 2. 合并后统一过滤,得到当前用户可见的结果
  128 + List<ImGroupMessageDO> result = filterGroupMessageList(messages, memberMap, userId, size);
  129 +
  130 + // 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数
  131 + appendMessageStatusAndReceipt(userId, result);
  132 + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size());
  133 + return result;
  134 + }
  135 +
  136 + /**
  137 + * 拉取当前仍在群主路径的消息。
  138 + *
  139 + * 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测,
  140 + * 直到命中可见消息或确认该来源已耗尽。
  141 + */
  142 + private List<ImGroupMessageDO> pullActiveGroupMessageList(Long userId, List<Long> groupIds, Long minId,
  143 + Integer size, LocalDateTime minSendTime,
  144 + Map<Long, ImGroupMemberDO> memberMap) {
  145 + // 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界
  146 + Long activeMinId = minId;
117 for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) { 147 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); 148 + // 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束
  149 + List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId,
  150 + minSendTime, size);
121 if (CollUtil.isEmpty(messages)) { 151 if (CollUtil.isEmpty(messages)) {
122 - break; 152 + return Collections.emptyList();
123 } 153 }
124 - // 1.2 过滤得到当前用户可见的结果(按 id 升序)  
125 - result = filterGroupMessageList(messages, memberMap, userId, size);  
126 - // 已拿到可见消息,或原始消息已不足一页(说明没有继续重试的必要)  
127 - if (CollUtil.isNotEmpty(result) || messages.size() < size) {  
128 - break; 154 + boolean hasVisibleMessage = CollUtil.anyMatch(messages,
  155 + message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId));
  156 + boolean sourceExhausted = messages.size() < size;
  157 + // 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息
  158 + if (hasVisibleMessage || sourceExhausted) {
  159 + return messages;
129 } 160 }
130 161
131 - // 1.3 更新 currentMinId,进行下一轮查询;如果本轮查询的最大消息编号没有前进,则说明后续没有更多消息了,直接 break 162 + // 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止
132 Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId); 163 Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId);
133 - if (maxMessageId == null || maxMessageId <= currentMinId) {  
134 - break; 164 + if (maxMessageId <= activeMinId) {
  165 + return Collections.emptyList();
135 } 166 }
136 - currentMinId = maxMessageId; 167 + activeMinId = maxMessageId;
137 } 168 }
138 -  
139 - // 2. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数  
140 - appendMessageStatusAndReceipt(userId, result);  
141 - log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size());  
142 - return result; 169 + return Collections.emptyList();
143 } 170 }
144 171
145 /** 172 /**
146 - * 查询一批原始群消息(包含当前在群、退群前补齐两部分) 173 + * 拉取已离开群路径的消息(退群前消息)
147 */ 174 */
148 - private List<ImGroupMessageDO> getGroupMessageList(Long userId, Long minId, Integer size,  
149 - LocalDateTime minSendTime,  
150 - Map<Long, ImGroupMemberDO> memberMap) {  
151 - // 1. 主查询:仅用"当前仍在群"的成员记录驱动(已退群的群由步骤 2 补齐)  
152 - List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);  
153 - memberMap.putAll(convertMap(activeMembers, ImGroupMemberDO::getGroupId));  
154 - List<ImGroupMessageDO> messages = new ArrayList<>();  
155 - if (CollUtil.isNotEmpty(activeMembers)) {  
156 - List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId);  
157 - messages.addAll(groupMessageMapper.selectListByMinId(groupIds, minId, minSendTime, size));  
158 - }  
159 -  
160 - // 2. 补齐"退群前"的消息:  
161 - // - 退群时间早于窗口起点的群直接忽略(消息已不在拉取范围);  
162 - // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,  
163 - // 避免把客户端已拥有的老消息再次推送。 175 + private List<ImGroupMessageDO> pullQuitGroupMessageList(Long userId, Long minId, Integer size,
  176 + LocalDateTime minSendTime,
  177 + Map<Long, ImGroupMemberDO> memberMap) {
  178 + // 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤
164 LocalDateTime minQuitTime = minSendTime; 179 LocalDateTime minQuitTime = minSendTime;
165 if (minId != null && minId > 0) { 180 if (minId != null && minId > 0) {
166 ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId); 181 ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId);
@@ -169,8 +184,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -169,8 +184,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
169 minQuitTime = minMessage.getSendTime(); 184 minQuitTime = minMessage.getSendTime();
170 } 185 }
171 } 186 }
  187 + // 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限
  188 + List<ImGroupMessageDO> messages = new ArrayList<>();
172 List<ImGroupMemberDO> quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime); 189 List<ImGroupMemberDO> quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime);
173 for (ImGroupMemberDO quitMember : quitMembers) { 190 for (ImGroupMemberDO quitMember : quitMembers) {
  191 + // 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用
174 List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( 192 List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
175 quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); 193 quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size);
176 if (CollUtil.isEmpty(quitGroupMessages)) { 194 if (CollUtil.isEmpty(quitGroupMessages)) {
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
@@ -185,6 +185,45 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -185,6 +185,45 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
185 } 185 }
186 186
187 @Test 187 @Test
  188 + public void testPullMessages_activeQueryRetriesWhenFirstBatchInvisible() {
  189 + LocalDateTime now = LocalDateTime.now();
  190 + LocalDateTime joinTime = now.minusHours(1);
  191 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  192 + .groupId(10L).userId(1L)
  193 + .status(CommonStatusEnum.ENABLE.getStatus())
  194 + .joinTime(joinTime).build();
  195 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member));
  196 +
  197 + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
  198 + .id(1L).groupId(10L).senderId(2L)
  199 + .sendTime(now.minusHours(3)).build();
  200 + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
  201 + .id(2L).groupId(10L).senderId(2L)
  202 + .receiverUserIds(List.of(2L, 3L))
  203 + .sendTime(now.minusHours(2)).build();
  204 + ImGroupMessageDO visibleMsg = ImGroupMessageDO.builder()
  205 + .id(3L).groupId(10L).senderId(2L)
  206 + .sendTime(now.minusMinutes(30)).build();
  207 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L),
  208 + any(LocalDateTime.class), eq(2)))
  209 + .thenReturn(List.of(beforeJoin, directedMsg));
  210 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(2L),
  211 + any(LocalDateTime.class), eq(2)))
  212 + .thenReturn(List.of(visibleMsg));
  213 + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class)))
  214 + .thenReturn(List.of());
  215 +
  216 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 2);
  217 +
  218 + assertEquals(1, result.size());
  219 + assertEquals(3L, result.get(0).getId());
  220 + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(0L),
  221 + any(LocalDateTime.class), eq(2));
  222 + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(2L),
  223 + any(LocalDateTime.class), eq(2));
  224 + }
  225 +
  226 + @Test
188 public void testPullMessages_quitMemberLoadsPreQuitMessages() { 227 public void testPullMessages_quitMemberLoadsPreQuitMessages() {
189 // 准备:当前群(在群)+ 一个最近退群 228 // 准备:当前群(在群)+ 一个最近退群
190 LocalDateTime now = LocalDateTime.now(); 229 LocalDateTime now = LocalDateTime.now();
@@ -232,8 +271,6 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -232,8 +271,6 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
232 // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime 271 // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime
233 LocalDateTime now = LocalDateTime.now(); 272 LocalDateTime now = LocalDateTime.now();
234 when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); 273 when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of());
235 - when(groupMessageMapper.selectListByMinId(anyList(), anyLong(), any(LocalDateTime.class), anyInt()))  
236 - .thenReturn(List.of());  
237 274
238 LocalDateTime boundarySendTime = now.minusDays(2); 275 LocalDateTime boundarySendTime = now.minusDays(2);
239 ImGroupMessageDO boundary = ImGroupMessageDO.builder() 276 ImGroupMessageDO boundary = ImGroupMessageDO.builder()
@@ -251,6 +288,70 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -251,6 +288,70 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
251 } 288 }
252 289
253 @Test 290 @Test
  291 + public void testPullMessages_quitQueryKeepsOriginalMinIdWhenActiveQueryRetries() {
  292 + LocalDateTime now = LocalDateTime.now();
  293 + LocalDateTime joinTime = now.minusHours(1);
  294 + ImGroupMemberDO activeMember = ImGroupMemberDO.builder()
  295 + .groupId(10L).userId(1L)
  296 + .status(CommonStatusEnum.ENABLE.getStatus())
  297 + .joinTime(joinTime).build();
  298 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember));
  299 +
  300 + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
  301 + .id(100L).groupId(10L).senderId(2L)
  302 + .sendTime(now.minusHours(3)).build();
  303 + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
  304 + .id(101L).groupId(10L).senderId(2L)
  305 + .receiverUserIds(List.of(2L, 3L))
  306 + .sendTime(now.minusMinutes(50)).build();
  307 + ImGroupMessageDO visibleActiveMsg = ImGroupMessageDO.builder()
  308 + .id(102L).groupId(10L).senderId(2L)
  309 + .sendTime(now.minusMinutes(30)).build();
  310 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(8L),
  311 + any(LocalDateTime.class), eq(2)))
  312 + .thenReturn(List.of(beforeJoin, directedMsg));
  313 + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(101L),
  314 + any(LocalDateTime.class), eq(2)))
  315 + .thenReturn(List.of(visibleActiveMsg));
  316 +
  317 + LocalDateTime boundarySendTime = now.minusDays(2);
  318 + ImGroupMessageDO boundary = ImGroupMessageDO.builder()
  319 + .id(8L).groupId(99L).senderId(2L).sendTime(boundarySendTime).build();
  320 + when(groupMessageMapper.selectById(8L)).thenReturn(boundary);
  321 +
  322 + LocalDateTime quitTime = now.minusHours(12);
  323 + ImGroupMemberDO quitMember = ImGroupMemberDO.builder()
  324 + .groupId(20L).userId(1L)
  325 + .status(CommonStatusEnum.DISABLE.getStatus())
  326 + .joinTime(now.minusDays(10))
  327 + .quitTime(quitTime).build();
  328 + when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime))
  329 + .thenReturn(List.of(quitMember));
  330 +
  331 + ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder()
  332 + .id(50L).groupId(20L).senderId(99L)
  333 + .sendTime(now.minusDays(1)).build();
  334 + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L),
  335 + any(LocalDateTime.class), eq(quitTime), eq(2)))
  336 + .thenReturn(List.of(quitGroupMsg));
  337 +
  338 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 8L, 2);
  339 +
  340 + assertEquals(2, result.size());
  341 + assertEquals(50L, result.get(0).getId());
  342 + assertEquals(102L, result.get(1).getId());
  343 + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(8L),
  344 + any(LocalDateTime.class), eq(2));
  345 + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(101L),
  346 + any(LocalDateTime.class), eq(2));
  347 + verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime);
  348 + verify(groupMessageMapper).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L),
  349 + any(LocalDateTime.class), eq(quitTime), eq(2));
  350 + verify(groupMessageMapper, never()).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(101L),
  351 + any(LocalDateTime.class), eq(quitTime), eq(2));
  352 + }
  353 +
  354 + @Test
254 public void testPullMessages_receiverUserIdsFilter() { 355 public void testPullMessages_receiverUserIdsFilter() {
255 // 准备 356 // 准备
256 LocalDateTime now = LocalDateTime.now(); 357 LocalDateTime now = LocalDateTime.now();
@@ -452,9 +553,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -452,9 +553,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
452 List<ImGroupMemberDO> members = List.of( 553 List<ImGroupMemberDO> members = List.of(
453 selfMember, 554 selfMember,
454 ImGroupMemberDO.builder().groupId(10L).userId(2L) 555 ImGroupMemberDO.builder().groupId(10L).userId(2L)
455 - .status(CommonStatusEnum.ENABLE.getStatus()).build(),  
456 - ImGroupMemberDO.builder().groupId(10L).userId(3L)  
457 - .status(CommonStatusEnum.DISABLE.getStatus()).build() 556 + .status(CommonStatusEnum.ENABLE.getStatus()).build()
458 ); 557 );
459 when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); 558 when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members);
460 559