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); }