From 37dfd6c07b937581a887d6d4e555a1c003ba6900 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 2 May 2026 09:05:54 +0800 Subject: [PATCH] ✨ feat(im): ImMessageTypeEnum 显式化 persistent + normal,sendXxxMessage 收敛 insert 与推送 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java | 14 ++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java | 14 ++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java | 43 +++++++++++++++++++++++++++++++++---------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java | 53 ++++++++++++++++++++++++++++++++--------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java | 31 +++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 42 ++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 498 insertions(+), 218 deletions(-) create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java index 2e493a9..f02d919 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java @@ -1,6 +1,10 @@ package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -24,6 +28,7 @@ public class ImGroupMessageSendReqVO { @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") @NotNull(message = "消息类型不能为空") + @InEnum(ImMessageTypeEnum.class) private Integer type; @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") @@ -36,4 +41,13 @@ public class ImGroupMessageSendReqVO { @Schema(description = "是否需要回执", example = "false") private Boolean receipt; + /** + * 仅允许用户消息(normal)类型 + */ + @AssertTrue(message = "消息类型不允许") + @JsonIgnore + public boolean isTypeNormal() { + return type == null || ImMessageTypeEnum.validate(type).isNormal(); + } + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java index cff487a..d6c703f 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java @@ -1,6 +1,10 @@ package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -22,10 +26,20 @@ public class ImPrivateMessageSendReqVO { @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") @NotNull(message = "消息类型不能为空") + @InEnum(ImMessageTypeEnum.class) private Integer type; @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") @NotEmpty(message = "消息内容不能为空") private String content; + /** + * 仅允许用户消息(normal)类型 + */ + @AssertTrue(message = "消息类型不允许") + @JsonIgnore + public boolean isTypeNormal() { + return type == null || ImMessageTypeEnum.validate(type).isNormal(); + } + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java index 8fad3b1..a2c70b1 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java @@ -1,8 +1,10 @@ package cn.iocoder.yudao.module.im.enums.message; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ArrayUtil; import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.RequiredArgsConstructor; import java.util.Arrays; @@ -12,30 +14,35 @@ import java.util.Arrays; * @author 芋道源码 */ @Getter -@AllArgsConstructor +@RequiredArgsConstructor public enum ImMessageTypeEnum implements ArrayValuable { - TEXT(0, "文本"), // 对应 TextMessage 类 - IMAGE(1, "图片"), // 对应 ImageMessage 类 - FILE(2, "文件"), // 对应 FileMessage 类 - VOICE(3, "语音"), // 对应 AudioMessage 类 - VIDEO(4, "视频"), // 对应 VideoMessage 类 + // ========== 用户聊天消息 ========== + TEXT(0, "文本", true, true), // 对应 TextMessage 类 + IMAGE(1, "图片", true, true), // 对应 ImageMessage 类 + FILE(2, "文件", true, true), // 对应 FileMessage 类 + VOICE(3, "语音", true, true), // 对应 AudioMessage 类 + VIDEO(4, "视频", true, true), // 对应 VideoMessage 类 - RECALL(10, "撤回"), // 对应 RecallMessage 类 - READ(11, "已读"), // 暂无 - RECEIPT(12, "回执"), // 暂无 - TIP_TEXT(21, "提示文本"), // 对应 TextMessage 类 + // ========== 信号类 ========== + RECALL(10, "撤回", true, false), // 对应 RecallMessage 类 + READ(11, "已读", false, false), // 暂无 + RECEIPT(12, "回执", false, false), // 暂无 - // ==================== 非单聊、群聊存储的类型,100 开始 ==================== + // ========== 系统提示 ========== + TIP_TIME(20, "时间分隔", false, false), // 暂无(前端本地生成) + TIP_TEXT(21, "系统提示", true, false), // 对应 TextMessage 类 - FRIEND_ADD(100, "好友添加"), // 暂无 - FRIEND_DELETE(101, "好友删除"), // 暂无 - FRIEND_UPDATE(102, "好友更新"), // 暂无(客户端收到后自行拉取) + // ========== 好友变更 ========== + FRIEND_ADD(100, "好友添加", false, false), // 暂无 + FRIEND_DELETE(101, "好友删除", false, false), // 暂无 + FRIEND_UPDATE(102, "好友更新", false, false), // 暂无(客户端收到后自行拉取) - GROUP_CREATE(200, "群创建"), // 暂无 - GROUP_UPDATE(201, "群信息变更"), // 暂无 - GROUP_DELETE(202, "群删除"), // 暂无(解散/退群/踢出均用此类型) - GROUP_MEMBER_UPDATE(203, "群成员信息变更"); // 暂无(客户端收到后自行拉取) + // ========== 群变更 ========== + GROUP_CREATE(200, "群创建", false, false), // 暂无 + GROUP_UPDATE(201, "群信息变更", false, false), // 暂无 + GROUP_DELETE(202, "群删除", false, false), // 暂无(解散/退群/踢出均用此类型) + GROUP_MEMBER_UPDATE(203, "群成员信息变更", false, false); // 暂无(客户端收到后自行拉取) public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); @@ -43,15 +50,40 @@ public enum ImMessageTypeEnum implements ArrayValuable { * 类型 */ private final Integer type; - /** * 名字 */ private final String name; + /** + * 是否入库 + *

+ * true:插入 im_xxx_message 消息表,离线 pull 能拉到; + * false:仅 WebSocket 推送,离线丢弃;状态由专用存储维护(如 READ 走 Redis 游标) + */ + private final boolean persistent; + /** + * 是不是用户聊天消息(normal vs event 二分) + *

+ * true:用户主动发的聊天消息,计入会话未读数(接收方非激活会话时 unreadCount + 1);用户发送入口仅允许这类; + * false:系统事件 / 信号 / 提示,不参与未读计数 + */ + private final boolean normal; @Override public Integer[] array() { return ARRAYS; } + /** + * 校验 type 已注册,并返回对应枚举;未注册立刻抛异常,避免新增 type 时漏配 persistent / normal 属性 + * + * @param type 消息类型 + * @return 枚举实例 + */ + public static ImMessageTypeEnum validate(Integer type) { + ImMessageTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values()); + Assert.notNull(result, "未注册的消息类型 type={}", type); + return result; + } + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java index b8db3ac..eeaa2c8 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java @@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGr import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; import java.util.Collection; import java.util.List; @@ -18,7 +19,10 @@ import java.util.Set; public interface ImGroupMessageService { /** - * 发送群聊消息 + * 【用户调用】发送群聊消息 + *

+ * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、敏感词、quote、@ 解析等业务校验。 + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。 * * @param senderId 发送人编号 * @param reqVO 发送请求 @@ -27,6 +31,34 @@ public interface ImGroupMessageService { ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO); /** + * 【系统调用】发送群聊消息:自动算可见用户 + 推送 + * + * @param senderId 发送人编号 + * @param dto 消息 DTO + * @return 消息 + */ + ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto); + + /** + * 【系统调用】发送群聊消息:显式指定推送目标 + * + * @param senderId 发送人编号 + * @param targetUserIds 推送目标用户编号集合(调用方在变更成员状态前抓取的快照) + * @param dto 消息 DTO + * @return 构造的消息 DO(持久化时 id 已回填) + */ + ImGroupMessageDO sendGroupMessage(Long senderId, Set targetUserIds, ImGroupMessageSendDTO dto); + + /** + * 【用户调用】撤回群聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + * @return 撤回后的提示消息 + */ + ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); + + /** * 拉取群聊消息(增量) * * @param userId 当前用户编号 @@ -46,15 +78,6 @@ public interface ImGroupMessageService { void readGroupMessages(Long userId, Long groupId, Long messageId); /** - * 撤回群聊消息 - * - * @param userId 当前用户编号 - * @param messageId 消息编号 - * @return 撤回后的提示消息 - */ - ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); - - /** * 获取群消息的已读用户列表 * * @param userId 当前用户编号 diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index 81ef9e6..8cb6871 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -21,6 +21,7 @@ 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.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; @@ -102,12 +103,82 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); groupMessageMapper.insert(message); - // 3. WebSocket 推送给群成员(异步) - sendGroupMessageEvent(message); + // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步) + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); + Set targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds); + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); return message; } @Override + public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) { + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId()); + Set targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds); + return sendGroupMessage(senderId, targetUserIds, dto); + } + + @Override + public ImGroupMessageDO sendGroupMessage(Long senderId, Set targetUserIds, ImGroupMessageSendDTO dto) { + // 1.1 content 序列化:null / String 透传,POJO 走 JSON + Object payload = dto.getContent(); + String contentString = payload == null || payload instanceof String + ? (String) payload + : JsonUtils.toJsonString(payload); + // 1.2 构建并保存消息 + ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) + .setSenderId(senderId).setGroupId(dto.getGroupId()) + .setType(dto.getType()).setContent(contentString) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) + .setReceiptStatus(Boolean.TRUE.equals(dto.getReceipt()) + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); + // 1.3 按 type.persistent 决定是否入库 + if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { + groupMessageMapper.insert(message); + } + + // 2. WebSocket 异步推送 + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); + return message; + } + + @Override + public void sendTipGroupMessage(Long senderId, Long groupId, Set memberUserIds, String content) { + sendGroupMessage(senderId, memberUserIds, new ImGroupMessageSendDTO() + .setGroupId(groupId).setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { + // 1.1 校验消息存在 + ImGroupMessageDO message = validateGroupMessageExists(messageId); + // 1.2 只能撤回自己发送的消息 + if (ObjUtil.notEqual(message.getSenderId(), userId)) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 1.3 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + // 1.4 只允许撤回限定时间内的消息 + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); + } + // 1.5 校验撤回人仍在群中 + groupMemberService.validateMemberInGroup(message.getGroupId(), userId); + + // 2. 更新原消息状态为撤回 + groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); + + // 3. 发送撤回事件 + return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId()) + .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId))); + } + + @Override public List pullGroupMessageList(Long userId, Long minId, Integer size) { if (size > MESSAGE_MAX_PULL_SIZE) { throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE); @@ -286,44 +357,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { } @Override - @Transactional(rollbackFor = Exception.class) - public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { - // 1.1 校验消息存在 - ImGroupMessageDO message = validateGroupMessageExists(messageId); - // 1.2 只能撤回自己发送的消息 - if (ObjUtil.notEqual(message.getSenderId(), userId)) { - throw exception(MESSAGE_RECALL_DENIED); - } - // 1.3 不能重复撤回 - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { - throw exception(MESSAGE_ALREADY_RECALLED); - } - // 1.4 只允许撤回限定时间内的消息 - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); - } - // 1.5 校验撤回人仍在群中 - groupMemberService.validateMemberInGroup(message.getGroupId(), userId); - - // 2. 更新原消息状态为撤回 - groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) - .setStatus(ImMessageStatusEnum.RECALL.getStatus())); - - // 3. 插入一条 RECALL 消息作为撤回信号(content 为 RecallMessage 序列化,前端据此找到原消息更新状态) - RecallMessage recallContent = new RecallMessage().setMessageId(messageId); - ImGroupMessageDO recallMessage = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) - .setSenderId(userId).setGroupId(message.getGroupId()) - .setType(ImMessageTypeEnum.RECALL.getType()).setContent(JsonUtils.toJsonString(recallContent)) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); - groupMessageMapper.insert(recallMessage); - - // 4. 异步推送撤回信号(前端据此更新原消息状态 + 插入撤回提示) - sendGroupMessageEvent(recallMessage); - return recallMessage; - } - - @Override public List getGroupReadUserIds(Long userId, Long groupId, Long messageId) { // 1.1 校验用户在群中(权限校验) groupMemberService.validateMemberInGroup(groupId, userId); @@ -365,28 +398,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { // ========== 异步 WebSocket 推送 ========== /** - * 发送群聊消息 WebSocket 事件 - *

- * 这里仅用于"新消息"(首发 or 撤回提示),{@code message.sendTime = now}: - *

    - *
  • 所有 ENABLE 成员的 {@code joinTime <= now},{@link #isMessageVisible} 的 joinTime 分支恒为真;
  • - *
  • ENABLE 成员 status 非 DISABLE,quitTime 分支不适用;
  • - *
- * 因此只剩 {@code receiverUserIds} 定向过滤会真正起效。 - * 故使用只含 userId 的轻量缓存 {@link ImGroupMemberService#getActiveGroupMemberUserIdsByGroupId(Long)}, - * 在本地以 {@link #getVisibleUserIds} 完成定向过滤即可。 - *

- * 注意:对历史消息的可见性判断仍需 joinTime/quitTime,见 {@code readGroupMessageEvent} 等处。 - */ - public void sendGroupMessageEvent(ImGroupMessageDO message) { - ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); - // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤) - List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); - Set targetUserIds = getVisibleUserIds(message, memberUserIds); - imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); - } - - /** * 发送已读 + 刷新群回执 WebSocket 事件 * * @param userId 当前用户编号 @@ -528,24 +539,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { } /** - * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。 + * 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。 *

- * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定, + * 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定, * 只应用 {@code receiverUserIds} 定向过滤;语义与 * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 */ - private Set getVisibleUserIds(ImGroupMessageDO message, Collection memberUserIds) { + private Set getVisibleUserIds(List receiverUserIds, Long senderId, Collection memberUserIds) { if (CollUtil.isEmpty(memberUserIds)) { return new HashSet<>(); } // 无定向接收列表 → 全员可见 - if (CollUtil.isEmpty(message.getReceiverUserIds())) { + if (CollUtil.isEmpty(receiverUserIds)) { return new HashSet<>(memberUserIds); } // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) - Set allowed = new HashSet<>(message.getReceiverUserIds()); - if (message.getSenderId() != null) { - allowed.add(message.getSenderId()); + Set allowed = new HashSet<>(receiverUserIds); + if (senderId != null) { + allowed.add(senderId); } Set result = new HashSet<>(); for (Long userId : memberUserIds) { @@ -571,22 +582,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId); } - @Override - public void sendTipGroupMessage(Long senderId, Long groupId, Set memberUserIds, String content) { - // 1. 插入 TIP_TEXT 消息 - ImGroupMessageDO tipMessage = new ImGroupMessageDO() - .setClientMessageId(IdUtil.fastSimpleUUID()) - .setSenderId(senderId).setGroupId(groupId) - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); - groupMessageMapper.insert(tipMessage); - - // 2. 推送给指定用户 - imWebSocketService.sendGroupMessageAsync(memberUserIds, - ImGroupMessageDTO.ofSend(tipMessage)); - } - // ==================== 管理后台 ==================== @Override diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java index 1dfd668..9d3c3dd 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java @@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.I import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; import java.util.List; @@ -16,7 +17,10 @@ import java.util.List; public interface ImPrivateMessageService { /** - * 发送私聊消息 + * 【用户调用】发送私聊消息 + *

+ * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、好友校验、敏感词、quote 解析等业务校验。 + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。 * * @param senderId 发送人编号 * @param reqVO 发送请求 @@ -25,6 +29,33 @@ public interface ImPrivateMessageService { ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO); /** + * 【系统调用】发送私聊消息 + * + * @param senderId 发送人编号 + * @param dto 消息 DTO + * @return 构造的消息 DO(持久化时 id 已回填) + */ + ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto); + + /** + * 【系统调用】发送系统提示消息(TIP_TEXT) + * + * @param senderId 发送人编号 + * @param receiverId 接收人编号 + * @param content 提示文本 + */ + void sendTipPrivateMessage(Long senderId, Long receiverId, String content); + + /** + * 【用户调用】撤回私聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + * @return 撤回后的消息 + */ + ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId); + + /** * 拉取私聊消息(增量) * * @param userId 当前用户编号 @@ -59,15 +90,6 @@ public interface ImPrivateMessageService { Long getMaxReadMessageId(Long userId, Long peerId); /** - * 撤回私聊消息 - * - * @param userId 当前用户编号 - * @param messageId 消息编号 - * @return 撤回后的消息 - */ - ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId); - - /** * 查询私聊历史消息(游标拉取) * * @param userId 当前用户编号 @@ -76,17 +98,6 @@ public interface ImPrivateMessageService { */ List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO); - /** - * 发送系统提示消息(TIP_TEXT) - *

- * 跳过好友校验、敏感词过滤,用于系统自动生成的提示消息。 - * - * @param senderId 发送人编号 - * @param receiverId 接收人编号 - * @param content 提示文本 - */ - void sendTipPrivateMessage(Long senderId, Long receiverId, String content); - // ==================== 管理后台 ==================== /** diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java index ea9ccb8..453e484 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java @@ -14,6 +14,7 @@ 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.message.dto.ImPrivateMessageSendDTO; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; @@ -73,7 +74,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { sensitiveWordService.validateText(reqVO.getContent()); } - // 2.1 引用 quote 消息规范化 reqVO.setContent(normalizeQuoteContent(reqVO, senderId)); // 2.2 构建并保存消息 @@ -84,10 +84,70 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { // 3. WebSocket 异步推送(接收方 + 发送方多端同步) ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage); - imWebSocketService.sendPrivateMessageAsync(message.getSenderId(), websocketMessage); + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); + return message; + } + + @Override + public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto) { + // 1.1 content 序列化:null / String 透传,POJO 走 JSON + Object payload = dto.getContent(); + String contentString = payload == null || payload instanceof String + ? (String) payload + : JsonUtils.toJsonString(payload); + // 1.2 构建并保存消息 + ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) + .setSenderId(senderId).setReceiverId(dto.getReceiverId()) + .setType(dto.getType()).setContent(contentString) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); + // 1.3 按 type.persistent 决定是否入库 + if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { + privateMessageMapper.insert(message); + } + + // 2. WebSocket 异步推送(接收方 + 发送方多端同步) + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); + imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage); + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); return message; } + @Override + public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { + sendPrivateMessage(senderId, new ImPrivateMessageSendDTO().setReceiverId(receiverId) + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { + // 1.1 校验消息存在 + ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); + if (message == null) { + throw exception(MESSAGE_NOT_EXISTS); + } + // 1.2 只能撤回自己发送的消息 + if (ObjUtil.notEqual(message.getSenderId(), userId)) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 1.3 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + // 1.4 只允许撤回限定时间内的消息 + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); + } + + // 2. 更新原消息状态为撤回 + privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); + + // 3. 发送撤回事件 + return sendPrivateMessage(userId, new ImPrivateMessageSendDTO().setReceiverId(message.getReceiverId()) + .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId))); + } + /** * 私聊引用消息规范化 * @@ -139,7 +199,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { return messages; } - // TODO DONE @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】 @Override public void readPrivateMessages(Long userId, Long receiverId, Long messageId) { Assert.notNull(messageId, "已读消息编号不能为空"); @@ -166,53 +225,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { } @Override - @Transactional(rollbackFor = Exception.class) - public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { - // 1.1 校验消息存在 - ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); - if (message == null) { - throw exception(MESSAGE_NOT_EXISTS); - } - // 1.2 只能撤回自己发送的消息 - if (ObjUtil.notEqual(message.getSenderId(), userId)) { - throw exception(MESSAGE_RECALL_DENIED); - } - // 1.3 不能重复撤回 - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { - throw exception(MESSAGE_ALREADY_RECALLED); - } - // 1.4 只允许撤回限定时间内的消息 - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); - } - - // 2. 更新原消息状态为撤回 - privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) - .setStatus(ImMessageStatusEnum.RECALL.getStatus())); - - // 3. 插入一条 RECALL 消息作为撤回信号(content 为 RecallMessage 序列化,前端据此找到原消息更新状态) - RecallMessage recallContent = new RecallMessage().setMessageId(messageId); - ImPrivateMessageDO recallMessage = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) - .setSenderId(userId).setReceiverId(message.getReceiverId()) - .setType(ImMessageTypeEnum.RECALL.getType()).setContent(JsonUtils.toJsonString(recallContent)) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); - privateMessageMapper.insert(recallMessage); - - // 4. 异步推送撤回信号(前端据此更新原消息状态 + 插入撤回提示) - ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(recallMessage); - imWebSocketService.sendPrivateMessageAsync(recallMessage.getReceiverId(), websocketMessage); - imWebSocketService.sendPrivateMessageAsync(recallMessage.getSenderId(), websocketMessage); - return recallMessage; - } - - @Override public List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) { return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit()); } // ==================== 管理后台 ==================== - // DONE @AI:这些挪到 controller 拼接 VO @Override public PageResult getPrivateMessagePage(ImPrivateMessageManagerPageReqVO reqVO) { return privateMessageMapper.selectPage(reqVO); @@ -223,19 +241,4 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { return privateMessageMapper.selectById(id); } - @Override - public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { - // 1. 插入 TIP_TEXT 消息 - ImPrivateMessageDO tipMessage = new ImPrivateMessageDO() - .setClientMessageId(IdUtil.fastSimpleUUID()).setSenderId(senderId).setReceiverId(receiverId) - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); - privateMessageMapper.insert(tipMessage); - - // 2. 推送给双方 - ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofSend(tipMessage); - imWebSocketService.sendPrivateMessageAsync(receiverId, dto); - imWebSocketService.sendPrivateMessageAsync(senderId, dto); - } - } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java new file mode 100644 index 0000000..b2378fc --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.im.service.message.dto; + +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; + +import java.util.List; + +/** + * IM 群聊消息发送 DTO + * + * @author 芋道源码 + */ +@Data +public class ImGroupMessageSendDTO { + + /** + * 群编号 + */ + private Long groupId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容 + *

+ * 支持 String / POJO;非 String 时由 service 序列化为 JSON + */ + private Object content; + /** + * @ 目标用户编号列表 + */ + private List atUserIds; + /** + * 定向接收用户编号列表 + *

+ * 为空表示全员可见 + */ + private List receiverUserIds; + /** + * 是否需要回执 + *

+ * 缺省视为无需回执(false) + */ + private Boolean receipt; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java new file mode 100644 index 0000000..e48a637 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.service.message.dto; + +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; + +/** + * IM 私聊消息发送 DTO + * + * @author 芋道源码 + */ +@Data +public class ImPrivateMessageSendDTO { + + /** + * 接收人编号 + */ + private Long receiverId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容 + *

+ * 支持 String / POJO;非 String 时由 service 序列化为 JSON + */ + private Object content; + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 5be560c..5943aab 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -16,9 +16,11 @@ 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.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; @@ -26,11 +28,7 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import java.time.LocalDateTime; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; @@ -907,18 +905,18 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); } - // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ========== + // ========== sendGroupMessage(senderId, dto):receiverUserIds 定向过滤 ========== @Test - public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() { + public void testSendGroupMessage_noReceiverUserIds_broadcastsToAllMembers() { // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(1L) - .sendTime(LocalDateTime.now()).build(); + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":1}"); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) .thenReturn(List.of(1L, 2L, 3L, 4L)); - groupMessageService.sendGroupMessageEvent(message); + groupMessageService.sendGroupMessage(1L, dto); verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> ids.size() == 4 && ids.contains(1L) && ids.contains(2L) @@ -927,17 +925,17 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { } @Test - public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() { + public void testSendGroupMessage_withReceiverUserIds_onlyTargetedPlusSender() { // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(1L) - .receiverUserIds(List.of(2L, 3L)) - .sendTime(LocalDateTime.now()).build(); + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":1}") + .setReceiverUserIds(List.of(2L, 3L)); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) .thenReturn(List.of(1L, 2L, 3L, 4L)); - groupMessageService.sendGroupMessageEvent(message); + groupMessageService.sendGroupMessage(1L, dto); verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> ids.size() == 3 && ids.contains(1L) && ids.contains(2L) @@ -946,23 +944,91 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { } @Test - public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { + public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) // 预期:仅定向用户可见 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(99L) - .receiverUserIds(List.of(2L, 3L)) - .sendTime(LocalDateTime.now()).build(); + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":1}") + .setReceiverUserIds(List.of(2L, 3L)); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) .thenReturn(List.of(1L, 2L, 3L, 4L)); - groupMessageService.sendGroupMessageEvent(message); + groupMessageService.sendGroupMessage(99L, dto); verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), any(ImGroupMessageDTO.class)); } + // ========== sendGroupMessage(senderId, dto):helper 行为 ========== + + @Test + public void testSendGroupMessage_dto_persistsAndSerializesPojoContent() { + // 准备:persistent=true 类型 + POJO content + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent(new RecallMessage().setMessageId(50L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); + + groupMessageService.sendGroupMessage(1L, dto); + + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON + receiptStatus=NO_RECEIPT + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).insert(captor.capture()); + ImGroupMessageDO message = captor.getValue(); + assertEquals(1L, message.getSenderId()); + assertEquals(10L, message.getGroupId()); + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); + assertEquals("{\"messageId\":50}", message.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus()); + assertNotNull(message.getClientMessageId()); + assertNotNull(message.getSendTime()); + } + + @Test + public void testSendGroupMessage_dto_receiptPending() { + // 准备:dto.receipt=true → receiptStatus=PENDING + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":50}").setReceipt(true); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); + + groupMessageService.sendGroupMessage(1L, dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).insert(captor.capture()); + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus()); + } + + @Test + public void testSendGroupMessage_dto_nonPersistentTypeNotInserted() { + // 准备:persistent=false 类型(GROUP_CREATE)→ 不入库,仅推送 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_CREATE.getType()); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); + + groupMessageService.sendGroupMessage(1L, dto); + + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + verify(imWebSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() { + // 准备:调用方传入显式 targets(覆盖跨成员 disable 边界场景:解散 / 退群 / 踢人) + Set targets = Set.of(1L, 2L, 3L); + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent("解散通知"); + + groupMessageService.sendGroupMessage(1L, targets, dto); + + // 断言:不读取 active members,按调用方 targets 推送 + verify(groupMemberService, never()).getActiveGroupMemberUserIdsByGroupId(anyLong()); + verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class)); + } + // ========== sendTipGroupMessage ========== @Test diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index 16fd0ce..6061d50 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -10,8 +10,10 @@ 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.service.message.dto.ImPrivateMessageSendDTO; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; @@ -333,6 +335,46 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { assertNull(result); } + // ========== sendPrivateMessage(senderId, dto):helper 行为 ========== + + @Test + public void testSendPrivateMessage_dto_persistsAndSerializesPojoContent() { + // 准备:persistent=true 类型 + POJO content + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() + .setReceiverId(2L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent(new RecallMessage().setMessageId(50L)); + + privateMessageService.sendPrivateMessage(1L, dto); + + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON + ArgumentCaptor captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class); + verify(privateMessageMapper).insert(captor.capture()); + ImPrivateMessageDO message = captor.getValue(); + assertEquals(1L, message.getSenderId()); + assertEquals(2L, message.getReceiverId()); + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); + assertEquals("{\"messageId\":50}", message.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); + assertNotNull(message.getClientMessageId()); + assertNotNull(message.getSendTime()); + // 断言:sender + receiver 双端推送 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() { + // 准备:persistent=false 类型(FRIEND_ADD)→ 不入库,仅推送 + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() + .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_ADD.getType()); + + privateMessageService.sendPrivateMessage(1L, dto); + + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + // ========== sendTipPrivateMessage ========== @Test -- libgit2 0.21.4