Commit 711a692c3a490332fab5ef68dfd70837c2d299bb

Authored by YunaiV
1 parent ceb60978

🐛 fix(im): 私聊已读改用前端上报 messageId,修正 RECEIPT 语义

原 readPrivateMessages 走 select-then-update 两步:先按 status=UNREAD
查未读,再按 id in 翻转,maxReadId 取已 update 行的 max(id)。该实现
有两个问题:
1. select 与 update 之间到达的消息会被一并标 READ,但 maxReadId 取的
   是 select 时刻的最大值,对方端拿到的「已读到哪条」语义偏弱,前端按
   maxReadId 卡边界时也会与实际状态错位。
2. 接口与群聊 readGroupMessages(userId, groupId, messageId) 不对称。

改造:
- 接口签名增加 messageId(已读位置,含),与群聊接口对齐;
- mapper 替换为单条 update SQL:id <= messageId AND status=UNREAD 直接
  翻转,消除两步竞态窗口;
- READ / RECEIPT 推送的已读位置直接采用前端上报的 messageId;
- 落地 ImPrivateMessageServiceImpl 原 TODO;
- 同步更新 testReadMessages_success / testReadMessages_noUnread。
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
@@ -50,8 +50,10 @@ public class ImPrivateMessageController { @@ -50,8 +50,10 @@ public class ImPrivateMessageController {
50 @PutMapping("/read") 50 @PutMapping("/read")
51 @Operation(summary = "标记私聊消息已读") 51 @Operation(summary = "标记私聊消息已读")
52 @Parameter(name = "receiverId", description = "接收方用户编号(对方)", required = true, example = "2") 52 @Parameter(name = "receiverId", description = "接收方用户编号(对方)", required = true, example = "2")
53 - public CommonResult<Boolean> readPrivateMessages(@RequestParam("receiverId") Long receiverId) {  
54 - privateMessageService.readPrivateMessages(getLoginUserId(), receiverId); 53 + @Parameter(name = "messageId", description = "已读位置(含),通常是会话内最大消息编号", required = true, example = "100")
  54 + public CommonResult<Boolean> readPrivateMessages(@RequestParam("receiverId") Long receiverId,
  55 + @RequestParam("messageId") Long messageId) {
  56 + privateMessageService.readPrivateMessages(getLoginUserId(), receiverId, messageId);
55 return success(true); 57 return success(true);
56 } 58 }
57 59
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java
@@ -67,16 +67,25 @@ public interface ImPrivateMessageMapper extends BaseMapperX&lt;ImPrivateMessageDO&gt; @@ -67,16 +67,25 @@ public interface ImPrivateMessageMapper extends BaseMapperX&lt;ImPrivateMessageDO&gt;
67 .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); 67 .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId));
68 } 68 }
69 69
70 - default List<ImPrivateMessageDO> selectListBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) {  
71 - return selectList(new LambdaQueryWrapperX<ImPrivateMessageDO>() 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 + */
  83 + default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId,
  84 + Integer whereStatus, ImPrivateMessageDO updateObj) {
  85 + return update(updateObj, new LambdaQueryWrapperX<ImPrivateMessageDO>()
72 .eq(ImPrivateMessageDO::getSenderId, senderId) 86 .eq(ImPrivateMessageDO::getSenderId, senderId)
73 .eq(ImPrivateMessageDO::getReceiverId, receiverId) 87 .eq(ImPrivateMessageDO::getReceiverId, receiverId)
74 - .eq(ImPrivateMessageDO::getStatus, status));  
75 - }  
76 -  
77 - default void updateByIdsAndStatus(List<Long> ids, Integer whereStatus, ImPrivateMessageDO updateObj) {  
78 - update(updateObj, new LambdaQueryWrapperX<ImPrivateMessageDO>()  
79 - .in(ImPrivateMessageDO::getId, ids) 88 + .le(ImPrivateMessageDO::getId, maxMessageId)
80 .eq(ImPrivateMessageDO::getStatus, whereStatus)); 89 .eq(ImPrivateMessageDO::getStatus, whereStatus));
81 } 90 }
82 91
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
@@ -34,11 +34,15 @@ public interface ImPrivateMessageService { @@ -34,11 +34,15 @@ public interface ImPrivateMessageService {
34 34
35 /** 35 /**
36 * 标记私聊消息已读 36 * 标记私聊消息已读
  37 + * <p>
  38 + * 语义:将「对方发给当前用户、id <= messageId 的未读消息」一次性翻转为已读,
  39 + * 与群聊 readGroupMessages 对称,避免"select-then-update"两步式带来的竞态。
37 * 40 *
38 * @param userId 当前用户编号 41 * @param userId 当前用户编号
39 * @param receiverId 接收方用户编号(对方) 42 * @param receiverId 接收方用户编号(对方)
  43 + * @param messageId 已读位置(含),通常是前端会话内最大消息 id
40 */ 44 */
41 - void readPrivateMessages(Long userId, Long receiverId); 45 + void readPrivateMessages(Long userId, Long receiverId, Long messageId);
42 46
43 /** 47 /**
44 * 撤回私聊消息 48 * 撤回私聊消息
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
1 package cn.iocoder.yudao.module.im.service.message; 1 package cn.iocoder.yudao.module.im.service.message;
2 2
3 -import cn.hutool.core.collection.CollUtil; 3 +import cn.hutool.core.lang.Assert;
4 import cn.hutool.core.util.IdUtil; 4 import cn.hutool.core.util.IdUtil;
5 import cn.hutool.core.util.ObjUtil; 5 import cn.hutool.core.util.ObjUtil;
6 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 6 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
@@ -26,7 +26,6 @@ import java.time.LocalDateTime; @@ -26,7 +26,6 @@ import java.time.LocalDateTime;
26 import java.util.List; 26 import java.util.List;
27 27
28 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 28 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
29 -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;  
30 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 29 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
31 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; 30 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*;
32 31
@@ -97,26 +96,24 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -97,26 +96,24 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
97 return messages; 96 return messages;
98 } 97 }
99 98
100 - // TODO @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】 99 + // TODO DONE @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】
101 @Override 100 @Override
102 - public void readPrivateMessages(Long userId, Long receiverId) {  
103 - // 1.1 查询未读消息列表  
104 - List<ImPrivateMessageDO> unreadMessages = privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(  
105 - receiverId, userId, ImMessageStatusEnum.UNREAD.getStatus());  
106 - if (CollUtil.isEmpty(unreadMessages)) { 101 + public void readPrivateMessages(Long userId, Long receiverId, Long messageId) {
  102 + Assert.notNull(messageId, "已读消息编号不能为空");
  103 + // 1. 一步翻转:把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息全部置为已读
  104 + // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 拆成单条 SQL 后也消除了竞态窗口
  105 + int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  106 + receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(),
  107 + new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));
  108 + if (updated == 0) {
107 return; 109 return;
108 } 110 }
109 - // 1.2 根据 id in + status 条件更新,避免并发问题  
110 - List<Long> messageIds = convertList(unreadMessages, ImPrivateMessageDO::getId);  
111 - privateMessageMapper.updateByIdsAndStatus(messageIds, ImMessageStatusEnum.UNREAD.getStatus(),  
112 - new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));  
113 111
114 - // 2. 异步发送 READ + RECEIPT 事件(携带已读位置)  
115 - Long maxReadId = CollUtil.max(messageIds); 112 + // 2. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
116 imWebSocketService.sendPrivateMessageAsync(userId, 113 imWebSocketService.sendPrivateMessageAsync(userId,
117 - ImPrivateMessageDTO.ofRead(userId, receiverId, maxReadId)); 114 + ImPrivateMessageDTO.ofRead(userId, receiverId, messageId));
118 imWebSocketService.sendPrivateMessageAsync(receiverId, 115 imWebSocketService.sendPrivateMessageAsync(receiverId,
119 - ImPrivateMessageDTO.ofReceipt(userId, receiverId, maxReadId)); 116 + ImPrivateMessageDTO.ofReceipt(userId, receiverId, messageId));
120 } 117 }
121 118
122 @Override 119 @Override
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
@@ -153,21 +153,18 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -153,21 +153,18 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
153 153
154 @Test 154 @Test
155 public void testReadMessages_success() { 155 public void testReadMessages_success() {
156 - // 准备:mock 未读消息(id=1 和 id=5,maxReadId 应为 5)  
157 - List<ImPrivateMessageDO> unreadMessages = List.of(  
158 - ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L)  
159 - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(),  
160 - ImPrivateMessageDO.builder().id(5L).senderId(2L).receiverId(1L)  
161 - .status(ImMessageStatusEnum.UNREAD.getStatus()).build()  
162 - );  
163 - when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L,  
164 - ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages); 156 + // 准备:前端上报已读到 messageId=5;mapper 返回更新行数 2 表示有未读被翻转
  157 + when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  158 + eq(2L), eq(1L), eq(5L),
  159 + eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)))
  160 + .thenReturn(2);
165 161
166 // 调用 162 // 调用
167 - privateMessageService.readPrivateMessages(1L, 2L); 163 + privateMessageService.readPrivateMessages(1L, 2L, 5L);
168 164
169 // 断言:更新了消息状态 165 // 断言:更新了消息状态
170 - verify(privateMessageMapper).updateByIdsAndStatus(eq(List.of(1L, 5L)), 166 + verify(privateMessageMapper).updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  167 + eq(2L), eq(1L), eq(5L),
171 eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)); 168 eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class));
172 169
173 // 断言:发送了 READ + RECEIPT 事件,payload 字段正确 170 // 断言:发送了 READ + RECEIPT 事件,payload 字段正确
@@ -182,13 +179,13 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -182,13 +179,13 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
182 assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType()); 179 assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType());
183 assertEquals(1L, readPayload.getSenderId()); 180 assertEquals(1L, readPayload.getSenderId());
184 assertEquals(2L, readPayload.getReceiverId()); 181 assertEquals(2L, readPayload.getReceiverId());
185 - assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId"); 182 + assertEquals(5L, readPayload.getId(), "READ id 应为前端上报的 messageId");
186 183
187 // 第二次:发给对方的 RECEIPT 事件 184 // 第二次:发给对方的 RECEIPT 事件
188 assertEquals(2L, userCaptor.getAllValues().get(1)); 185 assertEquals(2L, userCaptor.getAllValues().get(1));
189 ImPrivateMessageDTO receiptPayload = contentCaptor.getAllValues().get(1); 186 ImPrivateMessageDTO receiptPayload = contentCaptor.getAllValues().get(1);
190 assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType()); 187 assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType());
191 - assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为 maxReadId"); 188 + assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为前端上报的 messageId");
192 } 189 }
193 190
194 // ========== 撤回测试 ========== 191 // ========== 撤回测试 ==========
@@ -295,15 +292,16 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -295,15 +292,16 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
295 292
296 @Test 293 @Test
297 public void testReadMessages_noUnread() { 294 public void testReadMessages_noUnread() {
298 - // 准备:没有未读消息  
299 - when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L,  
300 - ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(List.of()); 295 + // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息
  296 + when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  297 + eq(2L), eq(1L), eq(5L),
  298 + eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)))
  299 + .thenReturn(0);
301 300
302 // 调用 301 // 调用
303 - privateMessageService.readPrivateMessages(1L, 2L); 302 + privateMessageService.readPrivateMessages(1L, 2L, 5L);
304 303
305 - // 断言:不更新、不推送  
306 - verify(privateMessageMapper, never()).updateByIdsAndStatus(anyList(), anyInt(), any(ImPrivateMessageDO.class)); 304 + // 断言:不推送 READ / RECEIPT
307 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); 305 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
308 } 306 }
309 307