Commit 5e824ccb4e68576724ae0eddbe4d9eeac71cc2ea

Authored by YunaiV
1 parent 3361c757

✨ feat(message): 优化群聊的 enrichPullResultForViewer 已读状态、人数

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
... ...