Commit 5e824ccb4e68576724ae0eddbe4d9eeac71cc2ea
1 parent
3361c757
✨ feat(message): 优化群聊的 enrichPullResultForViewer 已读状态、人数
Showing
2 changed files
with
173 additions
and
1 deletions
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| ... | ... | @@ -138,16 +138,71 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 138 | 138 | } |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | - // 3. 按可见性过滤(入群前不可见、定向消息排除),并按 id 升序重排 | |
| 141 | + // 3.1 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条, | |
| 142 | + // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息 | |
| 142 | 143 | List<ImGroupMessageDO> result = messages.stream() |
| 143 | 144 | .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) |
| 144 | 145 | .sorted(Comparator.comparing(ImGroupMessageDO::getId)) |
| 146 | + .limit(size) | |
| 145 | 147 | .toList(); |
| 148 | + // 3.2 按当前用户补齐离线拉取结果:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 | |
| 149 | + enrichPullResultForViewer(userId, result); | |
| 150 | + | |
| 146 | 151 | log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", |
| 147 | 152 | userId, minId, size, result.size()); |
| 148 | 153 | return result; |
| 149 | 154 | } |
| 150 | 155 | |
| 156 | + /** | |
| 157 | + * 离线拉取结果按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 | |
| 158 | + */ | |
| 159 | + @SuppressWarnings("StatementWithEmptyBody") | |
| 160 | + private void enrichPullResultForViewer(Long userId, List<ImGroupMessageDO> messages) { | |
| 161 | + if (CollUtil.isEmpty(messages)) { | |
| 162 | + return; | |
| 163 | + } | |
| 164 | + Map<Long, Long> viewerReadCursorByGroup = new HashMap<>(); // 群 → 已读位置 | |
| 165 | + Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) | |
| 166 | + Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 | |
| 167 | + for (ImGroupMessageDO message : messages) { | |
| 168 | + // 消息已读态(status):撤回 > 已读 > 未读 | |
| 169 | + Long groupId = message.getGroupId(); | |
| 170 | + long readCursor = viewerReadCursorByGroup.computeIfAbsent(groupId, gid -> { | |
| 171 | + Long readMax = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId); | |
| 172 | + return readMax != null ? readMax : -1L; | |
| 173 | + }); | |
| 174 | + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { | |
| 175 | + // 保持撤回态 | |
| 176 | + } else if (readCursor >= message.getId()) { | |
| 177 | + message.setStatus(ImMessageStatusEnum.READ.getStatus()); | |
| 178 | + } else { | |
| 179 | + message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); | |
| 180 | + } | |
| 181 | + | |
| 182 | + // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) | |
| 183 | + if (ObjUtil.notEqual(message.getSenderId(), userId) | |
| 184 | + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { | |
| 185 | + continue; | |
| 186 | + } | |
| 187 | + Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId, | |
| 188 | + gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid)); | |
| 189 | + List<ImGroupMemberDO> allMembers = membersByGroup.computeIfAbsent(groupId, | |
| 190 | + groupMemberService::getGroupMemberListByGroupId); | |
| 191 | + Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers); | |
| 192 | + visibleUserIds.remove(message.getSenderId()); | |
| 193 | + int readCount = 0; | |
| 194 | + if (CollUtil.isNotEmpty(visibleUserIds)) { | |
| 195 | + for (Long uid : visibleUserIds) { | |
| 196 | + Long pos = positions.get(uid); | |
| 197 | + if (pos != null && pos >= message.getId()) { | |
| 198 | + readCount++; | |
| 199 | + } | |
| 200 | + } | |
| 201 | + } | |
| 202 | + message.setReadCount(readCount); | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 151 | 206 | @Override |
| 152 | 207 | public void readGroupMessages(Long userId, Long groupId, Long messageId) { |
| 153 | 208 | Assert.notNull(messageId, "已读消息编号不能为空"); | ... | ... |
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
| ... | ... | @@ -311,6 +311,123 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 311 | 311 | assertEquals(List.of(1L, 3L), result.get(0).getAtUserIds()); |
| 312 | 312 | } |
| 313 | 313 | |
| 314 | + @Test | |
| 315 | + public void testPullMessages_pageTrimmedToSize() { | |
| 316 | + LocalDateTime now = LocalDateTime.now(); | |
| 317 | + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() | |
| 318 | + .groupId(10L).userId(1L) | |
| 319 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 320 | + .joinTime(now.minusDays(10)).build(); | |
| 321 | + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); | |
| 322 | + | |
| 323 | + ImGroupMessageDO activeMsg1 = ImGroupMessageDO.builder() | |
| 324 | + .id(1000L).groupId(10L).senderId(2L).sendTime(now.minusHours(2)).build(); | |
| 325 | + ImGroupMessageDO activeMsg2 = ImGroupMessageDO.builder() | |
| 326 | + .id(2000L).groupId(10L).senderId(2L).sendTime(now.minusHours(1)).build(); | |
| 327 | + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), | |
| 328 | + any(LocalDateTime.class), eq(2))) | |
| 329 | + .thenReturn(List.of(activeMsg1, activeMsg2)); | |
| 330 | + | |
| 331 | + LocalDateTime quitTime = now.minusDays(1); | |
| 332 | + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() | |
| 333 | + .groupId(20L).userId(1L) | |
| 334 | + .status(CommonStatusEnum.DISABLE.getStatus()) | |
| 335 | + .joinTime(now.minusDays(20)) | |
| 336 | + .quitTime(quitTime).build(); | |
| 337 | + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | |
| 338 | + .thenReturn(List.of(quitMember)); | |
| 339 | + | |
| 340 | + ImGroupMessageDO quit1 = ImGroupMessageDO.builder() | |
| 341 | + .id(150L).groupId(20L).senderId(99L).sendTime(now.minusDays(5)).build(); | |
| 342 | + ImGroupMessageDO quit2 = ImGroupMessageDO.builder() | |
| 343 | + .id(160L).groupId(20L).senderId(99L).sendTime(now.minusDays(4)).build(); | |
| 344 | + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), | |
| 345 | + any(LocalDateTime.class), eq(quitTime), eq(2))) | |
| 346 | + .thenReturn(List.of(quit1, quit2)); | |
| 347 | + | |
| 348 | + when(groupMessageReadRedisDAO.getReadMaxMessageId(anyLong(), eq(1L))).thenReturn(null); | |
| 349 | + | |
| 350 | + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 2); | |
| 351 | + | |
| 352 | + assertEquals(2, result.size()); | |
| 353 | + assertEquals(150L, result.get(0).getId()); | |
| 354 | + assertEquals(160L, result.get(1).getId()); | |
| 355 | + } | |
| 356 | + | |
| 357 | + @Test | |
| 358 | + public void testPullMessages_pullSetsReadFromRedisCursor() { | |
| 359 | + LocalDateTime now = LocalDateTime.now(); | |
| 360 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | |
| 361 | + .groupId(10L).userId(1L) | |
| 362 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 363 | + .joinTime(now.minusDays(10)).build(); | |
| 364 | + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); | |
| 365 | + | |
| 366 | + ImGroupMessageDO low = ImGroupMessageDO.builder() | |
| 367 | + .id(5L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 368 | + .sendTime(now.minusHours(2)).build(); | |
| 369 | + ImGroupMessageDO high = ImGroupMessageDO.builder() | |
| 370 | + .id(10L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 371 | + .sendTime(now.minusHours(1)).build(); | |
| 372 | + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), | |
| 373 | + any(LocalDateTime.class), eq(100))) | |
| 374 | + .thenReturn(List.of(low, high)); | |
| 375 | + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | |
| 376 | + .thenReturn(List.of()); | |
| 377 | + | |
| 378 | + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(7L); | |
| 379 | + | |
| 380 | + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); | |
| 381 | + | |
| 382 | + assertEquals(2, result.size()); | |
| 383 | + assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); | |
| 384 | + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus()); | |
| 385 | + } | |
| 386 | + | |
| 387 | + @Test | |
| 388 | + public void testPullMessages_receiptReadCountForSender() { | |
| 389 | + LocalDateTime now = LocalDateTime.now(); | |
| 390 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | |
| 391 | + .groupId(10L).userId(1L) | |
| 392 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 393 | + .joinTime(now.minusDays(10)).build(); | |
| 394 | + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); | |
| 395 | + | |
| 396 | + ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() | |
| 397 | + .id(100L).groupId(10L).senderId(1L) | |
| 398 | + .status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 399 | + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) | |
| 400 | + .sendTime(now.minusHours(1)).build(); | |
| 401 | + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), | |
| 402 | + any(LocalDateTime.class), eq(100))) | |
| 403 | + .thenReturn(List.of(receiptMsg)); | |
| 404 | + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | |
| 405 | + .thenReturn(List.of()); | |
| 406 | + | |
| 407 | + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(100L); | |
| 408 | + Map<Long, Long> positions = new HashMap<>(); | |
| 409 | + positions.put(1L, 100L); | |
| 410 | + positions.put(2L, 100L); | |
| 411 | + positions.put(3L, 50L); | |
| 412 | + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); | |
| 413 | + | |
| 414 | + List<ImGroupMemberDO> allMembers = List.of( | |
| 415 | + member, | |
| 416 | + ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 417 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 418 | + .joinTime(now.minusDays(20)).build(), | |
| 419 | + ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 420 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 421 | + .joinTime(now.minusDays(20)).build() | |
| 422 | + ); | |
| 423 | + when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); | |
| 424 | + | |
| 425 | + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); | |
| 426 | + | |
| 427 | + assertEquals(1, result.size()); | |
| 428 | + assertEquals(1, result.get(0).getReadCount()); | |
| 429 | + } | |
| 430 | + | |
| 314 | 431 | // ========== 撤回测试 ========== |
| 315 | 432 | |
| 316 | 433 | @Test | ... | ... |