Commit d6d90b2bd54bd52230b836ee1bf63a6f3a38beae

Authored by YunaiV
1 parent bee33379

✨ feat(im): 优化 private message 单挑的发送接口

Showing 19 changed files with 388 additions and 183 deletions
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
... ... @@ -37,6 +37,8 @@ public interface ErrorCodeConstants {
37 37 // ========== 好友 (1-040-600-000) ==========
38 38 ErrorCode FRIEND_NOT_EXISTS = new ErrorCode(1_040_600_000, "好友关系不存在");
39 39 ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友");
  40 + ErrorCode FRIEND_ADD_SELF = new ErrorCode(1_040_600_002, "不允许添加自己为好友");
  41 + ErrorCode FRIEND_ALREADY = new ErrorCode(1_040_600_003, "你们已经是好友了");
40 42  
41 43 // ========== 敏感词 (1-040-700-000) ==========
42 44 ErrorCode SENSITIVE_WORD_NOT_EXISTS = new ErrorCode(1_040_700_000, "敏感词不存在");
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java
... ... @@ -7,10 +7,10 @@ import lombok.RequiredArgsConstructor;
7 7 import java.util.Arrays;
8 8  
9 9 /**
10   - * IM 私聊消息状态枚举
  10 + * IM 消息状态枚举
11 11 * <p>
12   - * 私聊:UNREAD / READ / RECALL
13   - * 群聊:NORMAL / RECALL
  12 + * 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3)
  13 + * 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2)
14 14 *
15 15 * @author 芋道源码
16 16 */
... ... @@ -18,12 +18,10 @@ import java.util.Arrays;
18 18 @Getter
19 19 public enum ImMessageStatusEnum implements ArrayValuable<Integer> {
20 20  
21   - // ========== 私聊状态 ==========
22   - UNREAD(0, "未读"),
23   - READ(1, "已读"),
24   -
25   - // ========== 通用状态 ==========
26   - RECALL(2, "已撤回");
  21 + SENDING(-1, "发送中"), // 仅客户端使用
  22 + UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态)
  23 + RECALL(2, "已撤回"),
  24 + READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现
27 25  
28 26 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new);
29 27  
... ... @@ -31,7 +29,6 @@ public enum ImMessageStatusEnum implements ArrayValuable&lt;Integer&gt; {
31 29 * 状态
32 30 */
33 31 private final Integer status;
34   -
35 32 /**
36 33 * 名字
37 34 */
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImWebSocketTypeConstants.java deleted
1   -package cn.iocoder.yudao.module.im.enums.message;
2   -
3   -// DONE @芋艿:已 review,事件类型定义正确
4   -/**
5   - * IM WebSocket 事件类型常量
6   - *
7   - * @author 芋道源码
8   - */
9   -public interface ImWebSocketTypeConstants {
10   -
11   - /**
12   - * 私聊消息事件
13   - */
14   - String PRIVATE_MESSAGE = "im-private-message";
15   - /**
16   - * 群聊消息事件
17   - */
18   - String GROUP_MESSAGE = "im-group-message";
19   - /**
20   - * 已读事件
21   - */
22   - String READ = "im-read";
23   - /**
24   - * 回执事件
25   - */
26   - String RECEIPT = "im-receipt";
27   - /**
28   - * 撤回事件
29   - */
30   - String RECALL = "im-recall";
31   -
32   -}
33 0 \ No newline at end of file
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
... ... @@ -29,12 +29,13 @@ import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUti
29 29 public class ImPrivateMessageController {
30 30  
31 31 @Resource
32   - private ImPrivateMessageService imPrivateMessageService;
  32 + private ImPrivateMessageService privateMessageService;
33 33  
34 34 @PostMapping("/send")
35 35 @Operation(summary = "发送私聊消息")
36   - public CommonResult<ImPrivateMessageRespVO> sendPrivateMessage(@Valid @RequestBody ImPrivateMessageSendReqVO reqVO) {
37   - ImPrivateMessageDO message = imPrivateMessageService.sendPrivateMessage(getLoginUserId(), reqVO);
  36 + public CommonResult<ImPrivateMessageRespVO> sendPrivateMessage(
  37 + @Valid @RequestBody ImPrivateMessageSendReqVO reqVO) {
  38 + ImPrivateMessageDO message = privateMessageService.sendPrivateMessage(getLoginUserId(), reqVO);
38 39 return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class));
39 40 }
40 41  
... ... @@ -44,7 +45,7 @@ public class ImPrivateMessageController {
44 45 @Parameter(name = "size", description = "拉取数量", required = true, example = "100")
45 46 public CommonResult<List<ImPrivateMessageRespVO>> pullPrivateMessages(@RequestParam("minId") Long minId,
46 47 @RequestParam("size") Integer size) {
47   - List<ImPrivateMessageDO> messages = imPrivateMessageService.pullPrivateMessages(getLoginUserId(), minId, size);
  48 + List<ImPrivateMessageDO> messages = privateMessageService.pullPrivateMessages(getLoginUserId(), minId, size);
48 49 return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class));
49 50 }
50 51  
... ... @@ -52,7 +53,7 @@ public class ImPrivateMessageController {
52 53 @Operation(summary = "标记私聊消息已读")
53 54 @Parameter(name = "friendId", description = "好友用户编号", required = true, example = "2")
54 55 public CommonResult<Boolean> readPrivateMessages(@RequestParam("friendId") Long friendId) {
55   - imPrivateMessageService.readPrivateMessages(getLoginUserId(), friendId);
  56 + privateMessageService.readPrivateMessages(getLoginUserId(), friendId);
56 57 return success(true);
57 58 }
58 59  
... ... @@ -60,7 +61,7 @@ public class ImPrivateMessageController {
60 61 @Operation(summary = "撤回私聊消息")
61 62 @Parameter(name = "id", description = "消息编号", required = true, example = "1")
62 63 public CommonResult<Boolean> recallPrivateMessage(@RequestParam("id") Long id) {
63   - imPrivateMessageService.recallPrivateMessage(getLoginUserId(), id);
  64 + privateMessageService.recallPrivateMessage(getLoginUserId(), id);
64 65 return success(true);
65 66 }
66 67  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java
1 1 package cn.iocoder.yudao.module.im.dal.dataobject.friend;
2 2  
3   -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 3 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
5 4 import com.baomidou.mybatisplus.annotation.KeySequence;
6 5 import com.baomidou.mybatisplus.annotation.TableId;
7 6 import com.baomidou.mybatisplus.annotation.TableName;
8 7 import lombok.*;
9 8  
10   -import java.time.LocalDateTime;
11   -
12 9 /**
13 10 * IM 好友关系 DO
  11 + * <p>
  12 + * 业务语义:
  13 + * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A)
  14 + * - 软删除:`deleted=1` 表示单向解除好友;如果需要"全部不看到",双向都要 deleted=1
  15 + * - 免打扰:`muted` 控制是否屏蔽来自该好友的通知
14 16 *
15 17 * @author 芋道源码
16 18 */
... ... @@ -31,33 +33,23 @@ public class ImFriendDO extends BaseDO {
31 33 private Long id;
32 34 /**
33 35 * 用户编号
34   - *
  36 + * <p>
35 37 * 关联 AdminUserDO 的 id 字段
36 38 */
37 39 private Long userId;
38 40 /**
39 41 * 好友用户编号
40   - *
  42 + * <p>
41 43 * 关联 AdminUserDO 的 id 字段
42 44 */
43 45 private Long friendUserId;
44 46 /**
45   - * 好友状态
46   - * <p>
47   - * 枚举 {@link CommonStatusEnum}
48   - */
49   - private Integer status;
50   - /**
51 47 * 是否免打扰
52 48 */
53 49 private Boolean muted;
54   - /**
55   - * 添加好友时间
56   - */
57   - private LocalDateTime addTime;
58   - /**
59   - * 删除好友时间
60   - */
61   - private LocalDateTime deleteTime;
  50 +
  51 + // 说明:
  52 + // - 删除状态用 BaseDO 的 deleted 字段表达(0=正常,1=已删除)
  53 + // - 添加时间用 BaseDO 的 createTime;删除时间对应 updateTime
62 54  
63 55 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java
... ... @@ -67,7 +67,6 @@ public class ImGroupMessageDO extends BaseDO {
67 67 * 消息状态
68 68 * <p>
69 69 * 枚举 {@link ImMessageStatusEnum}
70   - * 群聊状态:NORMAL(0) / RECALL(2)
71 70 *
72 71 * 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型)
73 72 */
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java
... ... @@ -62,7 +62,6 @@ public class ImPrivateMessageDO extends BaseDO {
62 62 * 消息状态
63 63 * <p>
64 64 * 枚举 {@link ImMessageStatusEnum}
65   - * 私聊状态:UNREAD(0) / READ(1) / RECALL(2)
66 65 */
67 66 private Integer status;
68 67 /**
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java
... ... @@ -5,6 +5,8 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
5 5 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
6 6 import org.apache.ibatis.annotations.Mapper;
7 7  
  8 +import java.util.List;
  9 +
8 10 /**
9 11 * IM 好友关系 Mapper
10 12 *
... ... @@ -13,17 +15,16 @@ import org.apache.ibatis.annotations.Mapper;
13 15 @Mapper
14 16 public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
15 17  
16   - /**
17   - * 查询好友关系
18   - *
19   - * @param userId 用户编号
20   - * @param friendUserId 好友用户编号
21   - * @return 好友关系
22   - */
23 18 default ImFriendDO selectByUserIdAndFriendUserId(Long userId, Long friendUserId) {
24 19 return selectOne(new LambdaQueryWrapperX<ImFriendDO>()
25 20 .eq(ImFriendDO::getUserId, userId)
26 21 .eq(ImFriendDO::getFriendUserId, friendUserId));
27 22 }
28 23  
  24 + default List<ImFriendDO> selectListByUserId(Long userId) {
  25 + return selectList(new LambdaQueryWrapperX<ImFriendDO>()
  26 + .eq(ImFriendDO::getUserId, userId)
  27 + .orderByDesc(ImFriendDO::getId));
  28 + }
  29 +
29 30 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
1 1 package cn.iocoder.yudao.module.im.service.message;
2 2  
  3 +import cn.hutool.extra.spring.SpringUtil;
3 4 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
  5 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
4 6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
5 7 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
6 8 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
7 9 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
8 10 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
9   -import cn.iocoder.yudao.module.im.enums.message.ImWebSocketTypeConstants;
10 11 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
11 12 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  13 +import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;
  14 +import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;
  15 +import cn.iocoder.yudao.module.im.websocket.ImMessageRecallMessage;
  16 +import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage;
12 17 import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
13 18 import jakarta.annotation.Resource;
14 19 import lombok.extern.slf4j.Slf4j;
  20 +import org.springframework.scheduling.annotation.Async;
15 21 import org.springframework.stereotype.Service;
16 22 import org.springframework.validation.annotation.Validated;
17 23  
18 24 import java.time.LocalDateTime;
19   -import java.util.HashMap;
20 25 import java.util.List;
21   -import java.util.Map;
22 26  
23 27 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
24 28 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
... ... @@ -39,12 +43,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
39 43 private static final int MAX_PULL_SIZE = 1000;
40 44  
41 45 @Resource
42   - private ImPrivateMessageMapper imPrivateMessageMapper;
  46 + private ImPrivateMessageMapper privateMessageMapper;
43 47  
44 48 @Resource
45   - private ImFriendService imFriendService;
  49 + private ImFriendService friendService;
46 50 @Resource
47   - private ImSensitiveWordService imSensitiveWordService;
  51 + private ImSensitiveWordService sensitiveWordService;
48 52  
49 53 @Resource
50 54 private WebSocketMessageSender webSocketMessageSender;
... ... @@ -52,30 +56,27 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
52 56 @Override
53 57 public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) {
54 58 // 1.1 幂等校验:根据 senderId + clientMessageId 查重
55   - ImPrivateMessageDO existing = imPrivateMessageMapper.selectBySenderIdAndClientMessageId(
  59 + ImPrivateMessageDO existing = privateMessageMapper.selectBySenderIdAndClientMessageId(
56 60 senderId, reqVO.getClientMessageId());
57 61 if (existing != null) {
  62 + log.info("[sendPrivateMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]",
  63 + senderId, reqVO.getClientMessageId(), existing.getId());
58 64 return existing;
59 65 }
60 66 // 1.2 好友校验
61   - imFriendService.validateIsFriend(senderId, reqVO.getReceiverId());
  67 + friendService.validateFriendExists(senderId, reqVO.getReceiverId());
62 68 // 1.3 文本消息敏感词过滤
63 69 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
64   - imSensitiveWordService.validateText(reqVO.getContent());
  70 + sensitiveWordService.validateText(reqVO.getContent());
65 71 }
66 72  
67 73 // 2. 构建并保存消息
68   - ImPrivateMessageDO message = ImPrivateMessageDO.builder()
69   - .clientMessageId(reqVO.getClientMessageId())
70   - .senderId(senderId).receiverId(reqVO.getReceiverId())
71   - .type(reqVO.getType()).content(reqVO.getContent())
72   - .status(ImMessageStatusEnum.UNREAD.getStatus())
73   - .sendTime(LocalDateTime.now())
74   - .build();
75   - imPrivateMessageMapper.insert(message);
76   -
77   - // 3. WebSocket 推送给接收方
78   - sendPrivateMessageEvent(message);
  74 + ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
  75 + .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
  76 + privateMessageMapper.insert(message);
  77 +
  78 + // 3. WebSocket 推送给接收方(异步)
  79 + getSelf().sendPrivateMessageEvent(message);
79 80 return message;
80 81 }
81 82  
... ... @@ -84,39 +85,37 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
84 85 if (size > MAX_PULL_SIZE) {
85 86 throw exception(MESSAGE_PULL_SIZE_EXCEEDED);
86 87 }
87   - return imPrivateMessageMapper.selectListByMinId(userId, minId, size);
  88 + return privateMessageMapper.selectListByMinId(userId, minId, size);
88 89 }
89 90  
90 91 // DONE @芋艿:已 review,逻辑正确
91 92 @Override
92 93 public void readPrivateMessages(Long userId, Long friendId) {
93 94 // 1. 批量更新消息状态为已读
94   - int updated = imPrivateMessageMapper.updateStatusToRead(userId, friendId);
  95 + int updated = privateMessageMapper.updateStatusToRead(userId, friendId);
95 96 if (updated == 0) {
96 97 return; // 没有需要更新的消息
97 98 }
98 99 // 2. 发送 READ 事件给自己的其他终端(多端同步)
99   - Map<String, Object> readEvent = new HashMap<>();
100   - // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
101   - readEvent.put("friendId", friendId.toString());
102   - readEvent.put("messageScene", "private");
  100 + ImMessageReadMessage readMessage = new ImMessageReadMessage()
  101 + .setFriendId(friendId.toString())
  102 + .setMessageScene("private");
103 103 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
104   - ImWebSocketTypeConstants.READ, readEvent);
  104 + ImMessageReadMessage.TYPE, readMessage);
105 105  
106 106 // 3. 发送 RECEIPT 事件给对方(已读回执)
107   - Map<String, Object> receiptEvent = new HashMap<>();
108   - // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
109   - receiptEvent.put("userId", userId.toString());
110   - receiptEvent.put("messageScene", "private");
  107 + ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage()
  108 + .setUserId(userId.toString())
  109 + .setMessageScene("private");
111 110 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), friendId,
112   - ImWebSocketTypeConstants.RECEIPT, receiptEvent);
  111 + ImMessageReceiptMessage.TYPE, receiptMessage);
113 112 }
114 113  
115 114 // DONE @芋艿:已 review,逻辑正确
116 115 @Override
117 116 public void recallPrivateMessage(Long userId, Long messageId) {
118 117 // 1.1 校验消息存在
119   - ImPrivateMessageDO message = imPrivateMessageMapper.selectById(messageId);
  118 + ImPrivateMessageDO message = privateMessageMapper.selectById(messageId);
120 119 if (message == null) {
121 120 throw exception(MESSAGE_NOT_EXISTS);
122 121 }
... ... @@ -131,44 +130,38 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
131 130 }
132 131  
133 132 // 2. 更新消息状态为撤回
134   - imPrivateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId)
  133 + privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId)
135 134 .setStatus(ImMessageStatusEnum.RECALL.getStatus()));
136 135  
137   - // 3.1 发送 RECALL 事件给接收方
138   - // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
139   - Map<String, Object> recallEvent = new HashMap<>();
140   - recallEvent.put("messageId", messageId.toString());
141   - recallEvent.put("messageScene", "private");
142   - recallEvent.put("senderId", userId.toString());
  136 + // 3. 发送 RECALL 事件
  137 + ImMessageRecallMessage recallMessage = new ImMessageRecallMessage()
  138 + .setMessageId(messageId.toString())
  139 + .setSenderId(userId.toString())
  140 + .setMessageScene("private");
  141 + // 3.1 通知接收方
143 142 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
144   - ImWebSocketTypeConstants.RECALL, recallEvent);
145   - // 3.2 也通知发送方自己的其他终端
146   - // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
  143 + ImMessageRecallMessage.TYPE, recallMessage);
  144 + // 3.2 通知发送方自己的其他终端
147 145 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
148   - ImWebSocketTypeConstants.RECALL, recallEvent);
  146 + ImMessageRecallMessage.TYPE, recallMessage);
149 147 }
150 148  
151 149 /**
152 150 * 发送私聊消息 WebSocket 事件
153 151 */
154   - private void sendPrivateMessageEvent(ImPrivateMessageDO message) {
155   - Map<String, Object> event = new HashMap<>();
156   - // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
157   - event.put("id", message.getId().toString());
158   - event.put("clientMessageId", message.getClientMessageId());
159   - event.put("senderId", message.getSenderId().toString());
160   - event.put("receiverId", message.getReceiverId().toString());
161   - event.put("type", message.getType());
162   - event.put("content", message.getContent());
163   - event.put("status", message.getStatus());
164   - event.put("sendTime", message.getSendTime().toString());
165   - event.put("messageScene", "private");
  152 + @Async
  153 + public void sendPrivateMessageEvent(ImPrivateMessageDO message) {
  154 + ImPrivateMessageSendMessage websocketMessage = BeanUtils.toBean(message, ImPrivateMessageSendMessage.class);
166 155 // 推送给接收方
167 156 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
168   - ImWebSocketTypeConstants.PRIVATE_MESSAGE, event);
  157 + ImPrivateMessageSendMessage.TYPE, websocketMessage);
169 158 // 推送给发送方自己的其他终端(多端同步)
170 159 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(),
171   - ImWebSocketTypeConstants.PRIVATE_MESSAGE, event);
  160 + ImPrivateMessageSendMessage.TYPE, websocketMessage);
  161 + }
  162 +
  163 + private ImPrivateMessageServiceImpl getSelf() {
  164 + return SpringUtil.getBean(getClass());
172 165 }
173 166  
174 167 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java
... ... @@ -9,8 +9,6 @@ public interface ImSensitiveWordService {
9 9  
10 10 /**
11 11 * 校验文本是否包含敏感词
12   - * <p>
13   - * 如果包含,直接抛出业务异常
14 12 *
15 13 * @param text 待校验文本
16 14 */
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImGroupMessageSendMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.websocket;
  2 +
  3 +import lombok.Data;
  4 +import lombok.experimental.Accessors;
  5 +
  6 +import java.time.LocalDateTime;
  7 +import java.util.List;
  8 +
  9 +/**
  10 + * IM 群聊消息 WebSocket 推送消息
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Data
  15 +@Accessors(chain = true)
  16 +public class ImGroupMessageSendMessage {
  17 +
  18 + public static final String TYPE = "im-group-message-send";
  19 +
  20 + /**
  21 + * 消息编号
  22 + */
  23 + private Long id;
  24 + /**
  25 + * 客户端消息编号
  26 + */
  27 + private String clientMessageId;
  28 + /**
  29 + * 发送人编号
  30 + */
  31 + private Long senderId;
  32 + /**
  33 + * 群编号
  34 + */
  35 + private Long groupId;
  36 + /**
  37 + * 消息类型
  38 + */
  39 + private Integer type;
  40 + /**
  41 + * 消息内容
  42 + */
  43 + private String content;
  44 + /**
  45 + * 消息状态
  46 + */
  47 + private Integer status;
  48 + /**
  49 + * 发送时间
  50 + */
  51 + private LocalDateTime sendTime;
  52 + /**
  53 + * @ 的用户编号列表
  54 + */
  55 + private List<Long> atUserIds;
  56 + /**
  57 + * 定向接收用户编号列表
  58 + */
  59 + private List<Long> receiverUserIds;
  60 + /**
  61 + * 群回执状态
  62 + */
  63 + private Integer receiptStatus;
  64 +
  65 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReadMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.websocket;
  2 +
  3 +import lombok.Data;
  4 +import lombok.experimental.Accessors;
  5 +
  6 +/**
  7 + * IM 消息已读 WebSocket 推送消息
  8 + * <p>
  9 + * 用于多端同步:通知自己的其他终端"我已经读了某个会话"
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Data
  14 +@Accessors(chain = true)
  15 +public class ImMessageReadMessage {
  16 +
  17 + public static final String TYPE = "im-message-read";
  18 +
  19 + /**
  20 + * 私聊场景:好友编号
  21 + */
  22 + private String friendId;
  23 + /**
  24 + * 群聊场景:群编号
  25 + */
  26 + private String groupId;
  27 + /**
  28 + * 消息场景:private / group
  29 + */
  30 + private String messageScene;
  31 +
  32 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageRecallMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.websocket;
  2 +
  3 +import lombok.Data;
  4 +import lombok.experimental.Accessors;
  5 +
  6 +/**
  7 + * IM 消息撤回 WebSocket 推送消息
  8 + *
  9 + * @author 芋道源码
  10 + */
  11 +@Data
  12 +@Accessors(chain = true)
  13 +public class ImMessageRecallMessage {
  14 +
  15 + public static final String TYPE = "im-message-recall";
  16 +
  17 + /**
  18 + * 消息编号
  19 + */
  20 + private String messageId;
  21 + /**
  22 + * 发送人编号
  23 + */
  24 + private String senderId;
  25 + /**
  26 + * 群聊场景:群编号
  27 + */
  28 + private String groupId;
  29 + /**
  30 + * 消息场景:private / group
  31 + */
  32 + private String messageScene;
  33 +
  34 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReceiptMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.websocket;
  2 +
  3 +import lombok.Data;
  4 +import lombok.experimental.Accessors;
  5 +
  6 +/**
  7 + * IM 已读回执 WebSocket 推送消息
  8 + * <p>
  9 + * 私聊:通知对方"我已读了你的消息"
  10 + * 群聊:广播回执状态和已读人数
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Data
  15 +@Accessors(chain = true)
  16 +public class ImMessageReceiptMessage {
  17 +
  18 + public static final String TYPE = "im-message-receipt";
  19 +
  20 + // ========== 私聊场景 ==========
  21 +
  22 + /**
  23 + * 私聊场景:已读方的用户编号
  24 + */
  25 + private String userId;
  26 +
  27 + // ========== 群聊场景 ==========
  28 +
  29 + /**
  30 + * 群聊场景:消息编号
  31 + */
  32 + private String messageId;
  33 + /**
  34 + * 群聊场景:群编号
  35 + */
  36 + private String groupId;
  37 + /**
  38 + * 群聊场景:已读人数
  39 + */
  40 + private Integer readCount;
  41 + /**
  42 + * 群聊场景:回执状态
  43 + */
  44 + private Integer receiptStatus;
  45 +
  46 + // ========== 通用 ==========
  47 +
  48 + /**
  49 + * 消息场景:private / group
  50 + */
  51 + private String messageScene;
  52 +
  53 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.websocket;
  2 +
  3 +import lombok.Data;
  4 +import lombok.experimental.Accessors;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +/**
  9 + * IM 私聊消息 WebSocket 推送消息
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Data
  14 +@Accessors(chain = true)
  15 +public class ImPrivateMessageSendMessage {
  16 +
  17 + public static final String TYPE = "im-private-message-send";
  18 +
  19 + /**
  20 + * 消息编号
  21 + */
  22 + private Long id;
  23 + /**
  24 + * 客户端消息编号
  25 + */
  26 + private String clientMessageId;
  27 + /**
  28 + * 发送人编号
  29 + */
  30 + private Long senderId;
  31 + /**
  32 + * 接收人编号
  33 + */
  34 + private Long receiverId;
  35 + /**
  36 + * 消息类型
  37 + */
  38 + private Integer type;
  39 + /**
  40 + * 消息内容
  41 + */
  42 + private String content;
  43 + /**
  44 + * 消息状态
  45 + */
  46 + private Integer status;
  47 + /**
  48 + * 发送时间
  49 + */
  50 + private LocalDateTime sendTime;
  51 +
  52 +}
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
1 1 package cn.iocoder.yudao.module.im.service.message;
2 2  
  3 +import cn.hutool.extra.spring.SpringUtil;
3 4 import cn.iocoder.yudao.framework.common.exception.ServiceException;
4 5 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
5 6 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
... ... @@ -12,6 +13,7 @@ import org.junit.jupiter.api.Test;
12 13 import org.junit.jupiter.api.extension.ExtendWith;
13 14 import org.mockito.InjectMocks;
14 15 import org.mockito.Mock;
  16 +import org.mockito.MockedStatic;
15 17 import org.mockito.junit.jupiter.MockitoExtension;
16 18  
17 19 import java.time.LocalDateTime;
... ... @@ -55,32 +57,37 @@ public class ImPrivateMessageServiceImplTest {
55 57  
56 58 @Test
57 59 public void testSendMessage_success() {
58   - // 准备
59   - ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
60   - when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
61   - .thenReturn(null);
62   - when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> {
63   - ImPrivateMessageDO msg = invocation.getArgument(0);
64   - msg.setId(99L);
65   - return 1;
66   - });
67   -
68   - // 调用
69   - ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO);
70   -
71   - // 断言
72   - assertNotNull(result);
73   - assertEquals(1L, result.getSenderId());
74   - assertEquals(2L, result.getReceiverId());
75   - assertEquals(0, result.getType());
76   - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
77   - assertNotNull(result.getSendTime());
78   -
79   - // 验证调用
80   - verify(imFriendService).validateIsFriend(1L, 2L);
81   - verify(imSensitiveWordService).validateText(reqVO.getContent());
82   - verify(imPrivateMessageMapper).insert(any(ImPrivateMessageDO.class));
83   - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
  60 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  61 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class)))
  62 + .thenReturn(privateMessageService);
  63 +
  64 + // 准备
  65 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
  66 + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
  67 + .thenReturn(null);
  68 + when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> {
  69 + ImPrivateMessageDO msg = invocation.getArgument(0);
  70 + msg.setId(99L);
  71 + return 1;
  72 + });
  73 +
  74 + // 调用
  75 + ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO);
  76 +
  77 + // 断言
  78 + assertNotNull(result);
  79 + assertEquals(1L, result.getSenderId());
  80 + assertEquals(2L, result.getReceiverId());
  81 + assertEquals(0, result.getType());
  82 + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
  83 + assertNotNull(result.getSendTime());
  84 +
  85 + // 验证调用
  86 + verify(imFriendService).validateFriendExists(1L, 2L);
  87 + verify(imSensitiveWordService).validateText(reqVO.getContent());
  88 + verify(imPrivateMessageMapper).insert(any(ImPrivateMessageDO.class));
  89 + verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
  90 + }
84 91 }
85 92  
86 93 @Test
... ... @@ -110,7 +117,7 @@ public class ImPrivateMessageServiceImplTest {
110 117 when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
111 118 .thenReturn(null);
112 119 doThrow(new ServiceException(FRIEND_NOT_FRIEND))
113   - .when(imFriendService).validateIsFriend(1L, 2L);
  120 + .when(imFriendService).validateFriendExists(1L, 2L);
114 121  
115 122 // 调用并断言
116 123 ServiceException exception = assertThrows(ServiceException.class,
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java
... ... @@ -3,7 +3,10 @@ package cn.iocoder.yudao.module.im.service.message;
3 3 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
4 4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
5 5 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
6   -import cn.iocoder.yudao.module.im.enums.message.ImWebSocketTypeConstants;
  6 +import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;
  7 +import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;
  8 +import cn.iocoder.yudao.module.im.websocket.ImMessageRecallMessage;
  9 +import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage;
7 10 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
8 11 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
9 12 import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
... ... @@ -14,8 +17,6 @@ import org.mockito.InjectMocks;
14 17 import org.mockito.Mock;
15 18 import org.mockito.junit.jupiter.MockitoExtension;
16 19  
17   -import java.util.Map;
18   -
19 20 import static org.junit.jupiter.api.Assertions.*;
20 21 import static org.mockito.ArgumentMatchers.*;
21 22 import static org.mockito.Mockito.*;
... ... @@ -69,16 +70,14 @@ public class ImWebSocketEventTest {
69 70 typeCaptor.capture(), contentCaptor.capture());
70 71  
71 72 // 验证消息事件类型
72   - assertEquals(ImWebSocketTypeConstants.PRIVATE_MESSAGE, typeCaptor.getAllValues().get(0));
73   - assertEquals(ImWebSocketTypeConstants.PRIVATE_MESSAGE, typeCaptor.getAllValues().get(1));
74   -
75   - // 验证 payload 中 id 字段按 String 返回
76   - @SuppressWarnings("unchecked")
77   - Map<String, Object> payload = (Map<String, Object>) contentCaptor.getAllValues().get(0);
78   - assertTrue(payload.get("id") instanceof String, "id 应为 String 类型");
79   - assertTrue(payload.get("senderId") instanceof String, "senderId 应为 String 类型");
80   - assertTrue(payload.get("receiverId") instanceof String, "receiverId 应为 String 类型");
81   - assertEquals("private", payload.get("messageScene"));
  73 + assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(0));
  74 + assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(1));
  75 +
  76 + // 验证 payload 是 DTO 类型
  77 + ImPrivateMessageSendMessage payload = (ImPrivateMessageSendMessage) contentCaptor.getAllValues().get(0);
  78 + assertNotNull(payload.getId(), "id 不应为空");
  79 + assertEquals("1", payload.getSenderId());
  80 + assertEquals("2", payload.getReceiverId());
82 81 }
83 82  
84 83 @Test
... ... @@ -96,10 +95,10 @@ public class ImWebSocketEventTest {
96 95 typeCaptor.capture(), any());
97 96  
98 97 // 第一次:发给自己的 READ 事件
99   - assertEquals(ImWebSocketTypeConstants.READ, typeCaptor.getAllValues().get(0));
  98 + assertEquals(ImMessageReadMessage.TYPE, typeCaptor.getAllValues().get(0));
100 99 assertEquals(1L, userCaptor.getAllValues().get(0));
101 100 // 第二次:发给对方的 RECEIPT 事件
102   - assertEquals(ImWebSocketTypeConstants.RECEIPT, typeCaptor.getAllValues().get(1));
  101 + assertEquals(ImMessageReceiptMessage.TYPE, typeCaptor.getAllValues().get(1));
103 102 assertEquals(2L, userCaptor.getAllValues().get(1));
104 103 }
105 104  
... ... @@ -121,13 +120,12 @@ public class ImWebSocketEventTest {
121 120 typeCaptor.capture(), contentCaptor.capture());
122 121  
123 122 // 验证 RECALL 事件类型
124   - assertEquals(ImWebSocketTypeConstants.RECALL, typeCaptor.getAllValues().get(0));
  123 + assertEquals(ImMessageRecallMessage.TYPE, typeCaptor.getAllValues().get(0));
125 124  
126 125 // 验证 payload
127   - @SuppressWarnings("unchecked")
128   - Map<String, Object> payload = (Map<String, Object>) contentCaptor.getAllValues().get(0);
129   - assertEquals("10", payload.get("messageId"));
130   - assertEquals("private", payload.get("messageScene"));
  126 + ImMessageRecallMessage payload = (ImMessageRecallMessage) contentCaptor.getAllValues().get(0);
  127 + assertEquals("10", payload.getMessageId());
  128 + assertEquals("private", payload.getMessageScene());
131 129 }
132 130  
133 131 }
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java
... ... @@ -56,6 +56,14 @@ public interface AdminUserApi {
56 56 List<AdminUserRespDTO> getUserListByPostIds(Collection<Long> postIds);
57 57  
58 58 /**
  59 + * 根据昵称模糊搜索用户
  60 + *
  61 + * @param nickname 昵称关键词
  62 + * @return 用户列表
  63 + */
  64 + List<AdminUserRespDTO> getUserListByNickname(String nickname);
  65 +
  66 + /**
59 67 * 获得用户 Map
60 68 *
61 69 * @param ids 用户编号数组
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java
... ... @@ -81,6 +81,12 @@ public class AdminUserApiImpl implements AdminUserApi {
81 81 }
82 82  
83 83 @Override
  84 + public List<AdminUserRespDTO> getUserListByNickname(String nickname) {
  85 + List<AdminUserDO> users = userService.getUserListByNickname(nickname);
  86 + return BeanUtils.toBean(users, AdminUserRespDTO.class);
  87 + }
  88 +
  89 + @Override
84 90 public void validateUserList(Collection<Long> ids) {
85 91 userService.validateUserList(ids);
86 92 }
... ...