From b0b707ddc0b869f8f06ec041568dc4136cea0e8b Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 26 Apr 2026 10:30:45 +0800 Subject: [PATCH] ✨ feat(im): 新增 GET /max-read-message-id,私聊多端 / 离线补齐对方已读位置 私聊已读语义在 message.status 字段翻转,离线期间错过的 RECEIPT 推送没法 重放(与群聊不同——群聊已读在 message.readCount / receiptStatus 字段自带)。 新增此接口:客户端进入私聊会话或断线重连后,拿到对方已读到我发的最大消息 id,按 id <= maxReadId 翻转本地自发消息状态。 实现: - mapper 用 order by id desc + LIMIT 1 命中 (sender_id, receiver_id) 索引,无 max() 聚合开销; - 对方一条都没读过时返回 null(不做 0 兜底,避免与真实 id 混淆); - 补齐 hit / miss 两个单测。 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java | 8 ++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java | 23 ++++++++++------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java | 12 ++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java | 6 ++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 28 ++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java index 1e51440..206d074 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java @@ -57,6 +57,14 @@ public class ImPrivateMessageController { return success(true); } + @GetMapping("/max-read-message-id") + @Operation(summary = "查询对方已读到我发的最大消息 id", + description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,结果用于翻转本地自发消息状态") + @Parameter(name = "peerId", description = "对方用户编号", required = true, example = "2") + public CommonResult getMaxReadMessageId(@RequestParam("peerId") Long peerId) { + return success(privateMessageService.getMaxReadMessageId(getLoginUserId(), peerId)); + } + @DeleteMapping("/recall") @Operation(summary = "撤回私聊消息") @Parameter(name = "id", description = "消息编号", required = true, example = "1") diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java index d08b12f..f8a5693 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java @@ -67,19 +67,16 @@ public interface ImPrivateMessageMapper extends BaseMapperX .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); } - /** - * 标记 (senderId → receiverId) 这条会话上、id <= maxMessageId 且 status = whereStatus 的消息为新状态 - *

- * 用于「已读」语义:前端上报"我已读到 maxMessageId",由这一条 SQL 在 status=UNREAD 上幂等翻转, - * 避免"先 select 未读、再按 id in 更新"两步带来的竞态(select 后到达的消息被误标)。 - * - * @param senderId 发送方用户编号(对方) - * @param receiverId 接收方用户编号(当前用户) - * @param maxMessageId 已读位置(含) - * @param whereStatus 匹配的当前状态 - * @param updateObj 更新对象(仅设要变更的字段) - * @return 实际更新行数 - */ + default Long selectMaxIdBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) { + ImPrivateMessageDO message = selectOne(new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, senderId) + .eq(ImPrivateMessageDO::getReceiverId, receiverId) + .eq(ImPrivateMessageDO::getStatus, status) + .orderByDesc(ImPrivateMessageDO::getId) + .last("LIMIT 1")); + return message != null ? message.getId() : null; + } + default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId, Integer whereStatus, ImPrivateMessageDO updateObj) { return update(updateObj, new LambdaQueryWrapperX() diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java index 2f5eae2..fa1c7bd 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java @@ -45,6 +45,18 @@ public interface ImPrivateMessageService { void readPrivateMessages(Long userId, Long receiverId, Long messageId); /** + * 查询对方已读到我发的最大消息 id + *

+ * 用于多端 / 离线场景下的已读位置补齐:客户端进入会话或断线重连后, + * 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 翻转本地自发消息为已读,弥补离线期间错过的 RECEIPT 推送事件。 + * + * @param userId 当前用户编号 + * @param peerId 对方用户编号 + * @return 对方已读到的最大消息 id;对方一条都没读过时返回 null + */ + Long getMaxReadMessageId(Long userId, Long peerId); + + /** * 撤回私聊消息 * * @param userId 当前用户编号 diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java index 6048620..9412de5 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java @@ -117,6 +117,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { } @Override + public Long getMaxReadMessageId(Long userId, Long peerId) { + return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( + userId, peerId, ImMessageStatusEnum.READ.getStatus()); + } + + @Override @Transactional(rollbackFor = Exception.class) public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { // 1.1 校验消息存在 diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index a6eb305..16fd0ce 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -305,6 +305,34 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); } + // ========== getMaxReadMessageId 测试 ========== + + @Test + public void testGetMaxReadMessageId_hit() { + // 准备:对方读到我发的最大 id=10 + when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( + 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(10L); + + // 调用 + Long result = privateMessageService.getMaxReadMessageId(1L, 2L); + + // 断言 + assertEquals(10L, result); + } + + @Test + public void testGetMaxReadMessageId_miss() { + // 准备:对方一条都没读过 + when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( + 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(null); + + // 调用 + Long result = privateMessageService.getMaxReadMessageId(1L, 2L); + + // 断言:原样返回 null,前端按 falsy 跳过 + assertNull(result); + } + // ========== sendTipPrivateMessage ========== @Test -- libgit2 0.21.4