Commit b0b707ddc0b869f8f06ec041568dc4136cea0e8b

Authored by YunaiV
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 两个单测。
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
@@ -57,6 +57,14 @@ public class ImPrivateMessageController { @@ -57,6 +57,14 @@ public class ImPrivateMessageController {
57 return success(true); 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 @DeleteMapping("/recall") 68 @DeleteMapping("/recall")
61 @Operation(summary = "撤回私聊消息") 69 @Operation(summary = "撤回私聊消息")
62 @Parameter(name = "id", description = "消息编号", required = true, example = "1") 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&lt;ImPrivateMessageDO&gt; @@ -67,19 +67,16 @@ public interface ImPrivateMessageMapper extends BaseMapperX&lt;ImPrivateMessageDO&gt;
67 .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); 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 default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId, 80 default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId,
84 Integer whereStatus, ImPrivateMessageDO updateObj) { 81 Integer whereStatus, ImPrivateMessageDO updateObj) {
85 return update(updateObj, new LambdaQueryWrapperX<ImPrivateMessageDO>() 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,6 +45,18 @@ public interface ImPrivateMessageService {
45 void readPrivateMessages(Long userId, Long receiverId, Long messageId); 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 * @param userId 当前用户编号 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,6 +117,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
117 } 117 }
118 118
119 @Override 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 @Transactional(rollbackFor = Exception.class) 126 @Transactional(rollbackFor = Exception.class)
121 public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { 127 public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) {
122 // 1.1 校验消息存在 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,6 +305,34 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
305 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); 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 // ========== sendTipPrivateMessage ========== 336 // ========== sendTipPrivateMessage ==========
309 337
310 @Test 338 @Test