diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
index 5541d51..08c2806 100644
--- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
+++ b/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 {
// ========== 好友 (1-040-600-000) ==========
ErrorCode FRIEND_NOT_EXISTS = new ErrorCode(1_040_600_000, "好友关系不存在");
ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友");
+ ErrorCode FRIEND_ADD_SELF = new ErrorCode(1_040_600_002, "不允许添加自己为好友");
+ ErrorCode FRIEND_ALREADY = new ErrorCode(1_040_600_003, "你们已经是好友了");
// ========== 敏感词 (1-040-700-000) ==========
ErrorCode SENSITIVE_WORD_NOT_EXISTS = new ErrorCode(1_040_700_000, "敏感词不存在");
diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java
index 44b3720..79cb8d8 100644
--- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java
+++ b/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;
import java.util.Arrays;
/**
- * IM 私聊消息状态枚举
+ * IM 消息状态枚举
*
- * 私聊:UNREAD / READ / RECALL
- * 群聊:NORMAL / RECALL
+ * 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3)
+ * 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2)
*
* @author 芋道源码
*/
@@ -18,12 +18,10 @@ import java.util.Arrays;
@Getter
public enum ImMessageStatusEnum implements ArrayValuable {
- // ========== 私聊状态 ==========
- UNREAD(0, "未读"),
- READ(1, "已读"),
-
- // ========== 通用状态 ==========
- RECALL(2, "已撤回");
+ SENDING(-1, "发送中"), // 仅客户端使用
+ UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态)
+ RECALL(2, "已撤回"),
+ READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new);
@@ -31,7 +29,6 @@ public enum ImMessageStatusEnum implements ArrayValuable {
* 状态
*/
private final Integer status;
-
/**
* 名字
*/
diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImWebSocketTypeConstants.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImWebSocketTypeConstants.java
deleted file mode 100644
index 4d8f990..0000000
--- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImWebSocketTypeConstants.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package cn.iocoder.yudao.module.im.enums.message;
-
-// DONE @芋艿:已 review,事件类型定义正确
-/**
- * IM WebSocket 事件类型常量
- *
- * @author 芋道源码
- */
-public interface ImWebSocketTypeConstants {
-
- /**
- * 私聊消息事件
- */
- String PRIVATE_MESSAGE = "im-private-message";
- /**
- * 群聊消息事件
- */
- String GROUP_MESSAGE = "im-group-message";
- /**
- * 已读事件
- */
- String READ = "im-read";
- /**
- * 回执事件
- */
- String RECEIPT = "im-receipt";
- /**
- * 撤回事件
- */
- String RECALL = "im-recall";
-
-}
\ No newline at end of file
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
index 2917f7e..eeee448 100644
--- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
+++ b/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
public class ImPrivateMessageController {
@Resource
- private ImPrivateMessageService imPrivateMessageService;
+ private ImPrivateMessageService privateMessageService;
@PostMapping("/send")
@Operation(summary = "发送私聊消息")
- public CommonResult sendPrivateMessage(@Valid @RequestBody ImPrivateMessageSendReqVO reqVO) {
- ImPrivateMessageDO message = imPrivateMessageService.sendPrivateMessage(getLoginUserId(), reqVO);
+ public CommonResult sendPrivateMessage(
+ @Valid @RequestBody ImPrivateMessageSendReqVO reqVO) {
+ ImPrivateMessageDO message = privateMessageService.sendPrivateMessage(getLoginUserId(), reqVO);
return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class));
}
@@ -44,7 +45,7 @@ public class ImPrivateMessageController {
@Parameter(name = "size", description = "拉取数量", required = true, example = "100")
public CommonResult> pullPrivateMessages(@RequestParam("minId") Long minId,
@RequestParam("size") Integer size) {
- List messages = imPrivateMessageService.pullPrivateMessages(getLoginUserId(), minId, size);
+ List messages = privateMessageService.pullPrivateMessages(getLoginUserId(), minId, size);
return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class));
}
@@ -52,7 +53,7 @@ public class ImPrivateMessageController {
@Operation(summary = "标记私聊消息已读")
@Parameter(name = "friendId", description = "好友用户编号", required = true, example = "2")
public CommonResult readPrivateMessages(@RequestParam("friendId") Long friendId) {
- imPrivateMessageService.readPrivateMessages(getLoginUserId(), friendId);
+ privateMessageService.readPrivateMessages(getLoginUserId(), friendId);
return success(true);
}
@@ -60,7 +61,7 @@ public class ImPrivateMessageController {
@Operation(summary = "撤回私聊消息")
@Parameter(name = "id", description = "消息编号", required = true, example = "1")
public CommonResult recallPrivateMessage(@RequestParam("id") Long id) {
- imPrivateMessageService.recallPrivateMessage(getLoginUserId(), id);
+ privateMessageService.recallPrivateMessage(getLoginUserId(), id);
return success(true);
}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java
index 9235804..5636aae 100644
--- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java
+++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java
@@ -1,16 +1,18 @@
package cn.iocoder.yudao.module.im.dal.dataobject.friend;
-import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
-import java.time.LocalDateTime;
-
/**
* IM 好友关系 DO
+ *
+ * 业务语义:
+ * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A)
+ * - 软删除:`deleted=1` 表示单向解除好友;如果需要"全部不看到",双向都要 deleted=1
+ * - 免打扰:`muted` 控制是否屏蔽来自该好友的通知
*
* @author 芋道源码
*/
@@ -31,33 +33,23 @@ public class ImFriendDO extends BaseDO {
private Long id;
/**
* 用户编号
- *
+ *
* 关联 AdminUserDO 的 id 字段
*/
private Long userId;
/**
* 好友用户编号
- *
+ *
* 关联 AdminUserDO 的 id 字段
*/
private Long friendUserId;
/**
- * 好友状态
- *
- * 枚举 {@link CommonStatusEnum}
- */
- private Integer status;
- /**
* 是否免打扰
*/
private Boolean muted;
- /**
- * 添加好友时间
- */
- private LocalDateTime addTime;
- /**
- * 删除好友时间
- */
- private LocalDateTime deleteTime;
+
+ // 说明:
+ // - 删除状态用 BaseDO 的 deleted 字段表达(0=正常,1=已删除)
+ // - 添加时间用 BaseDO 的 createTime;删除时间对应 updateTime
}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java
index bc67977..a279ce2 100644
--- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java
+++ b/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 {
* 消息状态
*
* 枚举 {@link ImMessageStatusEnum}
- * 群聊状态:NORMAL(0) / RECALL(2)
*
* 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型)
*/
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java
index 7ce42cb..bb87939 100644
--- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java
+++ b/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 {
* 消息状态
*
* 枚举 {@link ImMessageStatusEnum}
- * 私聊状态:UNREAD(0) / READ(1) / RECALL(2)
*/
private Integer status;
/**
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java
index e317be2..620f3b6 100644
--- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java
+++ b/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;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import org.apache.ibatis.annotations.Mapper;
+import java.util.List;
+
/**
* IM 好友关系 Mapper
*
@@ -13,17 +15,16 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ImFriendMapper extends BaseMapperX {
- /**
- * 查询好友关系
- *
- * @param userId 用户编号
- * @param friendUserId 好友用户编号
- * @return 好友关系
- */
default ImFriendDO selectByUserIdAndFriendUserId(Long userId, Long friendUserId) {
return selectOne(new LambdaQueryWrapperX()
.eq(ImFriendDO::getUserId, userId)
.eq(ImFriendDO::getFriendUserId, friendUserId));
}
+ default List selectListByUserId(Long userId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(ImFriendDO::getUserId, userId)
+ .orderByDesc(ImFriendDO::getId));
+ }
+
}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
index b9c8941..dc0ba05 100644
--- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
+++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
@@ -1,24 +1,28 @@
package cn.iocoder.yudao.module.im.service.message;
+import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
-import cn.iocoder.yudao.module.im.enums.message.ImWebSocketTypeConstants;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
+import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;
+import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;
+import cn.iocoder.yudao.module.im.websocket.ImMessageRecallMessage;
+import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage;
import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
@@ -39,12 +43,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
private static final int MAX_PULL_SIZE = 1000;
@Resource
- private ImPrivateMessageMapper imPrivateMessageMapper;
+ private ImPrivateMessageMapper privateMessageMapper;
@Resource
- private ImFriendService imFriendService;
+ private ImFriendService friendService;
@Resource
- private ImSensitiveWordService imSensitiveWordService;
+ private ImSensitiveWordService sensitiveWordService;
@Resource
private WebSocketMessageSender webSocketMessageSender;
@@ -52,30 +56,27 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
@Override
public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) {
// 1.1 幂等校验:根据 senderId + clientMessageId 查重
- ImPrivateMessageDO existing = imPrivateMessageMapper.selectBySenderIdAndClientMessageId(
+ ImPrivateMessageDO existing = privateMessageMapper.selectBySenderIdAndClientMessageId(
senderId, reqVO.getClientMessageId());
if (existing != null) {
+ log.info("[sendPrivateMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]",
+ senderId, reqVO.getClientMessageId(), existing.getId());
return existing;
}
// 1.2 好友校验
- imFriendService.validateIsFriend(senderId, reqVO.getReceiverId());
+ friendService.validateFriendExists(senderId, reqVO.getReceiverId());
// 1.3 文本消息敏感词过滤
if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
- imSensitiveWordService.validateText(reqVO.getContent());
+ sensitiveWordService.validateText(reqVO.getContent());
}
// 2. 构建并保存消息
- ImPrivateMessageDO message = ImPrivateMessageDO.builder()
- .clientMessageId(reqVO.getClientMessageId())
- .senderId(senderId).receiverId(reqVO.getReceiverId())
- .type(reqVO.getType()).content(reqVO.getContent())
- .status(ImMessageStatusEnum.UNREAD.getStatus())
- .sendTime(LocalDateTime.now())
- .build();
- imPrivateMessageMapper.insert(message);
-
- // 3. WebSocket 推送给接收方
- sendPrivateMessageEvent(message);
+ ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
+ .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
+ privateMessageMapper.insert(message);
+
+ // 3. WebSocket 推送给接收方(异步)
+ getSelf().sendPrivateMessageEvent(message);
return message;
}
@@ -84,39 +85,37 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
if (size > MAX_PULL_SIZE) {
throw exception(MESSAGE_PULL_SIZE_EXCEEDED);
}
- return imPrivateMessageMapper.selectListByMinId(userId, minId, size);
+ return privateMessageMapper.selectListByMinId(userId, minId, size);
}
// DONE @芋艿:已 review,逻辑正确
@Override
public void readPrivateMessages(Long userId, Long friendId) {
// 1. 批量更新消息状态为已读
- int updated = imPrivateMessageMapper.updateStatusToRead(userId, friendId);
+ int updated = privateMessageMapper.updateStatusToRead(userId, friendId);
if (updated == 0) {
return; // 没有需要更新的消息
}
// 2. 发送 READ 事件给自己的其他终端(多端同步)
- Map readEvent = new HashMap<>();
- // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
- readEvent.put("friendId", friendId.toString());
- readEvent.put("messageScene", "private");
+ ImMessageReadMessage readMessage = new ImMessageReadMessage()
+ .setFriendId(friendId.toString())
+ .setMessageScene("private");
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
- ImWebSocketTypeConstants.READ, readEvent);
+ ImMessageReadMessage.TYPE, readMessage);
// 3. 发送 RECEIPT 事件给对方(已读回执)
- Map receiptEvent = new HashMap<>();
- // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
- receiptEvent.put("userId", userId.toString());
- receiptEvent.put("messageScene", "private");
+ ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage()
+ .setUserId(userId.toString())
+ .setMessageScene("private");
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), friendId,
- ImWebSocketTypeConstants.RECEIPT, receiptEvent);
+ ImMessageReceiptMessage.TYPE, receiptMessage);
}
// DONE @芋艿:已 review,逻辑正确
@Override
public void recallPrivateMessage(Long userId, Long messageId) {
// 1.1 校验消息存在
- ImPrivateMessageDO message = imPrivateMessageMapper.selectById(messageId);
+ ImPrivateMessageDO message = privateMessageMapper.selectById(messageId);
if (message == null) {
throw exception(MESSAGE_NOT_EXISTS);
}
@@ -131,44 +130,38 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
}
// 2. 更新消息状态为撤回
- imPrivateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId)
+ privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId)
.setStatus(ImMessageStatusEnum.RECALL.getStatus()));
- // 3.1 发送 RECALL 事件给接收方
- // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
- Map recallEvent = new HashMap<>();
- recallEvent.put("messageId", messageId.toString());
- recallEvent.put("messageScene", "private");
- recallEvent.put("senderId", userId.toString());
+ // 3. 发送 RECALL 事件
+ ImMessageRecallMessage recallMessage = new ImMessageRecallMessage()
+ .setMessageId(messageId.toString())
+ .setSenderId(userId.toString())
+ .setMessageScene("private");
+ // 3.1 通知接收方
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
- ImWebSocketTypeConstants.RECALL, recallEvent);
- // 3.2 也通知发送方自己的其他终端
- // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
+ ImMessageRecallMessage.TYPE, recallMessage);
+ // 3.2 通知发送方自己的其他终端
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
- ImWebSocketTypeConstants.RECALL, recallEvent);
+ ImMessageRecallMessage.TYPE, recallMessage);
}
/**
* 发送私聊消息 WebSocket 事件
*/
- private void sendPrivateMessageEvent(ImPrivateMessageDO message) {
- Map event = new HashMap<>();
- // DONE @AI:需要整理下,相关的 websocket DTO。结论:当前用 Map 更灵活,多场景复用,后续可抽取 DTO
- event.put("id", message.getId().toString());
- event.put("clientMessageId", message.getClientMessageId());
- event.put("senderId", message.getSenderId().toString());
- event.put("receiverId", message.getReceiverId().toString());
- event.put("type", message.getType());
- event.put("content", message.getContent());
- event.put("status", message.getStatus());
- event.put("sendTime", message.getSendTime().toString());
- event.put("messageScene", "private");
+ @Async
+ public void sendPrivateMessageEvent(ImPrivateMessageDO message) {
+ ImPrivateMessageSendMessage websocketMessage = BeanUtils.toBean(message, ImPrivateMessageSendMessage.class);
// 推送给接收方
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
- ImWebSocketTypeConstants.PRIVATE_MESSAGE, event);
+ ImPrivateMessageSendMessage.TYPE, websocketMessage);
// 推送给发送方自己的其他终端(多端同步)
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(),
- ImWebSocketTypeConstants.PRIVATE_MESSAGE, event);
+ ImPrivateMessageSendMessage.TYPE, websocketMessage);
+ }
+
+ private ImPrivateMessageServiceImpl getSelf() {
+ return SpringUtil.getBean(getClass());
}
}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java
index 64886b2..25bd621 100644
--- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java
+++ b/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 {
/**
* 校验文本是否包含敏感词
- *
- * 如果包含,直接抛出业务异常
*
* @param text 待校验文本
*/
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImGroupMessageSendMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImGroupMessageSendMessage.java
new file mode 100644
index 0000000..ae1653a
--- /dev/null
+++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImGroupMessageSendMessage.java
@@ -0,0 +1,65 @@
+package cn.iocoder.yudao.module.im.websocket;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * IM 群聊消息 WebSocket 推送消息
+ *
+ * @author 芋道源码
+ */
+@Data
+@Accessors(chain = true)
+public class ImGroupMessageSendMessage {
+
+ public static final String TYPE = "im-group-message-send";
+
+ /**
+ * 消息编号
+ */
+ private Long id;
+ /**
+ * 客户端消息编号
+ */
+ private String clientMessageId;
+ /**
+ * 发送人编号
+ */
+ private Long senderId;
+ /**
+ * 群编号
+ */
+ private Long groupId;
+ /**
+ * 消息类型
+ */
+ private Integer type;
+ /**
+ * 消息内容
+ */
+ private String content;
+ /**
+ * 消息状态
+ */
+ private Integer status;
+ /**
+ * 发送时间
+ */
+ private LocalDateTime sendTime;
+ /**
+ * @ 的用户编号列表
+ */
+ private List atUserIds;
+ /**
+ * 定向接收用户编号列表
+ */
+ private List receiverUserIds;
+ /**
+ * 群回执状态
+ */
+ private Integer receiptStatus;
+
+}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReadMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReadMessage.java
new file mode 100644
index 0000000..fec7b9b
--- /dev/null
+++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReadMessage.java
@@ -0,0 +1,32 @@
+package cn.iocoder.yudao.module.im.websocket;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * IM 消息已读 WebSocket 推送消息
+ *
+ * 用于多端同步:通知自己的其他终端"我已经读了某个会话"
+ *
+ * @author 芋道源码
+ */
+@Data
+@Accessors(chain = true)
+public class ImMessageReadMessage {
+
+ public static final String TYPE = "im-message-read";
+
+ /**
+ * 私聊场景:好友编号
+ */
+ private String friendId;
+ /**
+ * 群聊场景:群编号
+ */
+ private String groupId;
+ /**
+ * 消息场景:private / group
+ */
+ private String messageScene;
+
+}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageRecallMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageRecallMessage.java
new file mode 100644
index 0000000..6245429
--- /dev/null
+++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageRecallMessage.java
@@ -0,0 +1,34 @@
+package cn.iocoder.yudao.module.im.websocket;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * IM 消息撤回 WebSocket 推送消息
+ *
+ * @author 芋道源码
+ */
+@Data
+@Accessors(chain = true)
+public class ImMessageRecallMessage {
+
+ public static final String TYPE = "im-message-recall";
+
+ /**
+ * 消息编号
+ */
+ private String messageId;
+ /**
+ * 发送人编号
+ */
+ private String senderId;
+ /**
+ * 群聊场景:群编号
+ */
+ private String groupId;
+ /**
+ * 消息场景:private / group
+ */
+ private String messageScene;
+
+}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReceiptMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReceiptMessage.java
new file mode 100644
index 0000000..b92e696
--- /dev/null
+++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReceiptMessage.java
@@ -0,0 +1,53 @@
+package cn.iocoder.yudao.module.im.websocket;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * IM 已读回执 WebSocket 推送消息
+ *
+ * 私聊:通知对方"我已读了你的消息"
+ * 群聊:广播回执状态和已读人数
+ *
+ * @author 芋道源码
+ */
+@Data
+@Accessors(chain = true)
+public class ImMessageReceiptMessage {
+
+ public static final String TYPE = "im-message-receipt";
+
+ // ========== 私聊场景 ==========
+
+ /**
+ * 私聊场景:已读方的用户编号
+ */
+ private String userId;
+
+ // ========== 群聊场景 ==========
+
+ /**
+ * 群聊场景:消息编号
+ */
+ private String messageId;
+ /**
+ * 群聊场景:群编号
+ */
+ private String groupId;
+ /**
+ * 群聊场景:已读人数
+ */
+ private Integer readCount;
+ /**
+ * 群聊场景:回执状态
+ */
+ private Integer receiptStatus;
+
+ // ========== 通用 ==========
+
+ /**
+ * 消息场景:private / group
+ */
+ private String messageScene;
+
+}
diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java
new file mode 100644
index 0000000..2c066d2
--- /dev/null
+++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageSendMessage.java
@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.module.im.websocket;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+
+/**
+ * IM 私聊消息 WebSocket 推送消息
+ *
+ * @author 芋道源码
+ */
+@Data
+@Accessors(chain = true)
+public class ImPrivateMessageSendMessage {
+
+ public static final String TYPE = "im-private-message-send";
+
+ /**
+ * 消息编号
+ */
+ private Long id;
+ /**
+ * 客户端消息编号
+ */
+ private String clientMessageId;
+ /**
+ * 发送人编号
+ */
+ private Long senderId;
+ /**
+ * 接收人编号
+ */
+ private Long receiverId;
+ /**
+ * 消息类型
+ */
+ private Integer type;
+ /**
+ * 消息内容
+ */
+ private String content;
+ /**
+ * 消息状态
+ */
+ private Integer status;
+ /**
+ * 发送时间
+ */
+ private LocalDateTime sendTime;
+
+}
diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
index eda0d54..426489d 100644
--- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
+++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.service.message;
+import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
@@ -12,6 +13,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
+import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
@@ -55,32 +57,37 @@ public class ImPrivateMessageServiceImplTest {
@Test
public void testSendMessage_success() {
- // 准备
- ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
- when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
- .thenReturn(null);
- when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> {
- ImPrivateMessageDO msg = invocation.getArgument(0);
- msg.setId(99L);
- return 1;
- });
-
- // 调用
- ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO);
-
- // 断言
- assertNotNull(result);
- assertEquals(1L, result.getSenderId());
- assertEquals(2L, result.getReceiverId());
- assertEquals(0, result.getType());
- assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
- assertNotNull(result.getSendTime());
-
- // 验证调用
- verify(imFriendService).validateIsFriend(1L, 2L);
- verify(imSensitiveWordService).validateText(reqVO.getContent());
- verify(imPrivateMessageMapper).insert(any(ImPrivateMessageDO.class));
- verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
+ try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) {
+ springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class)))
+ .thenReturn(privateMessageService);
+
+ // 准备
+ ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
+ when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
+ .thenReturn(null);
+ when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> {
+ ImPrivateMessageDO msg = invocation.getArgument(0);
+ msg.setId(99L);
+ return 1;
+ });
+
+ // 调用
+ ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO);
+
+ // 断言
+ assertNotNull(result);
+ assertEquals(1L, result.getSenderId());
+ assertEquals(2L, result.getReceiverId());
+ assertEquals(0, result.getType());
+ assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
+ assertNotNull(result.getSendTime());
+
+ // 验证调用
+ verify(imFriendService).validateFriendExists(1L, 2L);
+ verify(imSensitiveWordService).validateText(reqVO.getContent());
+ verify(imPrivateMessageMapper).insert(any(ImPrivateMessageDO.class));
+ verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
+ }
}
@Test
@@ -110,7 +117,7 @@ public class ImPrivateMessageServiceImplTest {
when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
.thenReturn(null);
doThrow(new ServiceException(FRIEND_NOT_FRIEND))
- .when(imFriendService).validateIsFriend(1L, 2L);
+ .when(imFriendService).validateFriendExists(1L, 2L);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java
index 266fce7..d04106f 100644
--- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java
+++ b/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;
import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
-import cn.iocoder.yudao.module.im.enums.message.ImWebSocketTypeConstants;
+import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage;
+import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage;
+import cn.iocoder.yudao.module.im.websocket.ImMessageRecallMessage;
+import cn.iocoder.yudao.module.im.websocket.ImPrivateMessageSendMessage;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
@@ -14,8 +17,6 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import java.util.Map;
-
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -69,16 +70,14 @@ public class ImWebSocketEventTest {
typeCaptor.capture(), contentCaptor.capture());
// 验证消息事件类型
- assertEquals(ImWebSocketTypeConstants.PRIVATE_MESSAGE, typeCaptor.getAllValues().get(0));
- assertEquals(ImWebSocketTypeConstants.PRIVATE_MESSAGE, typeCaptor.getAllValues().get(1));
-
- // 验证 payload 中 id 字段按 String 返回
- @SuppressWarnings("unchecked")
- Map payload = (Map) contentCaptor.getAllValues().get(0);
- assertTrue(payload.get("id") instanceof String, "id 应为 String 类型");
- assertTrue(payload.get("senderId") instanceof String, "senderId 应为 String 类型");
- assertTrue(payload.get("receiverId") instanceof String, "receiverId 应为 String 类型");
- assertEquals("private", payload.get("messageScene"));
+ assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(0));
+ assertEquals(ImPrivateMessageSendMessage.TYPE, typeCaptor.getAllValues().get(1));
+
+ // 验证 payload 是 DTO 类型
+ ImPrivateMessageSendMessage payload = (ImPrivateMessageSendMessage) contentCaptor.getAllValues().get(0);
+ assertNotNull(payload.getId(), "id 不应为空");
+ assertEquals("1", payload.getSenderId());
+ assertEquals("2", payload.getReceiverId());
}
@Test
@@ -96,10 +95,10 @@ public class ImWebSocketEventTest {
typeCaptor.capture(), any());
// 第一次:发给自己的 READ 事件
- assertEquals(ImWebSocketTypeConstants.READ, typeCaptor.getAllValues().get(0));
+ assertEquals(ImMessageReadMessage.TYPE, typeCaptor.getAllValues().get(0));
assertEquals(1L, userCaptor.getAllValues().get(0));
// 第二次:发给对方的 RECEIPT 事件
- assertEquals(ImWebSocketTypeConstants.RECEIPT, typeCaptor.getAllValues().get(1));
+ assertEquals(ImMessageReceiptMessage.TYPE, typeCaptor.getAllValues().get(1));
assertEquals(2L, userCaptor.getAllValues().get(1));
}
@@ -121,13 +120,12 @@ public class ImWebSocketEventTest {
typeCaptor.capture(), contentCaptor.capture());
// 验证 RECALL 事件类型
- assertEquals(ImWebSocketTypeConstants.RECALL, typeCaptor.getAllValues().get(0));
+ assertEquals(ImMessageRecallMessage.TYPE, typeCaptor.getAllValues().get(0));
// 验证 payload
- @SuppressWarnings("unchecked")
- Map payload = (Map) contentCaptor.getAllValues().get(0);
- assertEquals("10", payload.get("messageId"));
- assertEquals("private", payload.get("messageScene"));
+ ImMessageRecallMessage payload = (ImMessageRecallMessage) contentCaptor.getAllValues().get(0);
+ assertEquals("10", payload.getMessageId());
+ assertEquals("private", payload.getMessageScene());
}
}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java
index 507fb4b..0092b27 100644
--- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java
+++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java
@@ -56,6 +56,14 @@ public interface AdminUserApi {
List getUserListByPostIds(Collection postIds);
/**
+ * 根据昵称模糊搜索用户
+ *
+ * @param nickname 昵称关键词
+ * @return 用户列表
+ */
+ List getUserListByNickname(String nickname);
+
+ /**
* 获得用户 Map
*
* @param ids 用户编号数组
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java
index 9876e3a..8bf1e19 100644
--- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java
+++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java
@@ -81,6 +81,12 @@ public class AdminUserApiImpl implements AdminUserApi {
}
@Override
+ public List getUserListByNickname(String nickname) {
+ List users = userService.getUserListByNickname(nickname);
+ return BeanUtils.toBean(users, AdminUserRespDTO.class);
+ }
+
+ @Override
public void validateUserList(Collection ids) {
userService.validateUserList(ids);
}