Commit 2d277765e7002b4b5cfc26bb089ae2da12ea9299

Authored by YunaiV
1 parent 98431230

📝 feat(im): 添加 IM 相关服务接口和实现,更新消息处理逻辑,增强敏感词校验功能

Showing 55 changed files with 2831 additions and 272 deletions
sql/mysql/im/im20240316.sql deleted
1   -/*
2   - Navicat Premium Data Transfer
3   -
4   - Source Server : mysql8_root
5   - Source Server Type : MySQL
6   - Source Server Version : 80200
7   - Source Host : chaojiniu.top:23306
8   - Source Schema : ruoyi-vue-pro
9   -
10   - Target Server Type : MySQL
11   - Target Server Version : 80200
12   - File Encoding : 65001
13   -
14   - Date: 16/03/2024 15:45:29
15   -*/
16   -
17   -SET NAMES utf8mb4;
18   -SET FOREIGN_KEY_CHECKS = 0;
19   -
20   --- ----------------------------
21   --- Table structure for im_conversation
22   --- ----------------------------
23   -DROP TABLE IF EXISTS `im_conversation`;
24   -CREATE TABLE `im_conversation` (
25   - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
26   - `user_id` bigint NOT NULL COMMENT '所属用户',
27   - `conversation_type` tinyint NOT NULL COMMENT '类型:1 单聊;2 群聊;4 通知会话(预留)',
28   - `target_id` bigint NOT NULL COMMENT '单聊时,用户编号;群聊时,群编号',
29   - `no` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '会话标志 单聊:s_{userId}_{targetId},需要排序 userId 和 targetId 群聊:g_groupId',
30   - `pinned` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否置顶 0否 1是',
31   - `last_read_time` datetime DEFAULT NULL COMMENT '最后已读时间',
32   - `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
33   - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
34   - `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
35   - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
36   - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
37   - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
38   - PRIMARY KEY (`id`)
39   -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话表';
40   -
41   --- ----------------------------
42   --- Table structure for im_group
43   --- ----------------------------
44   -DROP TABLE IF EXISTS `im_group`;
45   -CREATE TABLE `im_group` (
46   - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
47   - `group_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群名字',
48   - `owner_id` bigint NOT NULL COMMENT '群主id',
49   - `head_image` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群头像',
50   - `head_image_thumb` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群头像缩略图',
51   - `notice` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群公告',
52   - `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '群备注',
53   - `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
54   - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
55   - `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
56   - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
57   - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
58   - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
59   - PRIMARY KEY (`id`)
60   -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='群';
61   -
62   --- ----------------------------
63   --- Table structure for im_group_member
64   --- ----------------------------
65   -DROP TABLE IF EXISTS `im_group_member`;
66   -CREATE TABLE `im_group_member` (
67   - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
68   - `group_id` bigint DEFAULT NULL COMMENT '群 id',
69   - `user_id` bigint NOT NULL COMMENT '用户id',
70   - `nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '昵称',
71   - `avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像',
72   - `alias_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '组内显示名称',
73   - `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
74   - `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
75   - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
76   - `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
77   - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
78   - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
79   - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
80   - PRIMARY KEY (`id`)
81   -) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='群成员';
82   -
83   --- ----------------------------
84   --- Table structure for im_inbox
85   --- ----------------------------
86   -DROP TABLE IF EXISTS `im_inbox`;
87   -CREATE TABLE `im_inbox` (
88   - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
89   - `user_id` bigint NOT NULL COMMENT '用户编号',
90   - `message_id` bigint NOT NULL COMMENT '消息编号',
91   - `sequence` bigint NOT NULL COMMENT '序号,按照 user 递增',
92   - `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
93   - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
94   - `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
95   - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
96   - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
97   - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
98   - PRIMARY KEY (`id`)
99   -) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收件箱表';
100   -
101   --- ----------------------------
102   --- Table structure for im_message
103   --- ----------------------------
104   -DROP TABLE IF EXISTS `im_message`;
105   -CREATE TABLE `im_message` (
106   - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
107   - `client_message_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端消息编号 uuid,用于排重',
108   - `sender_id` bigint NOT NULL COMMENT '发送人编号',
109   - `receiver_id` bigint NOT NULL COMMENT '接收人编号',
110   - `sender_nickname` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送人昵称',
111   - `sender_avatar` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送人头像',
112   - `conversation_type` tinyint NOT NULL COMMENT '会话类型',
113   - `conversation_no` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '会话标志',
114   - `content_type` tinyint NOT NULL COMMENT '消息类型',
115   - `content` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息内容',
116   - `send_time` datetime DEFAULT NULL COMMENT '发送时间',
117   - `send_from` tinyint NOT NULL COMMENT '消息来源 100-用户发送;200-系统发送(一般是通知);',
118   - `message_status` tinyint DEFAULT NULL COMMENT '消息状态',
119   - `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
120   - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
121   - `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
122   - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
123   - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
124   - `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
125   - PRIMARY KEY (`id`)
126   -) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
127   -
128   -SET FOREIGN_KEY_CHECKS = 1;
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 {
18 18 // ========== 消息 (1-040-300-000) ==========
19 19 ErrorCode MESSAGE_NOT_EXISTS = new ErrorCode(1_040_300_000, "消息不存在");
20 20 ErrorCode MESSAGE_RECEIVER_NOT_EXISTS = new ErrorCode(1_040_300_001, "接收人不存在");
  21 + ErrorCode MESSAGE_RECALL_DENIED = new ErrorCode(1_040_300_002, "只能撤回自己发送的消息");
  22 + ErrorCode MESSAGE_ALREADY_RECALLED = new ErrorCode(1_040_300_003, "消息已撤回");
  23 + ErrorCode MESSAGE_SENSITIVE_WORD_BLOCKED = new ErrorCode(1_040_300_004, "消息包含敏感词,无法发送");
  24 + ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量超出限制");
  25 + ErrorCode MESSAGE_CLIENT_ID_DUPLICATE = new ErrorCode(1_040_300_006, "消息已发送,请勿重复提交");
21 26  
22 27 // ========== 群 (1-040-400-000) ==========
23 28 ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在");
  29 + ErrorCode GROUP_BANNED = new ErrorCode(1_040_400_001, "群已被封禁");
  30 + ErrorCode GROUP_DISSOLVED = new ErrorCode(1_040_400_002, "群已解散");
24 31  
25 32 // ========== 群成员 (1-040-500-000) ==========
26 33 ErrorCode GROUP_MEMBER_NOT_EXISTS = new ErrorCode(1_040_500_000, "群成员不存在");
  34 + ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中");
  35 + ErrorCode GROUP_MEMBER_ALREADY_QUIT = new ErrorCode(1_040_500_002, "您已退出该群");
  36 +
  37 + // ========== 好友 (1-040-600-000) ==========
  38 + ErrorCode FRIEND_NOT_EXISTS = new ErrorCode(1_040_600_000, "好友关系不存在");
  39 + ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友");
  40 +
  41 + // ========== 敏感词 (1-040-700-000) ==========
  42 + ErrorCode SENSITIVE_WORD_NOT_EXISTS = new ErrorCode(1_040_700_000, "敏感词不存在");
27 43  
28 44 }
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/conversation/ImConversationTypeEnum.java
1 1 package cn.iocoder.yudao.module.im.enums.conversation;
2 2  
3   -import cn.iocoder.yudao.framework.common.core.IntArrayValuable;
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4 4 import lombok.AllArgsConstructor;
5 5 import lombok.Getter;
6 6  
7 7 import java.util.Arrays;
8 8  
  9 +// TODO @AI:还需要这个枚举么?
9 10 /**
10 11 * IM 会话类型枚举
11 12 * 参考 <a href="https://doc.rentsoft.cn/zh-Hans/sdks/enum/conversationType">“会话类型”</a> 文档
... ... @@ -14,13 +15,13 @@ import java.util.Arrays;
14 15 */
15 16 @Getter
16 17 @AllArgsConstructor
17   -public enum ImConversationTypeEnum implements IntArrayValuable {
  18 +public enum ImConversationTypeEnum implements ArrayValuable<Integer> {
18 19  
19 20 SINGLE(1, "单聊"),
20 21 GROUP(3, "群聊"),
21 22 NOTIFICATION(4, "通知会话");
22 23  
23   - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImConversationTypeEnum::getType).toArray();
  24 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImConversationTypeEnum::getType).toArray(Integer[]::new);
24 25  
25 26 /**
26 27 * 类型
... ... @@ -57,7 +58,7 @@ public enum ImConversationTypeEnum implements IntArrayValuable {
57 58 }
58 59  
59 60 @Override
60   - public int[] array() {
  61 + public Integer[] array() {
61 62 return ARRAYS;
62 63 }
63 64  
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupMemberStatusEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.group;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.AllArgsConstructor;
  5 +import lombok.Getter;
  6 +
  7 +import java.util.Arrays;
  8 +
  9 +// TODO @AI:要不使用 commonstatusenum 替代掉这个;
  10 +/**
  11 + * IM 群成员状态枚举
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Getter
  16 +@AllArgsConstructor
  17 +public enum ImGroupMemberStatusEnum implements ArrayValuable<Integer> {
  18 +
  19 + NORMAL(0, "正常"),
  20 + QUIT(1, "已退出");
  21 +
  22 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMemberStatusEnum::getStatus).toArray(Integer[]::new);
  23 +
  24 + /**
  25 + * 状态
  26 + */
  27 + private final Integer status;
  28 +
  29 + /**
  30 + * 名字
  31 + */
  32 + private final String name;
  33 +
  34 + @Override
  35 + public Integer[] array() {
  36 + return ARRAYS;
  37 + }
  38 +
  39 +}
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.AllArgsConstructor;
  5 +import lombok.Getter;
  6 +
  7 +import java.util.Arrays;
  8 +
  9 +/**
  10 + * IM 群消息回执状态枚举
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Getter
  15 +@AllArgsConstructor
  16 +public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable<Integer> {
  17 +
  18 + NO_RECEIPT(0, "不需要回执"),
  19 + PENDING(1, "待完成"),
  20 + DONE(2, "已完成");
  21 +
  22 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new);
  23 +
  24 + /**
  25 + * 状态
  26 + */
  27 + private final Integer status;
  28 +
  29 + /**
  30 + * 名字
  31 + */
  32 + private final String name;
  33 +
  34 + @Override
  35 + public Integer[] array() {
  36 + return ARRAYS;
  37 + }
  38 +
  39 +}
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageContentTypeEnum.java
1 1 package cn.iocoder.yudao.module.im.enums.message;
2 2  
3   -import cn.iocoder.yudao.framework.common.core.IntArrayValuable;
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4 4 import lombok.AllArgsConstructor;
5 5 import lombok.Getter;
6 6  
7 7 import java.util.Arrays;
8 8  
  9 +// TODO @AI:还需要这个枚举么?
9 10 /**
10 11 * IM 消息的类型枚举
11 12 * <p>
... ... @@ -15,7 +16,7 @@ import java.util.Arrays;
15 16 */
16 17 @Getter
17 18 @AllArgsConstructor
18   -public enum ImMessageContentTypeEnum implements IntArrayValuable {
  19 +public enum ImMessageContentTypeEnum implements ArrayValuable<Integer> {
19 20  
20 21 TEXT(101, "文本消息"),
21 22 PICTURE(102, "图片消息"),
... ... @@ -60,7 +61,7 @@ public enum ImMessageContentTypeEnum implements IntArrayValuable {
60 61 BURN_CHANGE(1701, "阅后即焚开启或关闭通知"),
61 62 REVOKE(2101, "撤回消息通知");;
62 63  
63   - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImMessageContentTypeEnum::getType).toArray();
  64 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageContentTypeEnum::getType).toArray(Integer[]::new);
64 65  
65 66 /**
66 67 * 类型
... ... @@ -73,7 +74,7 @@ public enum ImMessageContentTypeEnum implements IntArrayValuable {
73 74 private final String name;
74 75  
75 76 @Override
76   - public int[] array() {
  77 + public Integer[] array() {
77 78 return ARRAYS;
78 79 }
79 80  
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSourceEnum.java
1 1 package cn.iocoder.yudao.module.im.enums.message;
2 2  
3   -import cn.iocoder.yudao.framework.common.core.IntArrayValuable;
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4 4 import lombok.Getter;
5 5 import lombok.RequiredArgsConstructor;
6 6  
7 7 import java.util.Arrays;
8 8  
  9 +// TODO @AI:还需要这个枚举么?
9 10 /**
10 11 * IM 消息的消息来源
11 12 */
12 13 @RequiredArgsConstructor
13 14 @Getter
14   -public enum ImMessageSourceEnum implements IntArrayValuable {
  15 +public enum ImMessageSourceEnum implements ArrayValuable<Integer> {
15 16  
16 17 USER_SEND(100, "用户发送"),
17 18 SYSTEM_SEND(200, "系统发送");
18 19  
19   - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImMessageSourceEnum::getSource).toArray();
  20 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageSourceEnum::getSource).toArray(Integer[]::new);
20 21  
21 22 /**
22 23 * 状态
... ... @@ -28,7 +29,7 @@ public enum ImMessageSourceEnum implements IntArrayValuable {
28 29 private final String name;
29 30  
30 31 @Override
31   - public int[] array() {
  32 + public Integer[] array() {
32 33 return ARRAYS;
33 34 }
34 35  
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java
1 1 package cn.iocoder.yudao.module.im.enums.message;
2 2  
3   -import cn.iocoder.yudao.framework.common.core.IntArrayValuable;
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4 4 import lombok.Getter;
5 5 import lombok.RequiredArgsConstructor;
6 6  
7 7 import java.util.Arrays;
8 8  
9 9 /**
10   - * IM 消息的状态枚举
  10 + * IM 私聊消息状态枚举
  11 + * <p>
  12 + * 私聊:UNREAD / READ / RECALL
  13 + * 群聊:NORMAL / RECALL
  14 + *
  15 + * @author 芋道源码
11 16 */
12 17 @RequiredArgsConstructor
13 18 @Getter
14   -public enum ImMessageStatusEnum implements IntArrayValuable {
  19 +public enum ImMessageStatusEnum implements ArrayValuable<Integer> {
15 20  
16   - DRAFT(0, "草稿"),
17   - SENDING(1, "发送中"),
18   - SUCCESS(2, "发送成功"),
19   - FAILURE(3, "发送失败"),
20   - DELETED(4, "已删除"),
21   - RECALL(5, "已撤回");
  21 + // ========== 私聊状态 ==========
  22 + UNREAD(0, "未读"),
  23 + READ(1, "已读"),
22 24  
23   - public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ImMessageStatusEnum::getStatus).toArray();
  25 + // ========== 通用状态 ==========
  26 + RECALL(2, "已撤回");
  27 +
  28 + // TODO @AI:0 这个,不是有 UNREAD 可以用了?!
  29 + /**
  30 + * 群聊消息的正常状态,与 UNREAD 共用值 0
  31 + */
  32 + public static final int NORMAL = 0;
  33 +
  34 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new);
24 35  
25 36 /**
26 37 * 状态
... ... @@ -33,7 +44,7 @@ public enum ImMessageStatusEnum implements IntArrayValuable {
33 44 private final String name;
34 45  
35 46 @Override
36   - public int[] array() {
  47 + public Integer[] array() {
37 48 return ARRAYS;
38 49 }
39 50  
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.AllArgsConstructor;
  5 +import lombok.Getter;
  6 +
  7 +import java.util.Arrays;
  8 +
  9 +/**
  10 + * IM 消息类型枚举
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Getter
  15 +@AllArgsConstructor
  16 +public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
  17 +
  18 + TEXT(0, "文本"),
  19 + IMAGE(1, "图片"),
  20 + FILE(2, "文件"),
  21 + VOICE(3, "语音"),
  22 + VIDEO(4, "视频"),
  23 +
  24 + RECALL(10, "撤回"),
  25 + READ(11, "已读"),
  26 + RECEIPT(12, "回执"),
  27 + TIP_TEXT(21, "提示文本");
  28 +
  29 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new);
  30 +
  31 + /**
  32 + * 类型
  33 + */
  34 + private final Integer type;
  35 +
  36 + /**
  37 + * 名字
  38 + */
  39 + private final String name;
  40 +
  41 + @Override
  42 + public Integer[] array() {
  43 + return ARRAYS;
  44 + }
  45 +
  46 +}
... ...
yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImWebSocketTypeConstants.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.message;
  2 +
  3 +// TODO @芋艿:稍后在 review
  4 +/**
  5 + * IM WebSocket 事件类型常量
  6 + *
  7 + * @author 芋道源码
  8 + */
  9 +public interface ImWebSocketTypeConstants {
  10 +
  11 + /**
  12 + * 私聊消息事件
  13 + */
  14 + String PRIVATE_MESSAGE = "im-private-message";
  15 + /**
  16 + * 群聊消息事件
  17 + */
  18 + String GROUP_MESSAGE = "im-group-message";
  19 + /**
  20 + * 已读事件
  21 + */
  22 + String READ = "im-read";
  23 + /**
  24 + * 回执事件
  25 + */
  26 + String RECEIPT = "im-receipt";
  27 + /**
  28 + * 撤回事件
  29 + */
  30 + String RECALL = "im-recall";
  31 +
  32 +}
0 33 \ No newline at end of file
... ...
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_
14 14 @ToString(callSuper = true)
15 15 public class ImGroupPageReqVO extends PageParam {
16 16  
17   - @Schema(description = "群名字", example = "芋艿")
18   - private String groupName;
  17 + @Schema(description = "群名称", example = "芋艿")
  18 + private String name;
19 19  
20   - @Schema(description = "群主id", example = "31460")
21   - private Long ownerId;
22   -
23   - @Schema(description = "群头像")
24   - private String headImage;
25   -
26   - @Schema(description = "群头像缩略图")
27   - private String headImageThumb;
  20 + @Schema(description = "群主用户编号", example = "31460")
  21 + private Long ownerUserId;
28 22  
29 23 @Schema(description = "群公告")
30 24 private String notice;
31 25  
32   - @Schema(description = "群备注", example = "你说的对")
33   - private String remark;
34   -
35 26 @Schema(description = "创建时间")
36 27 @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
37 28 private LocalDateTime[] createTime;
... ...
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;
2 2  
3 3 import io.swagger.v3.oas.annotations.media.Schema;
4 4 import lombok.*;
  5 +import com.alibaba.excel.annotation.*;
5 6  
6 7 import java.time.LocalDateTime;
7   -import com.alibaba.excel.annotation.*;
8 8  
  9 +// TODO @AI:不需要导出的注解;
9 10 @Schema(description = "管理后台 - 群 Response VO")
10 11 @Data
11 12 @ExcelIgnoreUnannotated
... ... @@ -15,29 +16,29 @@ public class ImGroupRespVO {
15 16 @ExcelProperty("编号")
16 17 private Long id;
17 18  
18   - @Schema(description = "群名字", example = "芋艿")
19   - @ExcelProperty("群名字")
20   - private String groupName;
  19 + @Schema(description = "群名称", example = "芋艿")
  20 + @ExcelProperty("群名称")
  21 + private String name;
21 22  
22   - @Schema(description = "群主id", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460")
23   - @ExcelProperty("群主id")
24   - private Long ownerId;
  23 + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460")
  24 + @ExcelProperty("群主用户编号")
  25 + private Long ownerUserId;
25 26  
26 27 @Schema(description = "群头像")
27 28 @ExcelProperty("群头像")
28   - private String headImage;
29   -
30   - @Schema(description = "群头像缩略图")
31   - @ExcelProperty("群头像缩略图")
32   - private String headImageThumb;
  29 + private String avatar;
33 30  
34 31 @Schema(description = "群公告")
35 32 @ExcelProperty("群公告")
36 33 private String notice;
37 34  
38   - @Schema(description = "群备注", example = "你说的对")
39   - @ExcelProperty("群备注")
40   - private String remark;
  35 + @Schema(description = "是否封禁")
  36 + @ExcelProperty("是否封禁")
  37 + private Boolean banned;
  38 +
  39 + @Schema(description = "是否解散")
  40 + @ExcelProperty("是否解散")
  41 + private Boolean dissolved;
41 42  
42 43 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
43 44 @ExcelProperty("创建时间")
... ...
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;
4 4 import lombok.*;
5 5 import jakarta.validation.constraints.*;
6 6  
  7 +// TODO @AI:去掉部分不需要的 save 字段;
7 8 @Schema(description = "管理后台 - 群新增/修改 Request VO")
8 9 @Data
9 10 public class ImGroupSaveReqVO {
... ... @@ -11,23 +12,26 @@ public class ImGroupSaveReqVO {
11 12 @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003")
12 13 private Long id;
13 14  
14   - @Schema(description = "群名字", example = "芋艿")
15   - private String groupName;
  15 + @Schema(description = "群名称", example = "芋艿")
  16 + private String name;
16 17  
17   - @Schema(description = "群主id", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460")
18   - @NotNull(message = "群主id不能为空")
19   - private Long ownerId;
  18 + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460")
  19 + @NotNull(message = "群主用户编号不能为空")
  20 + private Long ownerUserId;
20 21  
21 22 @Schema(description = "群头像")
22   - private String headImage;
23   -
24   - @Schema(description = "群头像缩略图")
25   - private String headImageThumb;
  23 + private String avatar;
26 24  
27 25 @Schema(description = "群公告")
28 26 private String notice;
29 27  
30   - @Schema(description = "群备注", example = "你说的对")
31   - private String remark;
  28 + @Schema(description = "是否封禁")
  29 + private Boolean banned;
  30 +
  31 + @Schema(description = "封禁原因")
  32 + private String bannedReason;
  33 +
  34 + @Schema(description = "是否解散")
  35 + private Boolean dissolved;
32 36  
33 37 }
34 38 \ No newline at end of file
... ...
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_
17 17 @ToString(callSuper = true)
18 18 public class ImGroupMemberPageReqVO extends PageParam {
19 19  
20   - @Schema(description = "群 id", example = "13279")
  20 + @Schema(description = "群编号", example = "13279")
21 21 private Long groupId;
22 22  
23   - @Schema(description = "用户id", example = "21730")
  23 + @Schema(description = "用户编号", example = "21730")
24 24 private Long userId;
25 25  
26   - @Schema(description = "昵称", example = "芋艿")
27   - private String nickname;
28   -
29   - @Schema(description = "头像")
30   - private String avatar;
31   -
32   - @Schema(description = "组内显示名称", example = "芋艿")
33   - private String aliasName;
34   -
35   - @Schema(description = "备注", example = "你说的对")
36   - private String remark;
  26 + @Schema(description = "成员状态", example = "0")
  27 + private Integer status;
37 28  
38 29 @Schema(description = "创建时间")
39 30 @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
... ...
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;
7 7  
8 8 import java.time.LocalDateTime;
9 9  
  10 +// TODO @AI:去掉部分导出字段
10 11 @Schema(description = "管理后台 - 群成员 Response VO")
11 12 @Data
12 13 @ExcelIgnoreUnannotated
... ... @@ -16,29 +17,37 @@ public class ImGroupMemberRespVO {
16 17 @ExcelProperty("编号")
17 18 private Long id;
18 19  
19   - @Schema(description = "群 id", example = "13279")
20   - @ExcelProperty("群 id")
  20 + @Schema(description = "群编号", example = "13279")
  21 + @ExcelProperty("群编号")
21 22 private Long groupId;
22 23  
23   - @Schema(description = "用户id", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730")
24   - @ExcelProperty("用户id")
  24 + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730")
  25 + @ExcelProperty("用户编号")
25 26 private Long userId;
26 27  
27   - @Schema(description = "昵称", example = "芋艿")
28   - @ExcelProperty("昵称")
29   - private String nickname;
  28 + @Schema(description = "组内显示名", example = "芋艿")
  29 + @ExcelProperty("组内显示名")
  30 + private String displayUserName;
30 31  
31   - @Schema(description = "头像")
32   - @ExcelProperty("头像")
33   - private String avatar;
  32 + @Schema(description = "群显示备注", example = "核心群")
  33 + @ExcelProperty("群显示备注")
  34 + private String displayGroupName;
34 35  
35   - @Schema(description = "组内显示名称", example = "芋艿")
36   - @ExcelProperty("组内显示名称")
37   - private String aliasName;
  36 + @Schema(description = "是否免打扰")
  37 + @ExcelProperty("是否免打扰")
  38 + private Boolean muted;
38 39  
39   - @Schema(description = "备注", example = "你说的对")
40   - @ExcelProperty("备注")
41   - private String remark;
  40 + @Schema(description = "成员状态", example = "0")
  41 + @ExcelProperty("成员状态")
  42 + private Integer status;
  43 +
  44 + @Schema(description = "入群时间")
  45 + @ExcelProperty("入群时间")
  46 + private LocalDateTime joinTime;
  47 +
  48 + @Schema(description = "退群时间")
  49 + @ExcelProperty("退群时间")
  50 + private LocalDateTime quitTime;
42 51  
43 52 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
44 53 @ExcelProperty("创建时间")
... ...
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;
4 4 import jakarta.validation.constraints.NotNull;
5 5 import lombok.Data;
6 6  
  7 +// TODO @AI:去掉部分不需要的 save 字段;
7 8 @Schema(description = "管理后台 - 群成员新增/修改 Request VO")
8 9 @Data
9 10 public class ImGroupMemberSaveReqVO {
... ... @@ -11,23 +12,23 @@ public class ImGroupMemberSaveReqVO {
11 12 @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17071")
12 13 private Long id;
13 14  
14   - @Schema(description = "群 id", example = "13279")
  15 + @Schema(description = "群编号", example = "13279")
15 16 private Long groupId;
16 17  
17   - @Schema(description = "用户id", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730")
18   - @NotNull(message = "用户id不能为空")
  18 + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730")
  19 + @NotNull(message = "用户编号不能为空")
19 20 private Long userId;
20 21  
21   - @Schema(description = "昵称", example = "芋艿")
22   - private String nickname;
  22 + @Schema(description = "组内显示名", example = "芋艿")
  23 + private String displayUserName;
23 24  
24   - @Schema(description = "头像")
25   - private String avatar;
  25 + @Schema(description = "群显示备注", example = "核心群")
  26 + private String displayGroupName;
26 27  
27   - @Schema(description = "组内显示名称", example = "芋艿")
28   - private String aliasName;
  28 + @Schema(description = "是否免打扰")
  29 + private Boolean muted;
29 30  
30   - @Schema(description = "备注", example = "你说的对")
31   - private String remark;
  31 + @Schema(description = "成员状态", example = "0")
  32 + private Integer status;
32 33  
33 34 }
34 35 \ No newline at end of file
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.*;
  5 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageReadReqVO;
  6 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO;
  7 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  9 +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
  10 +import io.swagger.v3.oas.annotations.Operation;
  11 +import io.swagger.v3.oas.annotations.Parameter;
  12 +import io.swagger.v3.oas.annotations.tags.Tag;
  13 +import jakarta.annotation.Resource;
  14 +import jakarta.validation.Valid;
  15 +import org.springframework.validation.annotation.Validated;
  16 +import org.springframework.web.bind.annotation.*;
  17 +
  18 +import java.util.List;
  19 +import java.util.stream.Collectors;
  20 +
  21 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  22 +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
  23 +
  24 +/**
  25 + * 管理后台 - IM 群聊消息
  26 + */
  27 +@Tag(name = "管理后台 - IM 群聊消息")
  28 +@RestController
  29 +@RequestMapping("/im/message/group")
  30 +@Validated
  31 +public class ImGroupMessageController {
  32 +
  33 + @Resource
  34 + private ImGroupMessageService imGroupMessageService;
  35 +
  36 + @PostMapping("/send")
  37 + @Operation(summary = "发送群聊消息")
  38 + public CommonResult<ImGroupMessageRespVO> sendMessage(@Valid @RequestBody ImGroupMessageSendReqVO reqVO) {
  39 + ImGroupMessageDO message = imGroupMessageService.sendMessage(getLoginUserId(), reqVO);
  40 + return success(toRespVO(message));
  41 + }
  42 +
  43 + // TODO @AI:需要在评估下,是否需要 nextMinId
  44 + @GetMapping("/pull")
  45 + @Operation(summary = "拉取群聊消息(增量)")
  46 + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0")
  47 + @Parameter(name = "size", description = "拉取数量", required = true, example = "100")
  48 + public CommonResult<ImMessagePullRespVO<ImGroupMessageRespVO>> pullMessages(
  49 + @RequestParam("minId") Long minId,
  50 + @RequestParam("size") Integer size) {
  51 + List<ImGroupMessageDO> messages = imGroupMessageService.pullMessages(getLoginUserId(), minId, size);
  52 + ImMessagePullRespVO<ImGroupMessageRespVO> resp = new ImMessagePullRespVO<>();
  53 + resp.setList(messages.stream().map(this::toRespVO).collect(Collectors.toList()));
  54 + resp.setNextMinId(messages.isEmpty() ? minId.toString()
  55 + : messages.get(messages.size() - 1).getId().toString());
  56 + return success(resp);
  57 + }
  58 +
  59 + // TODO @AI:它是不是不用 VO
  60 + @PutMapping("/read")
  61 + @Operation(summary = "标记群聊消息已读")
  62 + public CommonResult<Boolean> readMessages(@Valid @RequestBody ImGroupMessageReadReqVO reqVO) {
  63 + imGroupMessageService.readMessages(getLoginUserId(), reqVO.getGroupId());
  64 + return success(true);
  65 + }
  66 +
  67 + // TODO @AI:不使用 path variable
  68 + @DeleteMapping("/recall/{id}")
  69 + @Operation(summary = "撤回群聊消息")
  70 + @Parameter(name = "id", description = "消息编号", required = true, example = "1")
  71 + public CommonResult<Boolean> recallMessage(@PathVariable("id") Long id) {
  72 + imGroupMessageService.recallMessage(getLoginUserId(), id);
  73 + return success(true);
  74 + }
  75 +
  76 + // TODO @AI:这个接口,确认下是否需要!
  77 + @GetMapping("/read-users")
  78 + @Operation(summary = "获取群消息已读用户列表")
  79 + @Parameter(name = "groupId", description = "群编号", required = true, example = "1")
  80 + @Parameter(name = "messageId", description = "消息编号", required = true, example = "1")
  81 + public CommonResult<List<String>> getReadUsers(
  82 + @RequestParam("groupId") Long groupId,
  83 + @RequestParam("messageId") Long messageId) {
  84 + List<Long> readUserIds = imGroupMessageService.getReadUsers(getLoginUserId(), groupId, messageId);
  85 + // Long -> String 转换
  86 + return success(readUserIds.stream().map(String::valueOf).collect(Collectors.toList()));
  87 + }
  88 +
  89 + /**
  90 + * DO 转换为 RespVO(Long id 统一转 String)
  91 + */
  92 + // TODO @AI:BeanUtils?
  93 + private ImGroupMessageRespVO toRespVO(ImGroupMessageDO message) {
  94 + ImGroupMessageRespVO vo = new ImGroupMessageRespVO();
  95 + vo.setId(message.getId().toString());
  96 + vo.setClientMessageId(message.getClientMessageId());
  97 + vo.setSenderId(message.getSenderId().toString());
  98 + vo.setGroupId(message.getGroupId().toString());
  99 + vo.setType(message.getType());
  100 + vo.setContent(message.getContent());
  101 + vo.setStatus(message.getStatus());
  102 + vo.setSendTime(message.getSendTime());
  103 + vo.setAtUserIds(message.getAtUserIds());
  104 + vo.setReceiverUserIds(message.getReceiverUserIds());
  105 + vo.setReceiptStatus(message.getReceiptStatus());
  106 + return vo;
  107 + }
  108 +
  109 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.*;
  5 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageReadReqVO;
  6 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageRespVO;
  7 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  9 +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
  10 +import io.swagger.v3.oas.annotations.Operation;
  11 +import io.swagger.v3.oas.annotations.Parameter;
  12 +import io.swagger.v3.oas.annotations.tags.Tag;
  13 +import jakarta.annotation.Resource;
  14 +import jakarta.validation.Valid;
  15 +import org.springframework.validation.annotation.Validated;
  16 +import org.springframework.web.bind.annotation.*;
  17 +
  18 +import java.util.List;
  19 +import java.util.stream.Collectors;
  20 +
  21 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  22 +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
  23 +
  24 +/**
  25 + * 管理后台 - IM 私聊消息
  26 + */
  27 +@Tag(name = "管理后台 - IM 私聊消息")
  28 +@RestController
  29 +@RequestMapping("/im/message/private")
  30 +@Validated
  31 +public class ImPrivateMessageController {
  32 +
  33 + @Resource
  34 + private ImPrivateMessageService imPrivateMessageService;
  35 +
  36 + @PostMapping("/send")
  37 + @Operation(summary = "发送私聊消息")
  38 + public CommonResult<ImPrivateMessageRespVO> sendMessage(@Valid @RequestBody ImPrivateMessageSendReqVO reqVO) {
  39 + ImPrivateMessageDO message = imPrivateMessageService.sendMessage(getLoginUserId(), reqVO);
  40 + return success(toRespVO(message));
  41 + }
  42 +
  43 + // TODO @AI:需要在评估下,是否需要 nextMinId
  44 + @GetMapping("/pull")
  45 + @Operation(summary = "拉取私聊消息(增量)")
  46 + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0")
  47 + @Parameter(name = "size", description = "拉取数量", required = true, example = "100")
  48 + public CommonResult<ImMessagePullRespVO<ImPrivateMessageRespVO>> pullMessages(
  49 + @RequestParam("minId") Long minId,
  50 + @RequestParam("size") Integer size) {
  51 + List<ImPrivateMessageDO> messages = imPrivateMessageService.pullMessages(getLoginUserId(), minId, size);
  52 + ImMessagePullRespVO<ImPrivateMessageRespVO> resp = new ImMessagePullRespVO<>();
  53 + resp.setList(messages.stream().map(this::toRespVO).collect(Collectors.toList()));
  54 + resp.setNextMinId(messages.isEmpty() ? minId.toString()
  55 + : messages.get(messages.size() - 1).getId().toString());
  56 + return success(resp);
  57 + }
  58 +
  59 + // TODO @AI:它是不是不用 VO
  60 + @PutMapping("/read")
  61 + @Operation(summary = "标记私聊消息已读")
  62 + public CommonResult<Boolean> readMessages(@Valid @RequestBody ImPrivateMessageReadReqVO reqVO) {
  63 + imPrivateMessageService.readMessages(getLoginUserId(), reqVO.getFriendId());
  64 + return success(true);
  65 + }
  66 +
  67 + // TODO @AI:不使用 path variable
  68 + @DeleteMapping("/recall/{id}")
  69 + @Operation(summary = "撤回私聊消息")
  70 + @Parameter(name = "id", description = "消息编号", required = true, example = "1")
  71 + public CommonResult<Boolean> recallMessage(@PathVariable("id") Long id) {
  72 + imPrivateMessageService.recallMessage(getLoginUserId(), id);
  73 + return success(true);
  74 + }
  75 +
  76 + // TODO @AI:beanutils
  77 + /**
  78 + * DO 转换为 RespVO(Long id 统一转 String)
  79 + */
  80 + private ImPrivateMessageRespVO toRespVO(ImPrivateMessageDO message) {
  81 + ImPrivateMessageRespVO vo = new ImPrivateMessageRespVO();
  82 + vo.setId(message.getId().toString());
  83 + vo.setClientMessageId(message.getClientMessageId());
  84 + vo.setSenderId(message.getSenderId().toString());
  85 + vo.setReceiverId(message.getReceiverId().toString());
  86 + vo.setType(message.getType());
  87 + vo.setContent(message.getContent());
  88 + vo.setStatus(message.getStatus());
  89 + vo.setSendTime(message.getSendTime());
  90 + return vo;
  91 + }
  92 +
  93 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/ImMessagePullRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * 消息拉取 Response VO
  10 + */
  11 +@Schema(description = "管理后台 - 消息拉取 Response VO")
  12 +@Data
  13 +public class ImMessagePullRespVO<T> {
  14 +
  15 + @Schema(description = "消息列表")
  16 + private List<T> list;
  17 +
  18 + @Schema(description = "下次拉取的最小 id", example = "100")
  19 + private String nextMinId;
  20 +
  21 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageReadReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import lombok.Data;
  6 +
  7 +/**
  8 + * 群聊已读 Request VO
  9 + */
  10 +@Schema(description = "管理后台 - 群聊已读 Request VO")
  11 +@Data
  12 +public class ImGroupMessageReadReqVO {
  13 +
  14 + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  15 + @NotNull(message = "群编号不能为空")
  16 + private Long groupId;
  17 +
  18 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +/**
  9 + * 群聊消息 Response VO
  10 + */
  11 +@Schema(description = "管理后台 - 群聊消息 Response VO")
  12 +@Data
  13 +public class ImGroupMessageRespVO {
  14 +
  15 + @Schema(description = "消息编号", example = "1")
  16 + private String id;
  17 +
  18 + @Schema(description = "客户端消息编号", example = "uuid-xxx")
  19 + private String clientMessageId;
  20 +
  21 + @Schema(description = "发送人编号", example = "1")
  22 + private String senderId;
  23 +
  24 + @Schema(description = "群编号", example = "1")
  25 + private String groupId;
  26 +
  27 + @Schema(description = "消息类型", example = "0")
  28 + private Integer type;
  29 +
  30 + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}")
  31 + private String content;
  32 +
  33 + @Schema(description = "消息状态", example = "0")
  34 + private Integer status;
  35 +
  36 + @Schema(description = "发送时间")
  37 + private LocalDateTime sendTime;
  38 +
  39 + @Schema(description = "@目标用户编号列表", example = "1,2,3")
  40 + private String atUserIds;
  41 +
  42 + @Schema(description = "定向接收用户编号列表", example = "1,2")
  43 + private String receiverUserIds;
  44 +
  45 + @Schema(description = "回执状态", example = "0")
  46 + private Integer receiptStatus;
  47 +
  48 + @Schema(description = "已读人数(群回执使用)", example = "5")
  49 + private Integer readCount;
  50 +
  51 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotEmpty;
  5 +import jakarta.validation.constraints.NotNull;
  6 +import lombok.Data;
  7 +
  8 +import java.util.List;
  9 +
  10 +/**
  11 + * 群聊消息发送 Request VO
  12 + */
  13 +@Schema(description = "管理后台 - 群聊消息发送 Request VO")
  14 +@Data
  15 +public class ImGroupMessageSendReqVO {
  16 +
  17 + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx")
  18 + @NotEmpty(message = "客户端消息编号不能为空")
  19 + private String clientMessageId;
  20 +
  21 + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  22 + @NotNull(message = "群编号不能为空")
  23 + private Long groupId;
  24 +
  25 + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  26 + @NotNull(message = "消息类型不能为空")
  27 + private Integer type;
  28 +
  29 + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
  30 + @NotEmpty(message = "消息内容不能为空")
  31 + private String content;
  32 +
  33 + @Schema(description = "@目标用户编号列表", example = "[1,2,3]")
  34 + private List<Long> atUserIds;
  35 +
  36 + @Schema(description = "定向接收用户编号列表", example = "[1,2]")
  37 + private List<Long> receiverUserIds;
  38 +
  39 + @Schema(description = "是否需要回执", example = "false")
  40 + private Boolean needReceipt;
  41 +
  42 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageReadReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import lombok.Data;
  6 +
  7 +/**
  8 + * 私聊已读 Request VO
  9 + */
  10 +@Schema(description = "管理后台 - 私聊已读 Request VO")
  11 +@Data
  12 +public class ImPrivateMessageReadReqVO {
  13 +
  14 + @Schema(description = "好友用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
  15 + @NotNull(message = "好友用户编号不能为空")
  16 + private Long friendId;
  17 +
  18 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +/**
  9 + * 私聊消息 Response VO
  10 + */
  11 +@Schema(description = "管理后台 - 私聊消息 Response VO")
  12 +@Data
  13 +public class ImPrivateMessageRespVO {
  14 +
  15 + // TODO @AI:类型不对;
  16 + @Schema(description = "消息编号", example = "1")
  17 + private String id;
  18 +
  19 + @Schema(description = "客户端消息编号", example = "uuid-xxx")
  20 + private String clientMessageId;
  21 +
  22 + @Schema(description = "发送人编号", example = "1")
  23 + private String senderId;
  24 +
  25 + @Schema(description = "接收人编号", example = "2")
  26 + private String receiverId;
  27 +
  28 + @Schema(description = "消息类型", example = "0")
  29 + private Integer type;
  30 +
  31 + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}")
  32 + private String content;
  33 +
  34 + @Schema(description = "消息状态", example = "0")
  35 + private Integer status;
  36 +
  37 + @Schema(description = "发送时间")
  38 + private LocalDateTime sendTime;
  39 +
  40 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotEmpty;
  5 +import jakarta.validation.constraints.NotNull;
  6 +import lombok.Data;
  7 +
  8 +/**
  9 + * 私聊消息发送 Request VO
  10 + */
  11 +@Schema(description = "管理后台 - 私聊消息发送 Request VO")
  12 +@Data
  13 +public class ImPrivateMessageSendReqVO {
  14 +
  15 + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx")
  16 + @NotEmpty(message = "客户端消息编号不能为空")
  17 + private String clientMessageId;
  18 +
  19 + @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
  20 + @NotNull(message = "接收人编号不能为空")
  21 + private Long receiverId;
  22 +
  23 + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  24 + @NotNull(message = "消息类型不能为空")
  25 + private Integer type;
  26 +
  27 + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
  28 + @NotEmpty(message = "消息内容不能为空")
  29 + private String content;
  30 +
  31 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import com.baomidou.mybatisplus.annotation.KeySequence;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.*;
  8 +
  9 +// TODO @AI:好友关系是逻辑删除,群成员是 status 标识,感觉是不是要统一下?感觉都改成 status 会不会好点?这样增加一个添加时间、和删除时间;都使用 statusenum 标识;
  10 +/**
  11 + * IM 好友关系 DO
  12 + * <p>
  13 + * 删除好友采用逻辑删除,沿用 BaseDO 的 deleted。
  14 + * 重新添加好友时优先恢复原关系记录,不新插重复行。
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@TableName("im_friend")
  19 +@KeySequence("im_friend_seq")
  20 +@Data
  21 +@EqualsAndHashCode(callSuper = true)
  22 +@ToString(callSuper = true)
  23 +@Builder
  24 +@NoArgsConstructor
  25 +@AllArgsConstructor
  26 +public class ImFriendDO extends BaseDO {
  27 +
  28 + /**
  29 + * 编号
  30 + */
  31 + @TableId
  32 + private Long id;
  33 + /**
  34 + * 用户编号
  35 + *
  36 + * 关联 AdminUserDO 的 id 字段
  37 + */
  38 + private Long userId;
  39 + /**
  40 + * 好友用户编号
  41 + *
  42 + * 关联 AdminUserDO 的 id 字段
  43 + */
  44 + private Long friendUserId;
  45 + /**
  46 + * 是否免打扰
  47 + */
  48 + private Boolean muted;
  49 +
  50 +}
... ...
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 {
26 26 */
27 27 @TableId
28 28 private Long id;
29   - // TODO @hao:name,如果这个表已经是 group 了,不用在带额外的
30 29 /**
31   - * 群名字
  30 + * 群名称
32 31 */
33   - private String groupName;
34   - // TODO @hao:关联字段;
  32 + private String name;
35 33 /**
36   - * 群主编号
  34 + * 群主用户编号
  35 + * <p>
  36 + * 关联 AdminUserDO 的 id 字段
37 37 */
38   - private Long ownerId;
39   - // TODO @hao:头像使用 avatar 好了,整个项目统一;然后 Thumb 是不是不用存,这个更多是文件服务做裁剪
  38 + private Long ownerUserId;
40 39 /**
41 40 * 群头像
42 41 */
43   - private String headImage;
44   - /**
45   - * 群头像缩略图
46   - */
47   - private String headImageThumb;
  42 + private String avatar;
48 43 /**
49 44 * 群公告
50 45 */
51 46 private String notice;
52 47 /**
53   - * 群备注
  48 + * 是否封禁
  49 + */
  50 + private Boolean banned;
  51 + /**
  52 + * 封禁原因
  53 + */
  54 + private String bannedReason;
  55 + // TODO @AI:封禁时间;
  56 + /**
  57 + * 是否解散
54 58 */
55   - private String remark;
  59 + private Boolean dissolved;
  60 + // TODO @AI:解散时间
56 61  
57 62 }
58 63 \ No newline at end of file
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java
1 1 package cn.iocoder.yudao.module.im.dal.dataobject.group;
2 2  
3   -import lombok.*;
4   -import com.baomidou.mybatisplus.annotation.*;
5 3 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberStatusEnum;
  5 +import com.baomidou.mybatisplus.annotation.KeySequence;
  6 +import com.baomidou.mybatisplus.annotation.TableId;
  7 +import com.baomidou.mybatisplus.annotation.TableName;
  8 +import lombok.*;
6 9  
  10 +import java.time.LocalDateTime;
  11 +
  12 +// TODO @AI:好友关系是逻辑删除,群成员是 status 标识,感觉是不是要统一下?
7 13 /**
8 14 * IM 群成员 DO
9 15 *
... ... @@ -24,31 +30,43 @@ public class ImGroupMemberDO extends BaseDO {
24 30 */
25 31 @TableId
26 32 private Long id;
27   - // TODO @hao:groupId 和 userId 都写下关联字段哈
28 33 /**
29 34 * 群编号
  35 + * <p>
  36 + * 关联 {@link ImGroupDO#getId()}
30 37 */
31 38 private Long groupId;
32 39 /**
33 40 * 用户编号
  41 + * <p>
  42 + * 关联 AdminUserDO 的 id 字段
34 43 */
35 44 private Long userId;
36   - // TODO @hao:nickname 和 avatar 是不是不用存储哈;
37 45 /**
38   - * 昵称
  46 + * 组内显示名
  47 + */
  48 + private String displayUserName;
  49 + /**
  50 + * 群显示备注
  51 + */
  52 + private String displayGroupName;
  53 + /**
  54 + * 是否免打扰
39 55 */
40   - private String nickname;
  56 + private Boolean muted;
41 57 /**
42   - * 头像
  58 + * 成员状态
  59 + * <p>
  60 + * 枚举 {@link ImGroupMemberStatusEnum}
43 61 */
44   - private String avatar;
  62 + private Integer status;
45 63 /**
46   - * 组内显示名称
  64 + * 入群时间
47 65 */
48   - private String aliasName;
  66 + private LocalDateTime joinTime;
49 67 /**
50   - * 备注
  68 + * 退群时间
51 69 */
52   - private String remark;
  70 + private LocalDateTime quitTime;
53 71  
54 72 }
55 73 \ No newline at end of file
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.message;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
  5 +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  6 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  7 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  8 +import com.baomidou.mybatisplus.annotation.KeySequence;
  9 +import com.baomidou.mybatisplus.annotation.TableId;
  10 +import com.baomidou.mybatisplus.annotation.TableName;
  11 +import lombok.*;
  12 +
  13 +import java.time.LocalDateTime;
  14 +
  15 +/**
  16 + * IM 群聊消息 DO
  17 + *
  18 + * @author 芋道源码
  19 + */
  20 +@TableName("im_group_message")
  21 +@KeySequence("im_group_message_seq")
  22 +@Data
  23 +@EqualsAndHashCode(callSuper = true)
  24 +@ToString(callSuper = true)
  25 +@Builder
  26 +@NoArgsConstructor
  27 +@AllArgsConstructor
  28 +public class ImGroupMessageDO extends BaseDO {
  29 +
  30 + /**
  31 + * 编号
  32 + */
  33 + @TableId
  34 + private Long id;
  35 + /**
  36 + * 客户端消息编号,用于发送幂等
  37 + */
  38 + private String clientMessageId;
  39 + /**
  40 + * 发送人编号
  41 + *
  42 + * 关联 AdminUserDO 的 id 字段
  43 + */
  44 + private Long senderId;
  45 + /**
  46 + * 群编号
  47 + *
  48 + * 关联 {@link ImGroupDO#getId()}
  49 + */
  50 + private Long groupId;
  51 + /**
  52 + * 消息类型
  53 + * <p>
  54 + * 枚举 {@link ImMessageTypeEnum}
  55 + */
  56 + private Integer type;
  57 + /**
  58 + * 消息内容,JSON 格式
  59 + *
  60 + * 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
  61 + */
  62 + private String content;
  63 + /**
  64 + * 消息状态
  65 + * <p>
  66 + * 枚举 {@link ImMessageStatusEnum}
  67 + * 群聊状态:NORMAL(0) / RECALL(2)
  68 + */
  69 + // TODO @AI:会不会存在 READ 的情况?如果不存在,最好说明下,和单聊的差异;
  70 + private Integer status;
  71 + /**
  72 + * 发送时间
  73 + */
  74 + private LocalDateTime sendTime;
  75 + /**
  76 + * 定向接收用户编号列表,逗号分隔
  77 + * <p>
  78 + * 为空表示全员可见
  79 + *
  80 + * 关联 AdminUserDO 的 id 字段
  81 + */
  82 + // TODO @AI:使用 List<Long> 存储,避免逗号分隔的字符串解析问题
  83 + private String receiverUserIds;
  84 + /**
  85 + * @ 目标用户编号列表,逗号分隔
  86 + *
  87 + * 关联 AdminUserDO 的 id 字段
  88 + */
  89 + // TODO @AI:使用 List<Long> 存储,避免逗号分隔的字符串解析问题
  90 + private String atUserIds;
  91 + /**
  92 + * 回执状态
  93 + * <p>
  94 + * 枚举 {@link ImGroupMessageReceiptStatusEnum}
  95 + */
  96 + private Integer receiptStatus;
  97 +
  98 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.message;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  5 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  6 +import com.baomidou.mybatisplus.annotation.KeySequence;
  7 +import com.baomidou.mybatisplus.annotation.TableId;
  8 +import com.baomidou.mybatisplus.annotation.TableName;
  9 +import lombok.*;
  10 +
  11 +import java.time.LocalDateTime;
  12 +
  13 +/**
  14 + * IM 私聊消息 DO
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@TableName("im_private_message")
  19 +@KeySequence("im_private_message_seq")
  20 +@Data
  21 +@EqualsAndHashCode(callSuper = true)
  22 +@ToString(callSuper = true)
  23 +@Builder
  24 +@NoArgsConstructor
  25 +@AllArgsConstructor
  26 +public class ImPrivateMessageDO extends BaseDO {
  27 +
  28 + /**
  29 + * 编号
  30 + */
  31 + @TableId
  32 + private Long id;
  33 + /**
  34 + * 客户端消息编号,用于发送幂等
  35 + */
  36 + private String clientMessageId;
  37 + /**
  38 + * 发送人编号
  39 + *
  40 + * 关联 AdminUserDO 的 id 字段
  41 + */
  42 + private Long senderId;
  43 + /**
  44 + * 接收人编号
  45 + *
  46 + * 关联 AdminUserDO 的 id 字段
  47 + */
  48 + private Long receiverId;
  49 + /**
  50 + * 消息类型
  51 + * <p>
  52 + * 枚举 {@link ImMessageTypeEnum}
  53 + */
  54 + private Integer type;
  55 + /**
  56 + * 消息内容,JSON 格式
  57 + *
  58 + * 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
  59 + */
  60 + private String content;
  61 + /**
  62 + * 消息状态
  63 + * <p>
  64 + * 枚举 {@link ImMessageStatusEnum}
  65 + * 私聊状态:UNREAD(0) / READ(1) / RECALL(2)
  66 + */
  67 + private Integer status;
  68 + /**
  69 + * 发送时间
  70 + */
  71 + private LocalDateTime sendTime;
  72 +
  73 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  5 +import com.baomidou.mybatisplus.annotation.KeySequence;
  6 +import com.baomidou.mybatisplus.annotation.TableId;
  7 +import com.baomidou.mybatisplus.annotation.TableName;
  8 +import lombok.*;
  9 +
  10 +/**
  11 + * IM 敏感词 DO
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@TableName("im_sensitive_word")
  16 +@KeySequence("im_sensitive_word_seq")
  17 +@Data
  18 +@EqualsAndHashCode(callSuper = true)
  19 +@ToString(callSuper = true)
  20 +@Builder
  21 +@NoArgsConstructor
  22 +@AllArgsConstructor
  23 +public class ImSensitiveWordDO extends BaseDO {
  24 +
  25 + /**
  26 + * 编号
  27 + */
  28 + @TableId
  29 + private Long id;
  30 + /**
  31 + * 敏感词
  32 + */
  33 + private String word;
  34 + /**
  35 + * 状态
  36 + * <p>
  37 + * 枚举 {@link CommonStatusEnum}
  38 + */
  39 + private Integer status;
  40 +
  41 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  6 +import org.apache.ibatis.annotations.Mapper;
  7 +
  8 +/**
  9 + * IM 好友关系 Mapper
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Mapper
  14 +public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
  15 +
  16 + /**
  17 + * 查询好友关系
  18 + *
  19 + * @param userId 用户编号
  20 + * @param friendUserId 好友用户编号
  21 + * @return 好友关系
  22 + */
  23 + default ImFriendDO selectByUserIdAndFriendUserId(Long userId, Long friendUserId) {
  24 + return selectOne(new LambdaQueryWrapperX<ImFriendDO>()
  25 + .eq(ImFriendDO::getUserId, userId)
  26 + .eq(ImFriendDO::getFriendUserId, friendUserId));
  27 + }
  28 +
  29 +}
... ...
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;
7 7 import org.apache.ibatis.annotations.Mapper;
8 8 import cn.iocoder.yudao.module.im.controller.admin.group.vo.*;
9 9  
10   -// TODO @hao:这个也要有 Im 前缀
11 10 /**
12   - * 群 Mapper
  11 + * IM 群 Mapper
13 12 *
14 13 * @author 芋道源码
15 14 */
... ... @@ -18,12 +17,9 @@ public interface ImGroupMapper extends BaseMapperX&lt;ImGroupDO&gt; {
18 17  
19 18 default PageResult<ImGroupDO> selectPage(ImGroupPageReqVO reqVO) {
20 19 return selectPage(reqVO, new LambdaQueryWrapperX<ImGroupDO>()
21   - .likeIfPresent(ImGroupDO::getGroupName, reqVO.getGroupName())
22   - .eqIfPresent(ImGroupDO::getOwnerId, reqVO.getOwnerId())
23   - .eqIfPresent(ImGroupDO::getHeadImage, reqVO.getHeadImage())
24   - .eqIfPresent(ImGroupDO::getHeadImageThumb, reqVO.getHeadImageThumb())
  20 + .likeIfPresent(ImGroupDO::getName, reqVO.getName())
  21 + .eqIfPresent(ImGroupDO::getOwnerUserId, reqVO.getOwnerUserId())
25 22 .eqIfPresent(ImGroupDO::getNotice, reqVO.getNotice())
26   - .eqIfPresent(ImGroupDO::getRemark, reqVO.getRemark())
27 23 .betweenIfPresent(ImGroupDO::getCreateTime, reqVO.getCreateTime())
28 24 .orderByDesc(ImGroupDO::getId));
29 25 }
... ...
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;
10 10 import java.util.List;
11 11  
12 12 /**
13   - * 群成员 Mapper
  13 + * IM 群成员 Mapper
14 14 *
15 15 * @author 芋道源码
16 16 */
... ... @@ -21,10 +21,7 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
21 21 return selectPage(reqVO, new LambdaQueryWrapperX<ImGroupMemberDO>()
22 22 .eqIfPresent(ImGroupMemberDO::getGroupId, reqVO.getGroupId())
23 23 .eqIfPresent(ImGroupMemberDO::getUserId, reqVO.getUserId())
24   - .likeIfPresent(ImGroupMemberDO::getNickname, reqVO.getNickname())
25   - .eqIfPresent(ImGroupMemberDO::getAvatar, reqVO.getAvatar())
26   - .likeIfPresent(ImGroupMemberDO::getAliasName, reqVO.getAliasName())
27   - .eqIfPresent(ImGroupMemberDO::getRemark, reqVO.getRemark())
  24 + .eqIfPresent(ImGroupMemberDO::getStatus, reqVO.getStatus())
28 25 .betweenIfPresent(ImGroupMemberDO::getCreateTime, reqVO.getCreateTime())
29 26 .orderByDesc(ImGroupMemberDO::getId));
30 27 }
... ... @@ -32,4 +29,14 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
32 29 default List<ImGroupMemberDO> selectListByGroupId(Long groupId) {
33 30 return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>().eq(ImGroupMemberDO::getGroupId, groupId));
34 31 }
  32 +
  33 + default ImGroupMemberDO selectByGroupIdAndUserId(Long groupId, Long userId) {
  34 + return selectOne(new LambdaQueryWrapperX<ImGroupMemberDO>()
  35 + .eq(ImGroupMemberDO::getGroupId, groupId)
  36 + .eq(ImGroupMemberDO::getUserId, userId));
  37 + }
  38 +
  39 + default List<ImGroupMemberDO> selectListByUserId(Long userId) {
  40 + return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>().eq(ImGroupMemberDO::getUserId, userId));
  41 + }
35 42 }
36 43 \ No newline at end of file
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.message;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  6 +import org.apache.ibatis.annotations.Mapper;
  7 +
  8 +import java.util.List;
  9 +
  10 +/**
  11 + * IM 群聊消息 Mapper
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Mapper
  16 +public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
  17 +
  18 + /**
  19 + * 根据群编号和 minId 增量拉取群聊消息
  20 + *
  21 + * @param groupIds 用户所在的群编号列表
  22 + * @param minId 最小消息 id(不含)
  23 + * @param size 拉取数量
  24 + * @return 消息列表
  25 + */
  26 + default List<ImGroupMessageDO> selectListByMinId(List<Long> groupIds, Long minId, Integer size) {
  27 + return selectList(new LambdaQueryWrapperX<ImGroupMessageDO>()
  28 + .in(ImGroupMessageDO::getGroupId, groupIds)
  29 + .gt(ImGroupMessageDO::getId, minId)
  30 + .orderByAsc(ImGroupMessageDO::getId)
  31 + .last("LIMIT " + size));
  32 + }
  33 +
  34 + /**
  35 + * 根据 senderId 和 clientMessageId 查询(幂等校验)
  36 + */
  37 + default ImGroupMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) {
  38 + return selectOne(new LambdaQueryWrapperX<ImGroupMessageDO>()
  39 + .eq(ImGroupMessageDO::getSenderId, senderId)
  40 + .eq(ImGroupMessageDO::getClientMessageId, clientMessageId));
  41 + }
  42 +
  43 + /**
  44 + * 获取群内最新消息 id
  45 + */
  46 + default Long selectMaxIdByGroupId(Long groupId) {
  47 + ImGroupMessageDO msg = selectOne(new LambdaQueryWrapperX<ImGroupMessageDO>()
  48 + .eq(ImGroupMessageDO::getGroupId, groupId)
  49 + .orderByDesc(ImGroupMessageDO::getId)
  50 + .last("LIMIT 1"));
  51 + return msg == null ? null : msg.getId();
  52 + }
  53 +
  54 + /**
  55 + * 获取群内需要回执的消息(状态为 PENDING)
  56 + */
  57 + default List<ImGroupMessageDO> selectPendingReceiptMessages(Long groupId) {
  58 + return selectList(new LambdaQueryWrapperX<ImGroupMessageDO>()
  59 + .eq(ImGroupMessageDO::getGroupId, groupId)
  60 + .eq(ImGroupMessageDO::getReceiptStatus, 1)); // PENDING
  61 + }
  62 +
  63 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.message;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  6 +import org.apache.ibatis.annotations.Mapper;
  7 +
  8 +import java.util.List;
  9 +
  10 +/**
  11 + * IM 私聊消息 Mapper
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Mapper
  16 +public interface ImPrivateMessageMapper extends BaseMapperX<ImPrivateMessageDO> {
  17 +
  18 + /**
  19 + * 根据 minId 增量拉取私聊消息
  20 + *
  21 + * @param userId 当前用户编号
  22 + * @param minId 最小消息 id(不含)
  23 + * @param size 拉取数量
  24 + * @return 消息列表
  25 + */
  26 + default List<ImPrivateMessageDO> selectListByMinId(Long userId, Long minId, Integer size) {
  27 + return selectList(new LambdaQueryWrapperX<ImPrivateMessageDO>()
  28 + .and(w -> w.eq(ImPrivateMessageDO::getSenderId, userId)
  29 + .or()
  30 + .eq(ImPrivateMessageDO::getReceiverId, userId))
  31 + .gt(ImPrivateMessageDO::getId, minId)
  32 + .orderByAsc(ImPrivateMessageDO::getId)
  33 + .last("LIMIT " + size));
  34 + }
  35 +
  36 + /**
  37 + * 根据 senderId 和 clientMessageId 查询(幂等校验)
  38 + */
  39 + default ImPrivateMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) {
  40 + return selectOne(new LambdaQueryWrapperX<ImPrivateMessageDO>()
  41 + .eq(ImPrivateMessageDO::getSenderId, senderId)
  42 + .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId));
  43 + }
  44 +
  45 + /**
  46 + * 批量更新私聊消息已读状态
  47 + * 将发送方 = friendId、接收方 = userId 且状态为 UNREAD 的消息更新为 READ
  48 + */
  49 + default int updateStatusToRead(Long userId, Long friendId) {
  50 + return update(new ImPrivateMessageDO().setStatus(1), // READ
  51 + new LambdaQueryWrapperX<ImPrivateMessageDO>()
  52 + .eq(ImPrivateMessageDO::getSenderId, friendId)
  53 + .eq(ImPrivateMessageDO::getReceiverId, userId)
  54 + .eq(ImPrivateMessageDO::getStatus, 0)); // UNREAD
  55 + }
  56 +
  57 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.sensitiveword;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  5 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO;
  7 +import org.apache.ibatis.annotations.Mapper;
  8 +
  9 +import java.util.List;
  10 +
  11 +/**
  12 + * IM 敏感词 Mapper
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +@Mapper
  17 +public interface ImSensitiveWordMapper extends BaseMapperX<ImSensitiveWordDO> {
  18 +
  19 + // TODO @AI:应该 selectListByStatus
  20 + default List<ImSensitiveWordDO> selectListByEnabled() {
  21 + return selectList(new LambdaQueryWrapperX<ImSensitiveWordDO>()
  22 + .eq(ImSensitiveWordDO::getStatus, CommonStatusEnum.ENABLE.getStatus()));
  23 + }
  24 +
  25 +}
... ...
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;
2 2  
3 3  
4 4 /**
5   - * im Redis Key 枚举类
  5 + * IM Redis Key 枚举类
6 6 *
7 7 * @author 芋道源码
8 8 */
... ... @@ -21,4 +21,12 @@ public interface RedisKeyConstants {
21 21 * VALUE 数据类型: String
22 22 */
23 23 String INBOX_LOCK = "im_inbox_lock:%s";
  24 +
  25 + /**
  26 + * 群已读位置
  27 + * KEY 格式: im:group:read:{groupId}
  28 + * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
  29 + */
  30 + String GROUP_READ_POSITION = "im:group:read:%s";
  31 +
24 32 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/group/GroupReadPositionRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.redis.group;
  2 +
  3 +import jakarta.annotation.Resource;
  4 +import org.springframework.data.redis.core.StringRedisTemplate;
  5 +import org.springframework.stereotype.Repository;
  6 +
  7 +import java.util.Map;
  8 +
  9 +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_READ_POSITION;
  10 +
  11 +/**
  12 + * 群已读位置 Redis DAO
  13 + * <p>
  14 + * 采用 Redis Hash 存储:key=im:group:read:{groupId}, field=userId, value=maxReadMessageId
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Repository
  19 +public class GroupReadPositionRedisDAO {
  20 +
  21 + @Resource
  22 + private StringRedisTemplate stringRedisTemplate;
  23 +
  24 + /**
  25 + * 更新用户在某群的已读位置
  26 + *
  27 + * @param groupId 群编号
  28 + * @param userId 用户编号
  29 + * @param maxMessageId 最大已读消息编号
  30 + */
  31 + public void updateReadPosition(Long groupId, Long userId, Long maxMessageId) {
  32 + String key = String.format(GROUP_READ_POSITION, groupId);
  33 + // 只在新值大于旧值时更新
  34 + Object currentVal = stringRedisTemplate.opsForHash().get(key, userId.toString());
  35 + if (currentVal == null || Long.parseLong(currentVal.toString()) < maxMessageId) {
  36 + stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
  37 + }
  38 + }
  39 +
  40 + /**
  41 + * 获取用户在某群的已读位置
  42 + */
  43 + public Long getReadPosition(Long groupId, Long userId) {
  44 + String key = String.format(GROUP_READ_POSITION, groupId);
  45 + Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
  46 + return val == null ? 0L : Long.parseLong(val.toString());
  47 + }
  48 +
  49 + /**
  50 + * 获取某群所有用户的已读位置
  51 + */
  52 + public Map<Object, Object> getAllReadPositions(Long groupId) {
  53 + String key = String.format(GROUP_READ_POSITION, groupId);
  54 + return stringRedisTemplate.opsForHash().entries(key);
  55 + }
  56 +
  57 + /**
  58 + * 删除某群所有用户的已读位置(群解散时调用)
  59 + */
  60 + public void deleteGroupPositions(Long groupId) {
  61 + String key = String.format(GROUP_READ_POSITION, groupId);
  62 + stringRedisTemplate.delete(key);
  63 + }
  64 +
  65 + /**
  66 + * 删除用户在某群的已读位置(退群时调用)
  67 + */
  68 + public void deleteUserPosition(Long groupId, Long userId) {
  69 + String key = String.format(GROUP_READ_POSITION, groupId);
  70 + stringRedisTemplate.opsForHash().delete(key, userId.toString());
  71 + }
  72 +
  73 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.friend;
  2 +
  3 +/**
  4 + * IM 好友关系 Service 接口
  5 + *
  6 + * @author 芋道源码
  7 + */
  8 +public interface ImFriendService {
  9 +
  10 + /**
  11 + * 校验两个用户是否是好友关系(未删除状态)
  12 + * <p>
  13 + * 如果不是好友,直接抛出业务异常
  14 + *
  15 + * @param userId 用户编号
  16 + * @param friendUserId 好友用户编号
  17 + */
  18 + void validateIsFriend(Long userId, Long friendUserId);
  19 +
  20 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.friend;
  2 +
  3 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  4 +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
  5 +import jakarta.annotation.Resource;
  6 +import org.springframework.stereotype.Service;
  7 +import org.springframework.validation.annotation.Validated;
  8 +
  9 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  10 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
  11 +
  12 +/**
  13 + * IM 好友关系 Service 实现类
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +@Service
  18 +@Validated
  19 +public class ImFriendServiceImpl implements ImFriendService {
  20 +
  21 + @Resource
  22 + private ImFriendMapper imFriendMapper;
  23 +
  24 + // TODO @AI:逻辑这块,在优化下;
  25 + @Override
  26 + public void validateIsFriend(Long userId, Long friendUserId) {
  27 + // 好友关系表中,deleted = false 的记录表示仍是好友
  28 + // BaseMapperX 默认会过滤 deleted = true 的记录
  29 + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  30 + if (friend == null) {
  31 + throw exception(FRIEND_NOT_FRIEND);
  32 + }
  33 + }
  34 +
  35 +}
... ...
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 {
50 50 */
51 51 PageResult<ImGroupDO> getGroupPage(ImGroupPageReqVO pageReqVO);
52 52  
  53 + /**
  54 + * 校验群存在且未封禁、未解散
  55 + *
  56 + * @param groupId 群编号
  57 + * @return 群信息
  58 + */
  59 + ImGroupDO validateGroupExists(Long groupId);
  60 +
53 61 }
54 62 \ No newline at end of file
... ...
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 {
52 52 imGroupMapper.deleteById(id);
53 53 }
54 54  
55   - private void validateGroupExists(Long id) {
56   - if (imGroupMapper.selectById(id) == null) {
  55 + @Override
  56 + public ImGroupDO validateGroupExists(Long id) {
  57 + ImGroupDO group = imGroupMapper.selectById(id);
  58 + if (group == null) {
57 59 throw exception(GROUP_NOT_EXISTS);
58 60 }
  61 + if (Boolean.TRUE.equals(group.getBanned())) {
  62 + throw exception(GROUP_BANNED);
  63 + }
  64 + if (Boolean.TRUE.equals(group.getDissolved())) {
  65 + throw exception(GROUP_DISSOLVED);
  66 + }
  67 + return group;
59 68 }
60 69  
61 70 @Override
... ...
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 {
60 60 * @return 群成员列表
61 61 */
62 62 List<ImGroupMemberDO> selectByGroupId(Long groupId);
  63 +
  64 + /**
  65 + * 根据群编号和用户编号查询群成员
  66 + *
  67 + * @param groupId 群编号
  68 + * @param userId 用户编号
  69 + * @return 群成员
  70 + */
  71 + ImGroupMemberDO getGroupMember(Long groupId, Long userId);
  72 +
  73 + /**
  74 + * 查询用户所在的所有群的成员记录
  75 + *
  76 + * @param userId 用户编号
  77 + * @return 群成员记录列表
  78 + */
  79 + List<ImGroupMemberDO> getGroupMembersByUserId(Long userId);
63 80 }
64 81 \ No newline at end of file
... ...
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 {
75 75 return imGroupMemberMapper.selectListByGroupId(groupId);
76 76 }
77 77  
  78 + @Override
  79 + public ImGroupMemberDO getGroupMember(Long groupId, Long userId) {
  80 + return imGroupMemberMapper.selectByGroupIdAndUserId(groupId, userId);
  81 + }
  82 +
  83 + @Override
  84 + public List<ImGroupMemberDO> getGroupMembersByUserId(Long userId) {
  85 + return imGroupMemberMapper.selectListByUserId(userId);
  86 + }
  87 +
78 88 }
79 89 \ No newline at end of file
... ...
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 {
19 19 void saveInboxAndSendMessage(ImMessageDO imMessageDO);
20 20  
21 21 /**
22   - * 获得大于 sequence 的消息ids
  22 + * 获得大于 sequence 的消息 ids
23 23 *
24 24 * @param userId 用户编号
25 25 * @param sequence 序列号
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  5 +
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * IM 群聊消息 Service 接口
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +public interface ImGroupMessageService {
  14 +
  15 + /**
  16 + * 发送群聊消息
  17 + *
  18 + * @param senderId 发送人编号
  19 + * @param reqVO 发送请求
  20 + * @return 消息
  21 + */
  22 + ImGroupMessageDO sendMessage(Long senderId, ImGroupMessageSendReqVO reqVO);
  23 +
  24 + /**
  25 + * 拉取群聊消息(增量)
  26 + *
  27 + * @param userId 当前用户编号
  28 + * @param minId 最小消息 id(不含)
  29 + * @param size 拉取数量
  30 + * @return 消息列表
  31 + */
  32 + List<ImGroupMessageDO> pullMessages(Long userId, Long minId, Integer size);
  33 +
  34 + /**
  35 + * 标记群聊消息已读
  36 + *
  37 + * @param userId 当前用户编号
  38 + * @param groupId 群编号
  39 + */
  40 + void readMessages(Long userId, Long groupId);
  41 +
  42 + /**
  43 + * 撤回群聊消息
  44 + *
  45 + * @param userId 当前用户编号
  46 + * @param messageId 消息编号
  47 + */
  48 + void recallMessage(Long userId, Long messageId);
  49 +
  50 + /**
  51 + * 获取群消息的已读用户列表
  52 + *
  53 + * @param userId 当前用户编号
  54 + * @param groupId 群编号
  55 + * @param messageId 消息编号
  56 + * @return 已读用户编号列表
  57 + */
  58 + List<Long> getReadUsers(Long userId, Long groupId, Long messageId);
  59 +
  60 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
  6 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
  9 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  10 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
  11 +import cn.iocoder.yudao.module.im.dal.redis.group.GroupReadPositionRedisDAO;
  12 +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberStatusEnum;
  13 +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  14 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  15 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  16 +import cn.iocoder.yudao.module.im.enums.message.ImWebSocketTypeConstants;
  17 +import cn.iocoder.yudao.module.im.service.group.ImGroupService;
  18 +import cn.iocoder.yudao.module.im.service.groupmember.ImGroupMemberService;
  19 +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  20 +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
  21 +import jakarta.annotation.Resource;
  22 +import lombok.extern.slf4j.Slf4j;
  23 +import org.springframework.stereotype.Service;
  24 +import org.springframework.validation.annotation.Validated;
  25 +
  26 +import java.time.LocalDateTime;
  27 +import java.util.*;
  28 +import java.util.stream.Collectors;
  29 +
  30 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  31 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  32 +
  33 +// TODO @AI:参考 private message 先优化一把!
  34 +/**
  35 + * IM 群聊消息 Service 实现类
  36 + *
  37 + * @author 芋道源码
  38 + */
  39 +@Service
  40 +@Validated
  41 +@Slf4j
  42 +public class ImGroupMessageServiceImpl implements ImGroupMessageService {
  43 +
  44 + private static final int MAX_PULL_SIZE = 1000;
  45 +
  46 + @Resource
  47 + private ImGroupMessageMapper imGroupMessageMapper;
  48 + @Resource
  49 + private ImGroupService imGroupService;
  50 + @Resource
  51 + private ImGroupMemberService imGroupMemberService;
  52 + @Resource
  53 + private ImSensitiveWordService imSensitiveWordService;
  54 + @Resource
  55 + private GroupReadPositionRedisDAO groupReadPositionRedisDAO;
  56 + @Resource
  57 + private WebSocketSenderApi webSocketSenderApi;
  58 +
  59 + @Override
  60 + public ImGroupMessageDO sendMessage(Long senderId, ImGroupMessageSendReqVO reqVO) {
  61 + // 1. 幂等校验
  62 + ImGroupMessageDO existing = imGroupMessageMapper.selectBySenderIdAndClientMessageId(
  63 + senderId, reqVO.getClientMessageId());
  64 + if (existing != null) {
  65 + return existing;
  66 + }
  67 +
  68 + // 2. 校验群存在且未解散
  69 + ImGroupDO group = imGroupService.validateGroupExists(reqVO.getGroupId());
  70 +
  71 + // 3. 校验发送人在群中
  72 + ImGroupMemberDO member = imGroupMemberService.getGroupMember(reqVO.getGroupId(), senderId);
  73 + if (member == null || ImGroupMemberStatusEnum.QUIT.getStatus().equals(member.getStatus())) {
  74 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  75 + }
  76 +
  77 + // 4. 文本消息敏感词过滤
  78 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
  79 + imSensitiveWordService.validateText(reqVO.getContent());
  80 + }
  81 +
  82 + // 5. 构建消息
  83 + ImGroupMessageDO message = ImGroupMessageDO.builder()
  84 + .clientMessageId(reqVO.getClientMessageId())
  85 + .senderId(senderId)
  86 + .groupId(reqVO.getGroupId())
  87 + .type(reqVO.getType())
  88 + .content(reqVO.getContent())
  89 + .status(ImMessageStatusEnum.NORMAL) // 群聊正常状态 = 0
  90 + .sendTime(LocalDateTime.now())
  91 + .atUserIds(CollUtil.isNotEmpty(reqVO.getAtUserIds())
  92 + ? StrUtil.join(",", reqVO.getAtUserIds()) : null)
  93 + .receiverUserIds(CollUtil.isNotEmpty(reqVO.getReceiverUserIds())
  94 + ? StrUtil.join(",", reqVO.getReceiverUserIds()) : null)
  95 + .receiptStatus(Boolean.TRUE.equals(reqVO.getNeedReceipt())
  96 + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus()
  97 + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
  98 + .build();
  99 + imGroupMessageMapper.insert(message);
  100 +
  101 + // 6. WebSocket 推送给群成员
  102 + sendGroupMessageEvent(message, reqVO.getGroupId());
  103 + return message;
  104 + }
  105 +
  106 + @Override
  107 + public List<ImGroupMessageDO> pullMessages(Long userId, Long minId, Integer size) {
  108 + if (size > MAX_PULL_SIZE) {
  109 + throw exception(MESSAGE_PULL_SIZE_EXCEEDED);
  110 + }
  111 + // 获取用户所在的所有群
  112 + List<ImGroupMemberDO> members = imGroupMemberService.getGroupMembersByUserId(userId);
  113 + if (CollUtil.isEmpty(members)) {
  114 + return Collections.emptyList();
  115 + }
  116 + List<Long> groupIds = members.stream()
  117 + .map(ImGroupMemberDO::getGroupId)
  118 + .collect(Collectors.toList());
  119 +
  120 + // 拉取消息
  121 + List<ImGroupMessageDO> messages = imGroupMessageMapper.selectListByMinId(groupIds, minId, size);
  122 +
  123 + // 过滤:入群前消息不可见、定向接收过滤、退群后消息边界
  124 + Map<Long, ImGroupMemberDO> memberMap = members.stream()
  125 + .collect(Collectors.toMap(ImGroupMemberDO::getGroupId, m -> m, (a, b) -> a));
  126 +
  127 + return messages.stream().filter(msg -> {
  128 + ImGroupMemberDO m = memberMap.get(msg.getGroupId());
  129 + if (m == null) {
  130 + return false;
  131 + }
  132 + // 入群前消息不可见
  133 + if (m.getJoinTime() != null && msg.getSendTime().isBefore(m.getJoinTime())) {
  134 + return false;
  135 + }
  136 + // 退群后消息边界
  137 + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus())
  138 + && m.getQuitTime() != null && msg.getSendTime().isAfter(m.getQuitTime())) {
  139 + return false;
  140 + }
  141 + // 定向接收过滤
  142 + if (StrUtil.isNotBlank(msg.getReceiverUserIds())) {
  143 + Set<String> receivers = new HashSet<>(Arrays.asList(msg.getReceiverUserIds().split(",")));
  144 + if (!receivers.contains(userId.toString()) && !msg.getSenderId().equals(userId)) {
  145 + return false;
  146 + }
  147 + }
  148 + return true;
  149 + }).collect(Collectors.toList());
  150 + }
  151 +
  152 + @Override
  153 + public void readMessages(Long userId, Long groupId) {
  154 + // 1. 校验用户在群中(权限校验)
  155 + ImGroupMemberDO member = imGroupMemberService.getGroupMember(groupId, userId);
  156 + if (member == null || ImGroupMemberStatusEnum.QUIT.getStatus().equals(member.getStatus())) {
  157 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  158 + }
  159 +
  160 + // 2. 高效获取群内最新消息 id(倒序 LIMIT 1,非全量扫描)
  161 + Long maxMessageId = imGroupMessageMapper.selectMaxIdByGroupId(groupId);
  162 + if (maxMessageId == null) {
  163 + return;
  164 + }
  165 +
  166 + // 3. 更新 Redis 群已读位置
  167 + groupReadPositionRedisDAO.updateReadPosition(groupId, userId, maxMessageId);
  168 +
  169 + // 4. 发送 READ 事件给自己的其他终端
  170 + Map<String, Object> readEvent = new HashMap<>();
  171 + readEvent.put("groupId", groupId.toString());
  172 + readEvent.put("messageScene", "group");
  173 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  174 + ImWebSocketTypeConstants.READ, readEvent);
  175 +
  176 + // 5. 刷新群回执
  177 + refreshGroupReceipts(groupId);
  178 + }
  179 +
  180 + @Override
  181 + public void recallMessage(Long userId, Long messageId) {
  182 + // 1. 校验消息存在
  183 + ImGroupMessageDO message = imGroupMessageMapper.selectById(messageId);
  184 + if (message == null) {
  185 + throw exception(MESSAGE_NOT_EXISTS);
  186 + }
  187 + // 2. 只能撤回自己发送的消息
  188 + if (!message.getSenderId().equals(userId)) {
  189 + throw exception(MESSAGE_RECALL_DENIED);
  190 + }
  191 + // 3. 不能重复撤回
  192 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
  193 + throw exception(MESSAGE_ALREADY_RECALLED);
  194 + }
  195 + // 4. 更新消息状态
  196 + ImGroupMessageDO updateObj = new ImGroupMessageDO();
  197 + updateObj.setId(messageId);
  198 + updateObj.setStatus(ImMessageStatusEnum.RECALL.getStatus());
  199 + imGroupMessageMapper.updateById(updateObj);
  200 +
  201 + // 5. 发送 RECALL 事件给所有群成员
  202 + List<ImGroupMemberDO> members = imGroupMemberService.selectByGroupId(message.getGroupId());
  203 + Map<String, Object> recallEvent = new HashMap<>();
  204 + recallEvent.put("messageId", messageId.toString());
  205 + recallEvent.put("groupId", message.getGroupId().toString());
  206 + recallEvent.put("messageScene", "group");
  207 + recallEvent.put("senderId", userId.toString());
  208 + for (ImGroupMemberDO m : members) {
  209 + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus())) {
  210 + continue;
  211 + }
  212 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), m.getUserId(),
  213 + ImWebSocketTypeConstants.RECALL, recallEvent);
  214 + }
  215 + }
  216 +
  217 + @Override
  218 + public List<Long> getReadUsers(Long userId, Long groupId, Long messageId) {
  219 + // 1. 校验用户在群中(权限校验)
  220 + ImGroupMemberDO currentMember = imGroupMemberService.getGroupMember(groupId, userId);
  221 + if (currentMember == null || ImGroupMemberStatusEnum.QUIT.getStatus().equals(currentMember.getStatus())) {
  222 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  223 + }
  224 +
  225 + // 2. 获取消息
  226 + ImGroupMessageDO message = imGroupMessageMapper.selectById(messageId);
  227 + if (message == null) {
  228 + return Collections.emptyList();
  229 + }
  230 +
  231 + // 2. 获取所有成员和已读位置
  232 + List<ImGroupMemberDO> allMembers = imGroupMemberService.selectByGroupId(groupId);
  233 + Map<Object, Object> allPositions = groupReadPositionRedisDAO.getAllReadPositions(groupId);
  234 +
  235 + // 3. 计算该消息的可见成员集合
  236 + Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers);
  237 +
  238 + // 4. 只返回在可见范围内且已读位置 >= messageId 的用户
  239 + List<Long> readUserIds = new ArrayList<>();
  240 + for (Map.Entry<Object, Object> entry : allPositions.entrySet()) {
  241 + Long uid = Long.parseLong(entry.getKey().toString());
  242 + Long readMaxId = Long.parseLong(entry.getValue().toString());
  243 + if (readMaxId >= messageId && visibleUserIds.contains(uid)
  244 + && !uid.equals(message.getSenderId())) { // 排除发送者自己
  245 + readUserIds.add(uid);
  246 + }
  247 + }
  248 + return readUserIds;
  249 + }
  250 +
  251 + /**
  252 + * 刷新群回执(仅处理 PENDING 状态的消息,无需全量扫描)
  253 + */
  254 + private void refreshGroupReceipts(Long groupId) {
  255 + // 1. 只查 PENDING 状态的回执消息
  256 + List<ImGroupMessageDO> pendingMessages = imGroupMessageMapper.selectPendingReceiptMessages(groupId);
  257 + if (CollUtil.isEmpty(pendingMessages)) {
  258 + return;
  259 + }
  260 +
  261 + // 2. 加载成员和已读位置
  262 + List<ImGroupMemberDO> allMembers = imGroupMemberService.selectByGroupId(groupId);
  263 + Map<Object, Object> allPositions = groupReadPositionRedisDAO.getAllReadPositions(groupId);
  264 +
  265 + for (ImGroupMessageDO msg : pendingMessages) {
  266 + // 3. 计算该消息的可见成员(排除发送者自己)
  267 + Set<Long> visibleUserIds = getVisibleUserIds(msg, allMembers);
  268 + visibleUserIds.remove(msg.getSenderId()); // 发送者不计入回执统计
  269 +
  270 + if (visibleUserIds.isEmpty()) {
  271 + continue;
  272 + }
  273 +
  274 + // 4. 统计可见成员中的已读人数
  275 + int readCount = 0;
  276 + for (Long uid : visibleUserIds) {
  277 + Object val = allPositions.get(uid.toString());
  278 + if (val != null && Long.parseLong(val.toString()) >= msg.getId()) {
  279 + readCount++;
  280 + }
  281 + }
  282 +
  283 + // 5. 判断是否全部已读
  284 + boolean allRead = readCount >= visibleUserIds.size();
  285 + if (allRead) {
  286 + ImGroupMessageDO update = new ImGroupMessageDO();
  287 + update.setId(msg.getId());
  288 + update.setReceiptStatus(ImGroupMessageReceiptStatusEnum.DONE.getStatus());
  289 + imGroupMessageMapper.updateById(update);
  290 + }
  291 +
  292 + // 6. 广播 RECEIPT 事件给活跃成员
  293 + Map<String, Object> receiptEvent = new HashMap<>();
  294 + receiptEvent.put("messageId", msg.getId().toString());
  295 + receiptEvent.put("groupId", groupId.toString());
  296 + receiptEvent.put("readCount", readCount);
  297 + receiptEvent.put("receiptStatus", allRead
  298 + ? ImGroupMessageReceiptStatusEnum.DONE.getStatus()
  299 + : ImGroupMessageReceiptStatusEnum.PENDING.getStatus());
  300 +
  301 + for (ImGroupMemberDO m : allMembers) {
  302 + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus())) {
  303 + continue;
  304 + }
  305 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), m.getUserId(),
  306 + ImWebSocketTypeConstants.RECEIPT, receiptEvent);
  307 + }
  308 + }
  309 + }
  310 +
  311 + /**
  312 + * 计算一条群消息的可见成员集合
  313 + * <p>
  314 + * 规则:
  315 + * 1. 成员必须是 NORMAL 状态,或虽已 QUIT 但消息发送时间在退群之前
  316 + * 2. 成员的入群时间必须在消息发送之前(入群后才能看到消息)
  317 + * 3. 如果消息有 receiverUserIds 定向接收,则只有列表中的人可见
  318 + */
  319 + private Set<Long> getVisibleUserIds(ImGroupMessageDO msg, List<ImGroupMemberDO> allMembers) {
  320 + // 定向接收集合
  321 + Set<String> receiverSet = null;
  322 + if (StrUtil.isNotBlank(msg.getReceiverUserIds())) {
  323 + receiverSet = new HashSet<>(Arrays.asList(msg.getReceiverUserIds().split(",")));
  324 + }
  325 +
  326 + Set<Long> visible = new HashSet<>();
  327 + for (ImGroupMemberDO m : allMembers) {
  328 + // 入群时间在消息发送之后 → 不可见
  329 + if (m.getJoinTime() != null && msg.getSendTime().isBefore(m.getJoinTime())) {
  330 + continue;
  331 + }
  332 + // 已退群且退群时间在消息发送之前 → 不可见
  333 + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(m.getStatus())
  334 + && m.getQuitTime() != null && msg.getSendTime().isAfter(m.getQuitTime())) {
  335 + continue;
  336 + }
  337 + // 定向接收过滤
  338 + if (receiverSet != null && !receiverSet.contains(m.getUserId().toString())
  339 + && !m.getUserId().equals(msg.getSenderId())) {
  340 + continue;
  341 + }
  342 + visible.add(m.getUserId());
  343 + }
  344 + return visible;
  345 + }
  346 +
  347 + /**
  348 + * 发送群聊消息 WebSocket 事件
  349 + */
  350 + private void sendGroupMessageEvent(ImGroupMessageDO message, Long groupId) {
  351 + List<ImGroupMemberDO> members = imGroupMemberService.selectByGroupId(groupId);
  352 + Map<String, Object> event = new HashMap<>();
  353 + event.put("id", message.getId().toString());
  354 + event.put("clientMessageId", message.getClientMessageId());
  355 + event.put("senderId", message.getSenderId().toString());
  356 + event.put("groupId", message.getGroupId().toString());
  357 + event.put("type", message.getType());
  358 + event.put("content", message.getContent());
  359 + event.put("status", message.getStatus());
  360 + event.put("sendTime", message.getSendTime().toString());
  361 + event.put("messageScene", "group");
  362 + if (StrUtil.isNotBlank(message.getAtUserIds())) {
  363 + event.put("atUserIds", message.getAtUserIds());
  364 + }
  365 + if (StrUtil.isNotBlank(message.getReceiverUserIds())) {
  366 + event.put("receiverUserIds", message.getReceiverUserIds());
  367 + }
  368 + event.put("receiptStatus", message.getReceiptStatus());
  369 +
  370 + // 确定推送目标
  371 + Set<String> receiverSet = null;
  372 + if (StrUtil.isNotBlank(message.getReceiverUserIds())) {
  373 + receiverSet = new HashSet<>(Arrays.asList(message.getReceiverUserIds().split(",")));
  374 + }
  375 +
  376 + for (ImGroupMemberDO member : members) {
  377 + if (ImGroupMemberStatusEnum.QUIT.getStatus().equals(member.getStatus())) {
  378 + continue;
  379 + }
  380 + // 定向接收过滤
  381 + if (receiverSet != null && !receiverSet.contains(member.getUserId().toString())
  382 + && !member.getUserId().equals(message.getSenderId())) {
  383 + continue;
  384 + }
  385 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), member.getUserId(),
  386 + ImWebSocketTypeConstants.GROUP_MESSAGE, event);
  387 + }
  388 + }
  389 +
  390 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  5 +
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * IM 私聊消息 Service 接口
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +public interface ImPrivateMessageService {
  14 +
  15 + /**
  16 + * 发送私聊消息
  17 + *
  18 + * @param senderId 发送人编号
  19 + * @param reqVO 发送请求
  20 + * @return 消息
  21 + */
  22 + ImPrivateMessageDO sendMessage(Long senderId, ImPrivateMessageSendReqVO reqVO);
  23 +
  24 + /**
  25 + * 拉取私聊消息(增量)
  26 + *
  27 + * @param userId 当前用户编号
  28 + * @param minId 最小消息 id(不含)
  29 + * @param size 拉取数量
  30 + * @return 消息列表
  31 + */
  32 + List<ImPrivateMessageDO> pullMessages(Long userId, Long minId, Integer size);
  33 +
  34 + /**
  35 + * 标记私聊消息已读
  36 + *
  37 + * @param userId 当前用户编号
  38 + * @param friendId 好友用户编号
  39 + */
  40 + void readMessages(Long userId, Long friendId);
  41 +
  42 + /**
  43 + * 撤回私聊消息
  44 + *
  45 + * @param userId 当前用户编号
  46 + * @param messageId 消息编号
  47 + */
  48 + void recallMessage(Long userId, Long messageId);
  49 +
  50 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
  4 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  6 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  7 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  8 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  9 +import cn.iocoder.yudao.module.im.enums.message.ImWebSocketTypeConstants;
  10 +import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
  11 +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  12 +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
  13 +import jakarta.annotation.Resource;
  14 +import lombok.extern.slf4j.Slf4j;
  15 +import org.springframework.stereotype.Service;
  16 +import org.springframework.validation.annotation.Validated;
  17 +
  18 +import java.time.LocalDateTime;
  19 +import java.util.HashMap;
  20 +import java.util.List;
  21 +import java.util.Map;
  22 +
  23 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  24 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  25 +
  26 +/**
  27 + * IM 私聊消息 Service 实现类
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +@Service
  32 +@Validated
  33 +@Slf4j
  34 +public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
  35 +
  36 + /**
  37 + * pull 最大拉取数量
  38 + */
  39 + private static final int MAX_PULL_SIZE = 1000;
  40 +
  41 + @Resource
  42 + private ImPrivateMessageMapper imPrivateMessageMapper;
  43 +
  44 + @Resource
  45 + private ImFriendService imFriendService;
  46 + @Resource
  47 + private ImSensitiveWordService imSensitiveWordService;
  48 +
  49 + // TODO @AI:这个类貌似路径不对?!
  50 + @Resource
  51 + private WebSocketSenderApi webSocketSenderApi;
  52 +
  53 + @Override
  54 + public ImPrivateMessageDO sendMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) {
  55 + // 1.1 幂等校验:根据 senderId + clientMessageId 查重
  56 + ImPrivateMessageDO existing = imPrivateMessageMapper.selectBySenderIdAndClientMessageId(
  57 + senderId, reqVO.getClientMessageId());
  58 + if (existing != null) {
  59 + return existing;
  60 + }
  61 + // 1.2 好友校验
  62 + imFriendService.validateIsFriend(senderId, reqVO.getReceiverId());
  63 + // 1.3 文本消息敏感词过滤
  64 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
  65 + imSensitiveWordService.validateText(reqVO.getContent());
  66 + }
  67 +
  68 + // 2. 构建并保存消息
  69 + // TODO @AI:相同类型的字段:放在一行,避免过长;
  70 + ImPrivateMessageDO message = ImPrivateMessageDO.builder()
  71 + .clientMessageId(reqVO.getClientMessageId())
  72 + .senderId(senderId)
  73 + .receiverId(reqVO.getReceiverId())
  74 + .type(reqVO.getType())
  75 + .content(reqVO.getContent())
  76 + .status(ImMessageStatusEnum.UNREAD.getStatus())
  77 + .sendTime(LocalDateTime.now())
  78 + .build();
  79 + imPrivateMessageMapper.insert(message);
  80 +
  81 + // 3. WebSocket 推送给接收方
  82 + sendPrivateMessageEvent(message);
  83 + return message;
  84 + }
  85 +
  86 + @Override
  87 + public List<ImPrivateMessageDO> pullMessages(Long userId, Long minId, Integer size) {
  88 + // pull 分页保护
  89 + // TODO @AI:limit 这个参考校验,放在 controller 里做;
  90 + if (size > MAX_PULL_SIZE) {
  91 + throw exception(MESSAGE_PULL_SIZE_EXCEEDED);
  92 + }
  93 + // TODO @AI:可能要加一个,最大返回几个月的?!
  94 + return imPrivateMessageMapper.selectListByMinId(userId, minId, size);
  95 + }
  96 +
  97 + // TODO @芋艿:这里需要多 review 下;
  98 + @Override
  99 + public void readMessages(Long userId, Long friendId) {
  100 + // 1. 批量更新消息状态为已读
  101 + int updated = imPrivateMessageMapper.updateStatusToRead(userId, friendId);
  102 + if (updated == 0) {
  103 + return; // 没有需要更新的消息
  104 + }
  105 + // 2. 发送 READ 事件给自己的其他终端(多端同步)
  106 + Map<String, Object> readEvent = new HashMap<>();
  107 + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强!
  108 + readEvent.put("friendId", friendId.toString());
  109 + readEvent.put("messageScene", "private");
  110 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  111 + ImWebSocketTypeConstants.READ, readEvent);
  112 +
  113 + // 3. 发送 RECEIPT 事件给对方(已读回执)
  114 + Map<String, Object> receiptEvent = new HashMap<>();
  115 + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强!
  116 + receiptEvent.put("userId", userId.toString());
  117 + receiptEvent.put("messageScene", "private");
  118 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), friendId,
  119 + ImWebSocketTypeConstants.RECEIPT, receiptEvent);
  120 + }
  121 +
  122 + // TODO @芋艿:这里需要多 review 下;
  123 + @Override
  124 + public void recallMessage(Long userId, Long messageId) {
  125 + // 1.1 校验消息存在
  126 + ImPrivateMessageDO message = imPrivateMessageMapper.selectById(messageId);
  127 + if (message == null) {
  128 + throw exception(MESSAGE_NOT_EXISTS);
  129 + }
  130 + // 1.2 只能撤回自己发送的消息
  131 + // TODO @AI:Notequals;
  132 + if (!message.getSenderId().equals(userId)) {
  133 + throw exception(MESSAGE_RECALL_DENIED);
  134 + }
  135 + // 1.3 不能重复撤回
  136 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
  137 + throw exception(MESSAGE_ALREADY_RECALLED);
  138 + }
  139 +
  140 + // 2. 更新消息状态为撤回
  141 + imPrivateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId)
  142 + .setStatus(ImMessageStatusEnum.RECALL.getStatus()));
  143 +
  144 + // 3.1 发送 RECALL 事件给接收方
  145 + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强!
  146 + Map<String, Object> recallEvent = new HashMap<>();
  147 + recallEvent.put("messageId", messageId.toString());
  148 + recallEvent.put("messageScene", "private");
  149 + recallEvent.put("senderId", userId.toString());
  150 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
  151 + ImWebSocketTypeConstants.RECALL, recallEvent);
  152 + // 3.2 也通知发送方自己的其他终端
  153 + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强!
  154 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  155 + ImWebSocketTypeConstants.RECALL, recallEvent);
  156 + }
  157 +
  158 + /**
  159 + * 发送私聊消息 WebSocket 事件
  160 + */
  161 + private void sendPrivateMessageEvent(ImPrivateMessageDO message) {
  162 + Map<String, Object> event = new HashMap<>();
  163 + // TODO @AI:需要整理下,相关的 websocket DTO;可维护性变强!
  164 + event.put("id", message.getId().toString());
  165 + event.put("clientMessageId", message.getClientMessageId());
  166 + event.put("senderId", message.getSenderId().toString());
  167 + event.put("receiverId", message.getReceiverId().toString());
  168 + event.put("type", message.getType());
  169 + event.put("content", message.getContent());
  170 + event.put("status", message.getStatus());
  171 + event.put("sendTime", message.getSendTime().toString());
  172 + event.put("messageScene", "private");
  173 + // 推送给接收方
  174 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), message.getReceiverId(),
  175 + ImWebSocketTypeConstants.PRIVATE_MESSAGE, event);
  176 + // 推送给发送方自己的其他终端(多端同步)
  177 + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(),
  178 + ImWebSocketTypeConstants.PRIVATE_MESSAGE, event);
  179 + }
  180 +
  181 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.sensitiveword;
  2 +
  3 +/**
  4 + * IM 敏感词 Service 接口
  5 + *
  6 + * @author 芋道源码
  7 + */
  8 +public interface ImSensitiveWordService {
  9 +
  10 + /**
  11 + * 校验文本是否包含敏感词
  12 + * <p>
  13 + * 如果包含,直接抛出业务异常
  14 + *
  15 + * @param text 待校验文本
  16 + */
  17 + void validateText(String text);
  18 +
  19 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.sensitiveword;
  2 +
  3 +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO;
  4 +import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper;
  5 +import jakarta.annotation.Resource;
  6 +import org.springframework.stereotype.Service;
  7 +import org.springframework.validation.annotation.Validated;
  8 +
  9 +import java.util.List;
  10 +
  11 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  12 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED;
  13 +
  14 +// TODO @AI:需要内存化;晚点处理;
  15 +/**
  16 + * IM 敏感词 Service 实现类
  17 + *
  18 + * @author 芋道源码
  19 + */
  20 +@Service
  21 +@Validated
  22 +public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
  23 +
  24 + @Resource
  25 + private ImSensitiveWordMapper imSensitiveWordMapper;
  26 +
  27 + @Override
  28 + public void validateText(String text) {
  29 + if (text == null || text.isEmpty()) {
  30 + return;
  31 + }
  32 + // 查询所有启用状态的敏感词
  33 + List<ImSensitiveWordDO> enabledWords = imSensitiveWordMapper.selectListByEnabled();
  34 + // 逐词匹配
  35 + for (ImSensitiveWordDO wordDO : enabledWords) {
  36 + if (text.contains(wordDO.getWord())) {
  37 + throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED);
  38 + }
  39 + }
  40 + }
  41 +
  42 +}
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  4 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.ImGroupMessageSendReqVO;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  8 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
  9 +import cn.iocoder.yudao.module.im.dal.redis.group.GroupReadPositionRedisDAO;
  10 +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberStatusEnum;
  11 +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  12 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  13 +import cn.iocoder.yudao.module.im.service.group.ImGroupService;
  14 +import cn.iocoder.yudao.module.im.service.groupmember.ImGroupMemberService;
  15 +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  16 +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
  17 +import org.junit.jupiter.api.Test;
  18 +import org.junit.jupiter.api.extension.ExtendWith;
  19 +import org.mockito.InjectMocks;
  20 +import org.mockito.Mock;
  21 +import org.mockito.junit.jupiter.MockitoExtension;
  22 +
  23 +import java.time.LocalDateTime;
  24 +import java.util.*;
  25 +
  26 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  27 +import static org.junit.jupiter.api.Assertions.*;
  28 +import static org.mockito.ArgumentMatchers.*;
  29 +import static org.mockito.Mockito.*;
  30 +
  31 +// TODO @芋艿:这个需要在评估下;
  32 +/**
  33 + * IM 群聊消息 Service 单元测试
  34 + *
  35 + * @author 芋道源码
  36 + */
  37 +@ExtendWith(MockitoExtension.class)
  38 +public class ImGroupMessageServiceImplTest {
  39 +
  40 + @InjectMocks
  41 + private ImGroupMessageServiceImpl groupMessageService;
  42 +
  43 + @Mock
  44 + private ImGroupMessageMapper imGroupMessageMapper;
  45 + @Mock
  46 + private ImGroupService imGroupService;
  47 + @Mock
  48 + private ImGroupMemberService imGroupMemberService;
  49 + @Mock
  50 + private ImSensitiveWordService imSensitiveWordService;
  51 + @Mock
  52 + private GroupReadPositionRedisDAO groupReadPositionRedisDAO;
  53 + @Mock
  54 + private WebSocketSenderApi webSocketSenderApi;
  55 +
  56 + private ImGroupMessageSendReqVO buildSendReqVO() {
  57 + ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO();
  58 + reqVO.setClientMessageId("test-uuid-group-001");
  59 + reqVO.setGroupId(10L);
  60 + reqVO.setType(0); // TEXT
  61 + reqVO.setContent("{\"content\":\"群聊你好\"}");
  62 + return reqVO;
  63 + }
  64 +
  65 + // ========== 发送测试 ==========
  66 +
  67 + @Test
  68 + public void testSendMessage_success() {
  69 + // 准备
  70 + ImGroupMessageSendReqVO reqVO = buildSendReqVO();
  71 + when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001"))
  72 + .thenReturn(null);
  73 + ImGroupDO group = new ImGroupDO();
  74 + group.setId(10L);
  75 + group.setName("测试群");
  76 + when(imGroupService.validateGroupExists(10L)).thenReturn(group);
  77 +
  78 + ImGroupMemberDO member = new ImGroupMemberDO();
  79 + member.setGroupId(10L);
  80 + member.setUserId(1L);
  81 + member.setStatus(ImGroupMemberStatusEnum.NORMAL.getStatus());
  82 + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(member);
  83 +
  84 + List<ImGroupMemberDO> allMembers = List.of(
  85 + member,
  86 + ImGroupMemberDO.builder().groupId(10L).userId(2L)
  87 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build(),
  88 + ImGroupMemberDO.builder().groupId(10L).userId(3L)
  89 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build()
  90 + );
  91 + when(imGroupMemberService.selectByGroupId(10L)).thenReturn(allMembers);
  92 + when(imGroupMessageMapper.insert(any())).thenReturn(1);
  93 +
  94 + // 调用
  95 + ImGroupMessageDO result = groupMessageService.sendMessage(1L, reqVO);
  96 +
  97 + // 断言
  98 + assertNotNull(result);
  99 + assertEquals(1L, result.getSenderId());
  100 + assertEquals(10L, result.getGroupId());
  101 + assertEquals(ImMessageStatusEnum.NORMAL, result.getStatus());
  102 + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus());
  103 +
  104 + // 验证推送给 3 个群成员
  105 + verify(webSocketSenderApi, times(3)).sendObject(anyInt(), anyLong(), anyString(), any());
  106 + }
  107 +
  108 + @Test
  109 + public void testSendMessage_clientMessageIdIdempotent() {
  110 + // 准备
  111 + ImGroupMessageSendReqVO reqVO = buildSendReqVO();
  112 + ImGroupMessageDO existing = ImGroupMessageDO.builder()
  113 + .id(100L).clientMessageId("test-uuid-group-001").senderId(1L).groupId(10L)
  114 + .type(0).content("{\"content\":\"群聊你好\"}").status(0)
  115 + .sendTime(LocalDateTime.now()).build();
  116 + when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001"))
  117 + .thenReturn(existing);
  118 +
  119 + // 调用
  120 + ImGroupMessageDO result = groupMessageService.sendMessage(1L, reqVO);
  121 +
  122 + // 断言
  123 + assertEquals(100L, result.getId());
  124 + verify(imGroupMessageMapper, never()).insert(any());
  125 + }
  126 +
  127 + @Test
  128 + public void testSendMessage_notInGroup() {
  129 + // 准备
  130 + ImGroupMessageSendReqVO reqVO = buildSendReqVO();
  131 + when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001"))
  132 + .thenReturn(null);
  133 + ImGroupDO group = new ImGroupDO();
  134 + group.setId(10L);
  135 + when(imGroupService.validateGroupExists(10L)).thenReturn(group);
  136 + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(null);
  137 +
  138 + // 调用并断言
  139 + ServiceException exception = assertThrows(ServiceException.class,
  140 + () -> groupMessageService.sendMessage(1L, reqVO));
  141 + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
  142 + }
  143 +
  144 + // ========== pull 测试 ==========
  145 +
  146 + @Test
  147 + public void testPullMessages_joinTimeFilter() {
  148 + // 准备
  149 + LocalDateTime joinTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0);
  150 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  151 + .groupId(10L).userId(1L)
  152 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus())
  153 + .joinTime(joinTime).build();
  154 + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member));
  155 +
  156 + // 模拟一条入群前的消息和一条入群后的消息
  157 + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder()
  158 + .id(1L).groupId(10L).senderId(2L)
  159 + .sendTime(LocalDateTime.of(2026, 4, 12, 9, 0, 0)).build();
  160 + ImGroupMessageDO afterJoin = ImGroupMessageDO.builder()
  161 + .id(2L).groupId(10L).senderId(2L)
  162 + .sendTime(LocalDateTime.of(2026, 4, 12, 11, 0, 0)).build();
  163 + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100))
  164 + .thenReturn(List.of(beforeJoin, afterJoin));
  165 +
  166 + // 调用
  167 + List<ImGroupMessageDO> result = groupMessageService.pullMessages(1L, 0L, 100);
  168 +
  169 + // 断言:入群前消息不可见
  170 + assertEquals(1, result.size());
  171 + assertEquals(2L, result.get(0).getId());
  172 + }
  173 +
  174 + @Test
  175 + public void testPullMessages_quitTimeFilter() {
  176 + // 准备
  177 + LocalDateTime joinTime = LocalDateTime.of(2026, 4, 10, 10, 0, 0);
  178 + LocalDateTime quitTime = LocalDateTime.of(2026, 4, 12, 10, 0, 0);
  179 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  180 + .groupId(10L).userId(1L)
  181 + .status(ImGroupMemberStatusEnum.QUIT.getStatus())
  182 + .joinTime(joinTime).quitTime(quitTime).build();
  183 + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member));
  184 +
  185 + // 退群前后各一条消息
  186 + ImGroupMessageDO beforeQuit = ImGroupMessageDO.builder()
  187 + .id(1L).groupId(10L).senderId(2L)
  188 + .sendTime(LocalDateTime.of(2026, 4, 11, 10, 0, 0)).build();
  189 + ImGroupMessageDO afterQuit = ImGroupMessageDO.builder()
  190 + .id(2L).groupId(10L).senderId(2L)
  191 + .sendTime(LocalDateTime.of(2026, 4, 13, 10, 0, 0)).build();
  192 + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100))
  193 + .thenReturn(List.of(beforeQuit, afterQuit));
  194 +
  195 + // 调用
  196 + List<ImGroupMessageDO> result = groupMessageService.pullMessages(1L, 0L, 100);
  197 +
  198 + // 断言:退群后消息不可见
  199 + assertEquals(1, result.size());
  200 + assertEquals(1L, result.get(0).getId());
  201 + }
  202 +
  203 + @Test
  204 + public void testPullMessages_receiverUserIdsFilter() {
  205 + // 准备
  206 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  207 + .groupId(10L).userId(1L)
  208 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus())
  209 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();
  210 + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member));
  211 +
  212 + // 定向接收消息:只给用户 2 和 3
  213 + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder()
  214 + .id(1L).groupId(10L).senderId(5L)
  215 + .receiverUserIds("2,3")
  216 + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();
  217 + // 全员消息
  218 + ImGroupMessageDO allMsg = ImGroupMessageDO.builder()
  219 + .id(2L).groupId(10L).senderId(5L)
  220 + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();
  221 + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100))
  222 + .thenReturn(List.of(directedMsg, allMsg));
  223 +
  224 + // 调用
  225 + List<ImGroupMessageDO> result = groupMessageService.pullMessages(1L, 0L, 100);
  226 +
  227 + // 断言:定向接收的消息用户 1 看不到,只能看到全员消息
  228 + assertEquals(1, result.size());
  229 + assertEquals(2L, result.get(0).getId());
  230 + }
  231 +
  232 + @Test
  233 + public void testPullMessages_atFieldCorrectReturn() {
  234 + // 准备
  235 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  236 + .groupId(10L).userId(1L)
  237 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus())
  238 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();
  239 + when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member));
  240 +
  241 + ImGroupMessageDO atMsg = ImGroupMessageDO.builder()
  242 + .id(1L).groupId(10L).senderId(2L)
  243 + .atUserIds("1,3")
  244 + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();
  245 + when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100))
  246 + .thenReturn(List.of(atMsg));
  247 +
  248 + // 调用
  249 + List<ImGroupMessageDO> result = groupMessageService.pullMessages(1L, 0L, 100);
  250 +
  251 + // 断言:@ 字段正确返回
  252 + assertEquals(1, result.size());
  253 + assertEquals("1,3", result.get(0).getAtUserIds());
  254 + }
  255 +
  256 + // ========== 撤回测试 ==========
  257 +
  258 + @Test
  259 + public void testRecallMessage_success() {
  260 + // 准备
  261 + ImGroupMessageDO message = ImGroupMessageDO.builder()
  262 + .id(50L).senderId(1L).groupId(10L)
  263 + .status(ImMessageStatusEnum.NORMAL).build();
  264 + when(imGroupMessageMapper.selectById(50L)).thenReturn(message);
  265 + when(imGroupMessageMapper.updateById(any())).thenReturn(1);
  266 +
  267 + List<ImGroupMemberDO> members = List.of(
  268 + ImGroupMemberDO.builder().groupId(10L).userId(1L)
  269 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build(),
  270 + ImGroupMemberDO.builder().groupId(10L).userId(2L)
  271 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus()).build(),
  272 + ImGroupMemberDO.builder().groupId(10L).userId(3L)
  273 + .status(ImGroupMemberStatusEnum.QUIT.getStatus()).build()
  274 + );
  275 + when(imGroupMemberService.selectByGroupId(10L)).thenReturn(members);
  276 +
  277 + // 调用
  278 + groupMessageService.recallMessage(1L, 50L);
  279 +
  280 + // 断言:只给 2 个活跃成员发送 RECALL 事件(userId=3 已退群)
  281 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
  282 + }
  283 +
  284 + // ========== 群已读测试 ==========
  285 +
  286 + @Test
  287 + public void testReadMessages_notInGroup() {
  288 + // 准备:当前用户不在群中
  289 + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(null);
  290 +
  291 + // 调用并断言:越权校验
  292 + ServiceException exception = assertThrows(ServiceException.class,
  293 + () -> groupMessageService.readMessages(1L, 10L));
  294 + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
  295 + }
  296 +
  297 + @Test
  298 + public void testGetReadUsers_withVisibleScope() {
  299 + // 准备:用户 1 是群成员
  300 + ImGroupMemberDO currentMember = ImGroupMemberDO.builder()
  301 + .groupId(10L).userId(1L)
  302 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus())
  303 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();
  304 + when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(currentMember);
  305 +
  306 + // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00
  307 + ImGroupMessageDO message = ImGroupMessageDO.builder()
  308 + .id(80L).groupId(10L).senderId(5L)
  309 + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build();
  310 + when(imGroupMessageMapper.selectById(80L)).thenReturn(message);
  311 +
  312 + // 准备:群成员一览
  313 + // 用户 1: 正常,入群在消息之前
  314 + // 用户 2: 正常,入群在消息之前
  315 + // 用户 3: 正常,但入群在消息之后 → 不可见
  316 + // 用户 5: 发送者,不计入回执
  317 + List<ImGroupMemberDO> allMembers = List.of(
  318 + currentMember,
  319 + ImGroupMemberDO.builder().groupId(10L).userId(2L)
  320 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus())
  321 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(),
  322 + ImGroupMemberDO.builder().groupId(10L).userId(3L)
  323 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus())
  324 + .joinTime(LocalDateTime.of(2026, 4, 13, 0, 0, 0)).build(), // 消息之后才入群
  325 + ImGroupMemberDO.builder().groupId(10L).userId(5L)
  326 + .status(ImGroupMemberStatusEnum.NORMAL.getStatus())
  327 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者
  328 + );
  329 + when(imGroupMemberService.selectByGroupId(10L)).thenReturn(allMembers);
  330 +
  331 + // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200
  332 + Map<Object, Object> positions = new HashMap<>();
  333 + positions.put("1", "100");
  334 + positions.put("2", "50");
  335 + positions.put("3", "200");
  336 + when(groupReadPositionRedisDAO.getAllReadPositions(10L)).thenReturn(positions);
  337 +
  338 + // 调用:查询 messageId=80 的已读用户
  339 + List<Long> readUsers = groupMessageService.getReadUsers(1L, 10L, 80L);
  340 +
  341 + // 断言:
  342 + // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓(但排除发送者?不是发送者,所以算入)
  343 + // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗
  344 + // 用户 3: 入群在消息之后 → 不可见 → 不算
  345 + // 用户 5: 发送者 → 排除
  346 + assertEquals(1, readUsers.size());
  347 + assertTrue(readUsers.contains(1L));
  348 + assertFalse(readUsers.contains(2L));
  349 + assertFalse(readUsers.contains(3L));
  350 + assertFalse(readUsers.contains(5L));
  351 + }
  352 +
  353 + @Test
  354 + public void testGetReadUsers_notInGroup() {
  355 + // 准备:当前用户不在群中
  356 + when(imGroupMemberService.getGroupMember(10L, 99L)).thenReturn(null);
  357 +
  358 + // 调用并断言:越权校验
  359 + ServiceException exception = assertThrows(ServiceException.class,
  360 + () -> groupMessageService.getReadUsers(99L, 10L, 80L));
  361 + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
  362 + }
  363 +
  364 +}
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  4 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  6 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  7 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  8 +import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
  9 +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  10 +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.junit.jupiter.api.extension.ExtendWith;
  13 +import org.mockito.InjectMocks;
  14 +import org.mockito.Mock;
  15 +import org.mockito.junit.jupiter.MockitoExtension;
  16 +
  17 +import java.time.LocalDateTime;
  18 +import java.util.List;
  19 +
  20 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  21 +import static org.junit.jupiter.api.Assertions.*;
  22 +import static org.mockito.ArgumentMatchers.*;
  23 +import static org.mockito.Mockito.*;
  24 +
  25 +/**
  26 + * IM 私聊消息 Service 单元测试
  27 + *
  28 + * @author 芋道源码
  29 + */
  30 +@ExtendWith(MockitoExtension.class)
  31 +public class ImPrivateMessageServiceImplTest {
  32 +
  33 + @InjectMocks
  34 + private ImPrivateMessageServiceImpl privateMessageService;
  35 +
  36 + @Mock
  37 + private ImPrivateMessageMapper imPrivateMessageMapper;
  38 + @Mock
  39 + private ImFriendService imFriendService;
  40 + @Mock
  41 + private ImSensitiveWordService imSensitiveWordService;
  42 + @Mock
  43 + private WebSocketSenderApi webSocketSenderApi;
  44 +
  45 + private ImPrivateMessageSendReqVO buildSendReqVO() {
  46 + ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO();
  47 + reqVO.setClientMessageId("test-uuid-001");
  48 + reqVO.setReceiverId(2L);
  49 + reqVO.setType(0); // TEXT
  50 + reqVO.setContent("{\"content\":\"你好\"}");
  51 + return reqVO;
  52 + }
  53 +
  54 + // ========== 发送测试 ==========
  55 +
  56 + @Test
  57 + public void testSendMessage_success() {
  58 + // 准备
  59 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
  60 + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
  61 + .thenReturn(null);
  62 + when(imPrivateMessageMapper.insert(any())).thenReturn(1);
  63 +
  64 + // 调用
  65 + ImPrivateMessageDO result = privateMessageService.sendMessage(1L, reqVO);
  66 +
  67 + // 断言
  68 + assertNotNull(result);
  69 + assertEquals(1L, result.getSenderId());
  70 + assertEquals(2L, result.getReceiverId());
  71 + assertEquals(0, result.getType());
  72 + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
  73 + assertNotNull(result.getSendTime());
  74 +
  75 + // 验证调用
  76 + verify(imFriendService).validateIsFriend(1L, 2L);
  77 + verify(imSensitiveWordService).validateText(reqVO.getContent());
  78 + verify(imPrivateMessageMapper).insert(any());
  79 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
  80 + }
  81 +
  82 + @Test
  83 + public void testSendMessage_clientMessageIdIdempotent() {
  84 + // 准备:模拟已存在消息
  85 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
  86 + ImPrivateMessageDO existingMessage = ImPrivateMessageDO.builder()
  87 + .id(100L).clientMessageId("test-uuid-001").senderId(1L).receiverId(2L)
  88 + .type(0).content("{\"content\":\"你好\"}").status(0)
  89 + .sendTime(LocalDateTime.now()).build();
  90 + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
  91 + .thenReturn(existingMessage);
  92 +
  93 + // 调用
  94 + ImPrivateMessageDO result = privateMessageService.sendMessage(1L, reqVO);
  95 +
  96 + // 断言:返回已存在的消息
  97 + assertEquals(100L, result.getId());
  98 + // 验证不会重复插入
  99 + verify(imPrivateMessageMapper, never()).insert(any());
  100 + }
  101 +
  102 + @Test
  103 + public void testSendMessage_notFriend() {
  104 + // 准备
  105 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
  106 + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
  107 + .thenReturn(null);
  108 + doThrow(new ServiceException(FRIEND_NOT_FRIEND))
  109 + .when(imFriendService).validateIsFriend(1L, 2L);
  110 +
  111 + // 调用并断言
  112 + ServiceException exception = assertThrows(ServiceException.class,
  113 + () -> privateMessageService.sendMessage(1L, reqVO));
  114 + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode());
  115 + }
  116 +
  117 + // ========== pull 测试 ==========
  118 +
  119 + @Test
  120 + public void testPullMessages_success() {
  121 + // 准备
  122 + List<ImPrivateMessageDO> mockMessages = List.of(
  123 + ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(),
  124 + ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build()
  125 + );
  126 + when(imPrivateMessageMapper.selectListByMinId(1L, 0L, 100)).thenReturn(mockMessages);
  127 +
  128 + // 调用
  129 + List<ImPrivateMessageDO> result = privateMessageService.pullMessages(1L, 0L, 100);
  130 +
  131 + // 断言
  132 + assertEquals(2, result.size());
  133 + }
  134 +
  135 + @Test
  136 + public void testPullMessages_sizeExceeded() {
  137 + // 调用并断言
  138 + ServiceException exception = assertThrows(ServiceException.class,
  139 + () -> privateMessageService.pullMessages(1L, 0L, 1001));
  140 + assertEquals(MESSAGE_PULL_SIZE_EXCEEDED.getCode(), exception.getCode());
  141 + }
  142 +
  143 + // ========== 已读测试 ==========
  144 +
  145 + @Test
  146 + public void testReadMessages_success() {
  147 + // 准备
  148 + when(imPrivateMessageMapper.updateStatusToRead(1L, 2L)).thenReturn(3);
  149 +
  150 + // 调用
  151 + privateMessageService.readMessages(1L, 2L);
  152 +
  153 + // 断言:发送了 READ 和 RECEIPT 事件
  154 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
  155 + }
  156 +
  157 + // ========== 撤回测试 ==========
  158 +
  159 + @Test
  160 + public void testRecallMessage_success() {
  161 + // 准备
  162 + ImPrivateMessageDO message = ImPrivateMessageDO.builder()
  163 + .id(10L).senderId(1L).receiverId(2L)
  164 + .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
  165 + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message);
  166 + when(imPrivateMessageMapper.updateById(any())).thenReturn(1);
  167 +
  168 + // 调用
  169 + privateMessageService.recallMessage(1L, 10L);
  170 +
  171 + // 断言
  172 + verify(imPrivateMessageMapper).updateById(any());
  173 + // 验证推送了 RECALL 事件(给接收方和发送方)
  174 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(), anyString(), any());
  175 + }
  176 +
  177 + @Test
  178 + public void testRecallMessage_notOwn() {
  179 + // 准备
  180 + ImPrivateMessageDO message = ImPrivateMessageDO.builder()
  181 + .id(10L).senderId(2L).receiverId(1L)
  182 + .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
  183 + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message);
  184 +
  185 + // 调用并断言
  186 + ServiceException exception = assertThrows(ServiceException.class,
  187 + () -> privateMessageService.recallMessage(1L, 10L));
  188 + assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode());
  189 + }
  190 +
  191 + @Test
  192 + public void testRecallMessage_alreadyRecalled() {
  193 + // 准备
  194 + ImPrivateMessageDO message = ImPrivateMessageDO.builder()
  195 + .id(10L).senderId(1L).receiverId(2L)
  196 + .status(ImMessageStatusEnum.RECALL.getStatus()).build();
  197 + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message);
  198 +
  199 + // 调用并断言
  200 + ServiceException exception = assertThrows(ServiceException.class,
  201 + () -> privateMessageService.recallMessage(1L, 10L));
  202 + assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode());
  203 + }
  204 +
  205 +}
... ...
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.message;
  2 +
  3 +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  5 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  6 +import cn.iocoder.yudao.module.im.enums.message.ImWebSocketTypeConstants;
  7 +import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
  8 +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
  9 +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.junit.jupiter.api.extension.ExtendWith;
  12 +import org.mockito.ArgumentCaptor;
  13 +import org.mockito.InjectMocks;
  14 +import org.mockito.Mock;
  15 +import org.mockito.junit.jupiter.MockitoExtension;
  16 +
  17 +import java.util.Map;
  18 +
  19 +import static org.junit.jupiter.api.Assertions.*;
  20 +import static org.mockito.ArgumentMatchers.*;
  21 +import static org.mockito.Mockito.*;
  22 +
  23 +/**
  24 + * IM WebSocket 事件单元测试
  25 + * <p>
  26 + * 验证各事件的 payload 字段是否正确
  27 + *
  28 + * @author 芋道源码
  29 + */
  30 +@ExtendWith(MockitoExtension.class)
  31 +public class ImWebSocketEventTest {
  32 +
  33 + @InjectMocks
  34 + private ImPrivateMessageServiceImpl privateMessageService;
  35 +
  36 + @Mock
  37 + private ImPrivateMessageMapper imPrivateMessageMapper;
  38 + @Mock
  39 + private ImFriendService imFriendService;
  40 + @Mock
  41 + private ImSensitiveWordService imSensitiveWordService;
  42 + @Mock
  43 + private WebSocketSenderApi webSocketSenderApi;
  44 +
  45 + @Test
  46 + public void testSendMessage_websocketEvent() {
  47 + // 准备
  48 + ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO();
  49 + reqVO.setClientMessageId("evt-uuid");
  50 + reqVO.setReceiverId(2L);
  51 + reqVO.setType(0);
  52 + reqVO.setContent("{\"content\":\"事件测试\"}");
  53 +
  54 + when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "evt-uuid")).thenReturn(null);
  55 + when(imPrivateMessageMapper.insert(any())).thenReturn(1);
  56 +
  57 + // 调用
  58 + privateMessageService.sendMessage(1L, reqVO);
  59 +
  60 + // 捕获 WebSocket 调用
  61 + ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
  62 + ArgumentCaptor<Object> contentCaptor = ArgumentCaptor.forClass(Object.class);
  63 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(),
  64 + typeCaptor.capture(), contentCaptor.capture());
  65 +
  66 + // 验证消息事件类型
  67 + assertEquals(ImWebSocketTypeConstants.PRIVATE_MESSAGE, typeCaptor.getAllValues().get(0));
  68 + assertEquals(ImWebSocketTypeConstants.PRIVATE_MESSAGE, typeCaptor.getAllValues().get(1));
  69 +
  70 + // 验证 payload 中 id 字段按 String 返回
  71 + @SuppressWarnings("unchecked")
  72 + Map<String, Object> payload = (Map<String, Object>) contentCaptor.getAllValues().get(0);
  73 + assertTrue(payload.get("id") instanceof String, "id 应为 String 类型");
  74 + assertTrue(payload.get("senderId") instanceof String, "senderId 应为 String 类型");
  75 + assertTrue(payload.get("receiverId") instanceof String, "receiverId 应为 String 类型");
  76 + assertEquals("private", payload.get("messageScene"));
  77 + }
  78 +
  79 + @Test
  80 + public void testReadMessage_readAndReceiptEvents() {
  81 + // 准备
  82 + when(imPrivateMessageMapper.updateStatusToRead(1L, 2L)).thenReturn(1);
  83 +
  84 + // 调用
  85 + privateMessageService.readMessages(1L, 2L);
  86 +
  87 + // 捕获
  88 + ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
  89 + ArgumentCaptor<Long> userCaptor = ArgumentCaptor.forClass(Long.class);
  90 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), userCaptor.capture(),
  91 + typeCaptor.capture(), any());
  92 +
  93 + // 第一次:发给自己的 READ 事件
  94 + assertEquals(ImWebSocketTypeConstants.READ, typeCaptor.getAllValues().get(0));
  95 + assertEquals(1L, userCaptor.getAllValues().get(0));
  96 + // 第二次:发给对方的 RECEIPT 事件
  97 + assertEquals(ImWebSocketTypeConstants.RECEIPT, typeCaptor.getAllValues().get(1));
  98 + assertEquals(2L, userCaptor.getAllValues().get(1));
  99 + }
  100 +
  101 + @Test
  102 + public void testRecallMessage_recallEvent() {
  103 + // 准备
  104 + ImPrivateMessageDO message = ImPrivateMessageDO.builder()
  105 + .id(10L).senderId(1L).receiverId(2L).status(0).build();
  106 + when(imPrivateMessageMapper.selectById(10L)).thenReturn(message);
  107 + when(imPrivateMessageMapper.updateById(any())).thenReturn(1);
  108 +
  109 + // 调用
  110 + privateMessageService.recallMessage(1L, 10L);
  111 +
  112 + // 捕获
  113 + ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
  114 + ArgumentCaptor<Object> contentCaptor = ArgumentCaptor.forClass(Object.class);
  115 + verify(webSocketSenderApi, times(2)).sendObject(anyInt(), anyLong(),
  116 + typeCaptor.capture(), contentCaptor.capture());
  117 +
  118 + // 验证 RECALL 事件类型
  119 + assertEquals(ImWebSocketTypeConstants.RECALL, typeCaptor.getAllValues().get(0));
  120 +
  121 + // 验证 payload
  122 + @SuppressWarnings("unchecked")
  123 + Map<String, Object> payload = (Map<String, Object>) contentCaptor.getAllValues().get(0);
  124 + assertEquals("10", payload.get("messageId"));
  125 + assertEquals("private", payload.get("messageScene"));
  126 + }
  127 +
  128 +}
... ...