Commit b0b707ddc0b869f8f06ec041568dc4136cea0e8b
1 parent
711a692c
✨ 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 两个单测。
Showing
5 changed files
with
64 additions
and
13 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
| ... | ... | @@ -57,6 +57,14 @@ public class ImPrivateMessageController { |
| 57 | 57 | return success(true); |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | + @GetMapping("/max-read-message-id") | |
| 61 | + @Operation(summary = "查询对方已读到我发的最大消息 id", | |
| 62 | + description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,结果用于翻转本地自发消息状态") | |
| 63 | + @Parameter(name = "peerId", description = "对方用户编号", required = true, example = "2") | |
| 64 | + public CommonResult<Long> getMaxReadMessageId(@RequestParam("peerId") Long peerId) { | |
| 65 | + return success(privateMessageService.getMaxReadMessageId(getLoginUserId(), peerId)); | |
| 66 | + } | |
| 67 | + | |
| 60 | 68 | @DeleteMapping("/recall") |
| 61 | 69 | @Operation(summary = "撤回私聊消息") |
| 62 | 70 | @Parameter(name = "id", description = "消息编号", required = true, example = "1") | ... | ... |
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<ImPrivateMessageDO> |
| 67 | 67 | .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | - /** | |
| 71 | - * 标记 (senderId → receiverId) 这条会话上、id <= maxMessageId 且 status = whereStatus 的消息为新状态 | |
| 72 | - * <p> | |
| 73 | - * 用于「已读」语义:前端上报"我已读到 maxMessageId",由这一条 SQL 在 status=UNREAD 上幂等翻转, | |
| 74 | - * 避免"先 select 未读、再按 id in 更新"两步带来的竞态(select 后到达的消息被误标)。 | |
| 75 | - * | |
| 76 | - * @param senderId 发送方用户编号(对方) | |
| 77 | - * @param receiverId 接收方用户编号(当前用户) | |
| 78 | - * @param maxMessageId 已读位置(含) | |
| 79 | - * @param whereStatus 匹配的当前状态 | |
| 80 | - * @param updateObj 更新对象(仅设要变更的字段) | |
| 81 | - * @return 实际更新行数 | |
| 82 | - */ | |
| 70 | + default Long selectMaxIdBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) { | |
| 71 | + ImPrivateMessageDO message = selectOne(new LambdaQueryWrapperX<ImPrivateMessageDO>() | |
| 72 | + .eq(ImPrivateMessageDO::getSenderId, senderId) | |
| 73 | + .eq(ImPrivateMessageDO::getReceiverId, receiverId) | |
| 74 | + .eq(ImPrivateMessageDO::getStatus, status) | |
| 75 | + .orderByDesc(ImPrivateMessageDO::getId) | |
| 76 | + .last("LIMIT 1")); | |
| 77 | + return message != null ? message.getId() : null; | |
| 78 | + } | |
| 79 | + | |
| 83 | 80 | default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId, |
| 84 | 81 | Integer whereStatus, ImPrivateMessageDO updateObj) { |
| 85 | 82 | return update(updateObj, new LambdaQueryWrapperX<ImPrivateMessageDO>() | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
| ... | ... | @@ -45,6 +45,18 @@ public interface ImPrivateMessageService { |
| 45 | 45 | void readPrivateMessages(Long userId, Long receiverId, Long messageId); |
| 46 | 46 | |
| 47 | 47 | /** |
| 48 | + * 查询对方已读到我发的最大消息 id | |
| 49 | + * <p> | |
| 50 | + * 用于多端 / 离线场景下的已读位置补齐:客户端进入会话或断线重连后, | |
| 51 | + * 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 翻转本地自发消息为已读,弥补离线期间错过的 RECEIPT 推送事件。 | |
| 52 | + * | |
| 53 | + * @param userId 当前用户编号 | |
| 54 | + * @param peerId 对方用户编号 | |
| 55 | + * @return 对方已读到的最大消息 id;对方一条都没读过时返回 null | |
| 56 | + */ | |
| 57 | + Long getMaxReadMessageId(Long userId, Long peerId); | |
| 58 | + | |
| 59 | + /** | |
| 48 | 60 | * 撤回私聊消息 |
| 49 | 61 | * |
| 50 | 62 | * @param userId 当前用户编号 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
| ... | ... | @@ -117,6 +117,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | 119 | @Override |
| 120 | + public Long getMaxReadMessageId(Long userId, Long peerId) { | |
| 121 | + return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( | |
| 122 | + userId, peerId, ImMessageStatusEnum.READ.getStatus()); | |
| 123 | + } | |
| 124 | + | |
| 125 | + @Override | |
| 120 | 126 | @Transactional(rollbackFor = Exception.class) |
| 121 | 127 | public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { |
| 122 | 128 | // 1.1 校验消息存在 | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
| ... | ... | @@ -305,6 +305,34 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { |
| 305 | 305 | verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); |
| 306 | 306 | } |
| 307 | 307 | |
| 308 | + // ========== getMaxReadMessageId 测试 ========== | |
| 309 | + | |
| 310 | + @Test | |
| 311 | + public void testGetMaxReadMessageId_hit() { | |
| 312 | + // 准备:对方读到我发的最大 id=10 | |
| 313 | + when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( | |
| 314 | + 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(10L); | |
| 315 | + | |
| 316 | + // 调用 | |
| 317 | + Long result = privateMessageService.getMaxReadMessageId(1L, 2L); | |
| 318 | + | |
| 319 | + // 断言 | |
| 320 | + assertEquals(10L, result); | |
| 321 | + } | |
| 322 | + | |
| 323 | + @Test | |
| 324 | + public void testGetMaxReadMessageId_miss() { | |
| 325 | + // 准备:对方一条都没读过 | |
| 326 | + when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( | |
| 327 | + 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(null); | |
| 328 | + | |
| 329 | + // 调用 | |
| 330 | + Long result = privateMessageService.getMaxReadMessageId(1L, 2L); | |
| 331 | + | |
| 332 | + // 断言:原样返回 null,前端按 falsy 跳过 | |
| 333 | + assertNull(result); | |
| 334 | + } | |
| 335 | + | |
| 308 | 336 | // ========== sendTipPrivateMessage ========== |
| 309 | 337 | |
| 310 | 338 | @Test | ... | ... |