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,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 | List<ImGroupMessageDO> result = messages.stream() | 143 | List<ImGroupMessageDO> result = messages.stream() |
| 143 | .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) | 144 | .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) |
| 144 | .sorted(Comparator.comparing(ImGroupMessageDO::getId)) | 145 | .sorted(Comparator.comparing(ImGroupMessageDO::getId)) |
| 146 | + .limit(size) | ||
| 145 | .toList(); | 147 | .toList(); |
| 148 | + // 3.2 按当前用户补齐离线拉取结果:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 | ||
| 149 | + enrichPullResultForViewer(userId, result); | ||
| 150 | + | ||
| 146 | log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", | 151 | log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", |
| 147 | userId, minId, size, result.size()); | 152 | userId, minId, size, result.size()); |
| 148 | return result; | 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 | @Override | 206 | @Override |
| 152 | public void readGroupMessages(Long userId, Long groupId, Long messageId) { | 207 | public void readGroupMessages(Long userId, Long groupId, Long messageId) { |
| 153 | Assert.notNull(messageId, "已读消息编号不能为空"); | 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,6 +311,123 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 311 | assertEquals(List.of(1L, 3L), result.get(0).getAtUserIds()); | 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 | @Test | 433 | @Test |