Commit 37dfd6c07b937581a887d6d4e555a1c003ba6900

Authored by YunaiV
1 parent 41762f0a

✨ 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
1 1 package cn.iocoder.yudao.module.im.controller.admin.message.vo.group;
2 2  
  3 +import cn.iocoder.yudao.framework.common.validation.InEnum;
  4 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  5 +import com.fasterxml.jackson.annotation.JsonIgnore;
3 6 import io.swagger.v3.oas.annotations.media.Schema;
  7 +import jakarta.validation.constraints.AssertTrue;
4 8 import jakarta.validation.constraints.NotEmpty;
5 9 import jakarta.validation.constraints.NotNull;
6 10 import lombok.Data;
... ... @@ -24,6 +28,7 @@ public class ImGroupMessageSendReqVO {
24 28  
25 29 @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
26 30 @NotNull(message = "消息类型不能为空")
  31 + @InEnum(ImMessageTypeEnum.class)
27 32 private Integer type;
28 33  
29 34 @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
... ... @@ -36,4 +41,13 @@ public class ImGroupMessageSendReqVO {
36 41 @Schema(description = "是否需要回执", example = "false")
37 42 private Boolean receipt;
38 43  
  44 + /**
  45 + * 仅允许用户消息(normal)类型
  46 + */
  47 + @AssertTrue(message = "消息类型不允许")
  48 + @JsonIgnore
  49 + public boolean isTypeNormal() {
  50 + return type == null || ImMessageTypeEnum.validate(type).isNormal();
  51 + }
  52 +
39 53 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java
1 1 package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates;
2 2  
  3 +import cn.iocoder.yudao.framework.common.validation.InEnum;
  4 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  5 +import com.fasterxml.jackson.annotation.JsonIgnore;
3 6 import io.swagger.v3.oas.annotations.media.Schema;
  7 +import jakarta.validation.constraints.AssertTrue;
4 8 import jakarta.validation.constraints.NotEmpty;
5 9 import jakarta.validation.constraints.NotNull;
6 10 import lombok.Data;
... ... @@ -22,10 +26,20 @@ public class ImPrivateMessageSendReqVO {
22 26  
23 27 @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
24 28 @NotNull(message = "消息类型不能为空")
  29 + @InEnum(ImMessageTypeEnum.class)
25 30 private Integer type;
26 31  
27 32 @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
28 33 @NotEmpty(message = "消息内容不能为空")
29 34 private String content;
30 35  
  36 + /**
  37 + * 仅允许用户消息(normal)类型
  38 + */
  39 + @AssertTrue(message = "消息类型不允许")
  40 + @JsonIgnore
  41 + public boolean isTypeNormal() {
  42 + return type == null || ImMessageTypeEnum.validate(type).isNormal();
  43 + }
  44 +
31 45 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
1 1 package cn.iocoder.yudao.module.im.enums.message;
2 2  
  3 +import cn.hutool.core.lang.Assert;
  4 +import cn.hutool.core.util.ArrayUtil;
3 5 import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4   -import lombok.AllArgsConstructor;
5 6 import lombok.Getter;
  7 +import lombok.RequiredArgsConstructor;
6 8  
7 9 import java.util.Arrays;
8 10  
... ... @@ -12,30 +14,35 @@ import java.util.Arrays;
12 14 * @author 芋道源码
13 15 */
14 16 @Getter
15   -@AllArgsConstructor
  17 +@RequiredArgsConstructor
16 18 public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
17 19  
18   - TEXT(0, "文本"), // 对应 TextMessage 类
19   - IMAGE(1, "图片"), // 对应 ImageMessage 类
20   - FILE(2, "文件"), // 对应 FileMessage 类
21   - VOICE(3, "语音"), // 对应 AudioMessage 类
22   - VIDEO(4, "视频"), // 对应 VideoMessage 类
  20 + // ========== 用户聊天消息 ==========
  21 + TEXT(0, "文本", true, true), // 对应 TextMessage 类
  22 + IMAGE(1, "图片", true, true), // 对应 ImageMessage 类
  23 + FILE(2, "文件", true, true), // 对应 FileMessage 类
  24 + VOICE(3, "语音", true, true), // 对应 AudioMessage 类
  25 + VIDEO(4, "视频", true, true), // 对应 VideoMessage 类
23 26  
24   - RECALL(10, "撤回"), // 对应 RecallMessage 类
25   - READ(11, "已读"), // 暂无
26   - RECEIPT(12, "回执"), // 暂无
27   - TIP_TEXT(21, "提示文本"), // 对应 TextMessage 类
  27 + // ========== 信号类 ==========
  28 + RECALL(10, "撤回", true, false), // 对应 RecallMessage 类
  29 + READ(11, "已读", false, false), // 暂无
  30 + RECEIPT(12, "回执", false, false), // 暂无
28 31  
29   - // ==================== 非单聊、群聊存储的类型,100 开始 ====================
  32 + // ========== 系统提示 ==========
  33 + TIP_TIME(20, "时间分隔", false, false), // 暂无(前端本地生成)
  34 + TIP_TEXT(21, "系统提示", true, false), // 对应 TextMessage 类
30 35  
31   - FRIEND_ADD(100, "好友添加"), // 暂无
32   - FRIEND_DELETE(101, "好友删除"), // 暂无
33   - FRIEND_UPDATE(102, "好友更新"), // 暂无(客户端收到后自行拉取)
  36 + // ========== 好友变更 ==========
  37 + FRIEND_ADD(100, "好友添加", false, false), // 暂无
  38 + FRIEND_DELETE(101, "好友删除", false, false), // 暂无
  39 + FRIEND_UPDATE(102, "好友更新", false, false), // 暂无(客户端收到后自行拉取)
34 40  
35   - GROUP_CREATE(200, "群创建"), // 暂无
36   - GROUP_UPDATE(201, "群信息变更"), // 暂无
37   - GROUP_DELETE(202, "群删除"), // 暂无(解散/退群/踢出均用此类型)
38   - GROUP_MEMBER_UPDATE(203, "群成员信息变更"); // 暂无(客户端收到后自行拉取)
  41 + // ========== 群变更 ==========
  42 + GROUP_CREATE(200, "群创建", false, false), // 暂无
  43 + GROUP_UPDATE(201, "群信息变更", false, false), // 暂无
  44 + GROUP_DELETE(202, "群删除", false, false), // 暂无(解散/退群/踢出均用此类型)
  45 + GROUP_MEMBER_UPDATE(203, "群成员信息变更", false, false); // 暂无(客户端收到后自行拉取)
39 46  
40 47 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new);
41 48  
... ... @@ -43,15 +50,40 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
43 50 * 类型
44 51 */
45 52 private final Integer type;
46   -
47 53 /**
48 54 * 名字
49 55 */
50 56 private final String name;
  57 + /**
  58 + * 是否入库
  59 + * <p>
  60 + * true:插入 im_xxx_message 消息表,离线 pull 能拉到;
  61 + * false:仅 WebSocket 推送,离线丢弃;状态由专用存储维护(如 READ 走 Redis 游标)
  62 + */
  63 + private final boolean persistent;
  64 + /**
  65 + * 是不是用户聊天消息(normal vs event 二分)
  66 + * <p>
  67 + * true:用户主动发的聊天消息,计入会话未读数(接收方非激活会话时 unreadCount + 1);用户发送入口仅允许这类;
  68 + * false:系统事件 / 信号 / 提示,不参与未读计数
  69 + */
  70 + private final boolean normal;
51 71  
52 72 @Override
53 73 public Integer[] array() {
54 74 return ARRAYS;
55 75 }
56 76  
  77 + /**
  78 + * 校验 type 已注册,并返回对应枚举;未注册立刻抛异常,避免新增 type 时漏配 persistent / normal 属性
  79 + *
  80 + * @param type 消息类型
  81 + * @return 枚举实例
  82 + */
  83 + public static ImMessageTypeEnum validate(Integer type) {
  84 + ImMessageTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values());
  85 + Assert.notNull(result, "未注册的消息类型 type={}", type);
  86 + return result;
  87 + }
  88 +
57 89 }
... ...
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
5 5 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO;
6 6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO;
7 7 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  8 +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
8 9  
9 10 import java.util.Collection;
10 11 import java.util.List;
... ... @@ -18,7 +19,10 @@ import java.util.Set;
18 19 public interface ImGroupMessageService {
19 20  
20 21 /**
21   - * 发送群聊消息
  22 + * 【用户调用】发送群聊消息
  23 + * <p>
  24 + * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、敏感词、quote、@ 解析等业务校验。
  25 + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。
22 26 *
23 27 * @param senderId 发送人编号
24 28 * @param reqVO 发送请求
... ... @@ -27,6 +31,34 @@ public interface ImGroupMessageService {
27 31 ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO);
28 32  
29 33 /**
  34 + * 【系统调用】发送群聊消息:自动算可见用户 + 推送
  35 + *
  36 + * @param senderId 发送人编号
  37 + * @param dto 消息 DTO
  38 + * @return 消息
  39 + */
  40 + ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto);
  41 +
  42 + /**
  43 + * 【系统调用】发送群聊消息:显式指定推送目标
  44 + *
  45 + * @param senderId 发送人编号
  46 + * @param targetUserIds 推送目标用户编号集合(调用方在变更成员状态前抓取的快照)
  47 + * @param dto 消息 DTO
  48 + * @return 构造的消息 DO(持久化时 id 已回填)
  49 + */
  50 + ImGroupMessageDO sendGroupMessage(Long senderId, Set<Long> targetUserIds, ImGroupMessageSendDTO dto);
  51 +
  52 + /**
  53 + * 【用户调用】撤回群聊消息
  54 + *
  55 + * @param userId 当前用户编号
  56 + * @param messageId 消息编号
  57 + * @return 撤回后的提示消息
  58 + */
  59 + ImGroupMessageDO recallGroupMessage(Long userId, Long messageId);
  60 +
  61 + /**
30 62 * 拉取群聊消息(增量)
31 63 *
32 64 * @param userId 当前用户编号
... ... @@ -46,15 +78,6 @@ public interface ImGroupMessageService {
46 78 void readGroupMessages(Long userId, Long groupId, Long messageId);
47 79  
48 80 /**
49   - * 撤回群聊消息
50   - *
51   - * @param userId 当前用户编号
52   - * @param messageId 消息编号
53   - * @return 撤回后的提示消息
54   - */
55   - ImGroupMessageDO recallGroupMessage(Long userId, Long messageId);
56   -
57   - /**
58 81 * 获取群消息的已读用户列表
59 82 *
60 83 * @param userId 当前用户编号
... ...
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;
21 21 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
22 22 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
23 23 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
  24 +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
24 25 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
25 26 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
26 27 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
... ... @@ -102,12 +103,82 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
102 103 : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()));
103 104 groupMessageMapper.insert(message);
104 105  
105   - // 3. WebSocket 推送给群成员(异步)
106   - sendGroupMessageEvent(message);
  106 + // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步)
  107 + List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId());
  108 + Set<Long> targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds);
  109 + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
107 110 return message;
108 111 }
109 112  
110 113 @Override
  114 + public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) {
  115 + List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId());
  116 + Set<Long> targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds);
  117 + return sendGroupMessage(senderId, targetUserIds, dto);
  118 + }
  119 +
  120 + @Override
  121 + public ImGroupMessageDO sendGroupMessage(Long senderId, Set<Long> targetUserIds, ImGroupMessageSendDTO dto) {
  122 + // 1.1 content 序列化:null / String 透传,POJO 走 JSON
  123 + Object payload = dto.getContent();
  124 + String contentString = payload == null || payload instanceof String
  125 + ? (String) payload
  126 + : JsonUtils.toJsonString(payload);
  127 + // 1.2 构建并保存消息
  128 + ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
  129 + .setSenderId(senderId).setGroupId(dto.getGroupId())
  130 + .setType(dto.getType()).setContent(contentString)
  131 + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
  132 + .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds())
  133 + .setReceiptStatus(Boolean.TRUE.equals(dto.getReceipt())
  134 + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus()
  135 + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus());
  136 + // 1.3 按 type.persistent 决定是否入库
  137 + if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) {
  138 + groupMessageMapper.insert(message);
  139 + }
  140 +
  141 + // 2. WebSocket 异步推送
  142 + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
  143 + return message;
  144 + }
  145 +
  146 + @Override
  147 + public void sendTipGroupMessage(Long senderId, Long groupId, Set<Long> memberUserIds, String content) {
  148 + sendGroupMessage(senderId, memberUserIds, new ImGroupMessageSendDTO()
  149 + .setGroupId(groupId).setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content));
  150 + }
  151 +
  152 + @Override
  153 + @Transactional(rollbackFor = Exception.class)
  154 + public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) {
  155 + // 1.1 校验消息存在
  156 + ImGroupMessageDO message = validateGroupMessageExists(messageId);
  157 + // 1.2 只能撤回自己发送的消息
  158 + if (ObjUtil.notEqual(message.getSenderId(), userId)) {
  159 + throw exception(MESSAGE_RECALL_DENIED);
  160 + }
  161 + // 1.3 不能重复撤回
  162 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
  163 + throw exception(MESSAGE_ALREADY_RECALLED);
  164 + }
  165 + // 1.4 只允许撤回限定时间内的消息
  166 + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) {
  167 + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES);
  168 + }
  169 + // 1.5 校验撤回人仍在群中
  170 + groupMemberService.validateMemberInGroup(message.getGroupId(), userId);
  171 +
  172 + // 2. 更新原消息状态为撤回
  173 + groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId)
  174 + .setStatus(ImMessageStatusEnum.RECALL.getStatus()));
  175 +
  176 + // 3. 发送撤回事件
  177 + return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId())
  178 + .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId)));
  179 + }
  180 +
  181 + @Override
111 182 public List<ImGroupMessageDO> pullGroupMessageList(Long userId, Long minId, Integer size) {
112 183 if (size > MESSAGE_MAX_PULL_SIZE) {
113 184 throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE);
... ... @@ -286,44 +357,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
286 357 }
287 358  
288 359 @Override
289   - @Transactional(rollbackFor = Exception.class)
290   - public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) {
291   - // 1.1 校验消息存在
292   - ImGroupMessageDO message = validateGroupMessageExists(messageId);
293   - // 1.2 只能撤回自己发送的消息
294   - if (ObjUtil.notEqual(message.getSenderId(), userId)) {
295   - throw exception(MESSAGE_RECALL_DENIED);
296   - }
297   - // 1.3 不能重复撤回
298   - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
299   - throw exception(MESSAGE_ALREADY_RECALLED);
300   - }
301   - // 1.4 只允许撤回限定时间内的消息
302   - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) {
303   - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES);
304   - }
305   - // 1.5 校验撤回人仍在群中
306   - groupMemberService.validateMemberInGroup(message.getGroupId(), userId);
307   -
308   - // 2. 更新原消息状态为撤回
309   - groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId)
310   - .setStatus(ImMessageStatusEnum.RECALL.getStatus()));
311   -
312   - // 3. 插入一条 RECALL 消息作为撤回信号(content 为 RecallMessage 序列化,前端据此找到原消息更新状态)
313   - RecallMessage recallContent = new RecallMessage().setMessageId(messageId);
314   - ImGroupMessageDO recallMessage = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
315   - .setSenderId(userId).setGroupId(message.getGroupId())
316   - .setType(ImMessageTypeEnum.RECALL.getType()).setContent(JsonUtils.toJsonString(recallContent))
317   - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
318   - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus());
319   - groupMessageMapper.insert(recallMessage);
320   -
321   - // 4. 异步推送撤回信号(前端据此更新原消息状态 + 插入撤回提示)
322   - sendGroupMessageEvent(recallMessage);
323   - return recallMessage;
324   - }
325   -
326   - @Override
327 360 public List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId) {
328 361 // 1.1 校验用户在群中(权限校验)
329 362 groupMemberService.validateMemberInGroup(groupId, userId);
... ... @@ -365,28 +398,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
365 398 // ========== 异步 WebSocket 推送 ==========
366 399  
367 400 /**
368   - * 发送群聊消息 WebSocket 事件
369   - * <p>
370   - * 这里仅用于"新消息"(首发 or 撤回提示),{@code message.sendTime = now}:
371   - * <ul>
372   - * <li>所有 ENABLE 成员的 {@code joinTime <= now},{@link #isMessageVisible} 的 joinTime 分支恒为真;</li>
373   - * <li>ENABLE 成员 status 非 DISABLE,quitTime 分支不适用;</li>
374   - * </ul>
375   - * 因此只剩 {@code receiverUserIds} 定向过滤会真正起效。
376   - * 故使用只含 userId 的轻量缓存 {@link ImGroupMemberService#getActiveGroupMemberUserIdsByGroupId(Long)},
377   - * 在本地以 {@link #getVisibleUserIds} 完成定向过滤即可。
378   - * <p>
379   - * 注意:对历史消息的可见性判断仍需 joinTime/quitTime,见 {@code readGroupMessageEvent} 等处。
380   - */
381   - public void sendGroupMessageEvent(ImGroupMessageDO message) {
382   - ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message);
383   - // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤)
384   - List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId());
385   - Set<Long> targetUserIds = getVisibleUserIds(message, memberUserIds);
386   - imWebSocketService.sendGroupMessageAsync(targetUserIds, dto);
387   - }
388   -
389   - /**
390 401 * 发送已读 + 刷新群回执 WebSocket 事件
391 402 *
392 403 * @param userId 当前用户编号
... ... @@ -528,24 +539,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
528 539 }
529 540  
530 541 /**
531   - * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。
  542 + * 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。
532 543 * <p>
533   - * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定,
  544 + * 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定,
534 545 * 只应用 {@code receiverUserIds} 定向过滤;语义与
535 546 * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。
536 547 */
537   - private Set<Long> getVisibleUserIds(ImGroupMessageDO message, Collection<Long> memberUserIds) {
  548 + private Set<Long> getVisibleUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) {
538 549 if (CollUtil.isEmpty(memberUserIds)) {
539 550 return new HashSet<>();
540 551 }
541 552 // 无定向接收列表 → 全员可见
542   - if (CollUtil.isEmpty(message.getReceiverUserIds())) {
  553 + if (CollUtil.isEmpty(receiverUserIds)) {
543 554 return new HashSet<>(memberUserIds);
544 555 }
545 556 // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步)
546   - Set<Long> allowed = new HashSet<>(message.getReceiverUserIds());
547   - if (message.getSenderId() != null) {
548   - allowed.add(message.getSenderId());
  557 + Set<Long> allowed = new HashSet<>(receiverUserIds);
  558 + if (senderId != null) {
  559 + allowed.add(senderId);
549 560 }
550 561 Set<Long> result = new HashSet<>();
551 562 for (Long userId : memberUserIds) {
... ... @@ -571,22 +582,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
571 582 groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId);
572 583 }
573 584  
574   - @Override
575   - public void sendTipGroupMessage(Long senderId, Long groupId, Set<Long> memberUserIds, String content) {
576   - // 1. 插入 TIP_TEXT 消息
577   - ImGroupMessageDO tipMessage = new ImGroupMessageDO()
578   - .setClientMessageId(IdUtil.fastSimpleUUID())
579   - .setSenderId(senderId).setGroupId(groupId)
580   - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)
581   - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
582   - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus());
583   - groupMessageMapper.insert(tipMessage);
584   -
585   - // 2. 推送给指定用户
586   - imWebSocketService.sendGroupMessageAsync(memberUserIds,
587   - ImGroupMessageDTO.ofSend(tipMessage));
588   - }
589   -
590 585 // ==================== 管理后台 ====================
591 586  
592 587 @Override
... ...
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
5 5 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO;
6 6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
7 7 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  8 +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
8 9  
9 10 import java.util.List;
10 11  
... ... @@ -16,7 +17,10 @@ import java.util.List;
16 17 public interface ImPrivateMessageService {
17 18  
18 19 /**
19   - * 发送私聊消息
  20 + * 【用户调用】发送私聊消息
  21 + * <p>
  22 + * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、好友校验、敏感词、quote 解析等业务校验。
  23 + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。
20 24 *
21 25 * @param senderId 发送人编号
22 26 * @param reqVO 发送请求
... ... @@ -25,6 +29,33 @@ public interface ImPrivateMessageService {
25 29 ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO);
26 30  
27 31 /**
  32 + * 【系统调用】发送私聊消息
  33 + *
  34 + * @param senderId 发送人编号
  35 + * @param dto 消息 DTO
  36 + * @return 构造的消息 DO(持久化时 id 已回填)
  37 + */
  38 + ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto);
  39 +
  40 + /**
  41 + * 【系统调用】发送系统提示消息(TIP_TEXT)
  42 + *
  43 + * @param senderId 发送人编号
  44 + * @param receiverId 接收人编号
  45 + * @param content 提示文本
  46 + */
  47 + void sendTipPrivateMessage(Long senderId, Long receiverId, String content);
  48 +
  49 + /**
  50 + * 【用户调用】撤回私聊消息
  51 + *
  52 + * @param userId 当前用户编号
  53 + * @param messageId 消息编号
  54 + * @return 撤回后的消息
  55 + */
  56 + ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId);
  57 +
  58 + /**
28 59 * 拉取私聊消息(增量)
29 60 *
30 61 * @param userId 当前用户编号
... ... @@ -59,15 +90,6 @@ public interface ImPrivateMessageService {
59 90 Long getMaxReadMessageId(Long userId, Long peerId);
60 91  
61 92 /**
62   - * 撤回私聊消息
63   - *
64   - * @param userId 当前用户编号
65   - * @param messageId 消息编号
66   - * @return 撤回后的消息
67   - */
68   - ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId);
69   -
70   - /**
71 93 * 查询私聊历史消息(游标拉取)
72 94 *
73 95 * @param userId 当前用户编号
... ... @@ -76,17 +98,6 @@ public interface ImPrivateMessageService {
76 98 */
77 99 List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO);
78 100  
79   - /**
80   - * 发送系统提示消息(TIP_TEXT)
81   - * <p>
82   - * 跳过好友校验、敏感词过滤,用于系统自动生成的提示消息。
83   - *
84   - * @param senderId 发送人编号
85   - * @param receiverId 接收人编号
86   - * @param content 提示文本
87   - */
88   - void sendTipPrivateMessage(Long senderId, Long receiverId, String content);
89   -
90 101 // ==================== 管理后台 ====================
91 102  
92 103 /**
... ...
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;
14 14 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
15 15 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
16 16 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
  17 +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
17 18 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
18 19 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
19 20 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
... ... @@ -73,7 +74,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
73 74 sensitiveWordService.validateText(reqVO.getContent());
74 75 }
75 76  
76   -
77 77 // 2.1 引用 quote 消息规范化
78 78 reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
79 79 // 2.2 构建并保存消息
... ... @@ -84,10 +84,70 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
84 84 // 3. WebSocket 异步推送(接收方 + 发送方多端同步)
85 85 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
86 86 imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage);
87   - imWebSocketService.sendPrivateMessageAsync(message.getSenderId(), websocketMessage);
  87 + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage);
  88 + return message;
  89 + }
  90 +
  91 + @Override
  92 + public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto) {
  93 + // 1.1 content 序列化:null / String 透传,POJO 走 JSON
  94 + Object payload = dto.getContent();
  95 + String contentString = payload == null || payload instanceof String
  96 + ? (String) payload
  97 + : JsonUtils.toJsonString(payload);
  98 + // 1.2 构建并保存消息
  99 + ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
  100 + .setSenderId(senderId).setReceiverId(dto.getReceiverId())
  101 + .setType(dto.getType()).setContent(contentString)
  102 + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now());
  103 + // 1.3 按 type.persistent 决定是否入库
  104 + if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) {
  105 + privateMessageMapper.insert(message);
  106 + }
  107 +
  108 + // 2. WebSocket 异步推送(接收方 + 发送方多端同步)
  109 + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
  110 + imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage);
  111 + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage);
88 112 return message;
89 113 }
90 114  
  115 + @Override
  116 + public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) {
  117 + sendPrivateMessage(senderId, new ImPrivateMessageSendDTO().setReceiverId(receiverId)
  118 + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content));
  119 + }
  120 +
  121 + @Override
  122 + @Transactional(rollbackFor = Exception.class)
  123 + public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) {
  124 + // 1.1 校验消息存在
  125 + ImPrivateMessageDO message = privateMessageMapper.selectById(messageId);
  126 + if (message == null) {
  127 + throw exception(MESSAGE_NOT_EXISTS);
  128 + }
  129 + // 1.2 只能撤回自己发送的消息
  130 + if (ObjUtil.notEqual(message.getSenderId(), userId)) {
  131 + throw exception(MESSAGE_RECALL_DENIED);
  132 + }
  133 + // 1.3 不能重复撤回
  134 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
  135 + throw exception(MESSAGE_ALREADY_RECALLED);
  136 + }
  137 + // 1.4 只允许撤回限定时间内的消息
  138 + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) {
  139 + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES);
  140 + }
  141 +
  142 + // 2. 更新原消息状态为撤回
  143 + privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId)
  144 + .setStatus(ImMessageStatusEnum.RECALL.getStatus()));
  145 +
  146 + // 3. 发送撤回事件
  147 + return sendPrivateMessage(userId, new ImPrivateMessageSendDTO().setReceiverId(message.getReceiverId())
  148 + .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId)));
  149 + }
  150 +
91 151 /**
92 152 * 私聊引用消息规范化
93 153 *
... ... @@ -139,7 +199,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
139 199 return messages;
140 200 }
141 201  
142   - // TODO DONE @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】
143 202 @Override
144 203 public void readPrivateMessages(Long userId, Long receiverId, Long messageId) {
145 204 Assert.notNull(messageId, "已读消息编号不能为空");
... ... @@ -166,53 +225,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
166 225 }
167 226  
168 227 @Override
169   - @Transactional(rollbackFor = Exception.class)
170   - public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) {
171   - // 1.1 校验消息存在
172   - ImPrivateMessageDO message = privateMessageMapper.selectById(messageId);
173   - if (message == null) {
174   - throw exception(MESSAGE_NOT_EXISTS);
175   - }
176   - // 1.2 只能撤回自己发送的消息
177   - if (ObjUtil.notEqual(message.getSenderId(), userId)) {
178   - throw exception(MESSAGE_RECALL_DENIED);
179   - }
180   - // 1.3 不能重复撤回
181   - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
182   - throw exception(MESSAGE_ALREADY_RECALLED);
183   - }
184   - // 1.4 只允许撤回限定时间内的消息
185   - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) {
186   - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES);
187   - }
188   -
189   - // 2. 更新原消息状态为撤回
190   - privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId)
191   - .setStatus(ImMessageStatusEnum.RECALL.getStatus()));
192   -
193   - // 3. 插入一条 RECALL 消息作为撤回信号(content 为 RecallMessage 序列化,前端据此找到原消息更新状态)
194   - RecallMessage recallContent = new RecallMessage().setMessageId(messageId);
195   - ImPrivateMessageDO recallMessage = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
196   - .setSenderId(userId).setReceiverId(message.getReceiverId())
197   - .setType(ImMessageTypeEnum.RECALL.getType()).setContent(JsonUtils.toJsonString(recallContent))
198   - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now());
199   - privateMessageMapper.insert(recallMessage);
200   -
201   - // 4. 异步推送撤回信号(前端据此更新原消息状态 + 插入撤回提示)
202   - ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(recallMessage);
203   - imWebSocketService.sendPrivateMessageAsync(recallMessage.getReceiverId(), websocketMessage);
204   - imWebSocketService.sendPrivateMessageAsync(recallMessage.getSenderId(), websocketMessage);
205   - return recallMessage;
206   - }
207   -
208   - @Override
209 228 public List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) {
210 229 return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit());
211 230 }
212 231  
213 232 // ==================== 管理后台 ====================
214 233  
215   - // DONE @AI:这些挪到 controller 拼接 VO
216 234 @Override
217 235 public PageResult<ImPrivateMessageDO> getPrivateMessagePage(ImPrivateMessageManagerPageReqVO reqVO) {
218 236 return privateMessageMapper.selectPage(reqVO);
... ... @@ -223,19 +241,4 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
223 241 return privateMessageMapper.selectById(id);
224 242 }
225 243  
226   - @Override
227   - public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) {
228   - // 1. 插入 TIP_TEXT 消息
229   - ImPrivateMessageDO tipMessage = new ImPrivateMessageDO()
230   - .setClientMessageId(IdUtil.fastSimpleUUID()).setSenderId(senderId).setReceiverId(receiverId)
231   - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)
232   - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now());
233   - privateMessageMapper.insert(tipMessage);
234   -
235   - // 2. 推送给双方
236   - ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofSend(tipMessage);
237   - imWebSocketService.sendPrivateMessageAsync(receiverId, dto);
238   - imWebSocketService.sendPrivateMessageAsync(senderId, dto);
239   - }
240   -
241 244 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message.dto;
  2 +
  3 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  4 +import lombok.Data;
  5 +
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * IM 群聊消息发送 DTO
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Data
  14 +public class ImGroupMessageSendDTO {
  15 +
  16 + /**
  17 + * 群编号
  18 + */
  19 + private Long groupId;
  20 + /**
  21 + * 消息类型
  22 + * <p>
  23 + * 枚举 {@link ImMessageTypeEnum}
  24 + */
  25 + private Integer type;
  26 + /**
  27 + * 消息内容
  28 + * <p>
  29 + * 支持 String / POJO;非 String 时由 service 序列化为 JSON
  30 + */
  31 + private Object content;
  32 + /**
  33 + * @ 目标用户编号列表
  34 + */
  35 + private List<Long> atUserIds;
  36 + /**
  37 + * 定向接收用户编号列表
  38 + * <p>
  39 + * 为空表示全员可见
  40 + */
  41 + private List<Long> receiverUserIds;
  42 + /**
  43 + * 是否需要回执
  44 + * <p>
  45 + * 缺省视为无需回执(false)
  46 + */
  47 + private Boolean receipt;
  48 +
  49 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message.dto;
  2 +
  3 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  4 +import lombok.Data;
  5 +
  6 +/**
  7 + * IM 私聊消息发送 DTO
  8 + *
  9 + * @author 芋道源码
  10 + */
  11 +@Data
  12 +public class ImPrivateMessageSendDTO {
  13 +
  14 + /**
  15 + * 接收人编号
  16 + */
  17 + private Long receiverId;
  18 + /**
  19 + * 消息类型
  20 + * <p>
  21 + * 枚举 {@link ImMessageTypeEnum}
  22 + */
  23 + private Integer type;
  24 + /**
  25 + * 消息内容
  26 + * <p>
  27 + * 支持 String / POJO;非 String 时由 service 序列化为 JSON
  28 + */
  29 + private Object content;
  30 +
  31 +}
... ...
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;
16 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
17 17 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
18 18 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
  19 +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
19 20 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
20 21 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
21 22 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
  23 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
22 24 import org.junit.jupiter.api.Test;
23 25 import org.mockito.ArgumentCaptor;
24 26 import org.mockito.InjectMocks;
... ... @@ -26,11 +28,7 @@ import org.mockito.Mock;
26 28 import org.mockito.MockedStatic;
27 29  
28 30 import java.time.LocalDateTime;
29   -import java.util.Collection;
30   -import java.util.HashMap;
31   -import java.util.List;
32   -import java.util.Map;
33   -import java.util.Set;
  31 +import java.util.*;
34 32  
35 33 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
36 34 import static org.junit.jupiter.api.Assertions.*;
... ... @@ -907,18 +905,18 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
907 905 verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class));
908 906 }
909 907  
910   - // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ==========
  908 + // ========== sendGroupMessage(senderId, dto):receiverUserIds 定向过滤 ==========
911 909  
912 910 @Test
913   - public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() {
  911 + public void testSendGroupMessage_noReceiverUserIds_broadcastsToAllMembers() {
914 912 // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步)
915   - ImGroupMessageDO message = ImGroupMessageDO.builder()
916   - .id(100L).groupId(10L).senderId(1L)
917   - .sendTime(LocalDateTime.now()).build();
  913 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  914 + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
  915 + .setContent("{\"messageId\":1}");
918 916 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
919 917 .thenReturn(List.of(1L, 2L, 3L, 4L));
920 918  
921   - groupMessageService.sendGroupMessageEvent(message);
  919 + groupMessageService.sendGroupMessage(1L, dto);
922 920  
923 921 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
924 922 ids.size() == 4 && ids.contains(1L) && ids.contains(2L)
... ... @@ -927,17 +925,17 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
927 925 }
928 926  
929 927 @Test
930   - public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() {
  928 + public void testSendGroupMessage_withReceiverUserIds_onlyTargetedPlusSender() {
931 929 // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4}
932 930 // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤
933   - ImGroupMessageDO message = ImGroupMessageDO.builder()
934   - .id(100L).groupId(10L).senderId(1L)
935   - .receiverUserIds(List.of(2L, 3L))
936   - .sendTime(LocalDateTime.now()).build();
  931 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  932 + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
  933 + .setContent("{\"messageId\":1}")
  934 + .setReceiverUserIds(List.of(2L, 3L));
937 935 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
938 936 .thenReturn(List.of(1L, 2L, 3L, 4L));
939 937  
940   - groupMessageService.sendGroupMessageEvent(message);
  938 + groupMessageService.sendGroupMessage(1L, dto);
941 939  
942 940 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
943 941 ids.size() == 3 && ids.contains(1L) && ids.contains(2L)
... ... @@ -946,23 +944,91 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
946 944 }
947 945  
948 946 @Test
949   - public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() {
  947 + public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() {
950 948 // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御)
951 949 // 预期:仅定向用户可见
952   - ImGroupMessageDO message = ImGroupMessageDO.builder()
953   - .id(100L).groupId(10L).senderId(99L)
954   - .receiverUserIds(List.of(2L, 3L))
955   - .sendTime(LocalDateTime.now()).build();
  950 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  951 + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
  952 + .setContent("{\"messageId\":1}")
  953 + .setReceiverUserIds(List.of(2L, 3L));
956 954 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
957 955 .thenReturn(List.of(1L, 2L, 3L, 4L));
958 956  
959   - groupMessageService.sendGroupMessageEvent(message);
  957 + groupMessageService.sendGroupMessage(99L, dto);
960 958  
961 959 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
962 960 ids.size() == 2 && ids.contains(2L) && ids.contains(3L)),
963 961 any(ImGroupMessageDTO.class));
964 962 }
965 963  
  964 + // ========== sendGroupMessage(senderId, dto):helper 行为 ==========
  965 +
  966 + @Test
  967 + public void testSendGroupMessage_dto_persistsAndSerializesPojoContent() {
  968 + // 准备:persistent=true 类型 + POJO content
  969 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  970 + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
  971 + .setContent(new RecallMessage().setMessageId(50L));
  972 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L));
  973 +
  974 + groupMessageService.sendGroupMessage(1L, dto);
  975 +
  976 + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON + receiptStatus=NO_RECEIPT
  977 + ArgumentCaptor<ImGroupMessageDO> captor = ArgumentCaptor.forClass(ImGroupMessageDO.class);
  978 + verify(groupMessageMapper).insert(captor.capture());
  979 + ImGroupMessageDO message = captor.getValue();
  980 + assertEquals(1L, message.getSenderId());
  981 + assertEquals(10L, message.getGroupId());
  982 + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType());
  983 + assertEquals("{\"messageId\":50}", message.getContent());
  984 + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus());
  985 + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus());
  986 + assertNotNull(message.getClientMessageId());
  987 + assertNotNull(message.getSendTime());
  988 + }
  989 +
  990 + @Test
  991 + public void testSendGroupMessage_dto_receiptPending() {
  992 + // 准备:dto.receipt=true → receiptStatus=PENDING
  993 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  994 + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType())
  995 + .setContent("{\"messageId\":50}").setReceipt(true);
  996 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L));
  997 +
  998 + groupMessageService.sendGroupMessage(1L, dto);
  999 +
  1000 + ArgumentCaptor<ImGroupMessageDO> captor = ArgumentCaptor.forClass(ImGroupMessageDO.class);
  1001 + verify(groupMessageMapper).insert(captor.capture());
  1002 + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus());
  1003 + }
  1004 +
  1005 + @Test
  1006 + public void testSendGroupMessage_dto_nonPersistentTypeNotInserted() {
  1007 + // 准备:persistent=false 类型(GROUP_CREATE)→ 不入库,仅推送
  1008 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  1009 + .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_CREATE.getType());
  1010 + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L));
  1011 +
  1012 + groupMessageService.sendGroupMessage(1L, dto);
  1013 +
  1014 + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class));
  1015 + verify(imWebSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class));
  1016 + }
  1017 +
  1018 + @Test
  1019 + public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() {
  1020 + // 准备:调用方传入显式 targets(覆盖跨成员 disable 边界场景:解散 / 退群 / 踢人)
  1021 + Set<Long> targets = Set.of(1L, 2L, 3L);
  1022 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  1023 + .setGroupId(10L).setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent("解散通知");
  1024 +
  1025 + groupMessageService.sendGroupMessage(1L, targets, dto);
  1026 +
  1027 + // 断言:不读取 active members,按调用方 targets 推送
  1028 + verify(groupMemberService, never()).getActiveGroupMemberUserIdsByGroupId(anyLong());
  1029 + verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class));
  1030 + }
  1031 +
966 1032 // ========== sendTipGroupMessage ==========
967 1033  
968 1034 @Test
... ...
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;
10 10 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
11 11 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
12 12 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  13 +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
13 14 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
14 15 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  16 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
15 17 import org.junit.jupiter.api.Test;
16 18 import org.mockito.ArgumentCaptor;
17 19 import org.mockito.InjectMocks;
... ... @@ -333,6 +335,46 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
333 335 assertNull(result);
334 336 }
335 337  
  338 + // ========== sendPrivateMessage(senderId, dto):helper 行为 ==========
  339 +
  340 + @Test
  341 + public void testSendPrivateMessage_dto_persistsAndSerializesPojoContent() {
  342 + // 准备:persistent=true 类型 + POJO content
  343 + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO()
  344 + .setReceiverId(2L).setType(ImMessageTypeEnum.RECALL.getType())
  345 + .setContent(new RecallMessage().setMessageId(50L));
  346 +
  347 + privateMessageService.sendPrivateMessage(1L, dto);
  348 +
  349 + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON
  350 + ArgumentCaptor<ImPrivateMessageDO> captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class);
  351 + verify(privateMessageMapper).insert(captor.capture());
  352 + ImPrivateMessageDO message = captor.getValue();
  353 + assertEquals(1L, message.getSenderId());
  354 + assertEquals(2L, message.getReceiverId());
  355 + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType());
  356 + assertEquals("{\"messageId\":50}", message.getContent());
  357 + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus());
  358 + assertNotNull(message.getClientMessageId());
  359 + assertNotNull(message.getSendTime());
  360 + // 断言:sender + receiver 双端推送
  361 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class));
  362 + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class));
  363 + }
  364 +
  365 + @Test
  366 + public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() {
  367 + // 准备:persistent=false 类型(FRIEND_ADD)→ 不入库,仅推送
  368 + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO()
  369 + .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_ADD.getType());
  370 +
  371 + privateMessageService.sendPrivateMessage(1L, dto);
  372 +
  373 + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class));
  374 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class));
  375 + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class));
  376 + }
  377 +
336 378 // ========== sendTipPrivateMessage ==========
337 379  
338 380 @Test
... ...