Commit 2ab72872bee985729d6ff7bf6d68ce8d421337b1

Authored by YunaiV
1 parent e2eab4fe

✨ feat(im): 添加 WebSocket 服务和消息推送功能

新增 IM WebSocket 服务接口及实现,支持异步推送私聊和群聊消息。
同时,重构相关消息发送逻辑,提升代码可读性和维护性。

- 新增 ImWebSocketService 接口
- 实现 ImWebSocketServiceImpl 类
- 更新 ImPrivateMessageService 和 ImGroupMessageService 以使用 WebSocket 推送
Showing 19 changed files with 279 additions and 103 deletions
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java
... ... @@ -46,4 +46,7 @@ public class ImGroupMessageRespVO {
46 46 @Schema(description = "回执状态", example = "0")
47 47 private Integer receiptStatus;
48 48  
  49 + @Schema(description = "已读人数(回执消息、且发送人为当前用户时有值)", example = "3")
  50 + private Integer readCount;
  51 +
49 52 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
1 1 package cn.iocoder.yudao.module.im.service.friend;
2 2  
  3 +import cn.hutool.extra.spring.SpringUtil;
3 4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 5 import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
5 6 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
6 7 import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
  8 +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
  9 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  10 +import cn.iocoder.yudao.module.im.service.websocket.dto.friend.FriendUpdateMessage;
  11 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
7 12 import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
8   -import cn.hutool.extra.spring.SpringUtil;
9 13 import jakarta.annotation.Resource;
10 14 import org.springframework.cache.annotation.CacheEvict;
11 15 import org.springframework.cache.annotation.Cacheable;
... ... @@ -19,13 +23,17 @@ import java.util.Objects;
19 23  
20 24 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
21 25 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND;
22   -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  26 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF;
  27 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
  28 +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE;
  29 +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_TIP_MESSAGE;
23 30  
24 31 /**
25 32 * IM 好友关系 Service 实现类
26 33 *
27 34 * @author 芋道源码
28 35 */
  36 +@Slf4j
29 37 @Service
30 38 @Validated
31 39 public class ImFriendServiceImpl implements ImFriendService {
... ... @@ -36,6 +44,11 @@ public class ImFriendServiceImpl implements ImFriendService {
36 44 @Resource
37 45 private AdminUserApi adminUserApi;
38 46  
  47 + @Resource
  48 + private ImWebSocketService imWebSocketService;
  49 + @Resource
  50 + private ImPrivateMessageService privateMessageService;
  51 +
39 52 @Override
40 53 @Cacheable(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId", unless = "#result == null")
41 54 public boolean isFriend(Long userId, Long friendUserId) {
... ... @@ -62,10 +75,13 @@ public class ImFriendServiceImpl implements ImFriendService {
62 75 }
63 76  
64 77 // 2. 更新好友属性(目前仅免打扰)
65   - ImFriendDO updateObj = new ImFriendDO();
66   - updateObj.setId(friend.getId());
67   - updateObj.setMuted(reqVO.getMuted());
68   - imFriendMapper.updateById(updateObj);
  78 + imFriendMapper.updateById(new ImFriendDO().setId(friend.getId()).setMuted(reqVO.getMuted()));
  79 +
  80 + // 3. 推送好友更新通知(多端同步)
  81 + FriendUpdateMessage websocketContent = new FriendUpdateMessage()
  82 + .setFriendUserId(reqVO.getFriendUserId()).setMuted(reqVO.getMuted());
  83 + imWebSocketService.sendPrivateMessageAsync(userId,
  84 + ImPrivateMessageDTO.ofFriendUpdate(userId, reqVO.getFriendUserId(), websocketContent));
69 85 }
70 86  
71 87 @Override
... ... @@ -82,19 +98,29 @@ public class ImFriendServiceImpl implements ImFriendService {
82 98 getSelf().addFriend0(userId, friendUserId);
83 99 getSelf().addFriend0(friendUserId, userId);
84 100  
85   - // TODO @芋艿:好友添加成功后:1)插入 TIP_TEXT 系统提示消息并推送双方;2)通知双方好友列表变更。
86   - // 依赖 WebSocket 事件类型扩展(当前无 FRIEND_NEW),待统一后补上。
  101 + // 3.1 插入 TIP_TEXT 系统提示消息并推送双方
  102 + privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_ADD_TIP_MESSAGE);
  103 + // 3.2 推送好友添加通知给双方
  104 + imWebSocketService.sendPrivateMessageAsync(userId,
  105 + ImPrivateMessageDTO.ofFriendAdd(friendUserId, userId));
  106 + imWebSocketService.sendPrivateMessageAsync(friendUserId,
  107 + ImPrivateMessageDTO.ofFriendAdd(userId, friendUserId));
87 108 }
88 109  
89 110 @Override
90 111 @Transactional(rollbackFor = Exception.class)
91 112 public void deleteFriend(Long userId, Long friendUserId) {
92   - // 双向标记为 DISABLE + 记录 deleteTime
  113 + // 1. 双向标记为 DISABLE + 记录 deleteTime
93 114 getSelf().deleteFriend0(userId, friendUserId);
94 115 getSelf().deleteFriend0(friendUserId, userId);
95 116  
96   - // TODO @芋艿:【对齐】这里缺少了好友删除的系统提示 + FRIEND_DEL WebSocket 推送,
97   - // 待 WS 事件类型扩展后补上。
  117 + // 2.1 插入 TIP_TEXT 系统提示消息并推送双方
  118 + privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_DELETE_TIP_MESSAGE);
  119 + // 2.2 推送好友删除通知给双方
  120 + imWebSocketService.sendPrivateMessageAsync(userId,
  121 + ImPrivateMessageDTO.ofFriendDelete(userId, friendUserId));
  122 + imWebSocketService.sendPrivateMessageAsync(friendUserId,
  123 + ImPrivateMessageDTO.ofFriendDelete(friendUserId, userId));
98 124 }
99 125  
100 126 /**
... ... @@ -102,11 +128,14 @@ public class ImFriendServiceImpl implements ImFriendService {
102 128 * - 首次:新增记录(status=ENABLE,addTime=now)
103 129 * - 已存在且 ENABLE:无需重复操作
104 130 * - 已存在但 DISABLE:恢复关系(status=ENABLE,刷新 addTime,清空 deleteTime)
  131 + * <p>
  132 + * 并发安全:依靠 im_friend 表的唯一索引 uk_user_friend(user_id, friend_user_id) 保证幂等,
  133 + * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。
105 134 */
106 135 @CacheEvict(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId")
107 136 public void addFriend0(Long userId, Long friendUserId) {
108 137 ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
109   - // 情况一:恢复已删除的好友关系
  138 + // 情况一:已存在记录 → 恢复或跳过
110 139 if (exists != null) {
111 140 if (CommonStatusEnum.isEnable(exists.getStatus())) {
112 141 return;
... ... @@ -115,10 +144,16 @@ public class ImFriendServiceImpl implements ImFriendService {
115 144 .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now()));
116 145 return;
117 146 }
118   - // 情况二:新增好有关系
  147 + // 情况二:新增好友关系
119 148 ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId)
120 149 .muted(false).status(CommonStatusEnum.ENABLE.getStatus()).addTime(LocalDateTime.now()).build();
121   - imFriendMapper.insert(friend);
  150 + try {
  151 + imFriendMapper.insert(friend);
  152 + } catch (DuplicateKeyException e) {
  153 + // 并发场景:另一个请求已先一步插入,降级走已存在逻辑
  154 + log.warn("[addFriend0][userId({}) friendUserId({}) 并发插入冲突,降级处理]", userId, friendUserId);
  155 + addFriend0(userId, friendUserId);
  156 + }
122 157 }
123 158  
124 159 /**
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
... ... @@ -6,15 +6,13 @@ import cn.hutool.core.util.IdUtil;
6 6 import cn.hutool.core.util.ObjUtil;
7 7 import cn.hutool.extra.spring.SpringUtil;
8 8 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
9   -import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
10 9 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
11 10 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
12   -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
13 11 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO;
14 12 import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO;
15 13 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
16 14 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
17   -import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
  15 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
18 16 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
19 17 import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO;
20 18 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
... ... @@ -23,7 +21,8 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
23 21 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
24 22 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
25 23 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
26   -import cn.iocoder.yudao.module.im.websocket.ImGroupMessageDTO;
  24 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  25 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
27 26 import jakarta.annotation.Resource;
28 27 import lombok.extern.slf4j.Slf4j;
29 28 import org.springframework.scheduling.annotation.Async;
... ... @@ -69,7 +68,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
69 68 private ImSensitiveWordService sensitiveWordService;
70 69  
71 70 @Resource
72   - private WebSocketMessageSender webSocketMessageSender;
  71 + private ImWebSocketService imWebSocketService;
73 72  
74 73 @Override
75 74 public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO) {
... ... @@ -98,7 +97,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
98 97 groupMessageMapper.insert(message);
99 98  
100 99 // 3. WebSocket 推送给群成员(异步)
101   - getSelf().sendGroupMessageEvent(message);
  100 + sendGroupMessageEvent(message);
102 101 return message;
103 102 }
104 103  
... ... @@ -161,7 +160,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
161 160  
162 161 // 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止
163 162 Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId);
164   - if (maxMessageId <= activeMinId) {
  163 + if (maxMessageId == null || maxMessageId <= activeMinId) {
165 164 return Collections.emptyList();
166 165 }
167 166 activeMinId = maxMessageId;
... ... @@ -314,7 +313,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
314 313 groupMessageMapper.insert(recallMessage);
315 314  
316 315 // 4. 异步推送撤回提示消息(前端据此更新原消息状态 + 插入撤回提示)
317   - getSelf().sendGroupMessageEvent(recallMessage);
  316 + sendGroupMessageEvent(recallMessage);
318 317 return recallMessage;
319 318 }
320 319  
... ... @@ -363,21 +362,12 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
363 362 /**
364 363 * 发送群聊消息 WebSocket 事件
365 364 */
366   - @Async
367 365 public void sendGroupMessageEvent(ImGroupMessageDO message) {
368 366 ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message);
369   - // 广播给群内有效成员(含发送方自己,支持多端同步)
  367 + // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤)
370 368 List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByGroupId(message.getGroupId());
371   - // 定向消息仍需过滤,非目标用户不应收到推送
372   - List<Long> receiverList = message.getReceiverUserIds();
373   - for (ImGroupMemberDO member : members) {
374   - if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(member.getUserId())
375   - && ObjUtil.notEqual(member.getUserId(), message.getSenderId())) {
376   - continue;
377   - }
378   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), member.getUserId(),
379   - ImGroupMessageDTO.TYPE, dto);
380   - }
  369 + Set<Long> targetUserIds = getVisibleUserIds(message, members);
  370 + imWebSocketService.sendGroupMessageAsync(targetUserIds, dto);
381 371 }
382 372  
383 373 /**
... ... @@ -389,11 +379,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
389 379 * @param newMaxMessageId 本次已读位置
390 380 */
391 381 @Async
  382 + @SuppressWarnings("DataFlowIssue")
392 383 public void readGroupMessageEvent(Long userId, Long groupId, Long prevMaxMessageId, Long newMaxMessageId) {
393 384 // 1. 发送 READ 事件给自己的其他终端(多端同步)
394   - ImGroupMessageDTO readDTO = ImGroupMessageDTO.ofRead(userId, groupId, newMaxMessageId);
395   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
396   - ImGroupMessageDTO.TYPE, readDTO);
  385 + imWebSocketService.sendGroupMessageAsync(userId,
  386 + ImGroupMessageDTO.ofRead(userId, groupId, newMaxMessageId));
397 387  
398 388 // 2. 刷新 (prevMaxMessageId, newMaxMessageId] 区间内的待回执消息
399 389 List<ImGroupMessageDO> pendingMessages = groupMessageMapper.selectListByGroupIdAndPendingReceipt(
... ... @@ -422,10 +412,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
422 412 }
423 413  
424 414 // 2.2 发送 RECEIPT 事件给消息发送方(只有 ta 关心已读进度)
425   - ImGroupMessageDTO receiptDTO = ImGroupMessageDTO.ofReceipt(
426   - message.getId(), groupId, readCount, newReceiptStatus);
427   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(),
428   - ImGroupMessageDTO.TYPE, receiptDTO);
  415 + imWebSocketService.sendGroupMessageAsync(message.getSenderId(),
  416 + ImGroupMessageDTO.ofReceipt(message.getId(), groupId, readCount, newReceiptStatus));
429 417 }
430 418 }
431 419  
... ... @@ -483,6 +471,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
483 471 member -> isMessageVisible(msg, member, member.getUserId()));
484 472 }
485 473  
  474 +
486 475 private ImGroupMessageServiceImpl getSelf() {
487 476 return SpringUtil.getBean(getClass());
488 477 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
... ... @@ -58,4 +58,15 @@ public interface ImPrivateMessageService {
58 58 */
59 59 List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO);
60 60  
  61 + /**
  62 + * 发送系统提示消息(TIP_TEXT)
  63 + * <p>
  64 + * 跳过好友校验、敏感词过滤,用于系统自动生成的提示消息。
  65 + *
  66 + * @param senderId 发送人编号
  67 + * @param receiverId 接收人编号
  68 + * @param content 提示文本
  69 + */
  70 + void sendTipPrivateMessage(Long senderId, Long receiverId, String content);
  71 +
61 72 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
... ... @@ -3,24 +3,21 @@ package cn.iocoder.yudao.module.im.service.message;
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.util.IdUtil;
5 5 import cn.hutool.core.util.ObjUtil;
6   -import cn.hutool.extra.spring.SpringUtil;
7   -import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
8 6 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
9 7 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
10   -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
11 8 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO;
12 9 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
13 10 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
14   -import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
15 11 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
16 12 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
17 13 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
18 14 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
19 15 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
20   -import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageDTO;
  16 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  17 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  18 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
21 19 import jakarta.annotation.Resource;
22 20 import lombok.extern.slf4j.Slf4j;
23   -import org.springframework.scheduling.annotation.Async;
24 21 import org.springframework.stereotype.Service;
25 22 import org.springframework.transaction.annotation.Transactional;
26 23 import org.springframework.validation.annotation.Validated;
... ... @@ -31,9 +28,7 @@ import java.util.List;
31 28 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
32 29 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
33 30 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
34   -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE;
35   -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_PRIVATE_PULL_MAX_DAYS;
36   -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES;
  31 +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*;
37 32  
38 33 /**
39 34 * IM 私聊消息 Service 实现类
... ... @@ -54,7 +49,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
54 49 private ImSensitiveWordService sensitiveWordService;
55 50  
56 51 @Resource
57   - private WebSocketMessageSender webSocketMessageSender;
  52 + private ImWebSocketService imWebSocketService;
58 53  
59 54 @Override
60 55 public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) {
... ... @@ -80,8 +75,10 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
80 75 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
81 76 privateMessageMapper.insert(message);
82 77  
83   - // 3. WebSocket 推送给接收方(异步)
84   - getSelf().sendPrivateMessageEvent(message);
  78 + // 3. WebSocket 异步推送(接收方 + 发送方多端同步)
  79 + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
  80 + imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage);
  81 + imWebSocketService.sendPrivateMessageAsync(message.getSenderId(), websocketMessage);
85 82 return message;
86 83 }
87 84  
... ... @@ -116,7 +113,10 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
116 113  
117 114 // 2. 异步发送 READ + RECEIPT 事件(携带已读位置)
118 115 Long maxReadId = CollUtil.max(messageIds);
119   - getSelf().readPrivateMessageEvent(userId, receiverId, maxReadId);
  116 + imWebSocketService.sendPrivateMessageAsync(userId,
  117 + ImPrivateMessageDTO.ofRead(userId, receiverId, maxReadId));
  118 + imWebSocketService.sendPrivateMessageAsync(receiverId,
  119 + ImPrivateMessageDTO.ofReceipt(userId, receiverId, maxReadId));
120 120 }
121 121  
122 122 @Override
... ... @@ -153,52 +153,30 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
153 153 privateMessageMapper.insert(tipMessage);
154 154  
155 155 // 4. 异步推送撤回提示消息(前端据此更新原消息状态 + 插入撤回提示)
156   - getSelf().sendPrivateMessageEvent(tipMessage);
  156 + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(tipMessage);
  157 + imWebSocketService.sendPrivateMessageAsync(tipMessage.getReceiverId(), websocketMessage);
  158 + imWebSocketService.sendPrivateMessageAsync(tipMessage.getSenderId(), websocketMessage);
157 159 return tipMessage;
158 160 }
159 161  
160   - /**
161   - * 发送私聊消息 WebSocket 事件
162   - */
163   - @Async
164   - public void sendPrivateMessageEvent(ImPrivateMessageDO message) {
165   - ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofSend(message);
166   - // 推送给接收方
167   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
168   - ImPrivateMessageDTO.TYPE, dto);
169   -
170   - // 推送给发送方自己的其他终端(多端同步)
171   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(),
172   - ImPrivateMessageDTO.TYPE, dto);
173   - }
174   -
175   - /**
176   - * 发送已读 + 已读回执 WebSocket 事件
177   - *
178   - * @param userId 当前用户编号
179   - * @param receiverId 对方用户编号
180   - * @param maxReadId 已读位置(最大已读消息编号)
181   - */
182   - @Async
183   - public void readPrivateMessageEvent(Long userId, Long receiverId, Long maxReadId) {
184   - // 1. 发送 READ 事件给自己的其他终端(多端同步)
185   - ImPrivateMessageDTO readDTO = ImPrivateMessageDTO.ofRead(userId, receiverId, maxReadId);
186   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
187   - ImPrivateMessageDTO.TYPE, readDTO);
188   -
189   - // 2. 发送 RECEIPT 事件给对方(已读回执)
190   - ImPrivateMessageDTO receiptDTO = ImPrivateMessageDTO.ofReceipt(userId, receiverId, maxReadId);
191   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), receiverId,
192   - ImPrivateMessageDTO.TYPE, receiptDTO);
193   - }
194   -
195 162 @Override
196 163 public List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) {
197 164 return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit());
198 165 }
199 166  
200   - private ImPrivateMessageServiceImpl getSelf() {
201   - return SpringUtil.getBean(getClass());
  167 + @Override
  168 + public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) {
  169 + // 1. 插入 TIP_TEXT 消息
  170 + ImPrivateMessageDO tipMessage = new ImPrivateMessageDO()
  171 + .setClientMessageId(IdUtil.fastSimpleUUID()).setSenderId(senderId).setReceiverId(receiverId)
  172 + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)
  173 + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now());
  174 + privateMessageMapper.insert(tipMessage);
  175 +
  176 + // 2. 推送给双方
  177 + ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofSend(tipMessage);
  178 + imWebSocketService.sendPrivateMessageAsync(receiverId, dto);
  179 + imWebSocketService.sendPrivateMessageAsync(senderId, dto);
202 180 }
203 181  
204 182 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket;
  2 +
  3 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
  4 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  5 +
  6 +import java.util.Collection;
  7 +import java.util.Collections;
  8 +
  9 +/**
  10 + * IM WebSocket 推送 Service 接口
  11 + * <p>
  12 + * 统一封装 WebSocket 消息推送,所有方法默认异步执行。
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +public interface ImWebSocketService {
  17 +
  18 + /**
  19 + * 异步推送私聊消息给指定用户
  20 + *
  21 + * @param userId 目标用户编号
  22 + * @param dto 私聊消息 DTO
  23 + */
  24 + void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto);
  25 +
  26 + /**
  27 + * 异步推送群聊消息给指定用户
  28 + *
  29 + * @param userId 目标用户编号
  30 + * @param dto 群聊消息 DTO
  31 + */
  32 + default void sendGroupMessageAsync(Long userId, ImGroupMessageDTO dto) {
  33 + sendGroupMessageAsync(Collections.singleton(userId), dto);
  34 + }
  35 +
  36 + /**
  37 + * 异步批量推送群聊消息给多个用户
  38 + *
  39 + * @param userIds 目标用户编号列表
  40 + * @param dto 群聊消息 DTO
  41 + */
  42 + void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto);
  43 +
  44 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
  4 +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
  5 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
  6 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  7 +import jakarta.annotation.Resource;
  8 +import lombok.extern.slf4j.Slf4j;
  9 +import org.springframework.scheduling.annotation.Async;
  10 +import org.springframework.stereotype.Service;
  11 +
  12 +import java.util.Collection;
  13 +
  14 +/**
  15 + * IM WebSocket 推送 Service 实现类
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +@Service
  20 +@Slf4j
  21 +public class ImWebSocketServiceImpl implements ImWebSocketService {
  22 +
  23 + @Resource
  24 + private WebSocketMessageSender webSocketMessageSender;
  25 +
  26 + @Async
  27 + @Override
  28 + public void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) {
  29 + try {
  30 + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  31 + ImPrivateMessageDTO.TYPE, dto);
  32 + } catch (Exception e) {
  33 + log.error("[sendPrivateMessageAsync][userId({}) dto({}) 发送失败]", userId, dto, e);
  34 + }
  35 + }
  36 +
  37 + @Async
  38 + @Override
  39 + public void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto) {
  40 + for (Long userId : userIds) {
  41 + try {
  42 + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  43 + ImGroupMessageDTO.TYPE, dto);
  44 + } catch (Exception e) {
  45 + log.error("[sendGroupMessageAsync][userId({}) dto({}) 发送失败]", userId, dto, e);
  46 + }
  47 + }
  48 + }
  49 +
  50 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImGroupMessageDTO.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java
1   -package cn.iocoder.yudao.module.im.websocket;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto;
2 2  
3 3 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
4 4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java
1   -package cn.iocoder.yudao.module.im.websocket;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto;
2 2  
  3 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
3 4 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
4 5 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
5 6 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  7 +import cn.iocoder.yudao.module.im.service.websocket.dto.friend.FriendUpdateMessage;
6 8 import lombok.Data;
7 9 import lombok.experimental.Accessors;
8 10  
... ... @@ -100,4 +102,46 @@ public class ImPrivateMessageDTO {
100 102 .setSenderId(senderId).setReceiverId(receiverId);
101 103 }
102 104  
  105 + /**
  106 + * 构建好友添加推送 DTO(通知对方"你有一位新好友")
  107 + *
  108 + * @param userId 当前用户编号(即发起添加的用户)
  109 + * @param friendUserId 好友的用户编号(即接收通知的用户)
  110 + * @return 私聊 DTO
  111 + */
  112 + public static ImPrivateMessageDTO ofFriendAdd(Long userId, Long friendUserId) {
  113 + return new ImPrivateMessageDTO()
  114 + .setType(ImMessageTypeEnum.FRIEND_ADD.getType())
  115 + .setSenderId(userId).setReceiverId(friendUserId)
  116 + .setSendTime(LocalDateTime.now());
  117 + }
  118 +
  119 + /**
  120 + * 构建好友删除推送 DTO(通知对方"好友关系已解除")
  121 + *
  122 + * @param userId 当前用户编号(即发起删除的用户)
  123 + * @param friendUserId 好友的用户编号(即接收通知的用户)
  124 + * @return 私聊 DTO
  125 + */
  126 + public static ImPrivateMessageDTO ofFriendDelete(Long userId, Long friendUserId) {
  127 + return new ImPrivateMessageDTO()
  128 + .setType(ImMessageTypeEnum.FRIEND_DELETE.getType())
  129 + .setSenderId(userId).setReceiverId(friendUserId)
  130 + .setSendTime(LocalDateTime.now());
  131 + }
  132 + /**
  133 + * 构建好友更新推送 DTO(通知自己多端同步好友属性变更)
  134 + *
  135 + * @param userId 当前用户编号
  136 + * @param friendUserId 好友的用户编号
  137 + * @param content 更新后的好友属性
  138 + * @return 私聊 DTO
  139 + */
  140 + public static ImPrivateMessageDTO ofFriendUpdate(Long userId, Long friendUserId, FriendUpdateMessage content) {
  141 + return new ImPrivateMessageDTO()
  142 + .setType(ImMessageTypeEnum.FRIEND_UPDATE.getType())
  143 + .setSenderId(userId).setReceiverId(friendUserId)
  144 + .setContent(JsonUtils.toJsonString(content)).setSendTime(LocalDateTime.now());
  145 + }
  146 +
103 147 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/friend/FriendUpdateMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.friend;
  2 +
  3 +import lombok.Data;
  4 +import lombok.experimental.Accessors;
  5 +
  6 +/**
  7 + * 好友更新消息内容(目前仅免打扰)
  8 + */
  9 +@Data
  10 +@Accessors(chain = true)
  11 +public class FriendUpdateMessage {
  12 +
  13 + /**
  14 + * 好友的用户编号
  15 + */
  16 + private Long friendUserId;
  17 + /**
  18 + * 是否免打扰
  19 + */
  20 + private Boolean muted;
  21 +
  22 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/AudioMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/CardMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/FileMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/ImageMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/LocationMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/RecallMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4 import lombok.experimental.Accessors;
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/TextMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4  
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content/VideoMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java
1   -package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
2 2  
3 3 import lombok.Data;
4 4  
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java
... ... @@ -9,7 +9,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
9 9 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
10 10 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
11 11 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
12   -import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageDTO;
  12 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
13 13 import org.junit.jupiter.api.Test;
14 14 import org.mockito.ArgumentCaptor;
15 15 import org.mockito.InjectMocks;
... ...