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 package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; 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 import io.swagger.v3.oas.annotations.media.Schema; 6 import io.swagger.v3.oas.annotations.media.Schema;
  7 +import jakarta.validation.constraints.AssertTrue;
4 import jakarta.validation.constraints.NotEmpty; 8 import jakarta.validation.constraints.NotEmpty;
5 import jakarta.validation.constraints.NotNull; 9 import jakarta.validation.constraints.NotNull;
6 import lombok.Data; 10 import lombok.Data;
@@ -24,6 +28,7 @@ public class ImGroupMessageSendReqVO { @@ -24,6 +28,7 @@ public class ImGroupMessageSendReqVO {
24 28
25 @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") 29 @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
26 @NotNull(message = "消息类型不能为空") 30 @NotNull(message = "消息类型不能为空")
  31 + @InEnum(ImMessageTypeEnum.class)
27 private Integer type; 32 private Integer type;
28 33
29 @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") 34 @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
@@ -36,4 +41,13 @@ public class ImGroupMessageSendReqVO { @@ -36,4 +41,13 @@ public class ImGroupMessageSendReqVO {
36 @Schema(description = "是否需要回执", example = "false") 41 @Schema(description = "是否需要回执", example = "false")
37 private Boolean receipt; 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 package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; 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 import io.swagger.v3.oas.annotations.media.Schema; 6 import io.swagger.v3.oas.annotations.media.Schema;
  7 +import jakarta.validation.constraints.AssertTrue;
4 import jakarta.validation.constraints.NotEmpty; 8 import jakarta.validation.constraints.NotEmpty;
5 import jakarta.validation.constraints.NotNull; 9 import jakarta.validation.constraints.NotNull;
6 import lombok.Data; 10 import lombok.Data;
@@ -22,10 +26,20 @@ public class ImPrivateMessageSendReqVO { @@ -22,10 +26,20 @@ public class ImPrivateMessageSendReqVO {
22 26
23 @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") 27 @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
24 @NotNull(message = "消息类型不能为空") 28 @NotNull(message = "消息类型不能为空")
  29 + @InEnum(ImMessageTypeEnum.class)
25 private Integer type; 30 private Integer type;
26 31
27 @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") 32 @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
28 @NotEmpty(message = "消息内容不能为空") 33 @NotEmpty(message = "消息内容不能为空")
29 private String content; 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 package cn.iocoder.yudao.module.im.enums.message; 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 import cn.iocoder.yudao.framework.common.core.ArrayValuable; 5 import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4 -import lombok.AllArgsConstructor;  
5 import lombok.Getter; 6 import lombok.Getter;
  7 +import lombok.RequiredArgsConstructor;
6 8
7 import java.util.Arrays; 9 import java.util.Arrays;
8 10
@@ -12,30 +14,35 @@ import java.util.Arrays; @@ -12,30 +14,35 @@ import java.util.Arrays;
12 * @author 芋道源码 14 * @author 芋道源码
13 */ 15 */
14 @Getter 16 @Getter
15 -@AllArgsConstructor 17 +@RequiredArgsConstructor
16 public enum ImMessageTypeEnum implements ArrayValuable<Integer> { 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 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); 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,15 +50,40 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
43 * 类型 50 * 类型
44 */ 51 */
45 private final Integer type; 52 private final Integer type;
46 -  
47 /** 53 /**
48 * 名字 54 * 名字
49 */ 55 */
50 private final String name; 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 @Override 72 @Override
53 public Integer[] array() { 73 public Integer[] array() {
54 return ARRAYS; 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,6 +5,7 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGr
5 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; 5 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO;
6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; 6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO;
7 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; 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 import java.util.Collection; 10 import java.util.Collection;
10 import java.util.List; 11 import java.util.List;
@@ -18,7 +19,10 @@ import java.util.Set; @@ -18,7 +19,10 @@ import java.util.Set;
18 public interface ImGroupMessageService { 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 * @param senderId 发送人编号 27 * @param senderId 发送人编号
24 * @param reqVO 发送请求 28 * @param reqVO 发送请求
@@ -27,6 +31,34 @@ public interface ImGroupMessageService { @@ -27,6 +31,34 @@ public interface ImGroupMessageService {
27 ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO); 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 * @param userId 当前用户编号 64 * @param userId 当前用户编号
@@ -46,15 +78,6 @@ public interface ImGroupMessageService { @@ -46,15 +78,6 @@ public interface ImGroupMessageService {
46 void readGroupMessages(Long userId, Long groupId, Long messageId); 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 * @param userId 当前用户编号 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,6 +21,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
21 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 21 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
22 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; 22 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
23 import cn.iocoder.yudao.module.im.service.group.ImGroupService; 23 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
  24 +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
24 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 25 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
25 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 26 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
26 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; 27 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
@@ -102,12 +103,82 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -102,12 +103,82 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
102 : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); 103 : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()));
103 groupMessageMapper.insert(message); 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 return message; 110 return message;
108 } 111 }
109 112
110 @Override 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 public List<ImGroupMessageDO> pullGroupMessageList(Long userId, Long minId, Integer size) { 182 public List<ImGroupMessageDO> pullGroupMessageList(Long userId, Long minId, Integer size) {
112 if (size > MESSAGE_MAX_PULL_SIZE) { 183 if (size > MESSAGE_MAX_PULL_SIZE) {
113 throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE); 184 throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE);
@@ -286,44 +357,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -286,44 +357,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
286 } 357 }
287 358
288 @Override 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 public List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId) { 360 public List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId) {
328 // 1.1 校验用户在群中(权限校验) 361 // 1.1 校验用户在群中(权限校验)
329 groupMemberService.validateMemberInGroup(groupId, userId); 362 groupMemberService.validateMemberInGroup(groupId, userId);
@@ -365,28 +398,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -365,28 +398,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
365 // ========== 异步 WebSocket 推送 ========== 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 * 发送已读 + 刷新群回执 WebSocket 事件 401 * 发送已读 + 刷新群回执 WebSocket 事件
391 * 402 *
392 * @param userId 当前用户编号 403 * @param userId 当前用户编号
@@ -528,24 +539,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -528,24 +539,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
528 } 539 }
529 540
530 /** 541 /**
531 - * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。 542 + * 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。
532 * <p> 543 * <p>
533 - * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定, 544 + * 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定,
534 * 只应用 {@code receiverUserIds} 定向过滤;语义与 545 * 只应用 {@code receiverUserIds} 定向过滤;语义与
535 * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 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 if (CollUtil.isEmpty(memberUserIds)) { 549 if (CollUtil.isEmpty(memberUserIds)) {
539 return new HashSet<>(); 550 return new HashSet<>();
540 } 551 }
541 // 无定向接收列表 → 全员可见 552 // 无定向接收列表 → 全员可见
542 - if (CollUtil.isEmpty(message.getReceiverUserIds())) { 553 + if (CollUtil.isEmpty(receiverUserIds)) {
543 return new HashSet<>(memberUserIds); 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 Set<Long> result = new HashSet<>(); 561 Set<Long> result = new HashSet<>();
551 for (Long userId : memberUserIds) { 562 for (Long userId : memberUserIds) {
@@ -571,22 +582,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -571,22 +582,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
571 groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId); 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 @Override 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,6 +5,7 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.I
5 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; 5 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO;
6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; 6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
7 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; 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 import java.util.List; 10 import java.util.List;
10 11
@@ -16,7 +17,10 @@ import java.util.List; @@ -16,7 +17,10 @@ import java.util.List;
16 public interface ImPrivateMessageService { 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 * @param senderId 发送人编号 25 * @param senderId 发送人编号
22 * @param reqVO 发送请求 26 * @param reqVO 发送请求
@@ -25,6 +29,33 @@ public interface ImPrivateMessageService { @@ -25,6 +29,33 @@ public interface ImPrivateMessageService {
25 ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO); 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 * @param userId 当前用户编号 61 * @param userId 当前用户编号
@@ -59,15 +90,6 @@ public interface ImPrivateMessageService { @@ -59,15 +90,6 @@ public interface ImPrivateMessageService {
59 Long getMaxReadMessageId(Long userId, Long peerId); 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 * @param userId 当前用户编号 95 * @param userId 当前用户编号
@@ -76,17 +98,6 @@ public interface ImPrivateMessageService { @@ -76,17 +98,6 @@ public interface ImPrivateMessageService {
76 */ 98 */
77 List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO); 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,6 +14,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
14 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 14 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
15 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 15 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
16 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 16 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
  17 +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
17 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 18 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
18 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 19 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
19 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 20 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
@@ -73,7 +74,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -73,7 +74,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
73 sensitiveWordService.validateText(reqVO.getContent()); 74 sensitiveWordService.validateText(reqVO.getContent());
74 } 75 }
75 76
76 -  
77 // 2.1 引用 quote 消息规范化 77 // 2.1 引用 quote 消息规范化
78 reqVO.setContent(normalizeQuoteContent(reqVO, senderId)); 78 reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
79 // 2.2 构建并保存消息 79 // 2.2 构建并保存消息
@@ -84,10 +84,70 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -84,10 +84,70 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
84 // 3. WebSocket 异步推送(接收方 + 发送方多端同步) 84 // 3. WebSocket 异步推送(接收方 + 发送方多端同步)
85 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); 85 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
86 imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage); 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 return message; 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,7 +199,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
139 return messages; 199 return messages;
140 } 200 }
141 201
142 - // TODO DONE @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】  
143 @Override 202 @Override
144 public void readPrivateMessages(Long userId, Long receiverId, Long messageId) { 203 public void readPrivateMessages(Long userId, Long receiverId, Long messageId) {
145 Assert.notNull(messageId, "已读消息编号不能为空"); 204 Assert.notNull(messageId, "已读消息编号不能为空");
@@ -166,53 +225,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -166,53 +225,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
166 } 225 }
167 226
168 @Override 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 public List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) { 228 public List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) {
210 return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit()); 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 @Override 234 @Override
217 public PageResult<ImPrivateMessageDO> getPrivateMessagePage(ImPrivateMessageManagerPageReqVO reqVO) { 235 public PageResult<ImPrivateMessageDO> getPrivateMessagePage(ImPrivateMessageManagerPageReqVO reqVO) {
218 return privateMessageMapper.selectPage(reqVO); 236 return privateMessageMapper.selectPage(reqVO);
@@ -223,19 +241,4 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -223,19 +241,4 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
223 return privateMessageMapper.selectById(id); 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,9 +16,11 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
17 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; 17 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
18 import cn.iocoder.yudao.module.im.service.group.ImGroupService; 18 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
  19 +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
19 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 20 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
20 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 21 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
21 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; 22 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
  23 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
22 import org.junit.jupiter.api.Test; 24 import org.junit.jupiter.api.Test;
23 import org.mockito.ArgumentCaptor; 25 import org.mockito.ArgumentCaptor;
24 import org.mockito.InjectMocks; 26 import org.mockito.InjectMocks;
@@ -26,11 +28,7 @@ import org.mockito.Mock; @@ -26,11 +28,7 @@ import org.mockito.Mock;
26 import org.mockito.MockedStatic; 28 import org.mockito.MockedStatic;
27 29
28 import java.time.LocalDateTime; 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 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 33 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
36 import static org.junit.jupiter.api.Assertions.*; 34 import static org.junit.jupiter.api.Assertions.*;
@@ -907,18 +905,18 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -907,18 +905,18 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
907 verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); 905 verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class));
908 } 906 }
909 907
910 - // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ========== 908 + // ========== sendGroupMessage(senderId, dto):receiverUserIds 定向过滤 ==========
911 909
912 @Test 910 @Test
913 - public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() { 911 + public void testSendGroupMessage_noReceiverUserIds_broadcastsToAllMembers() {
914 // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) 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 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 916 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
919 .thenReturn(List.of(1L, 2L, 3L, 4L)); 917 .thenReturn(List.of(1L, 2L, 3L, 4L));
920 918
921 - groupMessageService.sendGroupMessageEvent(message); 919 + groupMessageService.sendGroupMessage(1L, dto);
922 920
923 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> 921 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
924 ids.size() == 4 && ids.contains(1L) && ids.contains(2L) 922 ids.size() == 4 && ids.contains(1L) && ids.contains(2L)
@@ -927,17 +925,17 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -927,17 +925,17 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
927 } 925 }
928 926
929 @Test 927 @Test
930 - public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() { 928 + public void testSendGroupMessage_withReceiverUserIds_onlyTargetedPlusSender() {
931 // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} 929 // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4}
932 // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 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 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 935 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
938 .thenReturn(List.of(1L, 2L, 3L, 4L)); 936 .thenReturn(List.of(1L, 2L, 3L, 4L));
939 937
940 - groupMessageService.sendGroupMessageEvent(message); 938 + groupMessageService.sendGroupMessage(1L, dto);
941 939
942 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> 940 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
943 ids.size() == 3 && ids.contains(1L) && ids.contains(2L) 941 ids.size() == 3 && ids.contains(1L) && ids.contains(2L)
@@ -946,23 +944,91 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -946,23 +944,91 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
946 } 944 }
947 945
948 @Test 946 @Test
949 - public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { 947 + public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() {
950 // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) 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 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) 954 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L))
957 .thenReturn(List.of(1L, 2L, 3L, 4L)); 955 .thenReturn(List.of(1L, 2L, 3L, 4L));
958 956
959 - groupMessageService.sendGroupMessageEvent(message); 957 + groupMessageService.sendGroupMessage(99L, dto);
960 958
961 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> 959 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
962 ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), 960 ids.size() == 2 && ids.contains(2L) && ids.contains(3L)),
963 any(ImGroupMessageDTO.class)); 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 // ========== sendTipGroupMessage ========== 1032 // ========== sendTipGroupMessage ==========
967 1033
968 @Test 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,8 +10,10 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
10 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 10 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
11 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 11 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
12 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 12 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  13 +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
13 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 14 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
14 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 15 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  16 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
15 import org.junit.jupiter.api.Test; 17 import org.junit.jupiter.api.Test;
16 import org.mockito.ArgumentCaptor; 18 import org.mockito.ArgumentCaptor;
17 import org.mockito.InjectMocks; 19 import org.mockito.InjectMocks;
@@ -333,6 +335,46 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -333,6 +335,46 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
333 assertNull(result); 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 // ========== sendTipPrivateMessage ========== 378 // ========== sendTipPrivateMessage ==========
337 379
338 @Test 380 @Test