From 4ab95695088f583f0530295c398521ff708d4bc1 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 20 Apr 2026 20:33:36 +0800 Subject: [PATCH] ✨ feat(im):优化私聊的推送为 ImPrivateMessageDTO --- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java | 7 ++----- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java | 52 ---------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java | 45 +++++++++++++++++++++++++++++---------------- 4 files changed, 136 insertions(+), 73 deletions(-) create mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java index b9c5754..42cf289 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ b/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 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage; import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; -import cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; -import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage; -import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage; -import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage; +import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -183,7 +180,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage() .setReceiverId(userId).setScene(ImMessageSceneEnum.PRIVATE.getScene()); webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), receiverId, - ImMessageReceiptMessage.TYPE, receiptMessage); + ImPrivateMessageDTO.TYPE, receiptDto); } @Override diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java new file mode 100644 index 0000000..4779e81 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java @@ -0,0 +1,105 @@ +package cn.iocoder.yudao.module.im.websocket; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +/** + * IM 私聊消息 WebSocket 统一推送 DTO + *

+ * 发送、已读、回执、撤回都通过 {@link #type} 字段区分, + * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class ImPrivateMessageDTO { + + public static final String TYPE = "im-private-message"; + + /** + * 消息编号 + *

+ * 普通消息:当前消息 id + * READ:已读位置(我已读到这条消息) + * RECEIPT:已读位置(对方已读到这条消息) + * RECALL:被撤回的原消息 id + */ + private Long id; + /** + * 客户端消息编号 + */ + private String clientMessageId; + /** + * 发送人编号 + */ + private Long senderId; + /** + * 接收人编号 + */ + private Long receiverId; + /** + * 消息类型 + *

+ * TEXT / IMAGE / FILE / VOICE / VIDEO / READ / RECEIPT / RECALL / TIP_TEXT + */ + private Integer type; + /** + * 消息内容 + */ + private String content; + /** + * 消息状态 + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + + // ========== 静态工厂方法 ========== + + /** + * 构建发送消息 DTO + * + * @param message 私聊消息 DO + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofSend(ImPrivateMessageDO message) { + return BeanUtils.toBean(message, ImPrivateMessageDTO.class); + } + + /** + * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个会话") + * + * @param senderId 当前用户编号 + * @param receiverId 对方用户编号 + * @param readId 已读位置(最大已读消息编号) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofRead(Long senderId, Long receiverId, Long readId) { + return new ImPrivateMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.READ.getType()) + .setSenderId(senderId).setReceiverId(receiverId); + } + + /** + * 构建已读回执 DTO(通知对方"我已读了你的消息") + * + * @param senderId 已读方的用户编号 + * @param receiverId 对方用户编号 + * @param readId 已读位置(最大已读消息编号) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofReceipt(Long senderId, Long receiverId, Long readId) { + return new ImPrivateMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.RECEIPT.getType()) + .setSenderId(senderId).setReceiverId(receiverId); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java deleted file mode 100644 index 2c066d2..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java +++ /dev/null @@ -1,52 +0,0 @@ -package cn.iocoder.yudao.module.im.websocket; - -import lombok.Data; -import lombok.experimental.Accessors; - -import java.time.LocalDateTime; - -/** - * IM 私聊消息 WebSocket 推送消息 - * - * @author 芋道源码 - */ -@Data -@Accessors(chain = true) -public class ImPrivateMessageSendMessage { - - public static final String TYPE = "im-private-message-send"; - - /** - * 消息编号 - */ - private Long id; - /** - * 客户端消息编号 - */ - private String clientMessageId; - /** - * 发送人编号 - */ - private Long senderId; - /** - * 接收人编号 - */ - private Long receiverId; - /** - * 消息类型 - */ - private Integer type; - /** - * 消息内容 - */ - private String content; - /** - * 消息状态 - */ - private Integer status; - /** - * 发送时间 - */ - private LocalDateTime sendTime; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java index cc4fd89..d8d7299 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java +++ b/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 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; -import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage; -import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage; -import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage; +import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageDTO; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; @@ -73,12 +72,12 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), typeCaptor.capture(), contentCaptor.capture()); - // 验证消息事件类型 - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(0)); - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(1)); + // 验证消息事件类型:统一为 im-private-message + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0)); + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1)); // 验证 payload 是 DTO 类型 - ImPrivateMessageSendMessage payload = (ImPrivateMessageSendMessage) contentCaptor.getAllValues().get(0); + ImPrivateMessageDTO payload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(0); assertNotNull(payload.getId(), "id 不应为空"); assertEquals(1L, payload.getSenderId()); assertEquals(2L, payload.getReceiverId()); @@ -91,9 +90,11 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { springUtilMockedStatic.when(() -> cn.hutool.extra.spring.SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) .thenReturn(privateMessageService); - // 准备:mock 未读消息 + // 准备:mock 未读消息(id=1 和 id=5,maxReadId 应为 5) List unreadMessages = List.of( ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(), + ImPrivateMessageDO.builder().id(5L).senderId(2L).receiverId(1L) .status(ImMessageStatusEnum.UNREAD.getStatus()).build() ); when(imPrivateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, @@ -105,15 +106,27 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { // 捕获 ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Object.class); verify(webSocketMessageSender, times(2)).sendObject(anyInt(), userCaptor.capture(), - typeCaptor.capture(), any()); + typeCaptor.capture(), contentCaptor.capture()); - // 第一次:发给自己的 READ 事件 - assertEquals(ImMessageReadMessage.TYPE, typeCaptor.getAllValues().get(0)); + // 第一次:发给自己的 READ 事件(统一 type = im-private-message) + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0)); assertEquals(1L, userCaptor.getAllValues().get(0)); - // 第二次:发给对方的 RECEIPT 事件 - assertEquals(ImMessageReceiptMessage.TYPE, typeCaptor.getAllValues().get(1)); + ImPrivateMessageDTO readPayload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(0); + assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType()); + assertEquals(1L, readPayload.getSenderId()); + assertEquals(2L, readPayload.getReceiverId()); + assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId"); + + // 第二次:发给对方的 RECEIPT 事件(统一 type = im-private-message) + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1)); assertEquals(2L, userCaptor.getAllValues().get(1)); + ImPrivateMessageDTO receiptPayload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(1); + assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType()); + assertEquals(1L, receiptPayload.getSenderId()); + assertEquals(2L, receiptPayload.getReceiverId()); + assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为 maxReadId"); } } @@ -140,9 +153,9 @@ public class ImWebSocketEventTest extends BaseMockitoUnitTest { verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), typeCaptor.capture(), contentCaptor.capture()); - // 验证:推送的是 TIP_TEXT 消息(通过 sendPrivateMessageEvent) - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(0)); - assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(1)); + // 验证:推送的是 TIP_TEXT 消息(统一 type = im-private-message) + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0)); + assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1)); } } -- libgit2 0.21.4