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,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<ImPrivateMessageDO> | @@ -67,16 +67,25 @@ public interface ImPrivateMessageMapper extends BaseMapperX<ImPrivateMessageDO> | ||
| 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 |