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 50 @PutMapping("/read")
51 51 @Operation(summary = "标记私聊消息已读")
52 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 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 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 86 .eq(ImPrivateMessageDO::getSenderId, senderId)
73 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 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 34  
35 35 /**
36 36 * 标记私聊消息已读
  37 + * <p>
  38 + * 语义:将「对方发给当前用户、id <= messageId 的未读消息」一次性翻转为已读,
  39 + * 与群聊 readGroupMessages 对称,避免"select-then-update"两步式带来的竞态。
37 40 *
38 41 * @param userId 当前用户编号
39 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 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 4 import cn.hutool.core.util.IdUtil;
5 5 import cn.hutool.core.util.ObjUtil;
6 6 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
... ... @@ -26,7 +26,6 @@ import java.time.LocalDateTime;
26 26 import java.util.List;
27 27  
28 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 29 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
31 30 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*;
32 31  
... ... @@ -97,26 +96,24 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
97 96 return messages;
98 97 }
99 98  
100   - // TODO @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】
  99 + // TODO DONE @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】
101 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 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 113 imWebSocketService.sendPrivateMessageAsync(userId,
117   - ImPrivateMessageDTO.ofRead(userId, receiverId, maxReadId));
  114 + ImPrivateMessageDTO.ofRead(userId, receiverId, messageId));
118 115 imWebSocketService.sendPrivateMessageAsync(receiverId,
119   - ImPrivateMessageDTO.ofReceipt(userId, receiverId, maxReadId));
  116 + ImPrivateMessageDTO.ofReceipt(userId, receiverId, messageId));
120 117 }
121 118  
122 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 153  
154 154 @Test
155 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 168 eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class));
172 169  
173 170 // 断言:发送了 READ + RECEIPT 事件,payload 字段正确
... ... @@ -182,13 +179,13 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
182 179 assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType());
183 180 assertEquals(1L, readPayload.getSenderId());
184 181 assertEquals(2L, readPayload.getReceiverId());
185   - assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId");
  182 + assertEquals(5L, readPayload.getId(), "READ id 应为前端上报的 messageId");
186 183  
187 184 // 第二次:发给对方的 RECEIPT 事件
188 185 assertEquals(2L, userCaptor.getAllValues().get(1));
189 186 ImPrivateMessageDTO receiptPayload = contentCaptor.getAllValues().get(1);
190 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 292  
296 293 @Test
297 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 305 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
308 306 }
309 307  
... ...