diff --git a/sql/mysql/im/im20240316.sql b/sql/mysql/im/im20240316.sql deleted file mode 100644 index 2777501..0000000 --- a/sql/mysql/im/im20240316.sql +++ /dev/null @@ -1,128 +0,0 @@ -/* - Navicat Premium Data Transfer - - Source Server : mysql8_root - Source Server Type : MySQL - Source Server Version : 80200 - Source Host : chaojiniu.top:23306 - Source Schema : ruoyi-vue-pro - - Target Server Type : MySQL - Target Server Version : 80200 - File Encoding : 65001 - - Date: 16/03/2024 15:45:29 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for im_conversation --- ---------------------------- -DROP TABLE IF EXISTS `im_conversation`; -CREATE TABLE `im_conversation` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', - `user_id` bigint NOT NULL COMMENT '所属用户', - `conversation_type` tinyint NOT NULL COMMENT '类型:1 单聊;2 群聊;4 通知会话(预留)', - `target_id` bigint NOT NULL COMMENT '单聊时,用户编号;群聊时,群编号', - `no` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '会话标志 单聊:s_{userId}_{targetId},需要排序 userId 和 targetId 群聊:g_groupId', - `pinned` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否置顶 0否 1是', - `last_read_time` datetime DEFAULT NULL COMMENT '最后已读时间', - `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话表'; - --- ---------------------------- --- Table structure for im_group --- ---------------------------- -DROP TABLE IF EXISTS `im_group`; -CREATE TABLE `im_group` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', - `group_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群名字', - `owner_id` bigint NOT NULL COMMENT '群主id', - `head_image` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群头像', - `head_image_thumb` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群头像缩略图', - `notice` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群公告', - `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群备注', - `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', - PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='群'; - --- ---------------------------- --- Table structure for im_group_member --- ---------------------------- -DROP TABLE IF EXISTS `im_group_member`; -CREATE TABLE `im_group_member` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', - `group_id` bigint DEFAULT NULL COMMENT '群 id', - `user_id` bigint NOT NULL COMMENT '用户id', - `nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '昵称', - `avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像', - `alias_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '组内显示名称', - `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', - `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', - PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='群成员'; - --- ---------------------------- --- Table structure for im_inbox --- ---------------------------- -DROP TABLE IF EXISTS `im_inbox`; -CREATE TABLE `im_inbox` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', - `user_id` bigint NOT NULL COMMENT '用户编号', - `message_id` bigint NOT NULL COMMENT '消息编号', - `sequence` bigint NOT NULL COMMENT '序号,按照 user 递增', - `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', - PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收件箱表'; - --- ---------------------------- --- Table structure for im_message --- ---------------------------- -DROP TABLE IF EXISTS `im_message`; -CREATE TABLE `im_message` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', - `client_message_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端消息编号 uuid,用于排重', - `sender_id` bigint NOT NULL COMMENT '发送人编号', - `receiver_id` bigint NOT NULL COMMENT '接收人编号', - `sender_nickname` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送人昵称', - `sender_avatar` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送人头像', - `conversation_type` tinyint NOT NULL COMMENT '会话类型', - `conversation_no` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '会话标志', - `content_type` tinyint NOT NULL COMMENT '消息类型', - `content` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息内容', - `send_time` datetime DEFAULT NULL COMMENT '发送时间', - `send_from` tinyint NOT NULL COMMENT '消息来源 100-用户发送;200-系统发送(一般是通知);', - `message_status` tinyint DEFAULT NULL COMMENT '消息状态', - `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', - PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表'; - -SET FOREIGN_KEY_CHECKS = 1; 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 3232338..5541d51 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 @@ -18,11 +18,27 @@ public interface ErrorCodeConstants { // ========== 消息 (1-040-300-000) ========== ErrorCode MESSAGE_NOT_EXISTS = new ErrorCode(1_040_300_000, "消息不存在"); ErrorCode MESSAGE_RECEIVER_NOT_EXISTS = new ErrorCode(1_040_300_001, "接收人不存在"); + ErrorCode MESSAGE_RECALL_DENIED = new ErrorCode(1_040_300_002, "只能撤回自己发送的消息"); + ErrorCode MESSAGE_ALREADY_RECALLED = new ErrorCode(1_040_300_003, "消息已撤回"); + ErrorCode MESSAGE_SENSITIVE_WORD_BLOCKED = new ErrorCode(1_040_300_004, "消息包含敏感词,无法发送"); + ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量超出限制"); + ErrorCode MESSAGE_CLIENT_ID_DUPLICATE = new ErrorCode(1_040_300_006, "消息已发送,请勿重复提交"); // ========== 群 (1-040-400-000) ========== ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); + ErrorCode GROUP_BANNED = new ErrorCode(1_040_400_001, "群已被封禁"); + ErrorCode GROUP_DISSOLVED = new ErrorCode(1_040_400_002, "群已解散"); // ========== 群成员 (1-040-500-000) ========== ErrorCode GROUP_MEMBER_NOT_EXISTS = new ErrorCode(1_040_500_000, "群成员不存在"); + ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); + ErrorCode GROUP_MEMBER_ALREADY_QUIT = new ErrorCode(1_040_500_002, "您已退出该群"); + + // ========== 好友 (1-040-600-000) ========== + ErrorCode FRIEND_NOT_EXISTS = new ErrorCode(1_040_600_000, "好友关系不存在"); + ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友"); + + // ========== 敏感词 (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/conversation/ImConversationTypeEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/conversation/ImConversationTypeEnum.java index d51dc78..4b3d5b0 100644 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/conversation/ImConversationTypeEnum.java +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/conversation/ImConversationTypeEnum.java @@ -1,11 +1,12 @@ package cn.iocoder.yudao.module.im.enums.conversation; -import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; import lombok.AllArgsConstructor; import lombok.Getter; import java.util.Arrays; +// TODO @AI:还需要这个枚举么? /** * IM 会话类型枚举 * 参考 “会话类型” 文档 @@ -14,13 +15,13 @@ import java.util.Arrays; */ @Getter @AllArgsConstructor -public enum ImConversationTypeEnum implements IntArrayValuable { +public enum ImConversationTypeEnum implements ArrayValuable { SINGLE(1, "单聊"), GROUP(3, "群聊"), NOTIFICATION(4, "通知会话"); - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImConversationTypeEnum::getType).toArray(); + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImConversationTypeEnum::getType).toArray(Integer[]::new); /** * 类型 @@ -57,7 +58,7 @@ public enum ImConversationTypeEnum implements IntArrayValuable { } @Override - public int[] array() { + public Integer[] array() { return ARRAYS; } diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupMemberStatusEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupMemberStatusEnum.java new file mode 100644 index 0000000..b7dbd85 --- /dev/null +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupMemberStatusEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.enums.group; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +// TODO @AI:要不使用 commonstatusenum 替代掉这个; +/** + * IM 群成员状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImGroupMemberStatusEnum implements ArrayValuable { + + NORMAL(0, "正常"), + QUIT(1, "已退出"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMemberStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java new file mode 100644 index 0000000..256560c --- /dev/null +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IM 群消息回执状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable { + + NO_RECEIPT(0, "不需要回执"), + PENDING(1, "待完成"), + DONE(2, "已完成"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageContentTypeEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageContentTypeEnum.java index 4703f03..f501d43 100644 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageContentTypeEnum.java +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageContentTypeEnum.java @@ -1,11 +1,12 @@ package cn.iocoder.yudao.module.im.enums.message; -import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; import lombok.AllArgsConstructor; import lombok.Getter; import java.util.Arrays; +// TODO @AI:还需要这个枚举么? /** * IM 消息的类型枚举 *

@@ -15,7 +16,7 @@ import java.util.Arrays; */ @Getter @AllArgsConstructor -public enum ImMessageContentTypeEnum implements IntArrayValuable { +public enum ImMessageContentTypeEnum implements ArrayValuable { TEXT(101, "文本消息"), PICTURE(102, "图片消息"), @@ -60,7 +61,7 @@ public enum ImMessageContentTypeEnum implements IntArrayValuable { BURN_CHANGE(1701, "阅后即焚开启或关闭通知"), REVOKE(2101, "撤回消息通知");; - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImMessageContentTypeEnum::getType).toArray(); + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageContentTypeEnum::getType).toArray(Integer[]::new); /** * 类型 @@ -73,7 +74,7 @@ public enum ImMessageContentTypeEnum implements IntArrayValuable { private final String name; @Override - public int[] array() { + public Integer[] array() { return ARRAYS; } diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSourceEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSourceEnum.java index c1f2393..3f27f25 100644 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSourceEnum.java +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSourceEnum.java @@ -1,22 +1,23 @@ package cn.iocoder.yudao.module.im.enums.message; -import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; import lombok.Getter; import lombok.RequiredArgsConstructor; import java.util.Arrays; +// TODO @AI:还需要这个枚举么? /** * IM 消息的消息来源 */ @RequiredArgsConstructor @Getter -public enum ImMessageSourceEnum implements IntArrayValuable { +public enum ImMessageSourceEnum implements ArrayValuable { USER_SEND(100, "用户发送"), SYSTEM_SEND(200, "系统发送"); - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImMessageSourceEnum::getSource).toArray(); + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageSourceEnum::getSource).toArray(Integer[]::new); /** * 状态 @@ -28,7 +29,7 @@ public enum ImMessageSourceEnum implements IntArrayValuable { private final String name; @Override - public int[] array() { + public Integer[] array() { return ARRAYS; } 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 e9d8b9a..04903f3 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 @@ -1,26 +1,37 @@ package cn.iocoder.yudao.module.im.enums.message; -import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; import lombok.Getter; import lombok.RequiredArgsConstructor; import java.util.Arrays; /** - * IM 消息的状态枚举 + * IM 私聊消息状态枚举 + *

+ * 私聊:UNREAD / READ / RECALL + * 群聊:NORMAL / RECALL + * + * @author 芋道源码 */ @RequiredArgsConstructor @Getter -public enum ImMessageStatusEnum implements IntArrayValuable { +public enum ImMessageStatusEnum implements ArrayValuable { - DRAFT(0, "草稿"), - SENDING(1, "发送中"), - SUCCESS(2, "发送成功"), - FAILURE(3, "发送失败"), - DELETED(4, "已删除"), - RECALL(5, "已撤回"); + // ========== 私聊状态 ========== + UNREAD(0, "未读"), + READ(1, "已读"), - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImMessageStatusEnum::getStatus).toArray(); + // ========== 通用状态 ========== + RECALL(2, "已撤回"); + + // TODO @AI:0 这个,不是有 UNREAD 可以用了?! + /** + * 群聊消息的正常状态,与 UNREAD 共用值 0 + */ + public static final int NORMAL = 0; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new); /** * 状态 @@ -33,7 +44,7 @@ public enum ImMessageStatusEnum implements IntArrayValuable { private final String name; @Override - public int[] array() { + public Integer[] array() { return ARRAYS; } diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java new file mode 100644 index 0000000..c9dcb1d --- /dev/null +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IM 消息类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImMessageTypeEnum implements ArrayValuable { + + TEXT(0, "文本"), + IMAGE(1, "图片"), + FILE(2, "文件"), + VOICE(3, "语音"), + VIDEO(4, "视频"), + + RECALL(10, "撤回"), + READ(11, "已读"), + RECEIPT(12, "回执"), + TIP_TEXT(21, "提示文本"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} 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 new file mode 100644 index 0000000..1a17e2e --- /dev/null +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImWebSocketTypeConstants.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.im.enums.message; + +// TODO @芋艿:稍后在 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/group/vo/ImGroupPageReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java index 543a0b1..fdb6016 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java @@ -14,24 +14,15 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_ @ToString(callSuper = true) public class ImGroupPageReqVO extends PageParam { - @Schema(description = "群名字", example = "芋艿") - private String groupName; + @Schema(description = "群名称", example = "芋艿") + private String name; - @Schema(description = "群主id", example = "31460") - private Long ownerId; - - @Schema(description = "群头像") - private String headImage; - - @Schema(description = "群头像缩略图") - private String headImageThumb; + @Schema(description = "群主用户编号", example = "31460") + private Long ownerUserId; @Schema(description = "群公告") private String notice; - @Schema(description = "群备注", example = "你说的对") - private String remark; - @Schema(description = "创建时间") @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) private LocalDateTime[] createTime; diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java index e980686..830b4b5 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java @@ -2,10 +2,11 @@ package cn.iocoder.yudao.module.im.controller.admin.group.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; +import com.alibaba.excel.annotation.*; import java.time.LocalDateTime; -import com.alibaba.excel.annotation.*; +// TODO @AI:不需要导出的注解; @Schema(description = "管理后台 - 群 Response VO") @Data @ExcelIgnoreUnannotated @@ -15,29 +16,29 @@ public class ImGroupRespVO { @ExcelProperty("编号") private Long id; - @Schema(description = "群名字", example = "芋艿") - @ExcelProperty("群名字") - private String groupName; + @Schema(description = "群名称", example = "芋艿") + @ExcelProperty("群名称") + private String name; - @Schema(description = "群主id", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") - @ExcelProperty("群主id") - private Long ownerId; + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") + @ExcelProperty("群主用户编号") + private Long ownerUserId; @Schema(description = "群头像") @ExcelProperty("群头像") - private String headImage; - - @Schema(description = "群头像缩略图") - @ExcelProperty("群头像缩略图") - private String headImageThumb; + private String avatar; @Schema(description = "群公告") @ExcelProperty("群公告") private String notice; - @Schema(description = "群备注", example = "你说的对") - @ExcelProperty("群备注") - private String remark; + @Schema(description = "是否封禁") + @ExcelProperty("是否封禁") + private Boolean banned; + + @Schema(description = "是否解散") + @ExcelProperty("是否解散") + private Boolean dissolved; @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) @ExcelProperty("创建时间") diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java index 69678b5..fb50f70 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; import jakarta.validation.constraints.*; +// TODO @AI:去掉部分不需要的 save 字段; @Schema(description = "管理后台 - 群新增/修改 Request VO") @Data public class ImGroupSaveReqVO { @@ -11,23 +12,26 @@ public class ImGroupSaveReqVO { @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") private Long id; - @Schema(description = "群名字", example = "芋艿") - private String groupName; + @Schema(description = "群名称", example = "芋艿") + private String name; - @Schema(description = "群主id", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") - @NotNull(message = "群主id不能为空") - private Long ownerId; + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") + @NotNull(message = "群主用户编号不能为空") + private Long ownerUserId; @Schema(description = "群头像") - private String headImage; - - @Schema(description = "群头像缩略图") - private String headImageThumb; + private String avatar; @Schema(description = "群公告") private String notice; - @Schema(description = "群备注", example = "你说的对") - private String remark; + @Schema(description = "是否封禁") + private Boolean banned; + + @Schema(description = "封禁原因") + private String bannedReason; + + @Schema(description = "是否解散") + private Boolean dissolved; } \ 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/groupmember/vo/ImGroupMemberPageReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberPageReqVO.java index 7fffdd9..242005e 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberPageReqVO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberPageReqVO.java @@ -17,23 +17,14 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_ @ToString(callSuper = true) public class ImGroupMemberPageReqVO extends PageParam { - @Schema(description = "群 id", example = "13279") + @Schema(description = "群编号", example = "13279") private Long groupId; - @Schema(description = "用户id", example = "21730") + @Schema(description = "用户编号", example = "21730") private Long userId; - @Schema(description = "昵称", example = "芋艿") - private String nickname; - - @Schema(description = "头像") - private String avatar; - - @Schema(description = "组内显示名称", example = "芋艿") - private String aliasName; - - @Schema(description = "备注", example = "你说的对") - private String remark; + @Schema(description = "成员状态", example = "0") + private Integer status; @Schema(description = "创建时间") @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberRespVO.java index 9fe83db..2333de3 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberRespVO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberRespVO.java @@ -7,6 +7,7 @@ import lombok.Data; import java.time.LocalDateTime; +// TODO @AI:去掉部分导出字段 @Schema(description = "管理后台 - 群成员 Response VO") @Data @ExcelIgnoreUnannotated @@ -16,29 +17,37 @@ public class ImGroupMemberRespVO { @ExcelProperty("编号") private Long id; - @Schema(description = "群 id", example = "13279") - @ExcelProperty("群 id") + @Schema(description = "群编号", example = "13279") + @ExcelProperty("群编号") private Long groupId; - @Schema(description = "用户id", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") - @ExcelProperty("用户id") + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") + @ExcelProperty("用户编号") private Long userId; - @Schema(description = "昵称", example = "芋艿") - @ExcelProperty("昵称") - private String nickname; + @Schema(description = "组内显示名", example = "芋艿") + @ExcelProperty("组内显示名") + private String displayUserName; - @Schema(description = "头像") - @ExcelProperty("头像") - private String avatar; + @Schema(description = "群显示备注", example = "核心群") + @ExcelProperty("群显示备注") + private String displayGroupName; - @Schema(description = "组内显示名称", example = "芋艿") - @ExcelProperty("组内显示名称") - private String aliasName; + @Schema(description = "是否免打扰") + @ExcelProperty("是否免打扰") + private Boolean muted; - @Schema(description = "备注", example = "你说的对") - @ExcelProperty("备注") - private String remark; + @Schema(description = "成员状态", example = "0") + @ExcelProperty("成员状态") + private Integer status; + + @Schema(description = "入群时间") + @ExcelProperty("入群时间") + private LocalDateTime joinTime; + + @Schema(description = "退群时间") + @ExcelProperty("退群时间") + private LocalDateTime quitTime; @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) @ExcelProperty("创建时间") diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberSaveReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberSaveReqVO.java index ccec984..1abc4c3 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberSaveReqVO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/groupmember/vo/ImGroupMemberSaveReqVO.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Data; +// TODO @AI:去掉部分不需要的 save 字段; @Schema(description = "管理后台 - 群成员新增/修改 Request VO") @Data public class ImGroupMemberSaveReqVO { @@ -11,23 +12,23 @@ public class ImGroupMemberSaveReqVO { @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17071") private Long id; - @Schema(description = "群 id", example = "13279") + @Schema(description = "群编号", example = "13279") private Long groupId; - @Schema(description = "用户id", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") - @NotNull(message = "用户id不能为空") + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") + @NotNull(message = "用户编号不能为空") private Long userId; - @Schema(description = "昵称", example = "芋艿") - private String nickname; + @Schema(description = "组内显示名", example = "芋艿") + private String displayUserName; - @Schema(description = "头像") - private String avatar; + @Schema(description = "群显示备注", example = "核心群") + private String displayGroupName; - @Schema(description = "组内显示名称", example = "芋艿") - private String aliasName; + @Schema(description = "是否免打扰") + private Boolean muted; - @Schema(description = "备注", example = "你说的对") - private String remark; + @Schema(description = "成员状态", example = "0") + private Integer status; } \ 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/ImGroupMessageController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java new file mode 100644 index 0000000..c86fa97 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.im.controller.admin.message; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.*; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageReadReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 管理后台 - IM 群聊消息 + */ +@Tag(name = "管理后台 - IM 群聊消息") +@RestController +@RequestMapping("/im/message/group") +@Validated +public class ImGroupMessageController { + + @Resource + private ImGroupMessageService imGroupMessageService; + + @PostMapping("/send") + @Operation(summary = "发送群聊消息") + public CommonResult sendMessage(@Valid @RequestBody ImGroupMessageSendReqVO reqVO) { + ImGroupMessageDO message = imGroupMessageService.sendMessage(getLoginUserId(), reqVO); + return success(toRespVO(message)); + } + + // TODO @AI:需要在评估下,是否需要 nextMinId + @GetMapping("/pull") + @Operation(summary = "拉取群聊消息(增量)") + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") + @Parameter(name = "size", description = "拉取数量", required = true, example = "100") + public CommonResult> pullMessages( + @RequestParam("minId") Long minId, + @RequestParam("size") Integer size) { + List messages = imGroupMessageService.pullMessages(getLoginUserId(), minId, size); + ImMessagePullRespVO resp = new ImMessagePullRespVO<>(); + resp.setList(messages.stream().map(this::toRespVO).collect(Collectors.toList())); + resp.setNextMinId(messages.isEmpty() ? minId.toString() + : messages.get(messages.size() - 1).getId().toString()); + return success(resp); + } + + // TODO @AI:它是不是不用 VO + @PutMapping("/read") + @Operation(summary = "标记群聊消息已读") + public CommonResult readMessages(@Valid @RequestBody ImGroupMessageReadReqVO reqVO) { + imGroupMessageService.readMessages(getLoginUserId(), reqVO.getGroupId()); + return success(true); + } + + // TODO @AI:不使用 path variable + @DeleteMapping("/recall/{id}") + @Operation(summary = "撤回群聊消息") + @Parameter(name = "id", description = "消息编号", required = true, example = "1") + public CommonResult recallMessage(@PathVariable("id") Long id) { + imGroupMessageService.recallMessage(getLoginUserId(), id); + return success(true); + } + + // TODO @AI:这个接口,确认下是否需要! + @GetMapping("/read-users") + @Operation(summary = "获取群消息已读用户列表") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1") + @Parameter(name = "messageId", description = "消息编号", required = true, example = "1") + public CommonResult> getReadUsers( + @RequestParam("groupId") Long groupId, + @RequestParam("messageId") Long messageId) { + List readUserIds = imGroupMessageService.getReadUsers(getLoginUserId(), groupId, messageId); + // Long -> String 转换 + return success(readUserIds.stream().map(String::valueOf).collect(Collectors.toList())); + } + + /** + * DO 转换为 RespVO(Long id 统一转 String) + */ + // TODO @AI:BeanUtils? + private ImGroupMessageRespVO toRespVO(ImGroupMessageDO message) { + ImGroupMessageRespVO vo = new ImGroupMessageRespVO(); + vo.setId(message.getId().toString()); + vo.setClientMessageId(message.getClientMessageId()); + vo.setSenderId(message.getSenderId().toString()); + vo.setGroupId(message.getGroupId().toString()); + vo.setType(message.getType()); + vo.setContent(message.getContent()); + vo.setStatus(message.getStatus()); + vo.setSendTime(message.getSendTime()); + vo.setAtUserIds(message.getAtUserIds()); + vo.setReceiverUserIds(message.getReceiverUserIds()); + vo.setReceiptStatus(message.getReceiptStatus()); + return vo; + } + +} 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 new file mode 100644 index 0000000..84796d4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.im.controller.admin.message; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.*; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageReadReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageRespVO; +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.service.message.ImPrivateMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 管理后台 - IM 私聊消息 + */ +@Tag(name = "管理后台 - IM 私聊消息") +@RestController +@RequestMapping("/im/message/private") +@Validated +public class ImPrivateMessageController { + + @Resource + private ImPrivateMessageService imPrivateMessageService; + + @PostMapping("/send") + @Operation(summary = "发送私聊消息") + public CommonResult sendMessage(@Valid @RequestBody ImPrivateMessageSendReqVO reqVO) { + ImPrivateMessageDO message = imPrivateMessageService.sendMessage(getLoginUserId(), reqVO); + return success(toRespVO(message)); + } + + // TODO @AI:需要在评估下,是否需要 nextMinId + @GetMapping("/pull") + @Operation(summary = "拉取私聊消息(增量)") + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") + @Parameter(name = "size", description = "拉取数量", required = true, example = "100") + public CommonResult> pullMessages( + @RequestParam("minId") Long minId, + @RequestParam("size") Integer size) { + List messages = imPrivateMessageService.pullMessages(getLoginUserId(), minId, size); + ImMessagePullRespVO resp = new ImMessagePullRespVO<>(); + resp.setList(messages.stream().map(this::toRespVO).collect(Collectors.toList())); + resp.setNextMinId(messages.isEmpty() ? minId.toString() + : messages.get(messages.size() - 1).getId().toString()); + return success(resp); + } + + // TODO @AI:它是不是不用 VO + @PutMapping("/read") + @Operation(summary = "标记私聊消息已读") + public CommonResult readMessages(@Valid @RequestBody ImPrivateMessageReadReqVO reqVO) { + imPrivateMessageService.readMessages(getLoginUserId(), reqVO.getFriendId()); + return success(true); + } + + // TODO @AI:不使用 path variable + @DeleteMapping("/recall/{id}") + @Operation(summary = "撤回私聊消息") + @Parameter(name = "id", description = "消息编号", required = true, example = "1") + public CommonResult recallMessage(@PathVariable("id") Long id) { + imPrivateMessageService.recallMessage(getLoginUserId(), id); + return success(true); + } + + // TODO @AI:beanutils + /** + * DO 转换为 RespVO(Long id 统一转 String) + */ + private ImPrivateMessageRespVO toRespVO(ImPrivateMessageDO message) { + ImPrivateMessageRespVO vo = new ImPrivateMessageRespVO(); + vo.setId(message.getId().toString()); + vo.setClientMessageId(message.getClientMessageId()); + vo.setSenderId(message.getSenderId().toString()); + vo.setReceiverId(message.getReceiverId().toString()); + vo.setType(message.getType()); + vo.setContent(message.getContent()); + vo.setStatus(message.getStatus()); + vo.setSendTime(message.getSendTime()); + return vo; + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/ImMessagePullRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/ImMessagePullRespVO.java new file mode 100644 index 0000000..0550aa4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/ImMessagePullRespVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 消息拉取 Response VO + */ +@Schema(description = "管理后台 - 消息拉取 Response VO") +@Data +public class ImMessagePullRespVO { + + @Schema(description = "消息列表") + private List list; + + @Schema(description = "下次拉取的最小 id", example = "100") + private String nextMinId; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageReadReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageReadReqVO.java new file mode 100644 index 0000000..6f0af25 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageReadReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 群聊已读 Request VO + */ +@Schema(description = "管理后台 - 群聊已读 Request VO") +@Data +public class ImGroupMessageReadReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "群编号不能为空") + private Long groupId; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java new file mode 100644 index 0000000..dc2c9e7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 群聊消息 Response VO + */ +@Schema(description = "管理后台 - 群聊消息 Response VO") +@Data +public class ImGroupMessageRespVO { + + @Schema(description = "消息编号", example = "1") + private String id; + + @Schema(description = "客户端消息编号", example = "uuid-xxx") + private String clientMessageId; + + @Schema(description = "发送人编号", example = "1") + private String senderId; + + @Schema(description = "群编号", example = "1") + private String groupId; + + @Schema(description = "消息类型", example = "0") + private Integer type; + + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; + + @Schema(description = "发送时间") + private LocalDateTime sendTime; + + @Schema(description = "@目标用户编号列表", example = "1,2,3") + private String atUserIds; + + @Schema(description = "定向接收用户编号列表", example = "1,2") + private String receiverUserIds; + + @Schema(description = "回执状态", example = "0") + private Integer receiptStatus; + + @Schema(description = "已读人数(群回执使用)", example = "5") + private Integer readCount; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java new file mode 100644 index 0000000..dfc6ef9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 群聊消息发送 Request VO + */ +@Schema(description = "管理后台 - 群聊消息发送 Request VO") +@Data +public class ImGroupMessageSendReqVO { + + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") + @NotEmpty(message = "客户端消息编号不能为空") + private String clientMessageId; + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "消息类型不能为空") + private Integer type; + + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") + @NotEmpty(message = "消息内容不能为空") + private String content; + + @Schema(description = "@目标用户编号列表", example = "[1,2,3]") + private List atUserIds; + + @Schema(description = "定向接收用户编号列表", example = "[1,2]") + private List receiverUserIds; + + @Schema(description = "是否需要回执", example = "false") + private Boolean needReceipt; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageReadReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageReadReqVO.java new file mode 100644 index 0000000..5a68e87 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageReadReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 私聊已读 Request VO + */ +@Schema(description = "管理后台 - 私聊已读 Request VO") +@Data +public class ImPrivateMessageReadReqVO { + + @Schema(description = "好友用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "好友用户编号不能为空") + private Long friendId; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java new file mode 100644 index 0000000..adfde17 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 私聊消息 Response VO + */ +@Schema(description = "管理后台 - 私聊消息 Response VO") +@Data +public class ImPrivateMessageRespVO { + + // TODO @AI:类型不对; + @Schema(description = "消息编号", example = "1") + private String id; + + @Schema(description = "客户端消息编号", example = "uuid-xxx") + private String clientMessageId; + + @Schema(description = "发送人编号", example = "1") + private String senderId; + + @Schema(description = "接收人编号", example = "2") + private String receiverId; + + @Schema(description = "消息类型", example = "0") + private Integer type; + + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; + + @Schema(description = "发送时间") + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java new file mode 100644 index 0000000..cff487a --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 私聊消息发送 Request VO + */ +@Schema(description = "管理后台 - 私聊消息发送 Request VO") +@Data +public class ImPrivateMessageSendReqVO { + + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") + @NotEmpty(message = "客户端消息编号不能为空") + private String clientMessageId; + + @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "接收人编号不能为空") + private Long receiverId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "消息类型不能为空") + private Integer type; + + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") + @NotEmpty(message = "消息内容不能为空") + private String content; + +} 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 new file mode 100644 index 0000000..a351935 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.friend; + +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.*; + +// TODO @AI:好友关系是逻辑删除,群成员是 status 标识,感觉是不是要统一下?感觉都改成 status 会不会好点?这样增加一个添加时间、和删除时间;都使用 statusenum 标识; +/** + * IM 好友关系 DO + *

+ * 删除好友采用逻辑删除,沿用 BaseDO 的 deleted。 + * 重新添加好友时优先恢复原关系记录,不新插重复行。 + * + * @author 芋道源码 + */ +@TableName("im_friend") +@KeySequence("im_friend_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImFriendDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 好友用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long friendUserId; + /** + * 是否免打扰 + */ + private Boolean muted; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java index 727f1aa..1bbc793 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java @@ -26,32 +26,37 @@ public class ImGroupDO extends BaseDO { */ @TableId private Long id; - // TODO @hao:name,如果这个表已经是 group 了,不用在带额外的 /** - * 群名字 + * 群名称 */ - private String groupName; - // TODO @hao:关联字段; + private String name; /** - * 群主编号 + * 群主用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 */ - private Long ownerId; - // TODO @hao:头像使用 avatar 好了,整个项目统一;然后 Thumb 是不是不用存,这个更多是文件服务做裁剪 + private Long ownerUserId; /** * 群头像 */ - private String headImage; - /** - * 群头像缩略图 - */ - private String headImageThumb; + private String avatar; /** * 群公告 */ private String notice; /** - * 群备注 + * 是否封禁 + */ + private Boolean banned; + /** + * 封禁原因 + */ + private String bannedReason; + // TODO @AI:封禁时间; + /** + * 是否解散 */ - private String remark; + private Boolean dissolved; + // TODO @AI:解散时间 } \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java index 6d7ada1..140b5ac 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java @@ -1,9 +1,15 @@ package cn.iocoder.yudao.module.im.dal.dataobject.group; -import lombok.*; -import com.baomidou.mybatisplus.annotation.*; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; +import java.time.LocalDateTime; + +// TODO @AI:好友关系是逻辑删除,群成员是 status 标识,感觉是不是要统一下? /** * IM 群成员 DO * @@ -24,31 +30,43 @@ public class ImGroupMemberDO extends BaseDO { */ @TableId private Long id; - // TODO @hao:groupId 和 userId 都写下关联字段哈 /** * 群编号 + *

+ * 关联 {@link ImGroupDO#getId()} */ private Long groupId; /** * 用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 */ private Long userId; - // TODO @hao:nickname 和 avatar 是不是不用存储哈; /** - * 昵称 + * 组内显示名 + */ + private String displayUserName; + /** + * 群显示备注 + */ + private String displayGroupName; + /** + * 是否免打扰 */ - private String nickname; + private Boolean muted; /** - * 头像 + * 成员状态 + *

+ * 枚举 {@link ImGroupMemberStatusEnum} */ - private String avatar; + private Integer status; /** - * 组内显示名称 + * 入群时间 */ - private String aliasName; + private LocalDateTime joinTime; /** - * 备注 + * 退群时间 */ - private String remark; + private LocalDateTime quitTime; } \ No newline at end of file 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 new file mode 100644 index 0000000..9f50a64 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java @@ -0,0 +1,98 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.message; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +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 + * + * @author 芋道源码 + */ +@TableName("im_group_message") +@KeySequence("im_group_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 客户端消息编号,用于发送幂等 + */ + private String clientMessageId; + /** + * 发送人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long senderId; + /** + * 群编号 + * + * 关联 {@link ImGroupDO#getId()} + */ + private Long groupId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容,JSON 格式 + * + * TODO @AI:/Users/yunai/Java/yudao-all-in-im/ruoyi-vue-pro/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content + */ + private String content; + /** + * 消息状态 + *

+ * 枚举 {@link ImMessageStatusEnum} + * 群聊状态:NORMAL(0) / RECALL(2) + */ + // TODO @AI:会不会存在 READ 的情况?如果不存在,最好说明下,和单聊的差异; + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + /** + * 定向接收用户编号列表,逗号分隔 + *

+ * 为空表示全员可见 + * + * 关联 AdminUserDO 的 id 字段 + */ + // TODO @AI:使用 List 存储,避免逗号分隔的字符串解析问题 + private String receiverUserIds; + /** + * @ 目标用户编号列表,逗号分隔 + * + * 关联 AdminUserDO 的 id 字段 + */ + // TODO @AI:使用 List 存储,避免逗号分隔的字符串解析问题 + private String atUserIds; + /** + * 回执状态 + *

+ * 枚举 {@link ImGroupMessageReceiptStatusEnum} + */ + private Integer receiptStatus; + +} 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 new file mode 100644 index 0000000..084b8f2 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.message; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +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 + * + * @author 芋道源码 + */ +@TableName("im_private_message") +@KeySequence("im_private_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImPrivateMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 客户端消息编号,用于发送幂等 + */ + private String clientMessageId; + /** + * 发送人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long senderId; + /** + * 接收人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long receiverId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容,JSON 格式 + * + * TODO @AI:/Users/yunai/Java/yudao-all-in-im/ruoyi-vue-pro/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/content + */ + private String content; + /** + * 消息状态 + *

+ * 枚举 {@link ImMessageStatusEnum} + * 私聊状态:UNREAD(0) / READ(1) / RECALL(2) + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java new file mode 100644 index 0000000..4f6d1fc --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword; + +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.*; + +/** + * IM 敏感词 DO + * + * @author 芋道源码 + */ +@TableName("im_sensitive_word") +@KeySequence("im_sensitive_word_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImSensitiveWordDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 敏感词 + */ + private String word; + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + 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 new file mode 100644 index 0000000..e317be2 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.im.dal.mysql.friend; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +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; + +/** + * IM 好友关系 Mapper + * + * @author 芋道源码 + */ +@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)); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java index e9eb282..46432ae 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java @@ -7,9 +7,8 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; import org.apache.ibatis.annotations.Mapper; import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; -// TODO @hao:这个也要有 Im 前缀 /** - * 群 Mapper + * IM 群 Mapper * * @author 芋道源码 */ @@ -18,12 +17,9 @@ public interface ImGroupMapper extends BaseMapperX { default PageResult selectPage(ImGroupPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX() - .likeIfPresent(ImGroupDO::getGroupName, reqVO.getGroupName()) - .eqIfPresent(ImGroupDO::getOwnerId, reqVO.getOwnerId()) - .eqIfPresent(ImGroupDO::getHeadImage, reqVO.getHeadImage()) - .eqIfPresent(ImGroupDO::getHeadImageThumb, reqVO.getHeadImageThumb()) + .likeIfPresent(ImGroupDO::getName, reqVO.getName()) + .eqIfPresent(ImGroupDO::getOwnerUserId, reqVO.getOwnerUserId()) .eqIfPresent(ImGroupDO::getNotice, reqVO.getNotice()) - .eqIfPresent(ImGroupDO::getRemark, reqVO.getRemark()) .betweenIfPresent(ImGroupDO::getCreateTime, reqVO.getCreateTime()) .orderByDesc(ImGroupDO::getId)); } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/groupmember/ImGroupMemberMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/groupmember/ImGroupMemberMapper.java index 5b4a410..60db092 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/groupmember/ImGroupMemberMapper.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/groupmember/ImGroupMemberMapper.java @@ -10,7 +10,7 @@ import org.apache.ibatis.annotations.Mapper; import java.util.List; /** - * 群成员 Mapper + * IM 群成员 Mapper * * @author 芋道源码 */ @@ -21,10 +21,7 @@ public interface ImGroupMemberMapper extends BaseMapperX { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(ImGroupMemberDO::getGroupId, reqVO.getGroupId()) .eqIfPresent(ImGroupMemberDO::getUserId, reqVO.getUserId()) - .likeIfPresent(ImGroupMemberDO::getNickname, reqVO.getNickname()) - .eqIfPresent(ImGroupMemberDO::getAvatar, reqVO.getAvatar()) - .likeIfPresent(ImGroupMemberDO::getAliasName, reqVO.getAliasName()) - .eqIfPresent(ImGroupMemberDO::getRemark, reqVO.getRemark()) + .eqIfPresent(ImGroupMemberDO::getStatus, reqVO.getStatus()) .betweenIfPresent(ImGroupMemberDO::getCreateTime, reqVO.getCreateTime()) .orderByDesc(ImGroupMemberDO::getId)); } @@ -32,4 +29,14 @@ public interface ImGroupMemberMapper extends BaseMapperX { default List selectListByGroupId(Long groupId) { return selectList(new LambdaQueryWrapperX().eq(ImGroupMemberDO::getGroupId, groupId)); } + + default ImGroupMemberDO selectByGroupIdAndUserId(Long groupId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getUserId, userId)); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX().eq(ImGroupMemberDO::getUserId, userId)); + } } \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java new file mode 100644 index 0000000..2058b55 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 群聊消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupMessageMapper extends BaseMapperX { + + /** + * 根据群编号和 minId 增量拉取群聊消息 + * + * @param groupIds 用户所在的群编号列表 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + default List selectListByMinId(List groupIds, Long minId, Integer size) { + return selectList(new LambdaQueryWrapperX() + .in(ImGroupMessageDO::getGroupId, groupIds) + .gt(ImGroupMessageDO::getId, minId) + .orderByAsc(ImGroupMessageDO::getId) + .last("LIMIT " + size)); + } + + /** + * 根据 senderId 和 clientMessageId 查询(幂等校验) + */ + default ImGroupMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupMessageDO::getSenderId, senderId) + .eq(ImGroupMessageDO::getClientMessageId, clientMessageId)); + } + + /** + * 获取群内最新消息 id + */ + default Long selectMaxIdByGroupId(Long groupId) { + ImGroupMessageDO msg = selectOne(new LambdaQueryWrapperX() + .eq(ImGroupMessageDO::getGroupId, groupId) + .orderByDesc(ImGroupMessageDO::getId) + .last("LIMIT 1")); + return msg == null ? null : msg.getId(); + } + + /** + * 获取群内需要回执的消息(状态为 PENDING) + */ + default List selectPendingReceiptMessages(Long groupId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMessageDO::getGroupId, groupId) + .eq(ImGroupMessageDO::getReceiptStatus, 1)); // PENDING + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java new file mode 100644 index 0000000..1d63e8f --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 私聊消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImPrivateMessageMapper extends BaseMapperX { + + /** + * 根据 minId 增量拉取私聊消息 + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + default List selectListByMinId(Long userId, Long minId, Integer size) { + return selectList(new LambdaQueryWrapperX() + .and(w -> w.eq(ImPrivateMessageDO::getSenderId, userId) + .or() + .eq(ImPrivateMessageDO::getReceiverId, userId)) + .gt(ImPrivateMessageDO::getId, minId) + .orderByAsc(ImPrivateMessageDO::getId) + .last("LIMIT " + size)); + } + + /** + * 根据 senderId 和 clientMessageId 查询(幂等校验) + */ + default ImPrivateMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, senderId) + .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); + } + + /** + * 批量更新私聊消息已读状态 + * 将发送方 = friendId、接收方 = userId 且状态为 UNREAD 的消息更新为 READ + */ + default int updateStatusToRead(Long userId, Long friendId) { + return update(new ImPrivateMessageDO().setStatus(1), // READ + new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, friendId) + .eq(ImPrivateMessageDO::getReceiverId, userId) + .eq(ImPrivateMessageDO::getStatus, 0)); // UNREAD + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java new file mode 100644 index 0000000..77c01a3 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.im.dal.mysql.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 敏感词 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImSensitiveWordMapper extends BaseMapperX { + + // TODO @AI:应该 selectListByStatus + default List selectListByEnabled() { + return selectList(new LambdaQueryWrapperX() + .eq(ImSensitiveWordDO::getStatus, CommonStatusEnum.ENABLE.getStatus())); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java index 76dba12..f064fda 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.im.dal.redis; /** - * im Redis Key 枚举类 + * IM Redis Key 枚举类 * * @author 芋道源码 */ @@ -21,4 +21,12 @@ public interface RedisKeyConstants { * VALUE 数据类型: String */ String INBOX_LOCK = "im_inbox_lock:%s"; + + /** + * 群已读位置 + * KEY 格式: im:group:read:{groupId} + * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId) + */ + String GROUP_READ_POSITION = "im:group:read:%s"; + } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/group/GroupReadPositionRedisDAO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/group/GroupReadPositionRedisDAO.java new file mode 100644 index 0000000..c21f332 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/group/GroupReadPositionRedisDAO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.im.dal.redis.group; + +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Map; + +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_READ_POSITION; + +/** + * 群已读位置 Redis DAO + *

+ * 采用 Redis Hash 存储:key=im:group:read:{groupId}, field=userId, value=maxReadMessageId + * + * @author 芋道源码 + */ +@Repository +public class GroupReadPositionRedisDAO { + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 更新用户在某群的已读位置 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @param maxMessageId 最大已读消息编号 + */ + public void updateReadPosition(Long groupId, Long userId, Long maxMessageId) { + String key = String.format(GROUP_READ_POSITION, groupId); + // 只在新值大于旧值时更新 + Object currentVal = stringRedisTemplate.opsForHash().get(key, userId.toString()); + if (currentVal == null || Long.parseLong(currentVal.toString()) < maxMessageId) { + stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); + } + } + + /** + * 获取用户在某群的已读位置 + */ + public Long getReadPosition(Long groupId, Long userId) { + String key = String.format(GROUP_READ_POSITION, groupId); + Object val = stringRedisTemplate.opsForHash().get(key, userId.toString()); + return val == null ? 0L : Long.parseLong(val.toString()); + } + + /** + * 获取某群所有用户的已读位置 + */ + public Map getAllReadPositions(Long groupId) { + String key = String.format(GROUP_READ_POSITION, groupId); + return stringRedisTemplate.opsForHash().entries(key); + } + + /** + * 删除某群所有用户的已读位置(群解散时调用) + */ + public void deleteGroupPositions(Long groupId) { + String key = String.format(GROUP_READ_POSITION, groupId); + stringRedisTemplate.delete(key); + } + + /** + * 删除用户在某群的已读位置(退群时调用) + */ + public void deleteUserPosition(Long groupId, Long userId) { + String key = String.format(GROUP_READ_POSITION, groupId); + stringRedisTemplate.opsForHash().delete(key, userId.toString()); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java new file mode 100644 index 0000000..85b77dd --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.im.service.friend; + +/** + * IM 好友关系 Service 接口 + * + * @author 芋道源码 + */ +public interface ImFriendService { + + /** + * 校验两个用户是否是好友关系(未删除状态) + *

+ * 如果不是好友,直接抛出业务异常 + * + * @param userId 用户编号 + * @param friendUserId 好友用户编号 + */ + void validateIsFriend(Long userId, Long friendUserId); + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java new file mode 100644 index 0000000..3053da8 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; + +/** + * IM 好友关系 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImFriendServiceImpl implements ImFriendService { + + @Resource + private ImFriendMapper imFriendMapper; + + // TODO @AI:逻辑这块,在优化下; + @Override + public void validateIsFriend(Long userId, Long friendUserId) { + // 好友关系表中,deleted = false 的记录表示仍是好友 + // BaseMapperX 默认会过滤 deleted = true 的记录 + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (friend == null) { + throw exception(FRIEND_NOT_FRIEND); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java index 4ebc373..a85e9b2 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java @@ -50,4 +50,12 @@ public interface ImGroupService { */ PageResult getGroupPage(ImGroupPageReqVO pageReqVO); + /** + * 校验群存在且未封禁、未解散 + * + * @param groupId 群编号 + * @return 群信息 + */ + ImGroupDO validateGroupExists(Long groupId); + } \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java index e7e20e9..7c05ff7 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java @@ -52,10 +52,19 @@ public class ImGroupServiceImpl implements ImGroupService { imGroupMapper.deleteById(id); } - private void validateGroupExists(Long id) { - if (imGroupMapper.selectById(id) == null) { + @Override + public ImGroupDO validateGroupExists(Long id) { + ImGroupDO group = imGroupMapper.selectById(id); + if (group == null) { throw exception(GROUP_NOT_EXISTS); } + if (Boolean.TRUE.equals(group.getBanned())) { + throw exception(GROUP_BANNED); + } + if (Boolean.TRUE.equals(group.getDissolved())) { + throw exception(GROUP_DISSOLVED); + } + return group; } @Override diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberService.java index 44f4f70..6d75136 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberService.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberService.java @@ -60,4 +60,21 @@ public interface ImGroupMemberService { * @return 群成员列表 */ List selectByGroupId(Long groupId); + + /** + * 根据群编号和用户编号查询群成员 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 群成员 + */ + ImGroupMemberDO getGroupMember(Long groupId, Long userId); + + /** + * 查询用户所在的所有群的成员记录 + * + * @param userId 用户编号 + * @return 群成员记录列表 + */ + List getGroupMembersByUserId(Long userId); } \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberServiceImpl.java index 1d5ac42..1c81819 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberServiceImpl.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/groupmember/ImGroupMemberServiceImpl.java @@ -75,4 +75,14 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { return imGroupMemberMapper.selectListByGroupId(groupId); } + @Override + public ImGroupMemberDO getGroupMember(Long groupId, Long userId) { + return imGroupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + } + + @Override + public List getGroupMembersByUserId(Long userId) { + return imGroupMemberMapper.selectListByUserId(userId); + } + } \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/inbox/ImInboxService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/inbox/ImInboxService.java index f9b1793..10cb04b 100755 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/inbox/ImInboxService.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/inbox/ImInboxService.java @@ -19,7 +19,7 @@ public interface ImInboxService { void saveInboxAndSendMessage(ImMessageDO imMessageDO); /** - * 获得大于 sequence 的消息ids + * 获得大于 sequence 的消息 ids * * @param userId 用户编号 * @param sequence 序列号 diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java new file mode 100644 index 0000000..41d88e3 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; + +import java.util.List; + +/** + * IM 群聊消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupMessageService { + + /** + * 发送群聊消息 + * + * @param senderId 发送人编号 + * @param reqVO 发送请求 + * @return 消息 + */ + ImGroupMessageDO sendMessage(Long senderId, ImGroupMessageSendReqVO reqVO); + + /** + * 拉取群聊消息(增量) + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + List pullMessages(Long userId, Long minId, Integer size); + + /** + * 标记群聊消息已读 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + */ + void readMessages(Long userId, Long groupId); + + /** + * 撤回群聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + */ + void recallMessage(Long userId, Long messageId); + + /** + * 获取群消息的已读用户列表 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param messageId 消息编号 + * @return 已读用户编号列表 + */ + List getReadUsers(Long userId, Long groupId, Long messageId); + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java new file mode 100644 index 0000000..267385d --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -0,0 +1,390 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +import cn.iocoder.yudao.module.im.dal.redis.group.GroupReadPositionRedisDAO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +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.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.groupmember.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +// TODO @AI:参考 private message 先优化一把! +/** + * IM 群聊消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImGroupMessageServiceImpl implements ImGroupMessageService { + + private static final int MAX_PULL_SIZE = 1000; + + @Resource + private ImGroupMessageMapper imGroupMessageMapper; + @Resource + private ImGroupService imGroupService; + @Resource + private ImGroupMemberService imGroupMemberService; + @Resource + private ImSensitiveWordService imSensitiveWordService; + @Resource + private GroupReadPositionRedisDAO groupReadPositionRedisDAO; + @Resource + private WebSocketSenderApi webSocketSenderApi; + + @Override + public ImGroupMessageDO sendMessage(Long senderId, ImGroupMessageSendReqVO reqVO) { + // 1. 幂等校验 + ImGroupMessageDO existing = imGroupMessageMapper.selectBySenderIdAndClientMessageId( + senderId, reqVO.getClientMessageId()); + if (existing != null) { + return existing; + } + + // 2. 校验群存在且未解散 + ImGroupDO group = imGroupService.validateGroupExists(reqVO.getGroupId()); + + // 3. 校验发送人在群中 + ImGroupMemberDO member = imGroupMemberService.getGroupMember(reqVO.getGroupId(), senderId); + if (member == null || ImGroupMemberStatusEnum.QUIT.getStatus().equals(member.getStatus())) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + + // 4. 文本消息敏感词过滤 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { + imSensitiveWordService.validateText(reqVO.getContent()); + } + + // 5. 构建消息 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .clientMessageId(reqVO.getClientMessageId()) + .senderId(senderId) + .groupId(reqVO.getGroupId()) + .type(reqVO.getType()) + .content(reqVO.getContent()) + .status(ImMessageStatusEnum.NORMAL) // 群聊正常状态 = 0 + .sendTime(LocalDateTime.now()) + .atUserIds(CollUtil.isNotEmpty(reqVO.getAtUserIds()) + ? StrUtil.join(",", reqVO.getAtUserIds()) : null) + .receiverUserIds(CollUtil.isNotEmpty(reqVO.getReceiverUserIds()) + ? StrUtil.join(",", reqVO.getReceiverUserIds()) : null) + .receiptStatus(Boolean.TRUE.equals(reqVO.getNeedReceipt()) + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) + .build(); + imGroupMessageMapper.insert(message); + + // 6. WebSocket 推送给群成员 + sendGroupMessageEvent(message, reqVO.getGroupId()); + return message; + } + + @Override + public List pullMessages(Long userId, Long minId, Integer size) { + if (size > MAX_PULL_SIZE) { + throw exception(MESSAGE_PULL_SIZE_EXCEEDED); + } + // 获取用户所在的所有群 + List members = imGroupMemberService.getGroupMembersByUserId(userId); + if (CollUtil.isEmpty(members)) { + return Collections.emptyList(); + } + List groupIds = members.stream() + .map(ImGroupMemberDO::getGroupId) + .collect(Collectors.toList()); + + // 拉取消息 + List messages = imGroupMessageMapper.selectListByMinId(groupIds, minId, size); + + // 过滤:入群前消息不可见、定向接收过滤、退群后消息边界 + Map memberMap = members.stream() + .collect(Collectors.toMap(ImGroupMemberDO::getGroupId, m -> m, (a, b) -> a)); + + return messages.stream().filter(msg -> { + ImGroupMemberDO m = memberMap.get(msg.getGroupId()); + if (m == null) { + return false; + } + // 入群前消息不可见 + if (m.getJoinTime() != null && msg.getSendTime().isBefore(m.getJoinTime())) { + return false; + } + // 退群后消息边界 + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus()) + && m.getQuitTime() != null && msg.getSendTime().isAfter(m.getQuitTime())) { + return false; + } + // 定向接收过滤 + if (StrUtil.isNotBlank(msg.getReceiverUserIds())) { + Set receivers = new HashSet<>(Arrays.asList(msg.getReceiverUserIds().split(","))); + if (!receivers.contains(userId.toString()) && !msg.getSenderId().equals(userId)) { + return false; + } + } + return true; + }).collect(Collectors.toList()); + } + + @Override + public void readMessages(Long userId, Long groupId) { + // 1. 校验用户在群中(权限校验) + ImGroupMemberDO member = imGroupMemberService.getGroupMember(groupId, userId); + if (member == null || ImGroupMemberStatusEnum.QUIT.getStatus().equals(member.getStatus())) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + + // 2. 高效获取群内最新消息 id(倒序 LIMIT 1,非全量扫描) + Long maxMessageId = imGroupMessageMapper.selectMaxIdByGroupId(groupId); + if (maxMessageId == null) { + return; + } + + // 3. 更新 Redis 群已读位置 + groupReadPositionRedisDAO.updateReadPosition(groupId, userId, maxMessageId); + + // 4. 发送 READ 事件给自己的其他终端 + Map readEvent = new HashMap<>(); + readEvent.put("groupId", groupId.toString()); + readEvent.put("messageScene", "group"); + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImWebSocketTypeConstants.READ, readEvent); + + // 5. 刷新群回执 + refreshGroupReceipts(groupId); + } + + @Override + public void recallMessage(Long userId, Long messageId) { + // 1. 校验消息存在 + ImGroupMessageDO message = imGroupMessageMapper.selectById(messageId); + if (message == null) { + throw exception(MESSAGE_NOT_EXISTS); + } + // 2. 只能撤回自己发送的消息 + if (!message.getSenderId().equals(userId)) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 3. 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + // 4. 更新消息状态 + ImGroupMessageDO updateObj = new ImGroupMessageDO(); + updateObj.setId(messageId); + updateObj.setStatus(ImMessageStatusEnum.RECALL.getStatus()); + imGroupMessageMapper.updateById(updateObj); + + // 5. 发送 RECALL 事件给所有群成员 + List members = imGroupMemberService.selectByGroupId(message.getGroupId()); + Map recallEvent = new HashMap<>(); + recallEvent.put("messageId", messageId.toString()); + recallEvent.put("groupId", message.getGroupId().toString()); + recallEvent.put("messageScene", "group"); + recallEvent.put("senderId", userId.toString()); + for (ImGroupMemberDO m : members) { + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus())) { + continue; + } + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), m.getUserId(), + ImWebSocketTypeConstants.RECALL, recallEvent); + } + } + + @Override + public List getReadUsers(Long userId, Long groupId, Long messageId) { + // 1. 校验用户在群中(权限校验) + ImGroupMemberDO currentMember = imGroupMemberService.getGroupMember(groupId, userId); + if (currentMember == null || ImGroupMemberStatusEnum.QUIT.getStatus().equals(currentMember.getStatus())) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + + // 2. 获取消息 + ImGroupMessageDO message = imGroupMessageMapper.selectById(messageId); + if (message == null) { + return Collections.emptyList(); + } + + // 2. 获取所有成员和已读位置 + List allMembers = imGroupMemberService.selectByGroupId(groupId); + Map allPositions = groupReadPositionRedisDAO.getAllReadPositions(groupId); + + // 3. 计算该消息的可见成员集合 + Set visibleUserIds = getVisibleUserIds(message, allMembers); + + // 4. 只返回在可见范围内且已读位置 >= messageId 的用户 + List readUserIds = new ArrayList<>(); + for (Map.Entry entry : allPositions.entrySet()) { + Long uid = Long.parseLong(entry.getKey().toString()); + Long readMaxId = Long.parseLong(entry.getValue().toString()); + if (readMaxId >= messageId && visibleUserIds.contains(uid) + && !uid.equals(message.getSenderId())) { // 排除发送者自己 + readUserIds.add(uid); + } + } + return readUserIds; + } + + /** + * 刷新群回执(仅处理 PENDING 状态的消息,无需全量扫描) + */ + private void refreshGroupReceipts(Long groupId) { + // 1. 只查 PENDING 状态的回执消息 + List pendingMessages = imGroupMessageMapper.selectPendingReceiptMessages(groupId); + if (CollUtil.isEmpty(pendingMessages)) { + return; + } + + // 2. 加载成员和已读位置 + List allMembers = imGroupMemberService.selectByGroupId(groupId); + Map allPositions = groupReadPositionRedisDAO.getAllReadPositions(groupId); + + for (ImGroupMessageDO msg : pendingMessages) { + // 3. 计算该消息的可见成员(排除发送者自己) + Set visibleUserIds = getVisibleUserIds(msg, allMembers); + visibleUserIds.remove(msg.getSenderId()); // 发送者不计入回执统计 + + if (visibleUserIds.isEmpty()) { + continue; + } + + // 4. 统计可见成员中的已读人数 + int readCount = 0; + for (Long uid : visibleUserIds) { + Object val = allPositions.get(uid.toString()); + if (val != null && Long.parseLong(val.toString()) >= msg.getId()) { + readCount++; + } + } + + // 5. 判断是否全部已读 + boolean allRead = readCount >= visibleUserIds.size(); + if (allRead) { + ImGroupMessageDO update = new ImGroupMessageDO(); + update.setId(msg.getId()); + update.setReceiptStatus(ImGroupMessageReceiptStatusEnum.DONE.getStatus()); + imGroupMessageMapper.updateById(update); + } + + // 6. 广播 RECEIPT 事件给活跃成员 + Map receiptEvent = new HashMap<>(); + receiptEvent.put("messageId", msg.getId().toString()); + receiptEvent.put("groupId", groupId.toString()); + receiptEvent.put("readCount", readCount); + receiptEvent.put("receiptStatus", allRead + ? ImGroupMessageReceiptStatusEnum.DONE.getStatus() + : ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + + for (ImGroupMemberDO m : allMembers) { + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus())) { + continue; + } + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), m.getUserId(), + ImWebSocketTypeConstants.RECEIPT, receiptEvent); + } + } + } + + /** + * 计算一条群消息的可见成员集合 + *

+ * 规则: + * 1. 成员必须是 NORMAL 状态,或虽已 QUIT 但消息发送时间在退群之前 + * 2. 成员的入群时间必须在消息发送之前(入群后才能看到消息) + * 3. 如果消息有 receiverUserIds 定向接收,则只有列表中的人可见 + */ + private Set getVisibleUserIds(ImGroupMessageDO msg, List allMembers) { + // 定向接收集合 + Set receiverSet = null; + if (StrUtil.isNotBlank(msg.getReceiverUserIds())) { + receiverSet = new HashSet<>(Arrays.asList(msg.getReceiverUserIds().split(","))); + } + + Set visible = new HashSet<>(); + for (ImGroupMemberDO m : allMembers) { + // 入群时间在消息发送之后 → 不可见 + if (m.getJoinTime() != null && msg.getSendTime().isBefore(m.getJoinTime())) { + continue; + } + // 已退群且退群时间在消息发送之前 → 不可见 + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus()) + && m.getQuitTime() != null && msg.getSendTime().isAfter(m.getQuitTime())) { + continue; + } + // 定向接收过滤 + if (receiverSet != null && !receiverSet.contains(m.getUserId().toString()) + && !m.getUserId().equals(msg.getSenderId())) { + continue; + } + visible.add(m.getUserId()); + } + return visible; + } + + /** + * 发送群聊消息 WebSocket 事件 + */ + private void sendGroupMessageEvent(ImGroupMessageDO message, Long groupId) { + List members = imGroupMemberService.selectByGroupId(groupId); + Map event = new HashMap<>(); + event.put("id", message.getId().toString()); + event.put("clientMessageId", message.getClientMessageId()); + event.put("senderId", message.getSenderId().toString()); + event.put("groupId", message.getGroupId().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", "group"); + if (StrUtil.isNotBlank(message.getAtUserIds())) { + event.put("atUserIds", message.getAtUserIds()); + } + if (StrUtil.isNotBlank(message.getReceiverUserIds())) { + event.put("receiverUserIds", message.getReceiverUserIds()); + } + event.put("receiptStatus", message.getReceiptStatus()); + + // 确定推送目标 + Set receiverSet = null; + if (StrUtil.isNotBlank(message.getReceiverUserIds())) { + receiverSet = new HashSet<>(Arrays.asList(message.getReceiverUserIds().split(","))); + } + + for (ImGroupMemberDO member : members) { + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(member.getStatus())) { + continue; + } + // 定向接收过滤 + if (receiverSet != null && !receiverSet.contains(member.getUserId().toString()) + && !member.getUserId().equals(message.getSenderId())) { + continue; + } + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), member.getUserId(), + ImWebSocketTypeConstants.GROUP_MESSAGE, event); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java new file mode 100644 index 0000000..64a6b4b --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java @@ -0,0 +1,50 @@ +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 java.util.List; + +/** + * IM 私聊消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImPrivateMessageService { + + /** + * 发送私聊消息 + * + * @param senderId 发送人编号 + * @param reqVO 发送请求 + * @return 消息 + */ + ImPrivateMessageDO sendMessage(Long senderId, ImPrivateMessageSendReqVO reqVO); + + /** + * 拉取私聊消息(增量) + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + List pullMessages(Long userId, Long minId, Integer size); + + /** + * 标记私聊消息已读 + * + * @param userId 当前用户编号 + * @param friendId 好友用户编号 + */ + void readMessages(Long userId, Long friendId); + + /** + * 撤回私聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + */ + void recallMessage(Long userId, Long messageId); + +} 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 new file mode 100644 index 0000000..05950af --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java @@ -0,0 +1,181 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +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.infra.api.websocket.WebSocketSenderApi; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +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.*; + +/** + * IM 私聊消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { + + /** + * pull 最大拉取数量 + */ + private static final int MAX_PULL_SIZE = 1000; + + @Resource + private ImPrivateMessageMapper imPrivateMessageMapper; + + @Resource + private ImFriendService imFriendService; + @Resource + private ImSensitiveWordService imSensitiveWordService; + + // TODO @AI:这个类貌似路径不对?! + @Resource + private WebSocketSenderApi webSocketSenderApi; + + @Override + public ImPrivateMessageDO sendMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) { + // 1.1 幂等校验:根据 senderId + clientMessageId 查重 + ImPrivateMessageDO existing = imPrivateMessageMapper.selectBySenderIdAndClientMessageId( + senderId, reqVO.getClientMessageId()); + if (existing != null) { + return existing; + } + // 1.2 好友校验 + imFriendService.validateIsFriend(senderId, reqVO.getReceiverId()); + // 1.3 文本消息敏感词过滤 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { + imSensitiveWordService.validateText(reqVO.getContent()); + } + + // 2. 构建并保存消息 + // TODO @AI:相同类型的字段:放在一行,避免过长; + 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); + return message; + } + + @Override + public List pullMessages(Long userId, Long minId, Integer size) { + // pull 分页保护 + // TODO @AI:limit 这个参考校验,放在 controller 里做; + if (size > MAX_PULL_SIZE) { + throw exception(MESSAGE_PULL_SIZE_EXCEEDED); + } + // TODO @AI:可能要加一个,最大返回几个月的?! + return imPrivateMessageMapper.selectListByMinId(userId, minId, size); + } + + // TODO @芋艿:这里需要多 review 下; + @Override + public void readMessages(Long userId, Long friendId) { + // 1. 批量更新消息状态为已读 + int updated = imPrivateMessageMapper.updateStatusToRead(userId, friendId); + if (updated == 0) { + return; // 没有需要更新的消息 + } + // 2. 发送 READ 事件给自己的其他终端(多端同步) + Map readEvent = new HashMap<>(); + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强! + readEvent.put("friendId", friendId.toString()); + readEvent.put("messageScene", "private"); + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImWebSocketTypeConstants.READ, readEvent); + + // 3. 发送 RECEIPT 事件给对方(已读回执) + Map receiptEvent = new HashMap<>(); + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强! + receiptEvent.put("userId", userId.toString()); + receiptEvent.put("messageScene", "private"); + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), friendId, + ImWebSocketTypeConstants.RECEIPT, receiptEvent); + } + + // TODO @芋艿:这里需要多 review 下; + @Override + public void recallMessage(Long userId, Long messageId) { + // 1.1 校验消息存在 + ImPrivateMessageDO message = imPrivateMessageMapper.selectById(messageId); + if (message == null) { + throw exception(MESSAGE_NOT_EXISTS); + } + // 1.2 只能撤回自己发送的消息 + // TODO @AI:Notequals; + if (!message.getSenderId().equals(userId)) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 1.3 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + + // 2. 更新消息状态为撤回 + imPrivateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); + + // 3.1 发送 RECALL 事件给接收方 + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强! + Map recallEvent = new HashMap<>(); + recallEvent.put("messageId", messageId.toString()); + recallEvent.put("messageScene", "private"); + recallEvent.put("senderId", userId.toString()); + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(), + ImWebSocketTypeConstants.RECALL, recallEvent); + // 3.2 也通知发送方自己的其他终端 + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强! + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImWebSocketTypeConstants.RECALL, recallEvent); + } + + /** + * 发送私聊消息 WebSocket 事件 + */ + private void sendPrivateMessageEvent(ImPrivateMessageDO message) { + Map event = new HashMap<>(); + // TODO @AI:需要整理下,相关的 websocket 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"); + // 推送给接收方 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(), + ImWebSocketTypeConstants.PRIVATE_MESSAGE, event); + // 推送给发送方自己的其他终端(多端同步) + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(), + ImWebSocketTypeConstants.PRIVATE_MESSAGE, event); + } + +} 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 new file mode 100644 index 0000000..64886b2 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +/** + * IM 敏感词 Service 接口 + * + * @author 芋道源码 + */ +public interface ImSensitiveWordService { + + /** + * 校验文本是否包含敏感词 + *

+ * 如果包含,直接抛出业务异常 + * + * @param text 待校验文本 + */ + void validateText(String text); + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java new file mode 100644 index 0000000..33e49f1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; + +// TODO @AI:需要内存化;晚点处理; +/** + * IM 敏感词 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { + + @Resource + private ImSensitiveWordMapper imSensitiveWordMapper; + + @Override + public void validateText(String text) { + if (text == null || text.isEmpty()) { + return; + } + // 查询所有启用状态的敏感词 + List enabledWords = imSensitiveWordMapper.selectListByEnabled(); + // 逐词匹配 + for (ImSensitiveWordDO wordDO : enabledWords) { + if (text.contains(wordDO.getWord())) { + throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED); + } + } + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java new file mode 100644 index 0000000..cfbf5d8 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -0,0 +1,364 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +import cn.iocoder.yudao.module.im.dal.redis.group.GroupReadPositionRedisDAO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.groupmember.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +// TODO @芋艿:这个需要在评估下; +/** + * IM 群聊消息 Service 单元测试 + * + * @author 芋道源码 + */ +@ExtendWith(MockitoExtension.class) +public class ImGroupMessageServiceImplTest { + + @InjectMocks + private ImGroupMessageServiceImpl groupMessageService; + + @Mock + private ImGroupMessageMapper imGroupMessageMapper; + @Mock + private ImGroupService imGroupService; + @Mock + private ImGroupMemberService imGroupMemberService; + @Mock + private ImSensitiveWordService imSensitiveWordService; + @Mock + private GroupReadPositionRedisDAO groupReadPositionRedisDAO; + @Mock + private WebSocketSenderApi webSocketSenderApi; + + private ImGroupMessageSendReqVO buildSendReqVO() { + ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); + reqVO.setClientMessageId("test-uuid-group-001"); + reqVO.setGroupId(10L); + reqVO.setType(0); // TEXT + reqVO.setContent("{\"content\":\"群聊你好\"}"); + return reqVO; + } + + // ========== 发送测试 ========== + + @Test + public void testSendMessage_success() { + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + group.setName("测试群"); + when(imGroupService.validateGroupExists(10L)).thenReturn(group); + + ImGroupMemberDO member = new ImGroupMemberDO(); + member.setGroupId(10L); + member.setUserId(1L); + member.setStatus(ImGroupMemberStatusEnum.NORMAL.getStatus()); + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(member); + + List allMembers = List.of( + member, + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build() + ); + when(imGroupMemberService.selectByGroupId(10L)).thenReturn(allMembers); + when(imGroupMessageMapper.insert(any())).thenReturn(1); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendMessage(1L, reqVO); + + // 断言 + assertNotNull(result); + assertEquals(1L, result.getSenderId()); + assertEquals(10L, result.getGroupId()); + assertEquals(ImMessageStatusEnum.NORMAL, result.getStatus()); + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); + + // 验证推送给 3 个群成员 + verify(webSocketSenderApi, times(3)).sendObject(anyInt(), anyLong(), anyString(), any()); + } + + @Test + public void testSendMessage_clientMessageIdIdempotent() { + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + ImGroupMessageDO existing = ImGroupMessageDO.builder() + .id(100L).clientMessageId("test-uuid-group-001").senderId(1L).groupId(10L) + .type(0).content("{\"content\":\"群聊你好\"}").status(0) + .sendTime(LocalDateTime.now()).build(); + when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(existing); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendMessage(1L, reqVO); + + // 断言 + assertEquals(100L, result.getId()); + verify(imGroupMessageMapper, never()).insert(any()); + } + + @Test + public void testSendMessage_notInGroup() { + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(imGroupService.validateGroupExists(10L)).thenReturn(group); + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(null); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.sendMessage(1L, reqVO)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== pull 测试 ========== + + @Test + public void testPullMessages_joinTimeFilter() { + // 准备 + LocalDateTime joinTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()) + .joinTime(joinTime).build(); + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); + + // 模拟一条入群前的消息和一条入群后的消息 + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .sendTime(LocalDateTime.of(2026, 4, 12, 9, 0, 0)).build(); + ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(2L) + .sendTime(LocalDateTime.of(2026, 4, 12, 11, 0, 0)).build(); + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) + .thenReturn(List.of(beforeJoin, afterJoin)); + + // 调用 + List result = groupMessageService.pullMessages(1L, 0L, 100); + + // 断言:入群前消息不可见 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getId()); + } + + @Test + public void testPullMessages_quitTimeFilter() { + // 准备 + LocalDateTime joinTime = LocalDateTime.of(2026, 4, 10, 10, 0, 0); + LocalDateTime quitTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(ImGroupMemberStatusEnum.QUIT.getStatus()) + .joinTime(joinTime).quitTime(quitTime).build(); + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); + + // 退群前后各一条消息 + ImGroupMessageDO beforeQuit = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .sendTime(LocalDateTime.of(2026, 4, 11, 10, 0, 0)).build(); + ImGroupMessageDO afterQuit = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(2L) + .sendTime(LocalDateTime.of(2026, 4, 13, 10, 0, 0)).build(); + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) + .thenReturn(List.of(beforeQuit, afterQuit)); + + // 调用 + List result = groupMessageService.pullMessages(1L, 0L, 100); + + // 断言:退群后消息不可见 + assertEquals(1, result.size()); + assertEquals(1L, result.get(0).getId()); + } + + @Test + public void testPullMessages_receiverUserIdsFilter() { + // 准备 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); + + // 定向接收消息:只给用户 2 和 3 + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(5L) + .receiverUserIds("2,3") + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); + // 全员消息 + ImGroupMessageDO allMsg = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) + .thenReturn(List.of(directedMsg, allMsg)); + + // 调用 + List result = groupMessageService.pullMessages(1L, 0L, 100); + + // 断言:定向接收的消息用户 1 看不到,只能看到全员消息 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getId()); + } + + @Test + public void testPullMessages_atFieldCorrectReturn() { + // 准备 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO atMsg = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .atUserIds("1,3") + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) + .thenReturn(List.of(atMsg)); + + // 调用 + List result = groupMessageService.pullMessages(1L, 0L, 100); + + // 断言:@ 字段正确返回 + assertEquals(1, result.size()); + assertEquals("1,3", result.get(0).getAtUserIds()); + } + + // ========== 撤回测试 ========== + + @Test + public void testRecallMessage_success() { + // 准备 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.NORMAL).build(); + when(imGroupMessageMapper.selectById(50L)).thenReturn(message); + when(imGroupMessageMapper.updateById(any())).thenReturn(1); + + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(ImGroupMemberStatusEnum.QUIT.getStatus()).build() + ); + when(imGroupMemberService.selectByGroupId(10L)).thenReturn(members); + + // 调用 + groupMessageService.recallMessage(1L, 50L); + + // 断言:只给 2 个活跃成员发送 RECALL 事件(userId=3 已退群) + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); + } + + // ========== 群已读测试 ========== + + @Test + public void testReadMessages_notInGroup() { + // 准备:当前用户不在群中 + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(null); + + // 调用并断言:越权校验 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.readMessages(1L, 10L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testGetReadUsers_withVisibleScope() { + // 准备:用户 1 是群成员 + ImGroupMemberDO currentMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(currentMember); + + // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(80L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); + when(imGroupMessageMapper.selectById(80L)).thenReturn(message); + + // 准备:群成员一览 + // 用户 1: 正常,入群在消息之前 + // 用户 2: 正常,入群在消息之前 + // 用户 3: 正常,但入群在消息之后 → 不可见 + // 用户 5: 发送者,不计入回执 + List allMembers = List.of( + currentMember, + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()) + .joinTime(LocalDateTime.of(2026, 4, 13, 0, 0, 0)).build(), // 消息之后才入群 + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 + ); + when(imGroupMemberService.selectByGroupId(10L)).thenReturn(allMembers); + + // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 + Map positions = new HashMap<>(); + positions.put("1", "100"); + positions.put("2", "50"); + positions.put("3", "200"); + when(groupReadPositionRedisDAO.getAllReadPositions(10L)).thenReturn(positions); + + // 调用:查询 messageId=80 的已读用户 + List readUsers = groupMessageService.getReadUsers(1L, 10L, 80L); + + // 断言: + // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓(但排除发送者?不是发送者,所以算入) + // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗ + // 用户 3: 入群在消息之后 → 不可见 → 不算 + // 用户 5: 发送者 → 排除 + assertEquals(1, readUsers.size()); + assertTrue(readUsers.contains(1L)); + assertFalse(readUsers.contains(2L)); + assertFalse(readUsers.contains(3L)); + assertFalse(readUsers.contains(5L)); + } + + @Test + public void testGetReadUsers_notInGroup() { + // 准备:当前用户不在群中 + when(imGroupMemberService.getGroupMember(10L, 99L)).thenReturn(null); + + // 调用并断言:越权校验 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getReadUsers(99L, 10L, 80L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + +} 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 new file mode 100644 index 0000000..ddb59a6 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -0,0 +1,205 @@ +package cn.iocoder.yudao.module.im.service.message; + +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; +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.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 私聊消息 Service 单元测试 + * + * @author 芋道源码 + */ +@ExtendWith(MockitoExtension.class) +public class ImPrivateMessageServiceImplTest { + + @InjectMocks + private ImPrivateMessageServiceImpl privateMessageService; + + @Mock + private ImPrivateMessageMapper imPrivateMessageMapper; + @Mock + private ImFriendService imFriendService; + @Mock + private ImSensitiveWordService imSensitiveWordService; + @Mock + private WebSocketSenderApi webSocketSenderApi; + + private ImPrivateMessageSendReqVO buildSendReqVO() { + ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); + reqVO.setClientMessageId("test-uuid-001"); + reqVO.setReceiverId(2L); + reqVO.setType(0); // TEXT + reqVO.setContent("{\"content\":\"你好\"}"); + return reqVO; + } + + // ========== 发送测试 ========== + + @Test + public void testSendMessage_success() { + // 准备 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + when(imPrivateMessageMapper.insert(any())).thenReturn(1); + + // 调用 + ImPrivateMessageDO result = privateMessageService.sendMessage(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()); + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); + } + + @Test + public void testSendMessage_clientMessageIdIdempotent() { + // 准备:模拟已存在消息 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + ImPrivateMessageDO existingMessage = ImPrivateMessageDO.builder() + .id(100L).clientMessageId("test-uuid-001").senderId(1L).receiverId(2L) + .type(0).content("{\"content\":\"你好\"}").status(0) + .sendTime(LocalDateTime.now()).build(); + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(existingMessage); + + // 调用 + ImPrivateMessageDO result = privateMessageService.sendMessage(1L, reqVO); + + // 断言:返回已存在的消息 + assertEquals(100L, result.getId()); + // 验证不会重复插入 + verify(imPrivateMessageMapper, never()).insert(any()); + } + + @Test + public void testSendMessage_notFriend() { + // 准备 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + doThrow(new ServiceException(FRIEND_NOT_FRIEND)) + .when(imFriendService).validateIsFriend(1L, 2L); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.sendMessage(1L, reqVO)); + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); + } + + // ========== pull 测试 ========== + + @Test + public void testPullMessages_success() { + // 准备 + List mockMessages = List.of( + ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(), + ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build() + ); + when(imPrivateMessageMapper.selectListByMinId(1L, 0L, 100)).thenReturn(mockMessages); + + // 调用 + List result = privateMessageService.pullMessages(1L, 0L, 100); + + // 断言 + assertEquals(2, result.size()); + } + + @Test + public void testPullMessages_sizeExceeded() { + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.pullMessages(1L, 0L, 1001)); + assertEquals(MESSAGE_PULL_SIZE_EXCEEDED.getCode(), exception.getCode()); + } + + // ========== 已读测试 ========== + + @Test + public void testReadMessages_success() { + // 准备 + when(imPrivateMessageMapper.updateStatusToRead(1L, 2L)).thenReturn(3); + + // 调用 + privateMessageService.readMessages(1L, 2L); + + // 断言:发送了 READ 和 RECEIPT 事件 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); + } + + // ========== 撤回测试 ========== + + @Test + public void testRecallMessage_success() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); + when(imPrivateMessageMapper.updateById(any())).thenReturn(1); + + // 调用 + privateMessageService.recallMessage(1L, 10L); + + // 断言 + verify(imPrivateMessageMapper).updateById(any()); + // 验证推送了 RECALL 事件(给接收方和发送方) + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); + } + + @Test + public void testRecallMessage_notOwn() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallMessage(1L, 10L)); + assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_alreadyRecalled() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.RECALL.getStatus()).build(); + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallMessage(1L, 10L)); + assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); + } + +} 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 new file mode 100644 index 0000000..8732178 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java @@ -0,0 +1,128 @@ +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.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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.*; + +/** + * IM WebSocket 事件单元测试 + *

+ * 验证各事件的 payload 字段是否正确 + * + * @author 芋道源码 + */ +@ExtendWith(MockitoExtension.class) +public class ImWebSocketEventTest { + + @InjectMocks + private ImPrivateMessageServiceImpl privateMessageService; + + @Mock + private ImPrivateMessageMapper imPrivateMessageMapper; + @Mock + private ImFriendService imFriendService; + @Mock + private ImSensitiveWordService imSensitiveWordService; + @Mock + private WebSocketSenderApi webSocketSenderApi; + + @Test + public void testSendMessage_websocketEvent() { + // 准备 + ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); + reqVO.setClientMessageId("evt-uuid"); + reqVO.setReceiverId(2L); + reqVO.setType(0); + reqVO.setContent("{\"content\":\"事件测试\"}"); + + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "evt-uuid")).thenReturn(null); + when(imPrivateMessageMapper.insert(any())).thenReturn(1); + + // 调用 + privateMessageService.sendMessage(1L, reqVO); + + // 捕获 WebSocket 调用 + ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Object.class); + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), + 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")); + } + + @Test + public void testReadMessage_readAndReceiptEvents() { + // 准备 + when(imPrivateMessageMapper.updateStatusToRead(1L, 2L)).thenReturn(1); + + // 调用 + privateMessageService.readMessages(1L, 2L); + + // 捕获 + ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), userCaptor.capture(), + typeCaptor.capture(), any()); + + // 第一次:发给自己的 READ 事件 + assertEquals(ImWebSocketTypeConstants.READ, typeCaptor.getAllValues().get(0)); + assertEquals(1L, userCaptor.getAllValues().get(0)); + // 第二次:发给对方的 RECEIPT 事件 + assertEquals(ImWebSocketTypeConstants.RECEIPT, typeCaptor.getAllValues().get(1)); + assertEquals(2L, userCaptor.getAllValues().get(1)); + } + + @Test + public void testRecallMessage_recallEvent() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L).status(0).build(); + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); + when(imPrivateMessageMapper.updateById(any())).thenReturn(1); + + // 调用 + privateMessageService.recallMessage(1L, 10L); + + // 捕获 + ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Object.class); + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), + typeCaptor.capture(), contentCaptor.capture()); + + // 验证 RECALL 事件类型 + assertEquals(ImWebSocketTypeConstants.RECALL, typeCaptor.getAllValues().get(0)); + + // 验证 payload + @SuppressWarnings("unchecked") + Map payload = (Map) contentCaptor.getAllValues().get(0); + assertEquals("10", payload.get("messageId")); + assertEquals("private", payload.get("messageScene")); + } + +}