Commit 04ea1c9db57a8c39b90ee88e3a01729649f016c5

Authored by YunaiV
1 parent dc7be585

✨ feat(im): 优化私聊消息已读的处理逻辑

yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSceneEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.Getter;
  5 +import lombok.RequiredArgsConstructor;
  6 +
  7 +import java.util.Arrays;
  8 +
  9 +/**
  10 + * IM 消息场景枚举
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@RequiredArgsConstructor
  15 +@Getter
  16 +public enum ImMessageSceneEnum implements ArrayValuable<Integer> {
  17 +
  18 + PRIVATE(1, "私聊"),
  19 + GROUP(2, "群聊");
  20 +
  21 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageSceneEnum::getScene).toArray(Integer[]::new);
  22 +
  23 + /**
  24 + * 场景
  25 + */
  26 + private final Integer scene;
  27 + /**
  28 + * 名字
  29 + */
  30 + private final String name;
  31 +
  32 + @Override
  33 + public Integer[] array() {
  34 + return ARRAYS;
  35 + }
  36 +
  37 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
... ... @@ -51,9 +51,9 @@ public class ImPrivateMessageController {
51 51  
52 52 @PutMapping("/read")
53 53 @Operation(summary = "标记私聊消息已读")
54   - @Parameter(name = "friendId", description = "好友用户编号", required = true, example = "2")
55   - public CommonResult<Boolean> readPrivateMessages(@RequestParam("friendId") Long friendId) {
56   - privateMessageService.readPrivateMessages(getLoginUserId(), friendId);
  54 + @Parameter(name = "receiverId", description = "接收方用户编号(对方)", required = true, example = "2")
  55 + public CommonResult<Boolean> readPrivateMessages(@RequestParam("receiverId") Long receiverId) {
  56 + privateMessageService.readPrivateMessages(getLoginUserId(), receiverId);
57 57 return success(true);
58 58 }
59 59  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
... ... @@ -34,10 +34,10 @@ public interface ImPrivateMessageService {
34 34 /**
35 35 * 标记私聊消息已读
36 36 *
37   - * @param userId 当前用户编号
38   - * @param friendId 好友用户编号
  37 + * @param userId 当前用户编号
  38 + * @param receiverId 接收方用户编号(对方)
39 39 */
40   - void readPrivateMessages(Long userId, Long friendId);
  40 + void readPrivateMessages(Long userId, Long receiverId);
41 41  
42 42 /**
43 43 * 撤回私聊消息
... ...
yudao-module-im/yudao-module-im-biz/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 4 import cn.hutool.extra.spring.SpringUtil;
4 5 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
5 6 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  7 +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
6 8 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
7 9 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
8 10 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
9 11 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  12 +import cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum;
10 13 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
11 14 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
12 15 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
13 16 import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;
14   -import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;
15 17 import cn.iocoder.yudao.module.im.websocket.ImMessageRecallMessage;
  18 +import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;
16 19 import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage;
17   -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
18 20 import jakarta.annotation.Resource;
19 21 import lombok.extern.slf4j.Slf4j;
20 22 import org.springframework.scheduling.annotation.Async;
... ... @@ -25,6 +27,7 @@ import java.time.LocalDateTime;
25 27 import java.util.List;
26 28  
27 29 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  30 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
28 31 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
29 32  
30 33 /**
... ... @@ -91,31 +94,21 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
91 94 return messages;
92 95 }
93 96  
94   - // TODO @AI:应该是将 friendId 改成 receiverId,毕竟消息是发给对方的
95 97 @Override
96   - public void readPrivateMessages(Long userId, Long friendId) {
97   - // 1. 批量更新消息状态为已读
98   - // TODO @AI:先查询;然后在 update;避免锁的问题;
99   - // TODO @AI:注意,update 的时候,也要 id in、status =,最后 updateObj 传入;避免并发问题;
100   - int updated = privateMessageMapper.updateStatusToRead(userId, friendId);
101   - if (updated == 0) {
102   - return; // 没有需要更新的消息
  98 + public void readPrivateMessages(Long userId, Long receiverId) {
  99 + // 1.1 查询未读消息列表
  100 + List<ImPrivateMessageDO> unreadMessages = privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(
  101 + receiverId, userId, ImMessageStatusEnum.UNREAD.getStatus());
  102 + if (CollUtil.isEmpty(unreadMessages)) {
  103 + return;
103 104 }
  105 + // 1.2 根据 id in + status 条件更新,避免并发问题
  106 + List<Long> messageIds = convertList(unreadMessages, ImPrivateMessageDO::getId);
  107 + privateMessageMapper.updateByIdsAndStatus(messageIds, ImMessageStatusEnum.UNREAD.getStatus(),
  108 + new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));
104 109  
105   - // TODO @AI:继续 async !
106   - // 2.1 发送 READ 事件给自己的其他终端(多端同步)
107   - ImMessageReadMessage readMessage = new ImMessageReadMessage()
108   - .setFriendId(friendId.toString())
109   - .setMessageScene("private");
110   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
111   - ImMessageReadMessage.TYPE, readMessage);
112   -
113   - // 2.2 发送 RECEIPT 事件给对方(已读回执)
114   - ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage()
115   - .setUserId(userId.toString())
116   - .setMessageScene("private");
117   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), friendId,
118   - ImMessageReceiptMessage.TYPE, receiptMessage);
  110 + // 2. 异步发送 READ + RECEIPT 事件
  111 + getSelf().readPrivateMessageEvent(userId, receiverId);
119 112 }
120 113  
121 114 // DONE @芋艿:已 review,逻辑正确
... ... @@ -142,9 +135,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
142 135  
143 136 // 3. 发送 RECALL 事件
144 137 ImMessageRecallMessage recallMessage = new ImMessageRecallMessage()
145   - .setMessageId(messageId.toString())
146   - .setSenderId(userId.toString())
147   - .setMessageScene("private");
  138 + .setMessageId(messageId).setSenderId(userId).setScene(ImMessageSceneEnum.PRIVATE.getScene());
148 139 // 3.1 通知接收方
149 140 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
150 141 ImMessageRecallMessage.TYPE, recallMessage);
... ... @@ -162,11 +153,33 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
162 153 // 推送给接收方
163 154 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
164 155 ImPrivateMessageSendMessage.TYPE, websocketMessage);
  156 +
165 157 // 推送给发送方自己的其他终端(多端同步)
166 158 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(),
167 159 ImPrivateMessageSendMessage.TYPE, websocketMessage);
168 160 }
169 161  
  162 + /**
  163 + * 发送已读 + 已读回执 WebSocket 事件
  164 + *
  165 + * @param userId 当前用户编号
  166 + * @param receiverId 对方用户编号
  167 + */
  168 + @Async
  169 + public void readPrivateMessageEvent(Long userId, Long receiverId) {
  170 + // 1. 发送 READ 事件给自己的其他终端(多端同步)
  171 + ImMessageReadMessage readMessage = new ImMessageReadMessage()
  172 + .setReceiverId(receiverId).setScene(ImMessageSceneEnum.PRIVATE.getScene());
  173 + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  174 + ImMessageReadMessage.TYPE, readMessage);
  175 +
  176 + // 2. 发送 RECEIPT 事件给对方(已读回执)
  177 + ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage()
  178 + .setReceiverId(userId).setScene(ImMessageSceneEnum.PRIVATE.getScene());
  179 + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), receiverId,
  180 + ImMessageReceiptMessage.TYPE, receiptMessage);
  181 + }
  182 +
170 183 private ImPrivateMessageServiceImpl getSelf() {
171 184 return SpringUtil.getBean(getClass());
172 185 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReadMessage.java
... ... @@ -17,16 +17,18 @@ public class ImMessageReadMessage {
17 17 public static final String TYPE = "im-message-read";
18 18  
19 19 /**
20   - * 私聊场景:好友编号
  20 + * 私聊场景:接收方编号
21 21 */
22   - private String friendId;
  22 + private Long receiverId;
23 23 /**
24 24 * 群聊场景:群编号
25 25 */
26   - private String groupId;
  26 + private Long groupId;
27 27 /**
28   - * 消息场景:private / group
  28 + * 消息场景
  29 + *
  30 + * 关联 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum}
29 31 */
30   - private String messageScene;
  32 + private Integer scene;
31 33  
32 34 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageRecallMessage.java
... ... @@ -17,18 +17,20 @@ public class ImMessageRecallMessage {
17 17 /**
18 18 * 消息编号
19 19 */
20   - private String messageId;
  20 + private Long messageId;
21 21 /**
22 22 * 发送人编号
23 23 */
24   - private String senderId;
  24 + private Long senderId;
25 25 /**
26 26 * 群聊场景:群编号
27 27 */
28   - private String groupId;
  28 + private Long groupId;
29 29 /**
30   - * 消息场景:private / group
  30 + * 消息场景
  31 + *
  32 + * 关联 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum}
31 33 */
32   - private String messageScene;
  34 + private Integer scene;
33 35  
34 36 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReceiptMessage.java
... ... @@ -17,23 +17,30 @@ public class ImMessageReceiptMessage {
17 17  
18 18 public static final String TYPE = "im-message-receipt";
19 19  
  20 + /**
  21 + * 消息场景
  22 + *
  23 + * 关联 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum}
  24 + */
  25 + private Integer scene;
  26 +
20 27 // ========== 私聊场景 ==========
21 28  
22 29 /**
23 30 * 私聊场景:已读方的用户编号
24 31 */
25   - private String userId;
  32 + private Long receiverId;
26 33  
27 34 // ========== 群聊场景 ==========
28 35  
29 36 /**
30 37 * 群聊场景:消息编号
31 38 */
32   - private String messageId;
  39 + private Long messageId;
33 40 /**
34 41 * 群聊场景:群编号
35 42 */
36   - private String groupId;
  43 + private Long groupId;
37 44 /**
38 45 * 群聊场景:已读人数
39 46 */
... ... @@ -43,11 +50,4 @@ public class ImMessageReceiptMessage {
43 50 */
44 51 private Integer receiptStatus;
45 52  
46   - // ========== 通用 ==========
47   -
48   - /**
49   - * 消息场景:private / group
50   - */
51   - private String messageScene;
52   -
53 53 }
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
... ... @@ -168,7 +168,7 @@ public class ImGroupMessageServiceImplTest {
168 168 .thenReturn(List.of(beforeJoin, afterJoin));
169 169  
170 170 // 调用
171   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessages(1L, 0L, 100);
  171 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
172 172  
173 173 // 断言:入群前消息不可见
174 174 assertEquals(1, result.size());
... ... @@ -197,7 +197,7 @@ public class ImGroupMessageServiceImplTest {
197 197 .thenReturn(List.of(beforeQuit, afterQuit));
198 198  
199 199 // 调用
200   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessages(1L, 0L, 100);
  200 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
201 201  
202 202 // 断言:退群后消息不可见
203 203 assertEquals(1, result.size());
... ... @@ -226,7 +226,7 @@ public class ImGroupMessageServiceImplTest {
226 226 .thenReturn(List.of(directedMsg, allMsg));
227 227  
228 228 // 调用
229   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessages(1L, 0L, 100);
  229 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
230 230  
231 231 // 断言:定向接收的消息用户 1 看不到,只能看到全员消息
232 232 assertEquals(1, result.size());
... ... @@ -250,7 +250,7 @@ public class ImGroupMessageServiceImplTest {
250 250 .thenReturn(List.of(atMsg));
251 251  
252 252 // 调用
253   - List<ImGroupMessageDO> result = groupMessageService.pullGroupMessages(1L, 0L, 100);
  253 + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
254 254  
255 255 // 断言:@ 字段正确返回
256 256 assertEquals(1, result.size());
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
... ... @@ -137,7 +137,7 @@ public class ImPrivateMessageServiceImplTest {
137 137 when(imPrivateMessageMapper.selectListByMinId(1L, 0L, 100)).thenReturn(mockMessages);
138 138  
139 139 // 调用
140   - List<ImPrivateMessageDO> result = privateMessageService.pullPrivateMessages(1L, 0L, 100);
  140 + List<ImPrivateMessageDO> result = privateMessageService.pullPrivateMessageList(1L, 0L, 100);
141 141  
142 142 // 断言
143 143 assertEquals(2, result.size());
... ... @@ -147,7 +147,7 @@ public class ImPrivateMessageServiceImplTest {
147 147 public void testPullMessages_sizeExceeded() {
148 148 // 调用并断言
149 149 ServiceException exception = assertThrows(ServiceException.class,
150   - () -> privateMessageService.pullPrivateMessages(1L, 0L, 1001));
  150 + () -> privateMessageService.pullPrivateMessageList(1L, 0L, 1001));
151 151 assertEquals(MESSAGE_PULL_SIZE_EXCEEDED.getCode(), exception.getCode());
152 152 }
153 153  
... ... @@ -155,13 +155,21 @@ public class ImPrivateMessageServiceImplTest {
155 155  
156 156 @Test
157 157 public void testReadMessages_success() {
158   - // 准备
159   - when(imPrivateMessageMapper.updateStatusToRead(1L, 2L)).thenReturn(3);
  158 + // 准备:mock 未读消息列表
  159 + List<ImPrivateMessageDO> unreadMessages = List.of(
  160 + ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L)
  161 + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(),
  162 + ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L)
  163 + .status(ImMessageStatusEnum.UNREAD.getStatus()).build()
  164 + );
  165 + when(imPrivateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L,
  166 + ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages);
160 167  
161 168 // 调用
162 169 privateMessageService.readPrivateMessages(1L, 2L);
163 170  
164   - // 断言:发送了 READ 和 RECEIPT 事件
  171 + // 断言:更新了消息状态 + 发送了 READ 和 RECEIPT 事件
  172 + verify(imPrivateMessageMapper).update(any(ImPrivateMessageDO.class), any());
165 173 verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
166 174 }
167 175  
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java
... ... @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.service.message;
3 3 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
4 4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
5 5 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  6 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
6 7 import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;
7 8 import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;
8 9 import cn.iocoder.yudao.module.im.websocket.ImMessageRecallMessage;
... ... @@ -17,6 +18,8 @@ import org.mockito.InjectMocks;
17 18 import org.mockito.Mock;
18 19 import org.mockito.junit.jupiter.MockitoExtension;
19 20  
  21 +import java.util.List;
  22 +
20 23 import static org.junit.jupiter.api.Assertions.*;
21 24 import static org.mockito.ArgumentMatchers.*;
22 25 import static org.mockito.Mockito.*;
... ... @@ -82,8 +85,13 @@ public class ImWebSocketEventTest {
82 85  
83 86 @Test
84 87 public void testReadMessage_readAndReceiptEvents() {
85   - // 准备
86   - when(imPrivateMessageMapper.updateStatusToRead(1L, 2L)).thenReturn(1);
  88 + // 准备:mock 未读消息
  89 + List<ImPrivateMessageDO> unreadMessages = List.of(
  90 + ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L)
  91 + .status(ImMessageStatusEnum.UNREAD.getStatus()).build()
  92 + );
  93 + when(imPrivateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L,
  94 + ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages);
87 95  
88 96 // 调用
89 97 privateMessageService.readPrivateMessages(1L, 2L);
... ...