Commit 711a692c3a490332fab5ef68dfd70837c2d299bb
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。
Showing
5 changed files
with
56 additions
and
46 deletions
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<ImPrivateMessageDO> |
| 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 | ... | ... |