Commit 4ab95695088f583f0530295c398521ff708d4bc1

Authored by YunaiV
1 parent ca0aadac

✨ feat(im):优化私聊的推送为 ImPrivateMessageDTO

yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
@@ -13,14 +13,11 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate @@ -13,14 +13,11 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate
13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; 13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
14 import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage; 14 import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
15 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; 15 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
16 -import cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum;  
17 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
18 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
19 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 18 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
20 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 19 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
21 -import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;  
22 -import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;  
23 -import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage; 20 +import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageDTO;
24 import jakarta.annotation.Resource; 21 import jakarta.annotation.Resource;
25 import lombok.extern.slf4j.Slf4j; 22 import lombok.extern.slf4j.Slf4j;
26 import org.springframework.scheduling.annotation.Async; 23 import org.springframework.scheduling.annotation.Async;
@@ -183,7 +180,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -183,7 +180,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
183 ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage() 180 ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage()
184 .setReceiverId(userId).setScene(ImMessageSceneEnum.PRIVATE.getScene()); 181 .setReceiverId(userId).setScene(ImMessageSceneEnum.PRIVATE.getScene());
185 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), receiverId, 182 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), receiverId,
186 - ImMessageReceiptMessage.TYPE, receiptMessage); 183 + ImPrivateMessageDTO.TYPE, receiptDto);
187 } 184 }
188 185
189 @Override 186 @Override
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java
1 package cn.iocoder.yudao.module.im.websocket; 1 package cn.iocoder.yudao.module.im.websocket;
2 2
  3 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  5 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
3 import lombok.Data; 6 import lombok.Data;
4 import lombok.experimental.Accessors; 7 import lombok.experimental.Accessors;
5 8
6 import java.time.LocalDateTime; 9 import java.time.LocalDateTime;
7 10
8 /** 11 /**
9 - * IM 私聊消息 WebSocket 推送消息 12 + * IM 私聊消息 WebSocket 统一推送 DTO
  13 + * <p>
  14 + * 发送、已读、回执、撤回都通过 {@link #type} 字段区分,
  15 + * 并统一复用 {@link #id} 字段表达目标消息或已读位置。
10 * 16 *
11 * @author 芋道源码 17 * @author 芋道源码
12 */ 18 */
13 @Data 19 @Data
14 @Accessors(chain = true) 20 @Accessors(chain = true)
15 -public class ImPrivateMessageSendMessage { 21 +public class ImPrivateMessageDTO {
16 22
17 - public static final String TYPE = "im-private-message-send"; 23 + public static final String TYPE = "im-private-message";
18 24
19 /** 25 /**
20 * 消息编号 26 * 消息编号
  27 + * <p>
  28 + * 普通消息:当前消息 id
  29 + * READ:已读位置(我已读到这条消息)
  30 + * RECEIPT:已读位置(对方已读到这条消息)
  31 + * RECALL:被撤回的原消息 id
21 */ 32 */
22 private Long id; 33 private Long id;
23 /** 34 /**
@@ -34,6 +45,8 @@ public class ImPrivateMessageSendMessage { @@ -34,6 +45,8 @@ public class ImPrivateMessageSendMessage {
34 private Long receiverId; 45 private Long receiverId;
35 /** 46 /**
36 * 消息类型 47 * 消息类型
  48 + * <p>
  49 + * TEXT / IMAGE / FILE / VOICE / VIDEO / READ / RECEIPT / RECALL / TIP_TEXT
37 */ 50 */
38 private Integer type; 51 private Integer type;
39 /** 52 /**
@@ -49,4 +62,44 @@ public class ImPrivateMessageSendMessage { @@ -49,4 +62,44 @@ public class ImPrivateMessageSendMessage {
49 */ 62 */
50 private LocalDateTime sendTime; 63 private LocalDateTime sendTime;
51 64
  65 + // ========== 静态工厂方法 ==========
  66 +
  67 + /**
  68 + * 构建发送消息 DTO
  69 + *
  70 + * @param message 私聊消息 DO
  71 + * @return 私聊 DTO
  72 + */
  73 + public static ImPrivateMessageDTO ofSend(ImPrivateMessageDO message) {
  74 + return BeanUtils.toBean(message, ImPrivateMessageDTO.class);
  75 + }
  76 +
  77 + /**
  78 + * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个会话")
  79 + *
  80 + * @param senderId 当前用户编号
  81 + * @param receiverId 对方用户编号
  82 + * @param readId 已读位置(最大已读消息编号)
  83 + * @return 私聊 DTO
  84 + */
  85 + public static ImPrivateMessageDTO ofRead(Long senderId, Long receiverId, Long readId) {
  86 + return new ImPrivateMessageDTO()
  87 + .setId(readId).setType(ImMessageTypeEnum.READ.getType())
  88 + .setSenderId(senderId).setReceiverId(receiverId);
  89 + }
  90 +
  91 + /**
  92 + * 构建已读回执 DTO(通知对方"我已读了你的消息")
  93 + *
  94 + * @param senderId 已读方的用户编号
  95 + * @param receiverId 对方用户编号
  96 + * @param readId 已读位置(最大已读消息编号)
  97 + * @return 私聊 DTO
  98 + */
  99 + public static ImPrivateMessageDTO ofReceipt(Long senderId, Long receiverId, Long readId) {
  100 + return new ImPrivateMessageDTO()
  101 + .setId(readId).setType(ImMessageTypeEnum.RECEIPT.getType())
  102 + .setSenderId(senderId).setReceiverId(receiverId);
  103 + }
  104 +
52 } 105 }
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java
@@ -6,11 +6,10 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate @@ -6,11 +6,10 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate
6 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
7 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; 7 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
8 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 8 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  9 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
9 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 10 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
10 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 11 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
11 -import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;  
12 -import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;  
13 -import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage; 12 +import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageDTO;
14 import org.junit.jupiter.api.Test; 13 import org.junit.jupiter.api.Test;
15 import org.mockito.ArgumentCaptor; 14 import org.mockito.ArgumentCaptor;
16 import org.mockito.InjectMocks; 15 import org.mockito.InjectMocks;
@@ -73,12 +72,12 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { @@ -73,12 +72,12 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest {
73 verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), 72 verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(),
74 typeCaptor.capture(), contentCaptor.capture()); 73 typeCaptor.capture(), contentCaptor.capture());
75 74
76 - // 验证消息事件类型  
77 - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(0));  
78 - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(1)); 75 + // 验证消息事件类型:统一为 im-private-message
  76 + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0));
  77 + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1));
79 78
80 // 验证 payload 是 DTO 类型 79 // 验证 payload 是 DTO 类型
81 - ImPrivateMessageSendMessage payload = (ImPrivateMessageSendMessage) contentCaptor.getAllValues().get(0); 80 + ImPrivateMessageDTO payload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(0);
82 assertNotNull(payload.getId(), "id 不应为空"); 81 assertNotNull(payload.getId(), "id 不应为空");
83 assertEquals(1L, payload.getSenderId()); 82 assertEquals(1L, payload.getSenderId());
84 assertEquals(2L, payload.getReceiverId()); 83 assertEquals(2L, payload.getReceiverId());
@@ -91,9 +90,11 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { @@ -91,9 +90,11 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest {
91 springUtilMockedStatic.when(() -> cn.hutool.extra.spring.SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) 90 springUtilMockedStatic.when(() -> cn.hutool.extra.spring.SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class)))
92 .thenReturn(privateMessageService); 91 .thenReturn(privateMessageService);
93 92
94 - // 准备:mock 未读消息 93 + // 准备:mock 未读消息(id=1 和 id=5,maxReadId 应为 5)
95 List<ImPrivateMessageDO> unreadMessages = List.of( 94 List<ImPrivateMessageDO> unreadMessages = List.of(
96 ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L) 95 ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L)
  96 + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(),
  97 + ImPrivateMessageDO.builder().id(5L).senderId(2L).receiverId(1L)
97 .status(ImMessageStatusEnum.UNREAD.getStatus()).build() 98 .status(ImMessageStatusEnum.UNREAD.getStatus()).build()
98 ); 99 );
99 when(imPrivateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, 100 when(imPrivateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L,
@@ -105,15 +106,27 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { @@ -105,15 +106,27 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest {
105 // 捕获 106 // 捕获
106 ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class); 107 ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
107 ArgumentCaptor<Long> userCaptor = ArgumentCaptor.forClass(Long.class); 108 ArgumentCaptor<Long> userCaptor = ArgumentCaptor.forClass(Long.class);
  109 + ArgumentCaptor<Object> contentCaptor = ArgumentCaptor.forClass(Object.class);
108 verify(webSocketMessageSender, times(2)).sendObject(anyInt(), userCaptor.capture(), 110 verify(webSocketMessageSender, times(2)).sendObject(anyInt(), userCaptor.capture(),
109 - typeCaptor.capture(), any()); 111 + typeCaptor.capture(), contentCaptor.capture());
110 112
111 - // 第一次:发给自己的 READ 事件  
112 - assertEquals(ImMessageReadMessage.TYPE, typeCaptor.getAllValues().get(0)); 113 + // 第一次:发给自己的 READ 事件(统一 type = im-private-message)
  114 + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0));
113 assertEquals(1L, userCaptor.getAllValues().get(0)); 115 assertEquals(1L, userCaptor.getAllValues().get(0));
114 - // 第二次:发给对方的 RECEIPT 事件  
115 - assertEquals(ImMessageReceiptMessage.TYPE, typeCaptor.getAllValues().get(1)); 116 + ImPrivateMessageDTO readPayload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(0);
  117 + assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType());
  118 + assertEquals(1L, readPayload.getSenderId());
  119 + assertEquals(2L, readPayload.getReceiverId());
  120 + assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId");
  121 +
  122 + // 第二次:发给对方的 RECEIPT 事件(统一 type = im-private-message)
  123 + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1));
116 assertEquals(2L, userCaptor.getAllValues().get(1)); 124 assertEquals(2L, userCaptor.getAllValues().get(1));
  125 + ImPrivateMessageDTO receiptPayload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(1);
  126 + assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType());
  127 + assertEquals(1L, receiptPayload.getSenderId());
  128 + assertEquals(2L, receiptPayload.getReceiverId());
  129 + assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为 maxReadId");
117 } 130 }
118 } 131 }
119 132
@@ -140,9 +153,9 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { @@ -140,9 +153,9 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest {
140 verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), 153 verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(),
141 typeCaptor.capture(), contentCaptor.capture()); 154 typeCaptor.capture(), contentCaptor.capture());
142 155
143 - // 验证:推送的是 TIP_TEXT 消息(通过 sendPrivateMessageEvent)  
144 - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(0));  
145 - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(1)); 156 + // 验证:推送的是 TIP_TEXT 消息(统一 type = im-private-message)
  157 + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0));
  158 + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1));
146 } 159 }
147 } 160 }
148 161