diff --git a/pom.xml b/pom.xml index ffcb6a9f0..c318d77e1 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ yudao-module-iot yudao-module-mes yudao-module-wms + yudao-module-im yudao-module-ai @@ -44,7 +45,7 @@ 3.14.0 1.7.2 - 1.18.42 + 1.18.46 3.5.9 1.6.3 UTF-8 diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index b24ce3fe8..4e423cf84 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -56,6 +56,8 @@ 1.4.0 1.22.2 + 0.29.5 + 2.5.1 1.18.46 1.6.3 5.8.44 @@ -595,6 +597,18 @@ ${jsoup.version} + + com.github.houbb + sensitive-word + ${sensitive-word.version} + + + + com.belerweb + pinyin4j + ${pinyin4j.version} + + org.reflections reflections diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java index 28265a5d5..735e0e9b8 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java @@ -124,6 +124,22 @@ public class CollectionUtils { return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toSet()); } + public static Set convertLinkedSet(Collection from, Function func) { + if (CollUtil.isEmpty(from)) { + return new LinkedHashSet<>(); + } + return from.stream().map(func).filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + public static Set convertLinkedSet(Collection from, Function func, Predicate filter) { + if (CollUtil.isEmpty(from)) { + return new LinkedHashSet<>(); + } + return from.stream().filter(filter).map(func).filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + public static Map convertMapByFilter(Collection from, Predicate filter, Function keyFunc) { if (CollUtil.isEmpty(from)) { return new HashMap<>(); @@ -372,4 +388,14 @@ public class CollectionUtils { return false; } -} \ No newline at end of file + /** + * 把单元素 head 与集合 tail 合并成新 List(head 在前,tail 顺序保留) + */ + public static List of(T head, Collection tail) { + List list = new ArrayList<>(); + list.add(head); + CollUtil.addAll(list, tail); + return list; + } + +} diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java index 34e4d85f2..ffc36c60b 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java @@ -236,6 +236,18 @@ public class LocalDateTimeUtils { return LocalDateTime.now().with(TemporalAdjusters.firstDayOfYear()).with(LocalTime.MIN); } + /** + * 获取最近 N 天的 0 点时刻序列(升序,含今天) + */ + public static List getLatestDays(int days) { + LocalDateTime today = getToday(); + List dates = new ArrayList<>(days); + for (int i = days - 1; i >= 0; i--) { + dates.add(today.minusDays(i)); + } + return dates; + } + public static List getDateRangeList(LocalDateTime startTime, LocalDateTime endTime, Integer interval) { diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java index d27498185..04eb7cb92 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java @@ -200,4 +200,11 @@ public class HttpUtils { } } + /** + * WebSocket URL 切换成 HTTP URL:ws:// → http://;wss:// → https://;其它格式原样保留 + */ + public static String wsUrlToHttp(String url) { + return StrUtil.startWithIgnoreCase(url, "ws") ? "http" + url.substring(2) : url; + } + } diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java index ac42e3ec8..fc17917c5 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java @@ -22,6 +22,7 @@ import java.lang.reflect.Type; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * JSON 工具类 @@ -173,6 +174,20 @@ public class JsonUtils { } } + /** + * 解析 JSON 字符串成 Map,空字符串或解析失败返回 null + */ + public static Map parseMap(String text) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + return objectMapper.readValue(text, new TypeReference>() {}); + } catch (IOException e) { + return null; + } + } + /** * 解析 JSON 字符串成指定类型的对象,如果解析失败,则返回 null * diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StrUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StrUtils.java index 47a53a595..7332a57a7 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StrUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StrUtils.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.framework.common.util.string; import cn.hutool.core.text.StrPool; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.pinyin.PinyinUtil; import org.aspectj.lang.JoinPoint; import java.util.Arrays; @@ -78,6 +79,16 @@ public class StrUtils { .collect(Collectors.joining("\n")); } + /** + * 转小写拼音,字之间以空格分隔,便于调用方按需拼接 / 取首字母 / 拼音搜索 + */ + public static String toPinyin(String str) { + if (StrUtil.isBlank(str)) { + return null; + } + return PinyinUtil.getPinyin(str); + } + /** * 拼接方法的参数 * diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java index 5ccee2008..d72ccc7bf 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java @@ -170,6 +170,14 @@ public interface BaseMapperX extends MPJBaseMapper { return CollUtil.getFirst(list); } + /** + * 获取满足条件的最新一条记录 + *

+ * 目的:解决并发场景下,插入多条记录后,使用 selectOne 会报错的问题 + */ + default T selectLastOne(LambdaQueryWrapper queryWrapper) { + return CollUtil.getLast(selectList(queryWrapper)); + } default Long selectCount() { return selectCount(new QueryWrapper<>()); diff --git a/yudao-gateway/src/main/resources/application.yaml b/yudao-gateway/src/main/resources/application.yaml index 4730c850c..4b97b5a8c 100644 --- a/yudao-gateway/src/main/resources/application.yaml +++ b/yudao-gateway/src/main/resources/application.yaml @@ -209,6 +209,13 @@ spring: - Path=/admin-api/wms/** filters: - RewritePath=/admin-api/wms/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs + ## im-server 服务 + - id: im-admin-api # 路由的编号 + uri: grayLb://im-server + predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 + - Path=/admin-api/im/** + filters: + - RewritePath=/admin-api/im/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs x-forwarded: prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀 default-filters: @@ -274,6 +281,9 @@ knife4j: - name: wms-server service-name: wms-server url: /admin-api/wms/v3/api-docs + - name: im-server + service-name: im-server + url: /admin-api/im/v3/api-docs --- #################### 芋道相关配置 #################### diff --git a/yudao-module-im/pom.xml b/yudao-module-im/pom.xml new file mode 100644 index 000000000..7afd4e0fb --- /dev/null +++ b/yudao-module-im/pom.xml @@ -0,0 +1,24 @@ + + + + cn.iocoder.cloud + yudao + ${revision} + + 4.0.0 + + yudao-module-im-api + yudao-module-im-server + + yudao-module-im + pom + + ${project.artifactId} + + im 模块,我们放即时通讯业务。 + 例如说:单聊、群聊、消息收发、消息撤回、消息已读等等 + + + diff --git a/yudao-module-im/yudao-module-im-api/pom.xml b/yudao-module-im/yudao-module-im-api/pom.xml new file mode 100644 index 000000000..8b8b84651 --- /dev/null +++ b/yudao-module-im/yudao-module-im-api/pom.xml @@ -0,0 +1,33 @@ + + + + cn.iocoder.cloud + yudao-module-im + ${revision} + + 4.0.0 + yudao-module-im-api + jar + + ${project.artifactId} + + im 模块 API,暴露给其它模块调用 + + + + + cn.iocoder.cloud + yudao-common + + + + + org.springframework.boot + spring-boot-starter-validation + true + + + + diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java new file mode 100644 index 000000000..49380970f --- /dev/null +++ b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java @@ -0,0 +1,5 @@ +/** + * @author anhaohao + * @since 2024/3/9 下午8:59 + */ +package cn.iocoder.yudao.module.im.api; \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/Dockerfile b/yudao-module-im/yudao-module-im-server/Dockerfile new file mode 100644 index 000000000..1c32ca50a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/Dockerfile @@ -0,0 +1,19 @@ +## AdoptOpenJDK 停止发布 OpenJDK 二进制,而 Eclipse Temurin 是它的延伸,提供更好的稳定性 +## 感谢复旦核博士的建议!灰子哥,牛皮! +FROM eclipse-temurin:21-jre + +## 创建目录,并使用它作为工作目录 +RUN mkdir -p /yudao-module-im-server +WORKDIR /yudao-module-im-server +## 将后端项目的 Jar 文件,复制到镜像中 +COPY ./target/yudao-module-im-server.jar app.jar + +## 设置 TZ 时区 +## 设置 JAVA_OPTS 环境变量,可通过 docker run -e "JAVA_OPTS=" 进行覆盖 +ENV TZ=Asia/Shanghai JAVA_OPTS="-Xms512m -Xmx512m" + +## 暴露后端项目的 48093 端口 +EXPOSE 48093 + +## 启动后端项目 +CMD java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -jar app.jar diff --git a/yudao-module-im/yudao-module-im-server/pom.xml b/yudao-module-im/yudao-module-im-server/pom.xml new file mode 100644 index 000000000..17a010f33 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/pom.xml @@ -0,0 +1,156 @@ + + + + cn.iocoder.cloud + yudao-module-im + ${revision} + + 4.0.0 + yudao-module-im-server + jar + + ${project.artifactId} + + im 模块,我们放即时通讯业务。 + 例如说:单聊、群聊、消息收发、消息撤回、消息已读等等 + + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-env + + + + + cn.iocoder.cloud + yudao-module-im-api + ${revision} + + + cn.iocoder.cloud + yudao-module-system-api + ${revision} + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-security + + + cn.iocoder.cloud + yudao-spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-validation + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-mybatis + + + cn.iocoder.cloud + yudao-spring-boot-starter-redis + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-rpc + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-job + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-mq + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-test + test + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-excel + + + + + com.github.houbb + sensitive-word + + + + + com.belerweb + pinyin4j + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-monitor + + + + org.projectlombok + lombok + + + + + + ${project.artifactId} + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + + + + diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/ImServerApplication.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/ImServerApplication.java new file mode 100644 index 000000000..184eb7a48 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/ImServerApplication.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.im; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 项目的启动类 + * + * 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + * 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + * 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + * + * @author 芋道源码 + */ +@SpringBootApplication +public class ImServerApplication { + + public static void main(String[] args) { + // 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + // 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + // 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + + SpringApplication.run(ImServerApplication.class, args); + + // 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + // 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + // 如果你碰到启动的问题,请认真阅读 https://cloud.iocoder.cn/quick-start/ 文章 + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java new file mode 100644 index 000000000..e2427a130 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.controller.admin.channel; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "用户 APP - IM 频道素材") +@RestController +@RequestMapping("/im/channel/material") +@Validated +public class ImChannelMaterialController { + + @Resource + private ImChannelMaterialService channelMaterialService; + + @GetMapping("/get") + @Operation(summary = "获取素材详情;用于客户端点击图文卡片渲染详情页") + @Parameter(name = "id", description = "素材编号", required = true, example = "1024") + public CommonResult getMaterial(@RequestParam("id") Long id) { + ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(id); + return success(BeanUtils.toBean(material, ImChannelMaterialRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/ImFacePackController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/ImFacePackController.java new file mode 100644 index 000000000..ea908242b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/ImFacePackController.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.im.controller.admin.face; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.face.vo.pack.ImFacePackUserRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO; +import cn.iocoder.yudao.module.im.service.face.ImFacePackItemService; +import cn.iocoder.yudao.module.im.service.face.ImFacePackService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; + +@Tag(name = "管理后台 - IM 表情包") +@RestController +@RequestMapping("/im/face-pack") +@Validated +public class ImFacePackController { + + @Resource + private ImFacePackService facePackService; + @Resource + private ImFacePackItemService facePackItemService; + + @GetMapping("/list") + @Operation(summary = "获得启用的表情包列表(含表情)") + public CommonResult> getFacePackList() { + // 1.1 拉所有启用表情包 + List packs = facePackService.getEnabledFacePackList(); + if (packs.isEmpty()) { + return success(List.of()); + } + // 1.2 拉这些包下所有启用表情,按 packId 分组 + List items = facePackItemService.getEnabledItemListByPackIds( + convertList(packs, ImFacePackDO::getId)); + Map> itemsByPackId = convertMultiMap(items, ImFacePackItemDO::getPackId); + + // 2. 拼装:BeanUtils 把 pack 字段映射 + 自己塞 items + List result = convertList(packs, pack -> { + ImFacePackUserRespVO vo = BeanUtils.toBean(pack, ImFacePackUserRespVO.class); + vo.setItems(BeanUtils.toBean(itemsByPackId.getOrDefault(pack.getId(), List.of()), ImFacePackUserRespVO.Item.class)); + return vo; + }); + return success(result); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/ImFaceUserItemController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/ImFaceUserItemController.java new file mode 100644 index 000000000..7f27c173c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/ImFaceUserItemController.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.im.controller.admin.face; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemRespVO; +import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; +import cn.iocoder.yudao.module.im.service.face.ImFaceUserItemService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 个人表情") +@RestController +@RequestMapping("/im/face-user-item") +@Validated +public class ImFaceUserItemController { + + @Resource + private ImFaceUserItemService faceUserItemService; + + @GetMapping("/list") + @Operation(summary = "获得我的个人表情列表") + public CommonResult> getFaceUserItemList() { + List items = faceUserItemService.getFaceUserItemList(getLoginUserId()); + return success(BeanUtils.toBean(items, ImFaceUserItemRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "添加个人表情") + public CommonResult createFaceUserItem(@Valid @RequestBody ImFaceUserItemSaveReqVO reqVO) { + return success(faceUserItemService.createFaceUserItem(getLoginUserId(), reqVO)); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除个人表情") + @Parameter(name = "id", description = "编号", required = true, example = "4096") + public CommonResult deleteFaceUserItem(@RequestParam("id") Long id) { + faceUserItemService.deleteFaceUserItem(getLoginUserId(), id); + return success(true); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/pack/ImFacePackUserRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/pack/ImFacePackUserRespVO.java new file mode 100644 index 000000000..5a73d821c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/pack/ImFacePackUserRespVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.im.controller.admin.face.vo.pack; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "IM 表情包(用户端) Response VO") +@Data +public class ImFacePackUserRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "表情包名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "猫主子") + private String name; + + @Schema(description = "表情包图标", example = "https://cdn.example.com/face/pack/cat.png") + private String icon; + + @Schema(description = "表情列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List items; + + @Schema(description = "IM 表情包项(用户端)") + @Data + public static class Item { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long id; + + @Schema(description = "表情图 URL", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://cdn.example.com/face/pack/cat-001.png") + private String url; + + @Schema(description = "表情名", example = "狗头") + private String name; + + @Schema(description = "渲染宽度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer width; + + @Schema(description = "渲染高度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer height; + + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/userItem/ImFaceUserItemRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/userItem/ImFaceUserItemRespVO.java new file mode 100644 index 000000000..61be77857 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/userItem/ImFaceUserItemRespVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "IM 个人表情 Response VO") +@Data +public class ImFaceUserItemRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096") + private Long id; + + @Schema(description = "表情图 URL", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://cdn.example.com/face/user/abc.gif") + private String url; + + @Schema(description = "表情名", example = "狗头") + private String name; + + @Schema(description = "渲染宽度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer width; + + @Schema(description = "渲染高度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer height; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/userItem/ImFaceUserItemSaveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/userItem/ImFaceUserItemSaveReqVO.java new file mode 100644 index 000000000..b9a508d00 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/face/vo/userItem/ImFaceUserItemSaveReqVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "IM 个人表情新增 Request VO") +@Data +public class ImFaceUserItemSaveReqVO { + + @Schema(description = "表情图 URL", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://cdn.example.com/face/user/abc.gif") + @NotBlank(message = "表情图 URL 不能为空") + @Size(max = 512, message = "表情图 URL 长度不能超过 512") + private String url; + + @Schema(description = "表情名", example = "狗头") + @Size(max = 64, message = "表情名长度不能超过 64") + private String name; + + @Schema(description = "渲染宽度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + @NotNull(message = "渲染宽度不能为空") + @Min(value = 1, message = "渲染宽度不能小于 1 像素") + @Max(value = 2048, message = "渲染宽度不能大于 2048 像素") + private Integer width; + + @Schema(description = "渲染高度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + @NotNull(message = "渲染高度不能为空") + @Min(value = 1, message = "渲染高度不能小于 1 像素") + @Max(value = 2048, message = "渲染高度不能大于 2048 像素") + private Integer height; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java new file mode 100644 index 000000000..43668f8d3 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java @@ -0,0 +1,127 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.singleton; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 好友") +@RestController +@RequestMapping("/im/friend") +@Validated +public class ImFriendController { + + @Resource + private ImFriendService friendService; + + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/list") + @Operation(summary = "获得当前登录用户的好友列表") + public CommonResult> getMyFriendList() { + // 含 DISABLE 历史好友:保留给前端展示「已删除好友」的历史对话信息;前端按 status 决定会话级联清理 + List friends = friendService.getFriendList(getLoginUserId()); + return success(buildFriendRespVOList(friends)); + } + + @GetMapping("/get") + @Operation(summary = "获得好友详情") + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") + public CommonResult getFriend(@RequestParam("friendUserId") Long friendUserId) { + ImFriendDO friend = friendService.getFriend(getLoginUserId(), friendUserId); + return success(buildFriendRespVO(friend)); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除好友(单向软删除)") + @Parameters({ + @Parameter(description = "好友的用户编号", required = true, example = "2048"), + @Parameter(description = "是否级联清理本端相关数据(如私聊会话)") + }) + public CommonResult deleteFriend( + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId, + @RequestParam(value = "clear", required = false) Boolean clear) { + friendService.deleteFriend(getLoginUserId(), friendUserId, clear); + return success(true); + } + + @PutMapping("/update") + @Operation(summary = "更新好友单边属性(备注 / 免打扰 / 联系人置顶)") + public CommonResult updateFriend(@Valid @RequestBody ImFriendUpdateReqVO reqVO) { + friendService.updateFriend(getLoginUserId(), reqVO); + return success(true); + } + + @PutMapping("/block") + @Operation(summary = "拉黑好友(必须先是好友;单边屏蔽对方私聊消息)") + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") + public CommonResult blockFriend( + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { + friendService.blockFriend(getLoginUserId(), friendUserId); + return success(true); + } + + @PutMapping("/unblock") + @Operation(summary = "移出黑名单") + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") + public CommonResult unblockFriend( + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { + friendService.unblockFriend(getLoginUserId(), friendUserId); + return success(true); + } + + // ========== 私有方法:VO 组装 ========== + + private List buildFriendRespVOList(Collection friends) { + if (CollUtil.isEmpty(friends)) { + return Collections.emptyList(); + } + // 批量聚合 AdminUser 信息(昵称 / 头像),避免 N+1 + Map userMap = adminUserApi.getUserMap( + convertList(friends, ImFriendDO::getFriendUserId)); + return convertList(friends, friend -> { + ImFriendRespVO vo = BeanUtils.toBean(friend, ImFriendRespVO.class); + MapUtils.findAndThen(userMap, friend.getFriendUserId(), user -> + vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); + // 备注 / 昵称的拼音,给前端做字母分桶 + 拼音搜索 + vo.setDisplayNamePinyin(StrUtils.toPinyin(vo.getDisplayName())) + .setNicknamePinyin(StrUtils.toPinyin(vo.getNickname())); + return vo; + }); + } + + private ImFriendRespVO buildFriendRespVO(ImFriendDO friend) { + if (friend == null) { + return null; + } + return CollUtil.getFirst(buildFriendRespVOList(singleton(friend))); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendRequestController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendRequestController.java new file mode 100644 index 000000000..10b23519b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendRequestController.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.service.friend.ImFriendRequestService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +/** + * IM 好友申请记录 Controller + * + * @author 芋道源码 + */ +@Tag(name = "管理后台 - IM 好友申请") +@RestController +@RequestMapping("/im/friend-request") +@Validated +public class ImFriendRequestController { + + @Resource + private ImFriendRequestService friendRequestService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/apply") + @Operation(summary = "发起好友申请") + public CommonResult applyFriend(@Valid @RequestBody ImFriendRequestApplyReqVO reqVO) { + ImFriendRequestDO request = friendRequestService.applyFriend(getLoginUserId(), reqVO); + return success(request != null ? request.getId() : null); + } + + @PutMapping("/agree") + @Operation(summary = "同意好友申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + public CommonResult agreeFriendRequest( + @RequestParam("id") @NotNull(message = "申请编号不能为空") Long id) { + friendRequestService.agreeFriendRequest(getLoginUserId(), id); + return success(true); + } + + @PutMapping("/refuse") + @Operation(summary = "拒绝好友申请") + public CommonResult refuseFriendRequest( + @RequestParam("id") @NotNull(message = "申请编号不能为空") Long id, + @RequestParam(value = "handleContent", required = false) + @Size(max = 255, message = "处理理由最多 255 个字符") String handleContent) { + friendRequestService.refuseFriendRequest(getLoginUserId(), id, handleContent); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)") + public CommonResult> getMyFriendRequestList( + @Parameter(description = "当前列表最旧记录的 id;首页不传") + @RequestParam(value = "maxId", required = false) Long maxId, + @Parameter(description = "单次拉取条数", required = true) + @RequestParam("limit") @Min(1) @Max(200) Integer limit) { + List list = friendRequestService.getMyFriendRequestList(getLoginUserId(), maxId, limit); + return success(buildList(list)); + } + + @GetMapping("/get") + @Operation(summary = "按 id 单查「我相关」的申请记录(带越权过滤;WebSocket 通知到达后用)") + @Parameter(name = "id", description = "申请记录编号", required = true) + public CommonResult getMyFriendRequest(@RequestParam("id") Long id) { + ImFriendRequestDO request = friendRequestService.getFriendRequest(id); + // 越权过滤:fromUser / toUser 必有一方是当前用户,否则当不存在返回 null + Long currentUserId = getLoginUserId(); + if (request == null || (ObjUtil.notEqual(request.getFromUserId(), currentUserId) + && ObjUtil.notEqual(request.getToUserId(), currentUserId))) { + return success(null); + } + return success(CollUtil.getFirst(buildList(Collections.singletonList(request)))); + } + + // ========== 私有方法:VO 组装 ========== + + private List buildList(List list) { + if (CollUtil.isEmpty(list)) { + return Collections.emptyList(); + } + // 双向 OR 列表,userIds 取 from + to 两组并集 + Set userIds = convertSetByFlatMap(list, + request -> Stream.of(request.getFromUserId(), request.getToUserId())); + Map userMap = adminUserApi.getUserMap(userIds); + return convertList(list, request -> { + ImFriendRequestRespVO vo = BeanUtils.toBean(request, ImFriendRequestRespVO.class); + MapUtils.findAndThen(userMap, request.getFromUserId(), user -> + vo.setFromNickname(user.getNickname()).setFromAvatar(user.getAvatar())); + MapUtils.findAndThen(userMap, request.getToUserId(), user -> + vo.setToNickname(user.getNickname()).setToAvatar(user.getAvatar())); + return vo; + }); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java new file mode 100644 index 000000000..2e17b5120 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * IM 好友 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IM 好友 Response VO") +@Data +public class ImFriendRespVO { + + @Schema(description = "关系记录编号", example = "1024") + private Long id; + + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long friendUserId; + + @Schema(description = "是否免打扰", example = "false") + private Boolean silent; + + @Schema(description = "好友展示备注(仅自己可见)", example = "老张") + private String displayName; + + @Schema(description = "好友展示备注的拼音(小写无空格)", example = "laozhang") + private String displayNamePinyin; + + @Schema(description = "添加来源", example = "1") + private Integer addSource; // 参见 ImFriendAddSourceEnum 枚举 + + @Schema(description = "是否置顶联系人", example = "false") + private Boolean pinned; + + @Schema(description = "是否拉黑(仅自己可见)", example = "false") + private Boolean blocked; + + @Schema(description = "好友状态", example = "0") + private Integer status; + + @Schema(description = "添加好友时间") + private LocalDateTime addTime; + + @Schema(description = "删除好友时间") + private LocalDateTime deleteTime; + + // ========== 下面是聚合字段,方便前端显示 ========== + + @Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道") + private String nickname; + + @Schema(description = "好友昵称的拼音(小写无空格)", example = "yudao") + private String nicknamePinyin; + + @Schema(description = "好友头像(实时聚合自 AdminUser)") + private String avatar; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java new file mode 100644 index 000000000..8eee96f3a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 好友更新 Request VO") +@Data +public class ImFriendUpdateReqVO { + + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "好友用户编号不能为空") + private Long friendUserId; + + @Schema(description = "是否免打扰;不传表示不修改", example = "true") + private Boolean silent; + + @Schema(description = "好友展示备注(仅自己可见);不传表示不修改,传空串表示清空", example = "老张") + @Size(max = 16, message = "好友备注最多 16 个字符") + private String displayName; + + @Schema(description = "是否置顶联系人;不传表示不修改", example = "true") + private Boolean pinned; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestApplyReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestApplyReqVO.java new file mode 100644 index 000000000..fea58c1c4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestApplyReqVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo.request; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendAddSourceEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * IM 好友申请 - 发起 Request VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IM 好友申请发起 Request VO") +@Data +public class ImFriendRequestApplyReqVO { + + @Schema(description = "接收方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "接收方用户编号不能为空") + private Long toUserId; + + @Schema(description = "申请理由", example = "我是芋艿(一种食材)") + @Size(max = 255, message = "申请理由最多 255 个字符") + private String applyContent; + + @Schema(description = "对接收方的备注(仅自己可见)", example = "老张") + @Size(max = 16, message = "好友备注最多 16 个字符") + private String displayName; + + @Schema(description = "添加来源", example = "1") + @InEnum(ImFriendAddSourceEnum.class) + private Integer addSource; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestRespVO.java new file mode 100644 index 000000000..6f8f93d76 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestRespVO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * IM 好友申请 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IM 好友申请 Response VO") +@Data +public class ImFriendRequestRespVO { + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "发起方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + private Long fromUserId; + + @Schema(description = "接收方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Long toUserId; + + @Schema(description = "处理结果", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer handleResult; // 参见 ImFriendRequestHandleResultEnum 枚举 + + @Schema(description = "申请理由", example = "我是芋艿(一种食材)") + private String applyContent; + + @Schema(description = "处理理由(接收方拒绝时可选填)", example = "暂不通过") + private String handleContent; + + @Schema(description = "添加来源", example = "1") + private Integer addSource; // 参见 ImFriendAddSourceEnum 枚举 + + @Schema(description = "处理时间") + private LocalDateTime handleTime; + + @Schema(description = "申请创建时间") + private LocalDateTime createTime; + + // ========== 下面是聚合字段,方便前端显示 ========== + + @Schema(description = "发起方昵称(实时聚合自 AdminUser)", example = "芋道") + private String fromNickname; + + @Schema(description = "发起方头像(实时聚合自 AdminUser)") + private String fromAvatar; + + @Schema(description = "接收方昵称(实时聚合自 AdminUser)", example = "老张") + private String toNickname; + + @Schema(description = "接收方头像(实时聚合自 AdminUser)") + private String toAvatar; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java new file mode 100644 index 000000000..e9a7477b8 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java @@ -0,0 +1,206 @@ +package cn.iocoder.yudao.module.im.controller.admin.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 群") +@RestController +@RequestMapping("/im/group") +@Validated +public class ImGroupController { + + @Resource + private ImGroupService groupService; + @Resource + private ImGroupMemberService groupMemberService; + @Resource + private ImGroupMessageService groupMessageService; + + // ==================== 群的写操作 ==================== + + @PostMapping("/create") + @Operation(summary = "创建群") + public CommonResult createGroup(@Valid @RequestBody ImGroupCreateReqVO createReqVO) { + ImGroupDO group = groupService.createGroup(createReqVO, getLoginUserId()); + // 新建群必无 pinnedMessages,跳过关联回填 + return success(BeanUtils.toBean(group, ImGroupRespVO.class)); + } + + @PutMapping("/update") + @Operation(summary = "更新群") + public CommonResult updateGroup(@Valid @RequestBody ImGroupUpdateReqVO updateReqVO) { + ImGroupDO group = groupService.updateGroup(updateReqVO, getLoginUserId()); + return success(buildGroupRespVO(group, getLoginUserId())); + } + + @DeleteMapping("/dissolve") + @Operation(summary = "解散群") + @Parameter(name = "id", description = "群编号", required = true) + public CommonResult dissolveGroup(@RequestParam("id") Long id) { + groupService.dissolveGroup(id, getLoginUserId()); + return success(true); + } + + // ==================== 群的读操作 ==================== + + @GetMapping("/get") + @Operation(summary = "获得群") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + public CommonResult getGroup(@RequestParam("id") Long id) { + ImGroupDO group = groupService.getGroup(id); + return success(buildGroupRespVO(group, getLoginUserId())); + } + + @GetMapping("/list") + @Operation(summary = "获得当前登录用户的群列表") + public CommonResult> getMyGroupList() { + Long loginUserId = getLoginUserId(); + List groups = groupService.getMyGroupList(loginUserId); + return success(buildGroupRespVOList(groups, loginUserId)); + } + + // ==================== 群成员的写操作 ==================== + + @PostMapping("/invite") + @Operation(summary = "邀请用户加入群") + public CommonResult inviteGroupMember(@Valid @RequestBody ImGroupMemberInviteReqVO inviteReqVO) { + groupService.inviteGroupMember(getLoginUserId(), inviteReqVO); + return success(true); + } + + @DeleteMapping("/quit") + @Operation(summary = "退出群") + @Parameter(name = "groupId", description = "群编号", required = true) + public CommonResult quitGroup(@RequestParam("groupId") Long groupId) { + groupService.quitGroup(groupId, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/kicking") + @Operation(summary = "移除群成员") + public CommonResult removeGroupMember(@Valid @RequestBody ImGroupMemberRemoveReqVO removeReqVO) { + groupService.removeGroupMember(getLoginUserId(), removeReqVO); + return success(true); + } + + @PutMapping("/add-admin") + @Operation(summary = "添加群管理员") + public CommonResult addGroupAdmin(@Valid @RequestBody ImGroupAdminAddReqVO reqVO) { + groupService.addGroupAdmin(getLoginUserId(), reqVO); + return success(true); + } + + @PutMapping("/remove-admin") + @Operation(summary = "撤销群管理员") + public CommonResult removeGroupAdmin(@Valid @RequestBody ImGroupAdminRemoveReqVO reqVO) { + groupService.removeGroupAdmin(getLoginUserId(), reqVO); + return success(true); + } + + @PutMapping("/transfer-owner") + @Operation(summary = "转让群主") + public CommonResult transferGroupOwner(@Valid @RequestBody ImGroupTransferOwnerReqVO transferReqVO) { + groupService.transferGroupOwner(getLoginUserId(), transferReqVO); + return success(true); + } + + // ==================== 群消息置顶 ==================== + + @PutMapping("/pin-message") + @Operation(summary = "置顶群消息(群主 / 管理员)") + public CommonResult pinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) { + groupService.pinGroupMessage(getLoginUserId(), reqVO.getId(), reqVO.getMessageId()); + return success(true); + } + + @PutMapping("/unpin-message") + @Operation(summary = "取消置顶群消息(群主 / 管理员)") + public CommonResult unpinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) { + groupService.unpinGroupMessage(getLoginUserId(), reqVO.getId(), reqVO.getMessageId()); + return success(true); + } + + // ==================== 群禁言 ==================== + + @PutMapping("/mute-all") + @Operation(summary = "全群禁言 / 取消(群主 / 管理员)") + public CommonResult muteAll(@Valid @RequestBody ImGroupMuteAllReqVO reqVO) { + groupService.muteAll(getLoginUserId(), reqVO); + return success(true); + } + + @PutMapping("/mute-member") + @Operation(summary = "禁言成员") + public CommonResult muteMember(@Valid @RequestBody ImGroupMuteMemberReqVO reqVO) { + groupService.muteMember(getLoginUserId(), reqVO); + return success(true); + } + + @PutMapping("/cancel-mute-member") + @Operation(summary = "取消成员禁言") + public CommonResult cancelMuteMember(@Valid @RequestBody ImGroupCancelMuteMemberReqVO reqVO) { + groupService.cancelMuteMember(getLoginUserId(), reqVO); + return success(true); + } + + /** 单群转 VO + 关联回填 pinnedMessages(仅当登录用户是该群有效成员) */ + private ImGroupRespVO buildGroupRespVO(ImGroupDO group, Long loginUserId) { + if (group == null) { + return null; + } + return buildGroupRespVOList(Collections.singletonList(group), loginUserId).get(0); + } + + /** + * 群列表批量转 VO + 关联回填 pinnedMessages + *

+ * 仅当登录用户是某群的有效成员时才回填该群的 pinnedMessages,避免非成员 / 已退群用户越权拿到置顶消息内容 + */ + private List buildGroupRespVOList(List groups, Long loginUserId) { + if (CollUtil.isEmpty(groups)) { + return Collections.emptyList(); + } + // 仅当前用户是有效成员的群才允许回填置顶消息 + Set activeGroupIds = convertSet( + groupMemberService.getActiveGroupMemberListByUserId(loginUserId), ImGroupMemberDO::getGroupId); + Set allMessageIds = convertSetByFlatMap(groups, group -> activeGroupIds.contains(group.getId()) + ? CollUtil.emptyIfNull(group.getPinnedMessageIds()).stream() : Stream.empty()); + Map messageMap = groupMessageService.getGroupMessageMap(allMessageIds); + // 转换输出 + return convertList(groups, group -> { + ImGroupRespVO vo = BeanUtils.toBean(group, ImGroupRespVO.class); + if (!activeGroupIds.contains(group.getId()) || CollUtil.isEmpty(group.getPinnedMessageIds())) { + return vo; + } + // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 + List pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get); + return vo.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class)); + }); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java new file mode 100644 index 000000000..5584af6b4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.im.controller.admin.group; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRespVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; + +@Tag(name = "管理后台 - 群成员") +@RestController +@RequestMapping("/im/group-member") +@Validated +public class ImGroupMemberController { + + @Resource + private ImGroupMemberService groupMemberService; + + @Resource + private AdminUserApi adminUserApi; + + @PutMapping("/update") + @Operation(summary = "更新群成员") + public CommonResult updateGroupMember(@Valid @RequestBody ImGroupMemberUpdateReqVO updateReqVO) { + groupMemberService.updateGroupMember(getLoginUserId(), updateReqVO); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得群成员") + @Parameters({ + @Parameter(name = "id", description = "编号(与 groupId + userId 二选一)", example = "1024"), + @Parameter(name = "groupId", description = "群编号(与 userId 配合查)", example = "1"), + @Parameter(name = "userId", description = "用户编号(与 groupId 配合查)", example = "100") + }) + public CommonResult getGroupMember(@RequestParam(value = "id", required = false) Long id, + @RequestParam(value = "groupId", required = false) Long groupId, + @RequestParam(value = "userId", required = false) Long userId) { + // 1. 查询群成员 + ImGroupMemberDO member; + if (id != null) { + member = groupMemberService.getGroupMember(id); + } else if (groupId != null && userId != null) { + member = groupMemberService.getGroupMember(groupId, userId); + } else { + // 避免 selectByGroupIdAndUserId 收到 null 参数走全表扫 / 抛 SQL 异常 + throw new IllegalArgumentException("参数缺失:需传 id 或 (groupId, userId)"); + } + if (member == null) { + return success(null); + } + + // 2. 校验当前登录用户是该成员所在群的有效成员 + Long loginUserId = getLoginUserId(); + groupMemberService.validateMemberInGroup(member.getGroupId(), loginUserId); + + // 3. 转化 VO + ImGroupMemberRespVO memberVO = BeanUtils.toBean(member, ImGroupMemberRespVO.class); + AdminUserRespDTO user = adminUserApi.getUser(member.getUserId()).getCheckedData(); + if (user != null) { + memberVO.setNickname(user.getNickname()).setAvatar(user.getAvatar()); + } + hidePrivateFieldsIfNotSelf(memberVO, member.getUserId(), loginUserId); + return success(memberVO); + } + + @GetMapping("/list") + @Operation(summary = "获得指定群的成员列表") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") + public CommonResult> getGroupMemberList(@RequestParam("groupId") Long groupId) { + // 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤) + // 说明:保留已退群成员,是为了前端展示历史消息时,仍能通过该接口拿到已退群成员的昵称 / 头像信息,避免显示为空 + List members = groupMemberService.getGroupMemberListByGroupId(groupId); + // 1.2 校验当前登录用户是否为群的有效成员,非成员不可查看 + Long loginUserId = getLoginUserId(); + if (CollUtil.findOne(members, member -> loginUserId.equals(member.getUserId()) + && CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus())) == null) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + + // 2.批量聚合 AdminUser 信息(昵称 / 头像) + Map userMap = adminUserApi.getUserMap( + convertList(members, ImGroupMemberDO::getUserId)); + return success(convertList(members, m -> { + ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class); + MapUtils.findAndThen(userMap, m.getUserId(), user -> + vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); + hidePrivateFieldsIfNotSelf(vo, m.getUserId(), loginUserId); + return vo; + })); + } + + /** + * 非本人查看时,置空成员的私人设置字段(groupRemark / silent) + */ + private void hidePrivateFieldsIfNotSelf(ImGroupMemberRespVO vo, Long memberUserId, Long loginUserId) { + if (ObjUtil.notEqual(loginUserId, memberUserId)) { + vo.setGroupRemark(null).setSilent(null); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupRequestController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupRequestController.java new file mode 100644 index 000000000..440e30baf --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupRequestController.java @@ -0,0 +1,155 @@ +package cn.iocoder.yudao.module.im.controller.admin.group; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.request.ImGroupRequestApplyReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.request.ImGroupRequestRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.group.ImGroupRequestService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +/** + * IM 加群申请 Controller + * + * @author 芋道源码 + */ +@Tag(name = "管理后台 - IM 加群申请") +@RestController +@RequestMapping("/im/group-request") +@Validated +public class ImGroupRequestController { + + @Resource + private ImGroupRequestService groupRequestService; + @Resource + private ImGroupService groupService; + @Resource + private ImGroupMemberService groupMemberService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/apply") + @Operation(summary = "申请加群") + public CommonResult applyJoinGroup(@Valid @RequestBody ImGroupRequestApplyReqVO reqVO) { + ImGroupRequestDO request = groupRequestService.applyJoinGroup(getLoginUserId(), reqVO); + return success(request != null ? request.getId() : null); + } + + @PutMapping("/agree") + @Operation(summary = "同意加群申请(群主或管理员)") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + public CommonResult agreeGroupRequest( + @RequestParam("id") @NotNull(message = "申请编号不能为空") Long id) { + groupRequestService.agreeGroupRequest(getLoginUserId(), id); + return success(true); + } + + @PutMapping("/refuse") + @Operation(summary = "拒绝加群申请(群主或管理员)") + public CommonResult refuseGroupRequest( + @RequestParam("id") @NotNull(message = "申请编号不能为空") Long id, + @RequestParam(value = "handleContent", required = false) + @Size(max = 255, message = "处理理由最多 255 个字符") String handleContent) { + groupRequestService.refuseGroupRequest(getLoginUserId(), id, handleContent); + return success(true); + } + + @GetMapping("/unhandled-list") + @Operation(summary = "查询「我管理的所有群」下的未处理加群申请列表(不分页);前端 store 据此派生横幅红点 + Drawer 列表") + public CommonResult> getUnhandledRequestList() { + List list = groupRequestService.getUnhandledRequestListByOwnerOrAdmin(getLoginUserId()); + return success(buildVOList(list)); + } + + @GetMapping("/list-by-group") + @Operation(summary = "查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") + public CommonResult> getGroupRequestListByGroupId( + @RequestParam("groupId") @NotNull(message = "群编号不能为空") Long groupId) { + List list = groupRequestService.getGroupRequestListByGroupId(getLoginUserId(), groupId); + return success(buildVOList(list)); + } + + @GetMapping("/get") + @Operation(summary = "按 id 单查申请记录(带越权过滤;WebSocket 通知到达后用)") + @Parameter(name = "id", description = "申请记录编号", required = true) + public CommonResult getGroupRequest(@RequestParam("id") Long id) { + ImGroupRequestDO request = groupRequestService.getGroupRequest(id); + if (request == null) { + return success(null); + } + // 越权过滤:申请人 / 邀请人 / 群主 / 管理员之外,当不存在返回 null + Long currentUserId = getLoginUserId(); + boolean canSee = ObjUtil.equal(request.getUserId(), currentUserId) + || ObjUtil.equal(request.getInviterUserId(), currentUserId) + || isGroupOwnerOrAdmin(request.getGroupId(), currentUserId); + if (!canSee) { + return success(null); + } + + // 转换并返回 + return success(CollUtil.getFirst(buildVOList(Collections.singletonList(request)))); + } + + /** + * 当前用户是否该群的有效群主 / 管理员 + */ + private boolean isGroupOwnerOrAdmin(Long groupId, Long userId) { + ImGroupMemberDO member = groupMemberService.getGroupMember(groupId, userId); + return member != null + && !CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus()) + && ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()); + } + + /** 申请记录列表批量转 VO + 关联回填用户 / 群信息 */ + private List buildVOList(List list) { + if (CollUtil.isEmpty(list)) { + return Collections.emptyList(); + } + // 1. 聚合 user / inviter 用户信息;convertSetByFlatMap 内部已过滤 null + Set userIds = convertSetByFlatMap(list, + request -> Stream.of(request.getUserId(), request.getInviterUserId())); + Map userMap = adminUserApi.getUserMap(userIds); + // 2. 聚合群信息(封禁 / 解散群也要回填,便于前端展示历史) + Set groupIds = convertSet(list, ImGroupRequestDO::getGroupId); + Map groupMap = groupService.getGroupMap(groupIds); + return convertList(list, request -> { + ImGroupRequestRespVO vo = BeanUtils.toBean(request, ImGroupRequestRespVO.class); + MapUtils.findAndThen(userMap, request.getUserId(), user -> + vo.setUserNickname(user.getNickname()).setUserAvatar(user.getAvatar())); + MapUtils.findAndThen(userMap, request.getInviterUserId(), user -> + vo.setInviterNickname(user.getNickname()).setInviterAvatar(user.getAvatar())); + MapUtils.findAndThen(groupMap, request.getGroupId(), group -> + vo.setGroupName(group.getName()).setGroupAvatar(group.getAvatar())); + return vo; + }); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminAddReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminAddReqVO.java new file mode 100644 index 000000000..e4183f0c7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminAddReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 添加群管理员 Request VO") +@Data +public class ImGroupAdminAddReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "目标用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[101, 102]") + @NotEmpty(message = "目标用户编号列表不能为空") + private List userIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminRemoveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminRemoveReqVO.java new file mode 100644 index 000000000..7c35dfb95 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminRemoveReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 撤销群管理员 Request VO") +@Data +public class ImGroupAdminRemoveReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "目标用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[101, 102]") + @NotEmpty(message = "目标用户编号列表不能为空") + private List userIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCancelMuteMemberReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCancelMuteMemberReqVO.java new file mode 100644 index 000000000..2e2243abb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCancelMuteMemberReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 取消成员禁言 Request VO") +@Data +public class ImGroupCancelMuteMemberReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "被取消禁言的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "用户编号不能为空") + private Long userId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java new file mode 100644 index 000000000..ec88708a8 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 群创建 Request VO") +@Data +public class ImGroupCreateReqVO { + + @Schema(description = "群名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道技术交流群") + @NotBlank(message = "群名称不能为空") + @Size(max = 64, message = "群名称长度不能超过 64") + private String name; + + @Schema(description = "初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)", example = "[1024, 2048]") + private List memberUserIds; + + @Schema(description = "进群是否需群主 / 管理员审批;不传默认 false 自由进群", example = "false") + private Boolean joinApproval; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMessagePinReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMessagePinReqVO.java new file mode 100644 index 000000000..49c14ffe9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMessagePinReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 管理后台 - 群消息置顶 / 取消置顶 Request VO + */ +@Schema(description = "管理后台 - 群消息置顶 / 取消置顶 Request VO") +@Data +public class ImGroupMessagePinReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9527") + @NotNull(message = "消息编号不能为空") + private Long messageId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteAllReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteAllReqVO.java new file mode 100644 index 000000000..0a23cb05c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteAllReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 全群禁言 / 取消 Request VO") +@Data +public class ImGroupMuteAllReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "是否全群禁言", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "是否全群禁言不能为空") + private Boolean mutedAll; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteMemberReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteMemberReqVO.java new file mode 100644 index 000000000..184764860 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteMemberReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 成员禁言 Request VO") +@Data +public class ImGroupMuteMemberReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "被禁言的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "用户编号不能为空") + private Long userId; + + @Schema(description = "禁言时长(秒),0 表示永久禁言", requiredMode = Schema.RequiredMode.REQUIRED, example = "600") + @NotNull(message = "禁言时长不能为空") + @Min(value = 0, message = "禁言时长不能小于 0 秒") + private Integer mutedSeconds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java new file mode 100644 index 000000000..fdb601639 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import lombok.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 群分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class ImGroupPageReqVO extends PageParam { + + @Schema(description = "群名称", example = "芋艿") + private String name; + + @Schema(description = "群主用户编号", example = "31460") + private Long ownerUserId; + + @Schema(description = "群公告") + private String notice; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java new file mode 100644 index 000000000..2dac1467b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 群 Response VO") +@Data +public class ImGroupRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") + private Long id; + + @Schema(description = "群名称", example = "芋艿") + private String name; + + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") + private Long ownerUserId; + + @Schema(description = "群头像") + private String avatar; + + @Schema(description = "群公告") + private String notice; + + @Schema(description = "是否封禁") + private Boolean banned; + + @Schema(description = "是否全群禁言") + private Boolean mutedAll; + + @Schema(description = "进群是否需群主 / 管理员审批", example = "false") + private Boolean joinApproval; + + @Schema(description = "封禁时间") + private LocalDateTime bannedTime; + + @Schema(description = "群状态", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "解散时间") + private LocalDateTime dissolvedTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") + private List pinnedMessages; + +} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java new file mode 100644 index 000000000..0275fd8ef --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import jakarta.validation.constraints.*; + +@Schema(description = "管理后台 - 群新增/修改 Request VO") +@Data +public class ImGroupSaveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") + private Long id; + + @Schema(description = "群名称", example = "芋艿") + private String name; + + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") + @NotNull(message = "群主用户编号不能为空") + private Long ownerUserId; + + @Schema(description = "群头像") + private String avatar; + + @Schema(description = "群公告") + private String notice; + +} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupTransferOwnerReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupTransferOwnerReqVO.java new file mode 100644 index 000000000..226d07e48 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupTransferOwnerReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 群主转让 Request VO") +@Data +public class ImGroupTransferOwnerReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "新群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "202") + @NotNull(message = "新群主用户编号不能为空") + private Long newOwnerUserId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java new file mode 100644 index 000000000..99bdfb358 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 群更新 Request VO") +@Data +public class ImGroupUpdateReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "群名称", example = "芋道技术交流群") + @Size(max = 64, message = "群名称长度不能超过 64") + private String name; + + @Schema(description = "群头像") + @Size(max = 512, message = "群头像长度不能超过 512") + private String avatar; + + @Schema(description = "群公告") + @Size(max = 2048, message = "群公告长度不能超过 2048") + private String notice; + + @Schema(description = "进群是否需群主 / 管理员审批", example = "true") + private Boolean joinApproval; + + @AssertTrue(message = "群名称不能为空") + @JsonIgnore + public boolean isNameValid() { + return name == null || StrUtil.isNotBlank(name); + } + + @AssertTrue(message = "群头像不能为空") + @JsonIgnore + public boolean isAvatarValid() { + return avatar == null || StrUtil.isNotBlank(avatar); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java new file mode 100644 index 000000000..a486190fb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 群成员邀请 Request VO") +@Data +public class ImGroupMemberCreateReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") + @NotNull(message = "用户编号不能为空") + private Long userId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java new file mode 100644 index 000000000..12cd09d37 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 群成员邀请 Request VO") +@Data +public class ImGroupMemberInviteReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "被邀请的用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]") + @NotEmpty(message = "被邀请的用户编号列表不能为空") + private List memberUserIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java new file mode 100644 index 000000000..9862b12d9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 群成员移除 Request VO") +@Data +public class ImGroupMemberRemoveReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "被移除的用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]") + @NotEmpty(message = "被移除的用户编号列表不能为空") + private List memberUserIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java new file mode 100644 index 000000000..ce095e134 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 群成员 Response VO") +@Data +public class ImGroupMemberRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17071") + private Long id; + + @Schema(description = "群编号", example = "13279") + private Long groupId; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") + private Long userId; + + @Schema(description = "组内显示名", example = "芋艿") + private String displayUserName; + + @Schema(description = "群备注", example = "核心群") + private String groupRemark; + + @Schema(description = "是否免打扰") + private Boolean silent; + + @Schema(description = "成员状态", example = "0") + private Integer status; + + @Schema(description = "成员角色", example = "3") + private Integer role; // 参见 ImGroupMemberRoleEnum 枚举类 + + @Schema(description = "入群时间") + private LocalDateTime joinTime; + + @Schema(description = "退群时间") + private LocalDateTime quitTime; + + @Schema(description = "禁言到期时间") + private LocalDateTime muteEndTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + // ========== 关联 AdminUser 的字段 ========== + + @Schema(description = "用户昵称", example = "芋道") + private String nickname; + + @Schema(description = "用户头像") + private String avatar; + +} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java new file mode 100644 index 000000000..8c2c6fd1f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.experimental.Accessors; + +@Schema(description = "管理后台 - 群成员更新 Request VO") +@Data +@Accessors(chain = true) +public class ImGroupMemberUpdateReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "群内昵称", example = "芋头") + private String displayUserName; + + @Schema(description = "群备注", example = "公司群") + private String groupRemark; + + @Schema(description = "是否免打扰") + private Boolean silent; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/request/ImGroupRequestApplyReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/request/ImGroupRequestApplyReqVO.java new file mode 100644 index 000000000..6f34a05c9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/request/ImGroupRequestApplyReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.request; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 加群申请发起 Request VO") +@Data +public class ImGroupRequestApplyReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "申请理由", example = "我是芋艿(一种食材)") + @Size(max = 255, message = "申请理由最多 255 个字符") + private String applyContent; + + @Schema(description = "加入来源", example = "1") + @InEnum(ImGroupAddSourceEnum.class) + private Integer addSource; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/request/ImGroupRequestRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/request/ImGroupRequestRespVO.java new file mode 100644 index 000000000..587ca8d0f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/request/ImGroupRequestRespVO.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 加群申请 Response VO") +@Data +public class ImGroupRequestRespVO { + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long groupId; + + @Schema(description = "申请人 / 被邀请人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + private Long userId; + + @Schema(description = "邀请人用户编号;NULL 表示用户主动申请", example = "200") + private Long inviterUserId; + + @Schema(description = "处理结果", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer handleResult; // 参见 ImGroupRequestHandleResultEnum 枚举 + + @Schema(description = "申请理由", example = "我想加入这个群") + private String applyContent; + + @Schema(description = "处理理由(拒绝时可选填)", example = "暂不通过") + private String handleContent; + + @Schema(description = "处理人用户编号", example = "31460") + private Long handleUserId; + + @Schema(description = "加入来源", example = "1") + private Integer addSource; // 参见 ImGroupAddSourceEnum 枚举 + + @Schema(description = "处理时间") + private LocalDateTime handleTime; + + @Schema(description = "申请创建时间") + private LocalDateTime createTime; + + // ========== 下面是聚合字段,方便前端显示 ========== + + @Schema(description = "申请人 / 被邀请人昵称(实时聚合自 AdminUser)", example = "芋道") + private String userNickname; + + @Schema(description = "申请人 / 被邀请人头像(实时聚合自 AdminUser)") + private String userAvatar; + + @Schema(description = "邀请人昵称(实时聚合自 AdminUser)", example = "老张") + private String inviterNickname; + + @Schema(description = "邀请人头像(实时聚合自 AdminUser)") + private String inviterAvatar; + + @Schema(description = "群名称(实时聚合自 ImGroup)", example = "芋道技术交流群") + private String groupName; + + @Schema(description = "群头像(实时聚合自 ImGroup)") + private String groupAvatar; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelManagerController.java new file mode 100644 index 000000000..2afc95ee2 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelManagerController.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IM 频道") +@RestController +@RequestMapping("/im/manager/channel") +@Validated +public class ImChannelManagerController { + + @Resource + private ImChannelService channelService; + + @PostMapping("/create") + @Operation(summary = "新增频道") + @PreAuthorize("@ss.hasPermission('im:manager:channel:create')") + public CommonResult createChannel(@Valid @RequestBody ImChannelSaveReqVO reqVO) { + return success(channelService.createChannel(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改频道") + @PreAuthorize("@ss.hasPermission('im:manager:channel:update')") + public CommonResult updateChannel(@Valid @RequestBody ImChannelSaveReqVO reqVO) { + channelService.updateChannel(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除频道") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel:delete')") + public CommonResult deleteChannel(@RequestParam("id") Long id) { + channelService.deleteChannel(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得频道分页") + @PreAuthorize("@ss.hasPermission('im:manager:channel:query')") + public CommonResult> getChannelPage(@Valid ImChannelPageReqVO pageReqVO) { + PageResult pageResult = channelService.getChannelPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, ImChannelRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获得频道详情") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel:query')") + public CommonResult getChannel(@RequestParam("id") Long id) { + ImChannelDO channel = channelService.getChannel(id); + return success(BeanUtils.toBean(channel, ImChannelRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得启用的频道精简列表;前端表单选择频道时调用") + public CommonResult> getSimpleChannelList() { + // TODO DONE @AI:getChannelListByStatus 统一命名 + List list = channelService.getChannelListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(BeanUtils.toBean(list, ImChannelRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMaterialManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMaterialManagerController.java new file mode 100644 index 000000000..e6e027143 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMaterialManagerController.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import cn.iocoder.yudao.module.im.service.channel.ImChannelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IM 频道素材") +@RestController +@RequestMapping("/im/manager/channel-material") +@Validated +public class ImChannelMaterialManagerController { + + @Resource + private ImChannelMaterialService channelMaterialService; + @Resource + private ImChannelService channelService; + + @PostMapping("/create") + @Operation(summary = "新增素材") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:create')") + public CommonResult createMaterial(@Valid @RequestBody ImChannelMaterialSaveReqVO reqVO) { + return success(channelMaterialService.createMaterial(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改素材") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:update')") + public CommonResult updateMaterial(@Valid @RequestBody ImChannelMaterialSaveReqVO reqVO) { + channelMaterialService.updateMaterial(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除素材") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:delete')") + public CommonResult deleteMaterial(@RequestParam("id") Long id) { + channelMaterialService.deleteMaterial(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得素材分页;含频道名回填") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:query')") + public CommonResult> getMaterialPage(@Valid ImChannelMaterialPageReqVO pageReqVO) { + PageResult pageResult = channelMaterialService.getMaterialPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 回填频道名 + List channels = channelService.getChannelList( + convertSet(pageResult.getList(), ImChannelMaterialDO::getChannelId)); + Map channelMap = convertMap(channels, ImChannelDO::getId); + return success(BeanUtils.toBean(pageResult, ImChannelMaterialRespVO.class, vo -> + MapUtils.findAndThen(channelMap, vo.getChannelId(), c -> vo.setChannelName(c.getName())))); + } + + @GetMapping("/get") + @Operation(summary = "获得素材详情(含富文本正文)") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:query')") + public CommonResult getMaterial(@RequestParam("id") Long id) { + ImChannelMaterialDO material = channelMaterialService.getMaterial(id); + return success(BeanUtils.toBean(material, ImChannelMaterialRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得指定频道下的素材精简列表;用于推送弹窗的素材下拉") + @Parameter(name = "channelId", description = "频道编号", required = true, example = "1") + public CommonResult> getSimpleMaterialList(@RequestParam("channelId") Long channelId) { + List list = channelMaterialService.getMaterialListByChannelId(channelId); + return success(BeanUtils.toBean(list, ImChannelMaterialRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelPageReqVO.java new file mode 100644 index 000000000..51ac09af9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelPageReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - IM 频道分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImChannelPageReqVO extends PageParam { + + @Schema(description = "频道业务码", example = "system_notice") + private String code; + + @Schema(description = "频道名称", example = "系统") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelRespVO.java new file mode 100644 index 000000000..59fed78af --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 频道 Response VO") +@Data +public class ImChannelRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "频道业务码", requiredMode = Schema.RequiredMode.REQUIRED, example = "system_notice") + private String code; + + @Schema(description = "频道名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "系统公告") + private String name; + + @Schema(description = "频道头像") + private String avatar; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelSaveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelSaveReqVO.java new file mode 100644 index 000000000..6e1b04283 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelSaveReqVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 频道新增 / 修改 Request VO") +@Data +public class ImChannelSaveReqVO { + + @Schema(description = "编号(修改时必填)", example = "1024") + private Long id; + + @Schema(description = "频道业务码;唯一", requiredMode = Schema.RequiredMode.REQUIRED, example = "system_notice") + @NotBlank(message = "频道编码不能为空") + @Size(max = 64, message = "频道编码长度不能超过 64") + @Pattern(regexp = "^[a-z][a-z0-9_]*$", message = "频道编码只能由小写字母 / 数字 / 下划线组成,且必须以字母开头") + private String code; + + @Schema(description = "频道名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "系统公告") + @NotBlank(message = "频道名称不能为空") + @Size(max = 64, message = "频道名称长度不能超过 64") + private String name; + + @Schema(description = "频道头像", example = "https://cdn.example.com/channel/system_notice.png") + @Size(max = 512, message = "头像长度不能超过 512") + private String avatar; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "排序不能为空") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + private Integer status; // 参见 CommonStatusEnum 枚举类(0 启用 / 1 禁用) + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialPageReqVO.java new file mode 100644 index 000000000..1bb834b3b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialPageReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 频道素材分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImChannelMaterialPageReqVO extends PageParam { + + @Schema(description = "频道编号", example = "1") + private Long channelId; + + @Schema(description = "内容类型", example = "1") + private Integer type; // 参见 ImChannelMaterialTypeEnum 枚举类 + + @Schema(description = "标题", example = "活动") + private String title; + + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialRespVO.java new file mode 100644 index 000000000..55aebed92 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialRespVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 频道素材 Response VO") +@Data +public class ImChannelMaterialRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long channelId; + + @Schema(description = "频道名称(关联查询填充)") + private String channelName; + + @Schema(description = "内容类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; // 参见 ImChannelMaterialTypeEnum 枚举类 + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED) + private String title; + + @Schema(description = "封面图") + private String coverUrl; + + @Schema(description = "摘要") + private String summary; + + @Schema(description = "正文;富文本 HTML") + private String content; + + @Schema(description = "跳转链接") + private String url; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialSaveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialSaveReqVO.java new file mode 100644 index 000000000..e30f35b91 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialSaveReqVO.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 频道素材新增 / 修改 Request VO") +@Data +public class ImChannelMaterialSaveReqVO { + + @Schema(description = "编号(修改时必填)", example = "1024") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "频道编号不能为空") + private Long channelId; + + @Schema(description = "内容类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "内容类型不能为空") + private Integer type; // 参见 ImChannelMaterialTypeEnum 枚举类 + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "双十一活动来啦") + @NotBlank(message = "标题不能为空") + @Size(max = 128, message = "标题长度不能超过 128") + private String title; + + @Schema(description = "封面图", example = "https://cdn.example.com/cover.png") + @Size(max = 512, message = "封面图长度不能超过 512") + private String coverUrl; + + @Schema(description = "摘要", example = "全场五折,戳详情看玩法") + @Size(max = 255, message = "摘要长度不能超过 255") + private String summary; + + @Schema(description = "正文;富文本 HTML") + private String content; + + @Schema(description = "跳转链接;为空表示走客户端内置详情页", example = "https://example.com/activity/123") + @Size(max = 512, message = "跳转链接长度不能超过 512") + private String url; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFacePackItemManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFacePackItemManagerController.java new file mode 100644 index 000000000..4a2543179 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFacePackItemManagerController.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO; +import cn.iocoder.yudao.module.im.service.face.ImFacePackItemService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IM 表情包项") +@RestController +@RequestMapping("/im/manager/face-pack-item") +@Validated +public class ImFacePackItemManagerController { + + @Resource + private ImFacePackItemService facePackItemService; + + @PostMapping("/create") + @Operation(summary = "新增表情") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:create')") + public CommonResult createFacePackItem(@Valid @RequestBody ImFacePackItemSaveReqVO reqVO) { + return success(facePackItemService.createFacePackItem(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改表情") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:update')") + public CommonResult updateFacePackItem(@Valid @RequestBody ImFacePackItemSaveReqVO reqVO) { + facePackItemService.updateFacePackItem(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除表情") + @Parameter(name = "id", description = "编号", required = true, example = "2048") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:delete')") + public CommonResult deleteFacePackItem(@RequestParam("id") Long id) { + facePackItemService.deleteFacePackItem(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除表情") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:delete')") + public CommonResult deleteFacePackItemList( + @RequestParam("ids") @Size(max = 100, message = "批量删除最多 100 条") List ids) { + facePackItemService.deleteFacePackItemList(ids); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得表情分页") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:query')") + public CommonResult> getFacePackItemPage(@Valid ImFacePackItemPageReqVO pageReqVO) { + PageResult pageResult = facePackItemService.getFacePackItemPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, ImFacePackItemRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获得表情详情") + @Parameter(name = "id", description = "编号", required = true, example = "2048") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:query')") + public CommonResult getFacePackItem(@RequestParam("id") Long id) { + ImFacePackItemDO item = facePackItemService.getFacePackItem(id); + return success(BeanUtils.toBean(item, ImFacePackItemRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFacePackManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFacePackManagerController.java new file mode 100644 index 000000000..ad2f8ff72 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFacePackManagerController.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import cn.iocoder.yudao.module.im.service.face.ImFacePackService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IM 表情包") +@RestController +@RequestMapping("/im/manager/face-pack") +@Validated +public class ImFacePackManagerController { + + @Resource + private ImFacePackService facePackService; + + @PostMapping("/create") + @Operation(summary = "新增表情包") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack:create')") + public CommonResult createFacePack(@Valid @RequestBody ImFacePackSaveReqVO reqVO) { + return success(facePackService.createFacePack(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改表情包") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack:update')") + public CommonResult updateFacePack(@Valid @RequestBody ImFacePackSaveReqVO reqVO) { + facePackService.updateFacePack(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除表情包") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack:delete')") + public CommonResult deleteFacePack(@RequestParam("id") Long id) { + facePackService.deleteFacePack(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除表情包") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('im:manager:face-pack:delete')") + public CommonResult deleteFacePackList(@RequestParam("ids") + @Size(max = 100, message = "批量删除最多 100 条") List ids) { + facePackService.deleteFacePackList(ids); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得表情包分页") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack:query')") + public CommonResult> getFacePackPage(@Valid ImFacePackPageReqVO pageReqVO) { + PageResult pageResult = facePackService.getFacePackPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, ImFacePackRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获得表情包详情") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:face-pack:query')") + public CommonResult getFacePack(@RequestParam("id") Long id) { + ImFacePackDO pack = facePackService.getFacePack(id); + return success(BeanUtils.toBean(pack, ImFacePackRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFaceUserItemManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFaceUserItemManagerController.java new file mode 100644 index 000000000..df4a06d6e --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFaceUserItemManagerController.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; +import cn.iocoder.yudao.module.im.service.face.ImFaceUserItemService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IM 用户表情") +@RestController +@RequestMapping("/im/manager/face-user-item") +@Validated +public class ImFaceUserItemManagerController { + + @Resource + private ImFaceUserItemService faceUserItemService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得用户表情分页") + @PreAuthorize("@ss.hasPermission('im:manager:face-user-item:query')") + public CommonResult> getFaceUserItemPage( + @Valid ImFaceUserItemManagerPageReqVO pageReqVO) { + PageResult pageResult = faceUserItemService.getFaceUserItemPage(pageReqVO); + // 关联回填用户昵称 + Map userMap = adminUserApi.getUserMap( + CollectionUtils.convertSet(pageResult.getList(), ImFaceUserItemDO::getUserId)); + List voList = CollectionUtils.convertList(pageResult.getList(), item -> { + ImFaceUserItemManagerRespVO vo = BeanUtils.toBean(item, ImFaceUserItemManagerRespVO.class); + AdminUserRespDTO user = userMap.get(item.getUserId()); + if (user != null) { + vo.setUserNickname(user.getNickname()); + } + return vo; + }); + return success(new PageResult<>(voList, pageResult.getTotal())); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除用户表情") + @Parameter(name = "id", description = "编号", required = true, example = "4096") + @PreAuthorize("@ss.hasPermission('im:manager:face-user-item:delete')") + public CommonResult deleteFaceUserItem(@RequestParam("id") Long id) { + faceUserItemService.deleteFaceUserItem(id); + return success(true); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemPageReqVO.java new file mode 100644 index 000000000..eefbbed95 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemPageReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - IM 表情包项分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImFacePackItemPageReqVO extends PageParam { + + @Schema(description = "所属表情包编号", example = "1024") + private Long packId; + + @Schema(description = "表情名,模糊匹配", example = "狗") + private String name; + + @Schema(description = "状态", example = "0") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; // 参见 CommonStatusEnum 枚举类 + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemRespVO.java new file mode 100644 index 000000000..817348f10 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemRespVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 表情包项 Response VO") +@Data +public class ImFacePackItemRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long id; + + @Schema(description = "所属表情包编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long packId; + + @Schema(description = "表情图 URL", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://cdn.example.com/face/pack/cat-001.png") + private String url; + + @Schema(description = "表情名", example = "狗头") + private String name; + + @Schema(description = "渲染宽度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer width; + + @Schema(description = "渲染高度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer height; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemSaveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemSaveReqVO.java new file mode 100644 index 000000000..de6842bea --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/item/ImFacePackItemSaveReqVO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 表情包项新增 / 修改 Request VO") +@Data +public class ImFacePackItemSaveReqVO { + + @Schema(description = "编号(修改时必填)", example = "2048") + private Long id; + + @Schema(description = "所属表情包编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "表情包编号不能为空") + private Long packId; + + @Schema(description = "表情图 URL", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://cdn.example.com/face/pack/cat-001.png") + @NotBlank(message = "表情图 URL 不能为空") + @Size(max = 512, message = "表情图 URL 长度不能超过 512") + private String url; + + @Schema(description = "表情名", example = "狗头") + @Size(max = 64, message = "表情名长度不能超过 64") + private String name; + + @Schema(description = "渲染宽度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + @NotNull(message = "渲染宽度不能为空") + @Min(value = 1, message = "渲染宽度不能小于 1 像素") + @Max(value = 2048, message = "渲染宽度不能大于 2048 像素") + private Integer width; + + @Schema(description = "渲染高度(像素)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + @NotNull(message = "渲染高度不能为空") + @Min(value = 1, message = "渲染高度不能小于 1 像素") + @Max(value = 2048, message = "渲染高度不能大于 2048 像素") + private Integer height; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "排序不能为空") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; // 参见 CommonStatusEnum 枚举类(0 启用 / 1 禁用) + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackPageReqVO.java new file mode 100644 index 000000000..e0220afb7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackPageReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 表情包分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImFacePackPageReqVO extends PageParam { + + @Schema(description = "表情包名称,模糊匹配", example = "猫") + private String name; + + @Schema(description = "状态", example = "0") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackRespVO.java new file mode 100644 index 000000000..bdc53d2d4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 表情包 Response VO") +@Data +public class ImFacePackRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "表情包名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "猫主子") + private String name; + + @Schema(description = "表情包图标", example = "https://cdn.example.com/face/pack/cat.png") + private String icon; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackSaveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackSaveReqVO.java new file mode 100644 index 000000000..86be43d35 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/pack/ImFacePackSaveReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 表情包新增 / 修改 Request VO") +@Data +public class ImFacePackSaveReqVO { + + @Schema(description = "编号(修改时必填)", example = "1024") + private Long id; + + @Schema(description = "表情包名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "猫主子") + @NotBlank(message = "表情包名称不能为空") + @Size(max = 64, message = "表情包名称长度不能超过 64") + private String name; + + @Schema(description = "表情包图标", example = "https://cdn.example.com/face/pack/cat.png") + @Size(max = 512, message = "图标长度不能超过 512") + private String icon; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "排序不能为空") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; // 参见 CommonStatusEnum 枚举类(0 启用 / 1 禁用) + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/useritem/ImFaceUserItemManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/useritem/ImFaceUserItemManagerPageReqVO.java new file mode 100644 index 000000000..dec8f1f39 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/useritem/ImFaceUserItemManagerPageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 用户表情分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImFaceUserItemManagerPageReqVO extends PageParam { + + @Schema(description = "所属用户编号", example = "1024") + private Long userId; + + @Schema(description = "表情名,模糊匹配", example = "狗") + private String name; + + @Schema(description = "添加时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/useritem/ImFaceUserItemManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/useritem/ImFaceUserItemManagerRespVO.java new file mode 100644 index 000000000..723fc48f7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/vo/useritem/ImFaceUserItemManagerRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 用户表情 Response VO") +@Data +public class ImFaceUserItemManagerRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096") + private Long id; + + @Schema(description = "所属用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "所属用户昵称", example = "张三") + private String userNickname; + + @Schema(description = "表情图 URL", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://cdn.example.com/face/user/abc.gif") + private String url; + + @Schema(description = "表情名", example = "狗头") + private String name; + + @Schema(description = "渲染宽度(像素)", example = "200") + private Integer width; + + @Schema(description = "渲染高度(像素)", example = "200") + private Integer height; + + @Schema(description = "添加时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/ImFriendManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/ImFriendManagerController.java new file mode 100644 index 000000000..b2f6ae709 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/ImFriendManagerController.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.friend; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; + +@Tag(name = "管理后台 - IM 好友管理") +@RestController +@RequestMapping("/im/manager/friend") +@Validated +public class ImFriendManagerController { + + @Resource + private ImFriendService friendService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得好友关系分页") + @PreAuthorize("@ss.hasPermission('im:manager:friend:query')") + public CommonResult> getFriendPage( + @Valid ImFriendManagerPageReqVO pageReqVO) { + // 1. 分页查询 + PageResult pageResult = friendService.getFriendPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 2.1 一次性批量查询用户 + 好友的昵称 + Set userIds = convertSetByFlatMap(pageResult.getList(), + f -> Stream.of(f.getUserId(), f.getFriendUserId())); + Map userMap = adminUserApi.getUserMap(userIds); + // 2.2 转换为 VO,填充昵称 + return success(BeanUtils.toBean(pageResult, ImFriendManagerRespVO.class, vo -> { + MapUtils.findAndThen(userMap, vo.getUserId(), + user -> vo.setUserNickname(user.getNickname())); + MapUtils.findAndThen(userMap, vo.getFriendUserId(), + user -> vo.setFriendNickname(user.getNickname())); + })); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/ImFriendRequestManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/ImFriendRequestManagerController.java new file mode 100644 index 000000000..eac0c9a9f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/ImFriendRequestManagerController.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.friend; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendRequestManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.service.friend.ImFriendRequestService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; + +@Tag(name = "管理后台 - IM 好友申请管理") +@RestController +@RequestMapping("/im/manager/friend-request") +@Validated +public class ImFriendRequestManagerController { + + @Resource + private ImFriendRequestService friendRequestService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得好友申请分页") + @PreAuthorize("@ss.hasPermission('im:manager:friend-request:query')") + public CommonResult> getFriendRequestPage( + @Valid ImFriendRequestManagerPageReqVO pageReqVO) { + // 1. 分页查询 + PageResult pageResult = friendRequestService.getFriendRequestPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + + // 2.1 一次性批量查询发起方 + 接收方的昵称 + Set userIds = convertSetByFlatMap(pageResult.getList(), + request -> Stream.of(request.getFromUserId(), request.getToUserId())); + Map userMap = adminUserApi.getUserMap(userIds); + // 2.2 转换为 VO,填充昵称 + return success(BeanUtils.toBean(pageResult, ImFriendRequestManagerRespVO.class, vo -> { + MapUtils.findAndThen(userMap, vo.getFromUserId(), + user -> vo.setFromNickname(user.getNickname())); + MapUtils.findAndThen(userMap, vo.getToUserId(), + user -> vo.setToNickname(user.getNickname())); + })); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendManagerPageReqVO.java new file mode 100644 index 000000000..54b5dcce1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendManagerPageReqVO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 好友关系分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImFriendManagerPageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "1024") + private Long userId; + + @Schema(description = "好友用户编号", example = "2048") + private Long friendUserId; + + @Schema(description = "好友状态", example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "是否免打扰", example = "false") + private Boolean silent; + + @Schema(description = "添加好友时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] addTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendManagerRespVO.java new file mode 100644 index 000000000..6664f2435 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendManagerRespVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 好友关系 Response VO") +@Data +public class ImFriendManagerRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "用户昵称", example = "张三") + private String userNickname; + + @Schema(description = "好友用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long friendUserId; + + @Schema(description = "好友昵称", example = "李四") + private String friendNickname; + + @Schema(description = "好友展示备注") + private String displayName; + + @Schema(description = "添加来源", example = "1") + private Integer addSource; // 参见 ImFriendAddSourceEnum 枚举类 + + @Schema(description = "是否免打扰", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean silent; + + @Schema(description = "是否置顶联系人", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean pinned; + + @Schema(description = "是否拉黑", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean blocked; + + @Schema(description = "好友状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "添加好友时间") + private LocalDateTime addTime; + + @Schema(description = "删除好友时间") + private LocalDateTime deleteTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendRequestManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendRequestManagerPageReqVO.java new file mode 100644 index 000000000..a07890928 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendRequestManagerPageReqVO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 好友申请分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImFriendRequestManagerPageReqVO extends PageParam { + + @Schema(description = "发起方用户编号", example = "1024") + private Long fromUserId; + + @Schema(description = "接收方用户编号", example = "2048") + private Long toUserId; + + @Schema(description = "处理结果", example = "0") + private Integer handleResult; // 参见 ImFriendRequestHandleResultEnum 枚举类 + + @Schema(description = "添加来源", example = "1") + private Integer addSource; // 参见 ImFriendAddSourceEnum 枚举类 + + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendRequestManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendRequestManagerRespVO.java new file mode 100644 index 000000000..9fa9d4d45 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/friend/vo/ImFriendRequestManagerRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 好友申请 Response VO") +@Data +public class ImFriendRequestManagerRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "发起方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long fromUserId; + + @Schema(description = "发起方昵称", example = "张三") + private String fromNickname; + + @Schema(description = "接收方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long toUserId; + + @Schema(description = "接收方昵称", example = "李四") + private String toNickname; + + @Schema(description = "申请理由", example = "我是芋艿") + private String applyContent; + + @Schema(description = "发起方对接收方的备注") + private String displayName; + + @Schema(description = "添加来源", example = "1") + private Integer addSource; // 参见 ImFriendAddSourceEnum 枚举类 + + @Schema(description = "处理结果", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer handleResult; // 参见 ImFriendRequestHandleResultEnum 枚举类 + + @Schema(description = "处理理由", example = "暂不通过") + private String handleContent; + + @Schema(description = "处理时间") + private LocalDateTime handleTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java new file mode 100644 index 000000000..46fd4eb11 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerBanReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 群聊管理") +@RestController +@RequestMapping("/im/manager/group") +@Validated +public class ImGroupManagerController { + + @Resource + private ImGroupService groupService; + @Resource + private ImGroupMemberService groupMemberService; + + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得群分页") + @PreAuthorize("@ss.hasPermission('im:manager:group:query')") + public CommonResult> getGroupPage(@Valid ImGroupManagerPageReqVO pageReqVO) { + // 1. 分页查询群 + PageResult pageResult = groupService.getGroupPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 2.1 批量查询相关数据 + Map userMap = adminUserApi.getUserMap( + convertSet(pageResult.getList(), ImGroupDO::getOwnerUserId)); + Map memberCountMap = groupMemberService.getActiveMemberCountMap( + convertSet(pageResult.getList(), ImGroupDO::getId)); + // 2.2 转换为 VO,填充群主昵称、群成员数量 + return success(BeanUtils.toBean(pageResult, ImGroupManagerRespVO.class, vo -> { + MapUtils.findAndThen(userMap, vo.getOwnerUserId(), + user -> vo.setOwnerNickname(user.getNickname())); + vo.setMemberCount(memberCountMap.getOrDefault(vo.getId(), 0L).intValue()); + })); + } + + @GetMapping("/get") + @Operation(summary = "获得群详情") + @Parameter(name = "id", description = "群编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:group:query')") + public CommonResult getGroup(@RequestParam("id") Long id) { + ImGroupDO group = groupService.getGroup(id); + return success(BeanUtils.toBean(group, ImGroupManagerRespVO.class)); + } + + @PutMapping("/ban") + @Operation(summary = "封禁群") + @PreAuthorize("@ss.hasPermission('im:manager:group:ban')") + public CommonResult banGroup(@Valid @RequestBody ImGroupManagerBanReqVO reqVO) { + groupService.banGroup(getLoginUserId(), reqVO); + return success(true); + } + + @PutMapping("/unban") + @Operation(summary = "解封群") + @Parameter(name = "id", description = "群编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:group:ban')") + public CommonResult unbanGroup(@RequestParam("id") Long id) { + groupService.unbanGroup(getLoginUserId(), id); + return success(true); + } + + @DeleteMapping("/dissolve") + @Operation(summary = "解散群") + @Parameter(name = "id", description = "群编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:group:dissolve')") + public CommonResult dissolveGroup(@RequestParam("id") Long id) { + groupService.dissolveGroupByManager(getLoginUserId(), id); + return success(true); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupMemberManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupMemberManagerController.java new file mode 100644 index 000000000..e1fa1f328 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupMemberManagerController.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.member.ImGroupMemberManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IM 群成员管理") +@RestController +@RequestMapping("/im/manager/group/member") +@Validated +public class ImGroupMemberManagerController { + + @Resource + private ImGroupMemberService groupMemberService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/list") + @Operation(summary = "获得群成员列表(含已退群成员,由前端按需过滤)") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:group:query')") + public CommonResult> getGroupMemberList(@RequestParam("groupId") Long groupId) { + // 1. 查询群全部成员(含已退群) + List members = groupMemberService.getGroupMemberListByGroupId(groupId); + if (CollUtil.isEmpty(members)) { + return success(Collections.emptyList()); + } + // 2.1 批量查询用户信息 + Map userMap = adminUserApi.getUserMap( + convertSet(members, ImGroupMemberDO::getUserId)); + // 2.2 转换为 VO,填充昵称、头像 + return success(BeanUtils.toBean(members, ImGroupMemberManagerRespVO.class, vo -> + MapUtils.findAndThen(userMap, vo.getUserId(), user -> + vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())))); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupRequestManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupRequestManagerController.java new file mode 100644 index 000000000..801031240 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupRequestManagerController.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupRequestManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupRequestService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; + +@Tag(name = "管理后台 - IM 加群申请管理") +@RestController +@RequestMapping("/im/manager/group-request") +@Validated +public class ImGroupRequestManagerController { + + @Resource + private ImGroupRequestService groupRequestService; + @Resource + private ImGroupService groupService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得加群申请分页") + @PreAuthorize("@ss.hasPermission('im:manager:group-request:query')") + public CommonResult> getGroupRequestPage( + @Valid ImGroupRequestManagerPageReqVO pageReqVO) { + // 1. 分页查询 + PageResult pageResult = groupRequestService.getGroupRequestPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + + // 2.1 批量聚合 user / inviter / handler 用户昵称 + Set userIds = convertSetByFlatMap(pageResult.getList(), + request -> Stream.of(request.getUserId(), request.getInviterUserId(), request.getHandleUserId()) + .filter(Objects::nonNull)); + Map userMap = adminUserApi.getUserMap(userIds); + // 2.2 批量聚合群信息(取群名) + Set groupIds = convertSet(pageResult.getList(), ImGroupRequestDO::getGroupId); + Map groupMap = groupService.getGroupMap(groupIds); + return success(BeanUtils.toBean(pageResult, ImGroupRequestManagerRespVO.class, vo -> { + MapUtils.findAndThen(userMap, vo.getUserId(), user -> vo.setUserNickname(user.getNickname())); + MapUtils.findAndThen(userMap, vo.getInviterUserId(), user -> vo.setInviterNickname(user.getNickname())); + MapUtils.findAndThen(userMap, vo.getHandleUserId(), user -> vo.setHandleNickname(user.getNickname())); + MapUtils.findAndThen(groupMap, vo.getGroupId(), group -> vo.setGroupName(group.getName())); + })); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerBanReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerBanReqVO.java new file mode 100644 index 000000000..a4fb81961 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerBanReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 群聊封禁 Request VO") +@Data +public class ImGroupManagerBanReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "封禁原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "违规内容") + @NotBlank(message = "封禁原因不能为空") + @Size(max = 200, message = "封禁原因长度不能超过 200") + private String reason; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerPageReqVO.java new file mode 100644 index 000000000..e7da76b1b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerPageReqVO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 群聊分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImGroupManagerPageReqVO extends PageParam { + + @Schema(description = "群名称,模糊匹配", example = "技术交流群") + private String name; + + @Schema(description = "群主用户编号", example = "1024") + private Long ownerUserId; + + @Schema(description = "群状态", example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类(0 正常 / 1 已解散) + + @Schema(description = "是否封禁", example = "false") + private Boolean banned; + + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerRespVO.java new file mode 100644 index 000000000..c1de79232 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupManagerRespVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 群聊 Response VO") +@Data +public class ImGroupManagerRespVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "群名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "技术交流群") + private String name; + + @Schema(description = "群头像") + private String avatar; + + @Schema(description = "群公告") + private String notice; + + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long ownerUserId; + + @Schema(description = "群主昵称", example = "张三") + private String ownerNickname; + + @Schema(description = "群成员数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Integer memberCount; + + @Schema(description = "群状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "解散时间") + private LocalDateTime dissolvedTime; + + @Schema(description = "是否封禁", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean banned; + + @Schema(description = "是否全群禁言") + private Boolean mutedAll; + + @Schema(description = "封禁原因") + private String bannedReason; + + @Schema(description = "封禁时间") + private LocalDateTime bannedTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupRequestManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupRequestManagerPageReqVO.java new file mode 100644 index 000000000..e51d06685 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupRequestManagerPageReqVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 加群申请分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImGroupRequestManagerPageReqVO extends PageParam { + + @Schema(description = "群编号", example = "1024") + private Long groupId; + + @Schema(description = "申请人 / 被邀请人用户编号", example = "2048") + private Long userId; + + @Schema(description = "邀请人用户编号", example = "31460") + private Long inviterUserId; + + @Schema(description = "处理结果", example = "0") + private Integer handleResult; // 参见 ImGroupRequestHandleResultEnum 枚举类 + + @Schema(description = "加入来源", example = "1") + private Integer addSource; // 参见 ImGroupAddSourceEnum 枚举类 + + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupRequestManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupRequestManagerRespVO.java new file mode 100644 index 000000000..6f8684e90 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/ImGroupRequestManagerRespVO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 加群申请 Response VO") +@Data +public class ImGroupRequestManagerRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long groupId; + + @Schema(description = "群名称", example = "芋道技术交流群") + private String groupName; + + @Schema(description = "申请人 / 被邀请人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + private Long userId; + + @Schema(description = "申请人 / 被邀请人昵称", example = "张三") + private String userNickname; + + @Schema(description = "邀请人用户编号;NULL 表示用户主动申请", example = "200") + private Long inviterUserId; + + @Schema(description = "邀请人昵称", example = "老张") + private String inviterNickname; + + @Schema(description = "申请理由", example = "我想加入这个群") + private String applyContent; + + @Schema(description = "加入来源", example = "1") + private Integer addSource; // 参见 ImGroupAddSourceEnum 枚举类 + + @Schema(description = "处理结果", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer handleResult; // 参见 ImGroupRequestHandleResultEnum 枚举类 + + @Schema(description = "处理人用户编号", example = "31460") + private Long handleUserId; + + @Schema(description = "处理人昵称", example = "管理员") + private String handleNickname; + + @Schema(description = "处理理由", example = "暂不通过") + private String handleContent; + + @Schema(description = "处理时间") + private LocalDateTime handleTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/member/ImGroupMemberManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/member/ImGroupMemberManagerRespVO.java new file mode 100644 index 000000000..b5d1346d9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/vo/member/ImGroupMemberManagerRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 群成员 Response VO") +@Data +public class ImGroupMemberManagerRespVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "用户昵称", example = "张三") + private String nickname; + + @Schema(description = "用户头像") + private String avatar; + + @Schema(description = "组内显示名", example = "三哥") + private String displayUserName; + + @Schema(description = "群备注", example = "技术交流群") + private String groupRemark; + + @Schema(description = "是否免打扰", example = "false") + private Boolean silent; + + @Schema(description = "成员状态", example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "成员角色;1=群主 2=管理员 3=普通成员", example = "3") + private Integer role; // 参见 ImGroupMemberRoleEnum 枚举类 + + @Schema(description = "入群时间") + private LocalDateTime joinTime; + + @Schema(description = "退群时间") + private LocalDateTime quitTime; + + @Schema(description = "禁言到期时间") + private LocalDateTime muteEndTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImChannelMessageManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImChannelMessageManagerController.java new file mode 100644 index 000000000..36a3b60bc --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImChannelMessageManagerController.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessagePageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessageRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import cn.iocoder.yudao.module.im.service.message.ImChannelMessageService; +import cn.iocoder.yudao.module.im.service.channel.ImChannelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IM 频道消息") +@RestController +@RequestMapping("/im/manager/channel-message") +@Validated +public class ImChannelMessageManagerController { + + @Resource + private ImChannelMessageService channelMessageService; + @Resource + private ImChannelService channelService; + @Resource + private ImChannelMaterialService channelMaterialService; + + @PostMapping("/send") + @Operation(summary = "立即推送频道消息") + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:send')") + public CommonResult sendMessage(@Valid @RequestBody ImChannelMessageSendReqVO reqVO) { + return success(channelMessageService.sendMessage(reqVO)); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除频道消息") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:delete')") + public CommonResult deleteMessage(@RequestParam("id") Long id) { + channelMessageService.deleteMessage(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得频道消息分页;回填频道名 / 素材标题") + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:query')") + public CommonResult> getMessagePage(@Valid ImChannelMessagePageReqVO pageReqVO) { + PageResult pageResult = channelMessageService.getMessagePage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 批量查询频道和素材,并回填频道名 / 素材标题 + Map channelMap = channelService.getChannelMap( + convertSet(pageResult.getList(), ImChannelMessageDO::getChannelId)); + Map materialMap = channelMaterialService.getMaterialMap( + convertSet(pageResult.getList(), ImChannelMessageDO::getMaterialId)); + return success(BeanUtils.toBean(pageResult, ImChannelMessageRespVO.class, vo -> { + MapUtils.findAndThen(channelMap, vo.getChannelId(), c -> vo.setChannelName(c.getName())); + MapUtils.findAndThen(materialMap, vo.getMaterialId(), + material -> vo.setMaterialTitle(material.getTitle()).setMaterialCoverUrl(material.getCoverUrl())); + })); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImGroupMessageManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImGroupMessageManagerController.java new file mode 100644 index 000000000..da49a9c16 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImGroupMessageManagerController.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.AT_USER_ID_ALL; + +@Tag(name = "管理后台 - IM 群聊消息") +@RestController +@RequestMapping("/im/manager/message/group") +@Validated +public class ImGroupMessageManagerController { + + @Resource + private ImGroupMessageService groupMessageService; + @Resource + private ImGroupService groupService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得群聊消息分页") + @PreAuthorize("@ss.hasPermission('im:manager:message:query')") + public CommonResult> getGroupMessagePage( + @Valid ImGroupMessageManagerPageReqVO pageReqVO) { + // 1. 分页查询 + PageResult pageResult = groupMessageService.getGroupMessagePage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 2.1 批量查询群名称、发送人昵称、@ 用户昵称(-1 表示 @所有人,跳过查询,由前端判断渲染) + Map groupMap = groupService.getGroupMap( + convertSet(pageResult.getList(), ImGroupMessageDO::getGroupId)); + Set userIds = convertSetByFlatMap(pageResult.getList(), m -> Stream.concat( + Stream.of(m.getSenderId()), + CollUtil.emptyIfNull(m.getAtUserIds()).stream() + .filter(id -> !Objects.equals(id, AT_USER_ID_ALL)))); + Map userMap = adminUserApi.getUserMap(userIds); + // 2.2 转换为 VO,填充群名 / 发送人昵称 / @ 用户昵称(-1 位置留 null,由前端展示「@所有人」) + return success(BeanUtils.toBean(pageResult, ImGroupMessageManagerRespVO.class, vo -> { + MapUtils.findAndThen(groupMap, vo.getGroupId(), group -> vo.setGroupName(group.getName())); + MapUtils.findAndThen(userMap, vo.getSenderId(), user -> vo.setSenderNickname(user.getNickname())); + if (CollUtil.isNotEmpty(vo.getAtUserIds())) { + vo.setAtUserNicknames(convertList(vo.getAtUserIds(), id -> { + AdminUserRespDTO user = userMap.get(id); + return user != null ? user.getNickname() : null; + })); + } + })); + } + + @GetMapping("/get") + @Operation(summary = "获得群聊消息详情") + @Parameter(name = "id", description = "消息编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:message:query')") + public CommonResult getGroupMessage(@RequestParam("id") Long id) { + ImGroupMessageDO message = groupMessageService.getGroupMessage(id); + return success(BeanUtils.toBean(message, ImGroupMessageManagerRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImPrivateMessageManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImPrivateMessageManagerController.java new file mode 100644 index 000000000..8db5b698b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImPrivateMessageManagerController.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; + +@Tag(name = "管理后台 - IM 私聊消息") +@RestController +@RequestMapping("/im/manager/message/private") +@Validated +public class ImPrivateMessageManagerController { + + @Resource + private ImPrivateMessageService privateMessageService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得私聊消息分页") + @PreAuthorize("@ss.hasPermission('im:manager:message:query')") + public CommonResult> getPrivateMessagePage( + @Valid ImPrivateMessageManagerPageReqVO pageReqVO) { + // 1. 分页查询 + PageResult pageResult = privateMessageService.getPrivateMessagePage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 2.1 一次性批量查询发送人 + 接收人昵称 + Map userMap = adminUserApi.getUserMap(convertSetByFlatMap(pageResult.getList(), + m -> Stream.of(m.getSenderId(), m.getReceiverId()))); + // 2.2 转换为 VO,填充昵称 + return success(BeanUtils.toBean(pageResult, ImPrivateMessageManagerRespVO.class, vo -> { + MapUtils.findAndThen(userMap, vo.getSenderId(), user -> vo.setSenderNickname(user.getNickname())); + MapUtils.findAndThen(userMap, vo.getReceiverId(), user -> vo.setReceiverNickname(user.getNickname())); + })); + } + + @GetMapping("/get") + @Operation(summary = "获得私聊消息详情") + @Parameter(name = "id", description = "消息编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:message:query')") + public CommonResult getPrivateMessage(@RequestParam("id") Long id) { + ImPrivateMessageDO message = privateMessageService.getPrivateMessage(id); + return success(BeanUtils.toBean(message, ImPrivateMessageManagerRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessagePageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessagePageReqVO.java new file mode 100644 index 000000000..48e1a44c5 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessagePageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 频道消息分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImChannelMessagePageReqVO extends PageParam { + + @Schema(description = "频道编号", example = "1") + private Long channelId; + + @Schema(description = "素材编号", example = "1024") + private Long materialId; + + @Schema(description = "发送时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessageRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessageRespVO.java new file mode 100644 index 000000000..9f76ed281 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessageRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - IM 频道消息 Response VO") +@Data +public class ImChannelMessageRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long channelId; + + @Schema(description = "频道名称(关联查询填充)") + private String channelName; + + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long materialId; + + @Schema(description = "素材标题(关联查询填充)") + private String materialTitle; + + @Schema(description = "素材封面 URL(关联查询填充)") + private String materialCoverUrl; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "消息内容;payload JSON 快照") + private String content; + + @Schema(description = "接收人编号列表;为空表示全员") + private List receiverUserIds; + + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessageSendReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessageSendReqVO.java new file mode 100644 index 000000000..a69ff16c3 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/channel/ImChannelMessageSendReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - IM 频道消息推送 Request VO") +@Data +public class ImChannelMessageSendReqVO { + + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "素材编号不能为空") + private Long materialId; + + @Schema(description = "接收人编号列表;为空表示全员", example = "[1024, 2048]") + private List receiverUserIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerPageReqVO.java new file mode 100644 index 000000000..86d236d15 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerPageReqVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 群聊消息分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImGroupMessageManagerPageReqVO extends PageParam { + + @Schema(description = "群编号", example = "1024") + private Long groupId; + + @Schema(description = "发送人编号", example = "1024") + private Long senderId; + + @Schema(description = "消息类型", example = "1") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "消息内容", example = "你好") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; // 参见 ImMessageStatusEnum 枚举类 + + @Schema(description = "发送时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java new file mode 100644 index 000000000..836b3b329 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - IM 群聊消息 Response VO") +@Data +public class ImGroupMessageManagerRespVO { + + @Schema(description = "消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "客户端消息编号", example = "c-uuid-xxx") + private String clientMessageId; + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long groupId; + + @Schema(description = "群名称", example = "技术交流群") + private String groupName; + + @Schema(description = "发送人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long senderId; + + @Schema(description = "发送人昵称", example = "张三") + private String senderNickname; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "消息内容(JSON 格式)", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "消息状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 ImMessageStatusEnum 枚举类 + + @Schema(description = "@ 目标用户编号列表(-1 表示 @所有人)") + private List atUserIds; + @Schema(description = "@ 目标用户昵称列表(-1 位置为 null,前端根据 atUserIds 自行展示「@所有人」)") + private List atUserNicknames; + + @Schema(description = "回执状态", example = "0") + private Integer receiptStatus; // 参见 ImGroupMessageReceiptStatusEnum 枚举类 + + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime sendTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerPageReqVO.java new file mode 100644 index 000000000..e6a72da76 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerPageReqVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 私聊消息分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImPrivateMessageManagerPageReqVO extends PageParam { + + @Schema(description = "发送人编号", example = "1024") + private Long senderId; + + @Schema(description = "接收人编号", example = "2048") + private Long receiverId; + + @Schema(description = "消息类型", example = "1") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "消息内容", example = "你好") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; // 参见 ImMessageStatusEnum 枚举类 + + @Schema(description = "发送时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java new file mode 100644 index 000000000..c467a0a87 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 私聊消息 Response VO") +@Data +public class ImPrivateMessageManagerRespVO { + + @Schema(description = "消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "客户端消息编号", example = "c-uuid-xxx") + private String clientMessageId; + + @Schema(description = "发送人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long senderId; + + @Schema(description = "发送人昵称", example = "张三") + private String senderNickname; + + @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long receiverId; + + @Schema(description = "接收人昵称", example = "李四") + private String receiverNickname; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "消息内容(JSON 格式)", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "消息状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 ImMessageStatusEnum 枚举类 + + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime sendTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/ImRtcCallManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/ImRtcCallManagerController.java new file mode 100644 index 000000000..417e58f0c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/ImRtcCallManagerController.java @@ -0,0 +1,113 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.rtc; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo.ImRtcCallManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo.ImRtcCallManagerRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo.ImRtcParticipantManagerRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IM 通话记录") +@RestController +@RequestMapping("/im/manager/rtc") +@Validated +public class ImRtcCallManagerController { + + @Resource + private ImRtcCallService rtcCallService; + @Resource + private ImGroupService groupService; + + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/page") + @Operation(summary = "获得通话记录分页") + @PreAuthorize("@ss.hasPermission('im:manager:rtc:query')") + public CommonResult> getCallPage(@Valid ImRtcCallManagerPageReqVO pageReqVO) { + PageResult pageResult = rtcCallService.getCallPage(pageReqVO); + return success(buildCallRespVOPage(pageResult)); + } + + @GetMapping("/get") + @Operation(summary = "获得通话记录详情") + @Parameter(name = "id", description = "通话编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:rtc:query')") + public CommonResult getCall(@RequestParam("id") Long id) { + ImRtcCallDO call = rtcCallService.getCall(id); + if (call == null) { + return success(null); + } + return success(CollUtil.getFirst(buildCallRespVOList(Collections.singletonList(call)))); + } + + @GetMapping("/participant-list") + @Operation(summary = "获得通话参与者列表") + @Parameter(name = "id", description = "通话编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:rtc:query')") + public CommonResult> getCallParticipantList(@RequestParam("id") Long id) { + List participants = rtcCallService.getCallParticipantListByCallId(id); + if (CollUtil.isEmpty(participants)) { + return success(Collections.emptyList()); + } + // 查询用户信息 + Map userMap = adminUserApi.getUserMap( + convertSet(participants, ImRtcParticipantDO::getUserId)); + // 组装返回 + return success(BeanUtils.toBean(participants, ImRtcParticipantManagerRespVO.class, vo -> + MapUtils.findAndThen(userMap, vo.getUserId(), + user -> vo.setUserNickname(user.getNickname())))); + } + + // ========== 私有方法:VO 组装 ========== + + private PageResult buildCallRespVOPage(PageResult pageResult) { + if (CollUtil.isEmpty(pageResult.getList())) { + return PageResult.empty(pageResult.getTotal()); + } + return new PageResult<>(buildCallRespVOList(pageResult.getList()), pageResult.getTotal()); + } + + private List buildCallRespVOList(List calls) { + // 查询用户信息 + Map userMap = adminUserApi.getUserMap( + convertSet(calls, ImRtcCallDO::getInviterUserId)); + Map groupMap = groupService.getGroupMap( + convertSet(calls, ImRtcCallDO::getGroupId)); + // 组装返回 + return BeanUtils.toBean(calls, ImRtcCallManagerRespVO.class, vo -> { + MapUtils.findAndThen(userMap, vo.getInviterUserId(), + user -> vo.setInviterNickname(user.getNickname())); + MapUtils.findAndThen(groupMap, vo.getGroupId(), + group -> vo.setGroupName(group.getName())); + }); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcCallManagerPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcCallManagerPageReqVO.java new file mode 100644 index 000000000..f7878fa02 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcCallManagerPageReqVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 通话记录分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImRtcCallManagerPageReqVO extends PageParam { + + @Schema(description = "发起人用户编号", example = "1024") + private Long inviterUserId; + + @Schema(description = "会话类型", example = "1") + private Integer conversationType; // 参见 ImConversationTypeEnum 枚举类 + + @Schema(description = "媒体类型", example = "1") + private Integer mediaType; // 参见 ImRtcCallMediaTypeEnum 枚举类 + + @Schema(description = "通话状态", example = "10") + private Integer status; // 参见 ImRtcCallStatusEnum 枚举类 + + @Schema(description = "结束原因", example = "1") + private Integer endReason; // 参见 ImRtcCallEndReasonEnum 枚举类 + + @Schema(description = "发起时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] startTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcCallManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcCallManagerRespVO.java new file mode 100644 index 000000000..0151581de --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcCallManagerRespVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 通话记录 Response VO") +@Data +public class ImRtcCallManagerRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "业务通话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") + private String room; + + @Schema(description = "会话类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer conversationType; // 参见 ImConversationTypeEnum 枚举类 + + @Schema(description = "媒体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer mediaType; // 参见 ImRtcCallMediaTypeEnum 枚举类 + + @Schema(description = "发起人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long inviterUserId; + + @Schema(description = "发起人昵称", example = "张三") + private String inviterNickname; + + @Schema(description = "群编号;私聊为空", example = "999") + private Long groupId; + + @Schema(description = "群名称;私聊为空", example = "测试群") + private String groupName; + + @Schema(description = "通话状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer status; // 参见 ImRtcCallStatusEnum 枚举类 + + @Schema(description = "结束原因", example = "1") + private Integer endReason; // 参见 ImRtcCallEndReasonEnum 枚举类 + + @Schema(description = "发起时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime startTime; + + @Schema(description = "接通时间;未接通为空") + private LocalDateTime acceptTime; + + @Schema(description = "结束时间;未结束为空") + private LocalDateTime endTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcParticipantManagerRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcParticipantManagerRespVO.java new file mode 100644 index 000000000..a990577db --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/rtc/vo/ImRtcParticipantManagerRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 通话参与者 Response VO") +@Data +public class ImRtcParticipantManagerRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "通话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long callId; + + @Schema(description = "参与者用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "参与者昵称", example = "张三") + private String userNickname; + + @Schema(description = "参与角色", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer role; // 参见 ImRtcParticipantRoleEnum 枚举类 + + @Schema(description = "参与状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + private Integer status; // 参见 ImRtcParticipantStatusEnum 枚举类 + + @Schema(description = "被邀请时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime inviteTime; + + @Schema(description = "接听时间;未接听为空") + private LocalDateTime acceptTime; + + @Schema(description = "离开时间;未加入为空") + private LocalDateTime leaveTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/ImSensitiveWordManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/ImSensitiveWordManagerController.java new file mode 100644 index 000000000..d69248d34 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/ImSensitiveWordManagerController.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IM 敏感词") +@RestController +@RequestMapping("/im/manager/sensitive-word") +@Validated +public class ImSensitiveWordManagerController { + + @Resource + private ImSensitiveWordService sensitiveWordService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "新增敏感词") + @PreAuthorize("@ss.hasPermission('im:manager:sensitive-word:create')") + public CommonResult createSensitiveWord(@Valid @RequestBody ImSensitiveWordSaveReqVO reqVO) { + return success(sensitiveWordService.createSensitiveWord(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改敏感词") + @PreAuthorize("@ss.hasPermission('im:manager:sensitive-word:update')") + public CommonResult updateSensitiveWord(@Valid @RequestBody ImSensitiveWordSaveReqVO reqVO) { + sensitiveWordService.updateSensitiveWord(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除敏感词") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:sensitive-word:delete')") + public CommonResult deleteSensitiveWord(@RequestParam("id") Long id) { + sensitiveWordService.deleteSensitiveWord(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除敏感词") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('im:manager:sensitive-word:delete')") + public CommonResult deleteSensitiveWordList( + @RequestParam("ids") + @Size(max = 100, message = "批量删除最多 100 条") List ids) { + sensitiveWordService.deleteSensitiveWordList(ids); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得敏感词分页") + @PreAuthorize("@ss.hasPermission('im:manager:sensitive-word:query')") + public CommonResult> getSensitiveWordPage( + @Valid ImSensitiveWordPageReqVO pageReqVO) { + // 1. 分页查询 + PageResult pageResult = sensitiveWordService.getSensitiveWordPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 2.1 批量查询创建人昵称 + Map userMap = adminUserApi.getUserMap(convertSet(pageResult.getList(), + word -> NumberUtils.parseLong(word.getCreator()))); + // 2.2 转换为 VO,填充创建人昵称 + return success(BeanUtils.toBean(pageResult, ImSensitiveWordRespVO.class, vo -> + MapUtils.findAndThen(userMap, NumberUtils.parseLong(vo.getCreator()), + user -> vo.setCreatorName(user.getNickname())))); + } + + @GetMapping("/get") + @Operation(summary = "获得敏感词详情") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:sensitive-word:query')") + public CommonResult getSensitiveWord(@RequestParam("id") Long id) { + ImSensitiveWordDO word = sensitiveWordService.getSensitiveWord(id); + return success(BeanUtils.toBean(word, ImSensitiveWordRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordPageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordPageReqVO.java new file mode 100644 index 000000000..4fc21f225 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordPageReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 敏感词分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImSensitiveWordPageReqVO extends PageParam { + + @Schema(description = "敏感词,模糊匹配", example = "敏感") + private String word; + + @Schema(description = "状态", example = "0") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; // 参见 CommonStatusEnum 枚举类(0 启用 / 1 禁用) + + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordRespVO.java new file mode 100644 index 000000000..578703fe4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordRespVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 敏感词 Response VO") +@Data +public class ImSensitiveWordRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "敏感词", requiredMode = Schema.RequiredMode.REQUIRED, example = "敏感词内容") + private String word; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "创建人") + private String creator; + @Schema(description = "创建人昵称", example = "张三") + private String creatorName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordSaveReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordSaveReqVO.java new file mode 100644 index 000000000..5d421406a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/sensitiveword/vo/ImSensitiveWordSaveReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 敏感词新增 / 修改 Request VO") +@Data +public class ImSensitiveWordSaveReqVO { + + @Schema(description = "编号(修改时必填)", example = "1024") + private Long id; + + @Schema(description = "敏感词", requiredMode = Schema.RequiredMode.REQUIRED, example = "敏感词内容") + @NotBlank(message = "敏感词不能为空") + @Size(max = 64, message = "敏感词长度不能超过 64") + private String word; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; // 参见 CommonStatusEnum 枚举类(0 启用 / 1 禁用) + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/ImStatisticsManagerController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/ImStatisticsManagerController.java new file mode 100644 index 000000000..82403e34b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/ImStatisticsManagerController.java @@ -0,0 +1,160 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.statistics; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo.*; +import cn.iocoder.yudao.module.im.service.statistics.ImStatisticsManagerService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - IM 数据看板") +@RestController +@RequestMapping("/im/manager/statistics") +@Validated +public class ImStatisticsManagerController { + + /** + * 群规模分桶名称的展示顺序 + */ + private static final List GROUP_SIZE_BUCKETS = Arrays.asList("1-9 人", "10-49 人", "50-199 人", "200+ 人"); + /** + * 看板分布默认时间窗口(天) + */ + private static final int DISTRIBUTION_WINDOW_DAYS = 30; + /** + * TOP 发送者数量 + */ + private static final int TOP_SENDER_LIMIT = 10; + + @Resource + private ImStatisticsManagerService statisticsService; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/overview") + @Operation(summary = "获得数据概览") + @PreAuthorize("@ss.hasPermission('im:manager:statistics:query')") + public CommonResult getOverview() { + LocalDateTime todayBegin = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowBegin = todayBegin.plusDays(1); + LocalDateTime yesterdayBegin = todayBegin.minusDays(1); + // 周活/月活定义为 N 天滚动窗口(含今天) + LocalDateTime weekBegin = todayBegin.minusDays(6); + LocalDateTime monthBegin = todayBegin.minusDays(29); + return success(new ImStatisticsManagerOverviewRespVO() + .setTotalUser(statisticsService.getTotalUserCount()) + .setNewUserToday(statisticsService.getNewUserCount(todayBegin, tomorrowBegin)) + .setTotalGroup(statisticsService.getTotalGroupCount()) + .setNewGroupToday(statisticsService.getNewGroupCount(todayBegin, tomorrowBegin)) + .setActiveUserDaily(statisticsService.getActiveUserCount(todayBegin, tomorrowBegin)) + .setActiveUserWeekly(statisticsService.getActiveUserCount(weekBegin, tomorrowBegin)) + .setActiveUserMonthly(statisticsService.getActiveUserCount(monthBegin, tomorrowBegin)) + .setPrivateMessageToday(statisticsService.getPrivateMessageCount(todayBegin, tomorrowBegin)) + .setGroupMessageToday(statisticsService.getGroupMessageCount(todayBegin, tomorrowBegin)) + .setPrivateMessageYesterday(statisticsService.getPrivateMessageCount(yesterdayBegin, todayBegin)) + .setGroupMessageYesterday(statisticsService.getGroupMessageCount(yesterdayBegin, todayBegin))); + } + + @GetMapping("/message-trend") + @Operation(summary = "获得消息趋势(私聊 / 群聊双线)") + @Parameter(name = "days", description = "回看天数(含今日)", example = "7") + @PreAuthorize("@ss.hasPermission('im:manager:statistics:query')") + public CommonResult getMessageTrend( + @RequestParam(value = "days", defaultValue = "7") @Min(1) @Max(90) int days) { + List dates = LocalDateTimeUtils.getLatestDays(days); + LocalDateTime beginTime = dates.get(0); + LocalDateTime endTime = dates.get(days - 1).plusDays(1); + Map privateMap = statisticsService.getPrivateMessageDailyCountMap(beginTime, endTime); + Map groupMap = statisticsService.getGroupMessageDailyCountMap(beginTime, endTime); + // 转换格式 + Map> series = new LinkedHashMap<>(); + series.put("private", alignSeries(dates, privateMap)); + series.put("group", alignSeries(dates, groupMap)); + return success(new ImStatisticsManagerTrendRespVO().setDates(dates).setSeries(series)); + } + + @GetMapping("/user-trend") + @Operation(summary = "获得用户趋势(新增注册 / 日活双线)") + @Parameter(name = "days", description = "回看天数(含今日)", example = "7") + @PreAuthorize("@ss.hasPermission('im:manager:statistics:query')") + public CommonResult getUserTrend( + @RequestParam(value = "days", defaultValue = "7") @Min(1) @Max(90) int days) { + List dates = LocalDateTimeUtils.getLatestDays(days); + LocalDateTime beginTime = dates.get(0); + LocalDateTime endTime = dates.get(days - 1).plusDays(1); + Map registerMap = statisticsService.getNewUserDailyCountMap(beginTime, endTime); + Map activeMap = statisticsService.getActiveUserDailyCountMap(beginTime, endTime); + // 转换格式 + Map> series = new LinkedHashMap<>(); + series.put("register", alignSeries(dates, registerMap)); + series.put("active", alignSeries(dates, activeMap)); + return success(new ImStatisticsManagerTrendRespVO().setDates(dates).setSeries(series)); + } + + @GetMapping("/message-type-distribution") + @Operation(summary = "获得消息类型分布(最近 30 天)") + @PreAuthorize("@ss.hasPermission('im:manager:statistics:query')") + public CommonResult> getMessageTypeDistribution() { + LocalDateTime endTime = LocalDate.now().plusDays(1).atStartOfDay(); + LocalDateTime beginTime = endTime.minusDays(DISTRIBUTION_WINDOW_DAYS); + Map typeCountMap = statisticsService.getMessageTypeCountMap(beginTime, endTime); + // 转换格式 + return success(convertList(typeCountMap.entrySet(), entry -> new ImStatisticsManagerMessageTypeRespVO() + .setType(entry.getKey()).setValue(entry.getValue()))); + } + + @GetMapping("/group-size-distribution") + @Operation(summary = "获得群规模分布") + @PreAuthorize("@ss.hasPermission('im:manager:statistics:query')") + public CommonResult> getGroupSizeDistribution() { + Map groupSizeMap = statisticsService.getGroupSizeCountMap(); + // 转换格式 + return success(convertList(GROUP_SIZE_BUCKETS, bucket -> new ImStatisticsManagerGroupSizeRespVO() + .setRange(bucket).setCount(groupSizeMap.getOrDefault(bucket, 0L)))); + } + + @GetMapping("/top-senders") + @Operation(summary = "获得消息 TOP 发送者(最近 30 天)") + @PreAuthorize("@ss.hasPermission('im:manager:statistics:query')") + public CommonResult> getTopSenders() { + LocalDateTime endTime = LocalDate.now().plusDays(1).atStartOfDay(); + LocalDateTime beginTime = endTime.minusDays(DISTRIBUTION_WINDOW_DAYS); + Map topSenderMap = statisticsService.getTopSenderCountMap(beginTime, endTime, TOP_SENDER_LIMIT); + // TOP 发送者:批量回填昵称 + Map userMap = adminUserApi.getUserMap(topSenderMap.keySet()); + return success(convertList(topSenderMap.entrySet(), entry -> { + ImStatisticsManagerTopSenderRespVO item = new ImStatisticsManagerTopSenderRespVO() + .setUserId(entry.getKey()).setMessageCount(entry.getValue()); + MapUtils.findAndThen(userMap, entry.getKey(), user -> item.setNickname(user.getNickname())); + return item; + })); + } + + /** + * 把每日聚合 Map 对齐到 dates 序列;缺失天补 0 + */ + private static List alignSeries(List dates, Map dailyMap) { + return convertList(dates, date -> dailyMap.getOrDefault(date, 0L)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerGroupSizeRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerGroupSizeRespVO.java new file mode 100644 index 000000000..064f0dec0 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerGroupSizeRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IM 数据看板群规模分布项 Response VO") +@Data +public class ImStatisticsManagerGroupSizeRespVO { + + @Schema(description = "区间名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "1-9 人") + private String range; + + @Schema(description = "群数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "320") + private Long count; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerMessageTypeRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerMessageTypeRespVO.java new file mode 100644 index 000000000..2da082775 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerMessageTypeRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IM 数据看板消息类型分布项 Response VO") +@Data +public class ImStatisticsManagerMessageTypeRespVO { + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8000") + private Long value; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerOverviewRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerOverviewRespVO.java new file mode 100644 index 000000000..dc9e713f3 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerOverviewRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IM 数据看板概览 Response VO") +@Data +public class ImStatisticsManagerOverviewRespVO { + + @Schema(description = "用户总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "12345") + private Long totalUser; + + @Schema(description = "今日新增用户数", requiredMode = Schema.RequiredMode.REQUIRED, example = "23") + private Long newUserToday; + + @Schema(description = "群总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "678") + private Long totalGroup; + + @Schema(description = "今日新建群数", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Long newGroupToday; + + @Schema(description = "日活用户(今日发过消息的去重用户数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1023") + private Long activeUserDaily; + + @Schema(description = "周活用户(最近 7 天发过消息的去重用户数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "4567") + private Long activeUserWeekly; + + @Schema(description = "月活用户(最近 30 天发过消息的去重用户数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "8901") + private Long activeUserMonthly; + + @Schema(description = "今日私聊消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8765") + private Long privateMessageToday; + + @Schema(description = "今日群聊消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "3210") + private Long groupMessageToday; + + @Schema(description = "昨日私聊消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "7890") + private Long privateMessageYesterday; + + @Schema(description = "昨日群聊消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "3000") + private Long groupMessageYesterday; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerTopSenderRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerTopSenderRespVO.java new file mode 100644 index 000000000..2f0615870 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerTopSenderRespVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IM 数据看板 TOP 发送者项 Response VO") +@Data +public class ImStatisticsManagerTopSenderRespVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "用户昵称", example = "张三") + private String nickname; + + @Schema(description = "消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "1500") + private Long messageCount; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerTrendRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerTrendRespVO.java new file mode 100644 index 000000000..e08d980cd --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/statistics/vo/ImStatisticsManagerTrendRespVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - IM 数据看板趋势 Response VO") +@Data +public class ImStatisticsManagerTrendRespVO { + + @Schema(description = "横轴日期序列(每天 00:00:00,由前端按需取日期部分)", requiredMode = Schema.RequiredMode.REQUIRED) + private List dates; + + @Schema(description = "数据系列:key 为系列名(如 private/group 或 register/active),value 为与 dates 等长的计数数组", + requiredMode = Schema.RequiredMode.REQUIRED) + private Map> series; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java new file mode 100644 index 000000000..08e557aaa --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.im.controller.admin.message; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.channel.ImChannelMessagePullRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.service.message.ImChannelMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +@Tag(name = "用户 APP - IM 频道消息") +@RestController +@RequestMapping("/im/channel/message") +@Validated +public class ImChannelMessageController { + + @Resource + private ImChannelMessageService channelMessageService; + + @GetMapping("/pull") + @Operation(summary = "拉取频道消息(离线增量);按 minId 游标分页") + public CommonResult> pull( + @RequestParam(value = "minId", defaultValue = "0") @PositiveOrZero(message = "minId 不能小于 0") Long minId, + @RequestParam(value = "size", defaultValue = "100") + @Min(value = 1, message = "size 必须大于 0") + @Max(value = 200, message = "size 一次最多 200 条") Integer size) { + // 1. 拉取消息列表 + Long userId = getLoginUserId(); + List list = channelMessageService.getMessageListForPull(userId, minId, size); + if (CollUtil.isEmpty(list)) { + return success(Collections.emptyList()); + } + // 2. 按 Redis 已读游标补 status;device A 已读后 device B 拉到这条不再算入未读 + Map readMaxByChannel = channelMessageService.getChannelReadMaxMessageIdMap( + userId, convertSet(list, ImChannelMessageDO::getChannelId)); + return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class, vo -> { + Long readMax = readMaxByChannel.get(vo.getChannelId()); + vo.setStatus(readMax != null && readMax >= vo.getId() + ? ImMessageStatusEnum.READ.getStatus() + : ImMessageStatusEnum.UNREAD.getStatus()); + })); + } + + @PutMapping("/read") + @Operation(summary = "标记频道消息已读") + @Parameter(name = "channelId", description = "频道编号", required = true, example = "1") + @Parameter(name = "messageId", description = "已读到的消息编号", required = true, example = "100") + public CommonResult readChannelMessages(@RequestParam("channelId") Long channelId, + @RequestParam("messageId") Long messageId) { + channelMessageService.readChannelMessages(getLoginUserId(), channelId, messageId); + return success(true); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java new file mode 100644 index 000000000..4bc719e8a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.im.controller.admin.message; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 群聊消息") +@RestController +@RequestMapping("/im/message/group") +@Validated +public class ImGroupMessageController { + + @Resource + private ImGroupMessageService groupMessageService; + + @PostMapping("/send") + @Operation(summary = "发送群聊消息") + public CommonResult sendGroupMessage(@Valid @RequestBody ImGroupMessageSendReqVO reqVO) { + ImGroupMessageDO message = groupMessageService.sendGroupMessage(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); + } + + @GetMapping("/pull") + @Operation(summary = "拉取群聊消息(增量)") + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") + @Parameter(name = "size", description = "拉取数量", required = true, example = "100") + public CommonResult> pullGroupMessageList( + @RequestParam("minId") Long minId, + @RequestParam("size") @Min(value = 1, message = "拉取数量最小值为 1") Integer size) { + List messages = groupMessageService.pullGroupMessageList(getLoginUserId(), minId, size); + return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); + } + + @PutMapping("/read") + @Operation(summary = "标记群聊消息已读") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1") + @Parameter(name = "messageId", description = "已读到的消息编号", required = true, example = "100") + public CommonResult readGroupMessages(@RequestParam("groupId") Long groupId, + @RequestParam("messageId") Long messageId) { + groupMessageService.readGroupMessages(getLoginUserId(), groupId, messageId); + return success(true); + } + + @DeleteMapping("/recall") + @Operation(summary = "撤回群聊消息") + @Parameter(name = "id", description = "消息编号", required = true, example = "1") + public CommonResult recallGroupMessage(@RequestParam("id") Long id) { + ImGroupMessageDO message = groupMessageService.recallGroupMessage(getLoginUserId(), id); + return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); + } + + @GetMapping("/get-read-user-ids") + @Operation(summary = "获取群消息已读用户列表") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1") + @Parameter(name = "messageId", description = "消息编号", required = true, example = "1") + public CommonResult> getGroupReadUserIds(@RequestParam("groupId") Long groupId, + @RequestParam("messageId") Long messageId) { + return success(groupMessageService.getGroupReadUserIds(getLoginUserId(), groupId, messageId)); + } + + @GetMapping("/list") + @Operation(summary = "查询群聊历史消息") + public CommonResult> getGroupMessageList(@Valid ImGroupMessageListReqVO reqVO) { + List messages = groupMessageService.getGroupMessageList(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http new file mode 100644 index 000000000..83d177313 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http @@ -0,0 +1,18 @@ +### 请求 /send 接口 => 成功 +POST {{baseUrl}}/im/message/send +Authorization: Bearer {{token}} +Content-Type: application/json +tenant-id: {{adminTenentId}} + +{ + "clientMessageId": "123", + "receiverId": 1, + "conversationType": 1, + "contentType": 101, + "content": "你好1" +} + +### 请求 /pull 接口 => 成功 +GET {{baseUrl}}/im/message/pull?sequence=0&size=2 +Authorization: Bearer {{token}} +tenant-id: {{adminTenentId}} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java new file mode 100644 index 000000000..d375ec747 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.im.controller.admin.message; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageRespVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 私聊消息") +@RestController +@RequestMapping("/im/message/private") +@Validated +public class ImPrivateMessageController { + + @Resource + private ImPrivateMessageService privateMessageService; + + @PostMapping("/send") + @Operation(summary = "发送私聊消息") + public CommonResult sendPrivateMessage( + @Valid @RequestBody ImPrivateMessageSendReqVO reqVO) { + ImPrivateMessageDO message = privateMessageService.sendPrivateMessage(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class)); + } + + @GetMapping("/pull") + @Operation(summary = "拉取私聊消息(增量)") + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") + @Parameter(name = "size", description = "拉取数量", required = true, example = "100") + public CommonResult> pullPrivateMessageList( + @RequestParam("minId") Long minId, + @RequestParam("size") @Min(value = 1, message = "拉取数量最小值为 1") Integer size) { + List messages = privateMessageService.pullPrivateMessageList(getLoginUserId(), minId, size); + return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class)); + } + + @PutMapping("/read") + @Operation(summary = "标记私聊消息已读") + @Parameter(name = "receiverId", description = "接收方用户编号(对方)", required = true, example = "2") + @Parameter(name = "messageId", description = "已读位置(含),通常是会话内最大消息编号", required = true, example = "100") + public CommonResult readPrivateMessages(@RequestParam("receiverId") Long receiverId, + @RequestParam("messageId") Long messageId) { + privateMessageService.readPrivateMessages(getLoginUserId(), receiverId, messageId); + return success(true); + } + + @GetMapping("/max-read-message-id") + @Operation(summary = "查询对方已读到我发的最大消息 id", + description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,结果用于翻转本地自发消息状态") + @Parameter(name = "peerId", description = "对方用户编号", required = true, example = "2") + public CommonResult getMaxReadMessageId(@RequestParam("peerId") Long peerId) { + return success(privateMessageService.getMaxReadMessageId(getLoginUserId(), peerId)); + } + + @DeleteMapping("/recall") + @Operation(summary = "撤回私聊消息") + @Parameter(name = "id", description = "消息编号", required = true, example = "1") + public CommonResult recallPrivateMessage(@RequestParam("id") Long id) { + ImPrivateMessageDO message = privateMessageService.recallPrivateMessage(getLoginUserId(), id); + return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class)); + } + + @GetMapping("/list") + @Operation(summary = "查询私聊历史消息") + public CommonResult> getPrivateMessageList(@Valid ImPrivateMessageListReqVO reqVO) { + List messages = privateMessageService.getPrivateMessageList(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java new file mode 100644 index 000000000..41bbbf9c4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.channel; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "用户 APP - IM 频道消息拉取 Response VO") +@Data +public class ImChannelMessagePullRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9001") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long channelId; + + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long materialId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125") + private Integer type; + + @Schema(description = "消息内容;payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "当前用户的已读态;按 Redis 游标计算填充", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 ImMessageStatusEnum 枚举类 + + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java new file mode 100644 index 000000000..76aa9586c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 群聊历史消息列表 Request VO") +@Data +public class ImGroupMessageListReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "起始消息编号,从该 id 往前拉取(不含)。为空则从最新消息开始", example = "1024") + private Long maxId; + + @Schema(description = "拉取数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + @NotNull(message = "拉取数量不能为空") + @Min(value = 1, message = "拉取数量最小值为 1") + @Max(value = 200, message = "拉取数量最大值为 200") + private Integer limit; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java new file mode 100644 index 000000000..ee8a16461 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 群聊消息 Response VO + */ +@Schema(description = "管理后台 - 群聊消息 Response VO") +@Data +public class ImGroupMessageRespVO { + + @Schema(description = "消息编号", example = "1") + private Long id; + + @Schema(description = "客户端消息编号", example = "uuid-xxx") + private String clientMessageId; + + @Schema(description = "发送人编号", example = "1") + private Long senderId; + + @Schema(description = "群编号", example = "1") + private Long groupId; + + @Schema(description = "消息类型", example = "0") + private Integer type; + + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; + + @Schema(description = "发送时间") + private LocalDateTime sendTime; + + @Schema(description = "@目标用户编号列表", example = "[1,2,3]") + private List atUserIds; + + @Schema(description = "定向接收用户编号列表", example = "[1,2]") + private List receiverUserIds; + + @Schema(description = "回执状态", example = "0") + private Integer receiptStatus; + + @Schema(description = "已读人数(回执消息、且发送人为当前用户时有值)", example = "3") + private Integer readCount; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java new file mode 100644 index 000000000..f02d91928 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 群聊消息发送 Request VO + */ +@Schema(description = "管理后台 - 群聊消息发送 Request VO") +@Data +public class ImGroupMessageSendReqVO { + + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") + @NotEmpty(message = "客户端消息编号不能为空") + private String clientMessageId; + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "消息类型不能为空") + @InEnum(ImMessageTypeEnum.class) + private Integer type; + + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") + @NotEmpty(message = "消息内容不能为空") + private String content; + + @Schema(description = "@目标用户编号列表", example = "[1,2,3]") + private List atUserIds; + + @Schema(description = "是否需要回执", example = "false") + private Boolean receipt; + + /** + * 仅允许用户消息(normal)类型 + */ + @AssertTrue(message = "消息类型不允许") + @JsonIgnore + public boolean isTypeNormal() { + return type == null || ImMessageTypeEnum.validate(type).isNormal(); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java new file mode 100644 index 000000000..c9259d912 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 私聊历史消息列表 Request VO") +@Data +public class ImPrivateMessageListReqVO { + + @Schema(description = "接收人编号(对方)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "接收人编号不能为空") + private Long receiverId; + + @Schema(description = "起始消息编号,从该 id 往前拉取(不含)。为空则从最新消息开始", example = "1024") + private Long maxId; + + @Schema(description = "拉取数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + @NotNull(message = "拉取数量不能为空") + @Min(value = 1, message = "拉取数量最小值为 1") + @Max(value = 200, message = "拉取数量最大值为 200") + private Integer limit; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java new file mode 100644 index 000000000..b353e77d3 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import jakarta.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 私聊消息分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class ImPrivateMessagePageReqVO extends PageParam { + + @Schema(description = "接收人编号(对方)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "接收人编号不能为空") + private Long receiverId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java new file mode 100644 index 000000000..3484b12e4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 私聊消息 Response VO + */ +@Schema(description = "管理后台 - 私聊消息 Response VO") +@Data +public class ImPrivateMessageRespVO { + + @Schema(description = "消息编号", example = "1") + private Long id; + + @Schema(description = "客户端消息编号", example = "uuid-xxx") + private String clientMessageId; + + @Schema(description = "发送人编号", example = "1") + private Long senderId; + + @Schema(description = "接收人编号", example = "2") + private Long receiverId; + + @Schema(description = "消息类型", example = "0") + private Integer type; + + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; + + @Schema(description = "发送时间") + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java new file mode 100644 index 000000000..d6c703fc1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 私聊消息发送 Request VO + */ +@Schema(description = "管理后台 - 私聊消息发送 Request VO") +@Data +public class ImPrivateMessageSendReqVO { + + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") + @NotEmpty(message = "客户端消息编号不能为空") + private String clientMessageId; + + @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "接收人编号不能为空") + private Long receiverId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "消息类型不能为空") + @InEnum(ImMessageTypeEnum.class) + private Integer type; + + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") + @NotEmpty(message = "消息内容不能为空") + private String content; + + /** + * 仅允许用户消息(normal)类型 + */ + @AssertTrue(message = "消息类型不允许") + @JsonIgnore + public boolean isTypeNormal() { + return type == null || ImMessageTypeEnum.validate(type).isNormal(); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java new file mode 100644 index 000000000..bebabf629 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java @@ -0,0 +1,169 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 实时通话") +@RestController +@RequestMapping("/im/rtc") +@Validated +public class ImRtcCallController { + + @Resource + private ImRtcCallService rtcCallService; + @Resource + private ImProperties imProperties; + + @PostMapping("/create") + @Operation(summary = "创建新通话;按 conversationType 区分私聊 / 群聊") + public CommonResult createCall(@Valid @RequestBody ImRtcCallCreateReqVO reqVO) { + Long userId = getLoginUserId(); + ImRtcCallDO call = rtcCallService.createCall(userId, reqVO); + return success(buildCallRespVO(call, userId)); + } + + @PostMapping("/invite") + @Operation(summary = "通话中追加邀请;仅群通话可用") + public CommonResult inviteCall(@Valid @RequestBody ImRtcCallInviteReqVO reqVO) { + rtcCallService.inviteCall(getLoginUserId(), reqVO); + return success(true); + } + + @PostMapping("/join") + @Operation(summary = "加入已有群通话;用于胶囊条「加入」按钮") + @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") + public CommonResult joinCall(@RequestParam("room") String room) { + Long userId = getLoginUserId(); + return success(buildCallRespVO(rtcCallService.joinCall(userId, room), userId)); + } + + @PostMapping("/accept") + @Operation(summary = "接听通话") + @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") + public CommonResult accept(@RequestParam("room") String room) { + Long userId = getLoginUserId(); + return success(buildCallRespVO(rtcCallService.acceptCall(userId, room), userId)); + } + + @PostMapping("/reject") + @Operation(summary = "拒绝通话") + @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") + public CommonResult reject(@RequestParam("room") String room) { + rtcCallService.rejectCall(getLoginUserId(), room); + return success(true); + } + + @PostMapping("/cancel") + @Operation(summary = "取消邀请;主叫接通前调用") + @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") + public CommonResult cancel(@RequestParam("room") String room) { + rtcCallService.cancelCall(getLoginUserId(), room); + return success(true); + } + + @PostMapping("/leave") + @Operation(summary = "离开通话;接通后调用") + @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") + public CommonResult leave(@RequestParam("room") String room) { + rtcCallService.leaveCall(getLoginUserId(), room); + return success(true); + } + + @PostMapping("/no-answer-call-check") + @Operation(summary = "前端 RUNNING 端 timer 兜底;触发后端立即扫描该 room 的振铃超时(接口静默)") + @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") + public CommonResult noAnswerCallCheck(@RequestParam("room") String room) { + rtcCallService.noAnswerCallCheck(getLoginUserId(), room); + return success(true); + } + + @GetMapping("/get-active-call") + @Operation(summary = "查询当前进行中的通话;用于群聊顶部「N 人正在通话」胶囊条") + @Parameter(name = "groupId", description = "群编号", required = true, example = "2048") + public CommonResult getActiveCall(@RequestParam("groupId") Long groupId) { + ImRtcCallDO call = rtcCallService.getActiveCall(getLoginUserId(), groupId); + return success(buildGroupActiveRespVO(call)); + } + + // ========== VO 拼装 ========== + + /** + * 拼装 invite / join / accept / refresh-token 的响应 VO;含 token + 参与者分桶 + * + * @param call 通话主表 + * @param userId 当前用户编号;token 按该用户签发 + * @return 响应 VO;call 为空返回 null + */ + private ImRtcCallRespVO buildCallRespVO(ImRtcCallDO call, Long userId) { + if (call == null) { + return null; + } + List participants = rtcCallService.getCallParticipantList(call.getRoom()); + boolean ended = ImRtcCallStatusEnum.isEnded(call.getStatus()); + return new ImRtcCallRespVO() + .setRoom(call.getRoom()) + .setLivekitUrl(imProperties.getRtc().getLivekitUrl()) + // 仅非 ENDED 场景才签 token,ENDED 场景不签,前端根据 token 是否存在,来判断是否展示「通话已结束」的提示 + .setToken(ended ? null : rtcCallService.signCallToken(userId, call.getRoom())) + .setConversationType(call.getConversationType()).setMediaType(call.getMediaType()) + .setStatus(call.getStatus()).setEndReason(call.getEndReason()) + .setInviterId(call.getInviterUserId()).setGroupId(call.getGroupId()) + .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING)) + .setJoinedUserIds(filterUserIds(participants, ImRtcParticipantStatusEnum.JOINED)); + } + + /** + * 拼装 get-active-call 的响应 VO + * + * @param call 通话主表 + * @return 响应 VO:只用于群聊胶囊条,不含 token + */ + private ImRtcGroupCallRespVO buildGroupActiveRespVO(ImRtcCallDO call) { + if (call == null) { + return null; + } + List participants = rtcCallService.getCallParticipantList(call.getRoom()); + return new ImRtcGroupCallRespVO().setRoom(call.getRoom()).setMediaType(call.getMediaType()) + .setGroupId(call.getGroupId()).setInviterId(call.getInviterUserId()) + .setJoinedUserIds(filterUserIds(participants, ImRtcParticipantStatusEnum.JOINED)) + .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING)); + } + + /** + * 按状态过滤参与者 userId;用 LinkedHashSet 保留前端展示顺序 + * + * @param participants 参与者列表 + * @param status 目标状态 + * @return userId 集合 + */ + private static Set filterUserIds(List participants, + ImRtcParticipantStatusEnum status) { + return CollectionUtils.convertLinkedSet(participants, ImRtcParticipantDO::getUserId, + participant -> Objects.equals(participant.getStatus(), status.getStatus())); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcLiveKitController.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcLiveKitController.java new file mode 100644 index 000000000..b68c2ae4b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcLiveKitController.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitClient; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitWebhookEventDTO; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * LiveKit 厂商对接入口(Webhook 回调) + *

+ * 安全由请求签名(JWT + body sha256)保证,不走登录鉴权;伪造请求会被签名校验直接拒绝 + * + * @author 芋道源码 + */ +@Tag(name = "LiveKit Webhook 回调") +@RestController +@RequestMapping("/im/livekit") +@Slf4j +public class ImRtcLiveKitController { + + @Resource + private LiveKitClient liveKitClient; + @Resource + private ImRtcCallService rtcCallService; + + /** + * LiveKit Webhook 回调入口 + * + * @param request HTTP 请求;用于取 Authorization 头做签名校验 + * @param rawBody 原始 JSON body;签名校验需要原始字节 + * @return 是否处理成功;伪造 / 非法事件返回 false + */ + @PostMapping("/webhook") + @Operation(summary = "LiveKit Webhook 回调;接收成员离开 / 房间结束等事件做业务态兜底清理") + @PermitAll + @TenantIgnore + public CommonResult webhook(HttpServletRequest request, @RequestBody String rawBody) { + // 1.1 校验签名;伪造请求直接 200 但忽略,避免给攻击者反馈 + if (!liveKitClient.verifyWebhookSignature(request.getHeader("Authorization"), rawBody)) { + log.warn("[webhook][签名校验失败 ip={} bodyLength={}]", + request.getRemoteAddr(), rawBody == null ? 0 : rawBody.length()); + return success(false); + } + // 1.2 解析事件载荷;非法 / 空 event 直接忽略 + LiveKitWebhookEventDTO event = JsonUtils.parseObject(rawBody, LiveKitWebhookEventDTO.class); + if (event == null || StrUtil.isBlank(event.getEvent())) { + return success(false); + } + + // 2. 交给 service 处理;幂等由 service 自己保证 + log.info("[webhook][事件处理 event={} room={}]", event.getEvent(), + event.getRoom() == null ? null : event.getRoom().getName()); + try { + rtcCallService.handleLiveKitEvent(event); + } catch (Exception e) { + log.error("[webhook][事件处理失败 event={} body={}]", event.getEvent(), rawBody, e); + } + return success(true); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallCreateReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallCreateReqVO.java new file mode 100644 index 000000000..9dc6a756d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallCreateReqVO.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallMediaTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 创建通话 Request VO") +@Data +public class ImRtcCallCreateReqVO { + + @Schema(description = "会话类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "会话类型不能为空") + @InEnum(ImConversationTypeEnum.class) + private Integer conversationType; + + @Schema(description = "媒体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "媒体类型不能为空") + @InEnum(ImRtcCallMediaTypeEnum.class) + private Integer mediaType; + + @Schema(description = "群编号;群聊场景必填", example = "2048") + private Long groupId; + + @Schema(description = "被邀请的用户编号集合;私聊必传 1 个对端,群聊必传至少 1 人") + private Set<@NotNull(message = "被邀请用户编号不能为空") Long> inviteeIds; + + /** + * 通话仅支持私聊和群聊 + */ + @AssertTrue(message = "会话类型不支持") + @JsonIgnore + public boolean isConversationTypeSupported() { + return conversationType == null || ImConversationTypeEnum.isPrivate(conversationType) + || ImConversationTypeEnum.isGroup(conversationType); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java new file mode 100644 index 000000000..7fcde58d4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 通话中追加邀请 Request VO;仅群通话可用") +@Data +public class ImRtcCallInviteReqVO { + + @Schema(description = "业务通话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "f47ac10b58cc4372a567") + @NotBlank(message = "通话编号不能为空") + private String room; + + @Schema(description = "新邀请的用户编号集合", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "请至少选择一位成员") + private Set<@NotNull(message = "被邀请用户编号不能为空") Long> inviteeIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java new file mode 100644 index 000000000..9f3113cd0 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 通话会话 Response VO;invite / accept / refreshToken 共用") +@Data +public class ImRtcCallRespVO { + + @Schema(description = "业务通话编号") + private String room; + + @Schema(description = "LiveKit Server WebSocket 地址;前端 connect 用") + private String livekitUrl; + + @Schema(description = "LiveKit 接入 Token;需要时调 refreshToken 重新签发") + private String token; + + @Schema(description = "会话类型") + private Integer conversationType; // 参见 ImConversationTypeEnum 枚举类 + + @Schema(description = "媒体类型") + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类 + + @Schema(description = "状态") + private Integer status; // 参见 ImCallStatusEnum 枚举类 + + @Schema(description = "结束原因;仅 status=ENDED 时有值") + private Integer endReason; // 参见 ImRtcCallEndReasonEnum 枚举类 + + @Schema(description = "发起人编号") + private Long inviterId; + + @Schema(description = "群编号;群通话才有") + private Long groupId; + + @Schema(description = "被邀请人编号集合") + private Set inviteeIds; + + @Schema(description = "已加入房间的成员编号集合") + private Set joinedUserIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java new file mode 100644 index 000000000..5d1d3ce8a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 群活跃通话 Response VO;不含 token,胶囊条仅展示用") +@Data +public class ImRtcGroupCallRespVO { + + @Schema(description = "业务通话编号") + private String room; + + @Schema(description = "群编号") + private Long groupId; + + @Schema(description = "媒体类型") + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类 + + @Schema(description = "发起人编号") + private Long inviterId; + + @Schema(description = "已加入房间的用户编号集合") + private Set joinedUserIds; + + @Schema(description = "被邀请池;用于胶囊条展开时显示待加入头像") + private Set inviteeIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java new file mode 100644 index 000000000..31a869524 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位 + */ +package cn.iocoder.yudao.module.im.controller.app; diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java new file mode 100644 index 000000000..f2e831e7b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java @@ -0,0 +1,6 @@ +/** + * 提供 RESTful API 给前端: + * 1. admin 包:提供给管理后台 yudao-ui-admin 前端项目 + * 2. app 包:提供给用户 APP yudao-ui-app 前端项目,它的 Controller 和 VO 都要添加 App 前缀,用于和管理后台进行区分 + */ +package cn.iocoder.yudao.module.im.controller; diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelDO.java new file mode 100644 index 000000000..f385cfa51 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelDO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.channel; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 频道 DO + *

+ * 业务语义: + * - 频道是运营单向推送的主体;C 端用户不能向频道发消息 + * - {@link #code} 是业务码(API / 字典外露),id 是数字主键给前端会话 targetId 用 + * + * @author 芋道源码 + */ +@TableName("im_channel") +@KeySequence("im_channel_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImChannelDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 频道业务码 + */ + private String code; + /** + * 频道名称 + */ + private String name; + /** + * 频道头像 + */ + private String avatar; + /** + * 排序 + */ + private Integer sort; + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMaterialDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMaterialDO.java new file mode 100644 index 000000000..0d49721c4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMaterialDO.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.channel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.enums.channel.ImChannelMaterialTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 频道素材 DO + *

+ * 业务语义: + * - 运营素材库,可被反复推送 + * - 一条素材 1:N 关联多条 {@link ImChannelMessageDO} + * - {@link #content} 富文本仅在素材详情接口按需返回,推送 payload 不带,避免压爆 WebSocket 通道 + * + * @author 芋道源码 + */ +@TableName("im_channel_material") +@KeySequence("im_channel_material_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImChannelMaterialDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 频道编号 + *

+ * 关联 {@link ImChannelDO#getId()} + */ + private Long channelId; + /** + * 素材内容类型 + *

+ * 枚举 {@link ImChannelMaterialTypeEnum} + */ + private Integer type; + /** + * 标题 + */ + private String title; + /** + * 封面图 + */ + private String coverUrl; + /** + * 摘要 + */ + private String summary; + /** + * 富文本 HTML;在 {@link ImChannelMaterialTypeEnum#CONTENT} 使用 + */ + private String content; + /** + * 跳转链接;在 {@link ImChannelMaterialTypeEnum#LINK} 使用 + */ + private String url; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFacePackDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFacePackDO.java new file mode 100644 index 000000000..0236218da --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFacePackDO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.face; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 表情包 DO(运营配置的系统表情包元数据) + * + * @author 芋道源码 + */ +@TableName("im_face_pack") +@KeySequence("im_face_pack_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImFacePackDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 表情包名称 + */ + private String name; + /** + * 表情包图标 + *

+ * 面板底部 tab 栏显示 + */ + private String icon; + /** + * 排序 + */ + private Integer sort; + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFacePackItemDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFacePackItemDO.java new file mode 100644 index 000000000..d3c56769a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFacePackItemDO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.face; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 表情包项 DO(系统表情包内的单张表情图) + * + * @author 芋道源码 + */ +@TableName("im_face_pack_item") +@KeySequence("im_face_pack_item_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImFacePackItemDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 所属表情包编号 + */ + private Long packId; + /** + * 表情图 URL + */ + private String url; + /** + * 表情名(可选;如「狗头」「捂脸」) + */ + private String name; + /** + * 渲染宽度(像素) + */ + private Integer width; + /** + * 渲染高度(像素) + */ + private Integer height; + /** + * 排序 + */ + private Integer sort; + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFaceUserItemDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFaceUserItemDO.java new file mode 100644 index 000000000..0aee3bb26 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/face/ImFaceUserItemDO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.face; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 用户私有表情 DO(个人表情包,对照微信「我的表情」) + * + * @author 芋道源码 + */ +@TableName("im_face_user_item") +@KeySequence("im_face_user_item_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImFaceUserItemDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 所属用户编号 + *

+ * 关联 AdminUserDO 的 id 编号 + */ + private Long userId; + /** + * 表情图 URL + */ + private String url; + /** + * 表情名(可选) + *

+ * 用户私有表情通常不带名字,留字段以备将来「重命名」交互 + */ + private String name; + /** + * 渲染宽度(像素) + */ + private Integer width; + /** + * 渲染高度(像素) + */ + private Integer height; + /** + * 排序 + */ + private Integer sort; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java new file mode 100644 index 000000000..db9543a0f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.friend; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 好友关系 DO + *

+ * 业务语义: + * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A) + * - 状态管理:{@link #status} 使用 {@link CommonStatusEnum},ENABLE=正常,DISABLE=已删除 + * - 免打扰:{@link #silent} 控制是否屏蔽来自该好友的通知 + * - 联系人置顶:{@link #pinned} 单边,影响联系人 / 会话排序 + * - 黑名单:{@link #blocked} 弱关联 friend,单边屏蔽对方消息(必须先是好友) + * + * @author 芋道源码 + */ +@TableName("im_friend") +@KeySequence("im_friend_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImFriendDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 好友用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long friendUserId; + /** + * 是否免打扰 + */ + private Boolean silent; + /** + * 好友展示备注 + */ + private String displayName; + /** + * 添加来源 + *

+ * 枚举 {@link cn.iocoder.yudao.module.im.enums.friend.ImFriendAddSourceEnum} + */ + private Integer addSource; + /** + * 是否置顶联系人 + */ + private Boolean pinned; + /** + * 是否拉黑(弱关联 friend,单边屏蔽对方私聊消息) + */ + private Boolean blocked; + /** + * 好友状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 添加好友时间 + */ + private LocalDateTime addTime; + /** + * 删除好友时间 + *

+ * 不为 null 时表示已删除 + */ + private LocalDateTime deleteTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendRequestDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendRequestDO.java new file mode 100644 index 000000000..ec3098736 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendRequestDO.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.friend; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendAddSourceEnum; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 好友申请记录 DO + *

+ * 配合「申请 - 审批」流程: + * - 发起方调 apply 接口落库(handleResult=UNHANDLED) + * - 接收方调 agree / refuse 处理(更新 handleResult / handleTime / handleContent) + * - 申请通过后,displayName / addSource 同步写入 {@link ImFriendDO} + * + * @author 芋道源码 + */ +@TableName("im_friend_request") +@KeySequence("im_friend_request_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImFriendRequestDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 发起方用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long fromUserId; + /** + * 接收方用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long toUserId; + + // ========== 申请发起阶段(发起方填写) ========== + /** + * 申请理由 + */ + private String applyContent; + /** + * 发起方对接收方的备注 + *

+ * 同意后写入 A 侧 {@link ImFriendDO#getDisplayName()};B 侧不动 + */ + private String displayName; + /** + * 添加来源 + *

+ * 枚举 {@link ImFriendAddSourceEnum} + */ + private Integer addSource; + + // ========== 申请处理阶段(接收方填写) ========== + /** + * 处理结果 + *

+ * 枚举 {@link ImFriendRequestHandleResultEnum} + */ + private Integer handleResult; + /** + * 处理理由(接收方拒绝时可选填) + */ + private String handleContent; + /** + * 处理时间 + */ + private LocalDateTime handleTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java new file mode 100644 index 000000000..f47f80d88 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java @@ -0,0 +1,94 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 群信息 DO + * + * @author 芋道源码 + */ +@TableName(value = "im_group",autoResultMap = true) +@KeySequence("im_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 群名称 + */ + private String name; + /** + * 群主用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + /** + * 群头像 + */ + private String avatar; + /** + * 群公告 + */ + private String notice; + /** + * 进群是否需群主 / 管理员审批 + *

+ * false 自由进群(默认);true 需审批,所有「申请」「邀请」路径都需群主 / 管理员同意 + */ + private Boolean joinApproval; + /** + * 是否封禁 + */ + private Boolean banned; + /** + * 封禁原因 + */ + private String bannedReason; + /** + * 封禁时间 + */ + private LocalDateTime bannedTime; + /** + * 群状态 + *

+ * 枚举 {@link CommonStatusEnum} + * ENABLE = 正常,DISABLE = 已解散 + */ + private Integer status; + /** + * 解散时间 + */ + private LocalDateTime dissolvedTime; + /** + * 是否全群禁言 + */ + private Boolean mutedAll; + /** + * 群置顶消息编号列表 + *

+ * 仅存 messageId,操作人 / 置顶时间从对应 PIN 事件的消息记录里反查 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List pinnedMessageIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java new file mode 100644 index 000000000..430264ccc --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.group; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 群成员 DO + * + * @author 芋道源码 + */ +@TableName("im_group_member") +@KeySequence("im_group_member_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupMemberDO extends BaseDO { + + /** + * 永久禁言到期时间 + */ + public static final LocalDateTime PERMANENT_MUTE_END_TIME = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 群编号 + *

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

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 组内显示名 + */ + private String displayUserName; + /** + * 群备注 + */ + private String groupRemark; + /** + * 是否免打扰 + */ + private Boolean silent; + /** + * 成员状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 成员角色 + *

+ * 枚举 {@link ImGroupMemberRoleEnum} + */ + private Integer role; + /** + * 入群时间 + */ + private LocalDateTime joinTime; + /** + * 加入来源 + *

+ * 枚举 {@link ImGroupAddSourceEnum} + */ + private Integer addSource; + /** + * 邀请人用户编号 + *

+ * 关联 AdminUserDO 的 id 字段;用户主动申请进群时为 NULL + */ + private Long inviterUserId; + /** + * 退群时间 + */ + private LocalDateTime quitTime; + /** + * 禁言到期时间 + *

+ * null 表示未禁言;非 null 且在未来表示禁言中; + */ + private LocalDateTime muteEndTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupRequestDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupRequestDO.java new file mode 100644 index 000000000..6ccb2cfdd --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupRequestDO.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.group; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 加群申请记录 DO + *

+ * 配合「申请 - 审批」流程: + *

    + *
  • 用户主动申请:调 apply 接口落库(inviterUserId=null,handleResult=UNHANDLED),群主 / 管理员审批
  • + *
  • 普通成员邀请:群 joinType=APPLY_AND_NORMAL_INVITE 时落库(inviterUserId=操作人),群主 / 管理员审批
  • + *
  • 处理:群主 / 管理员调 agree / refuse 推进状态机;同意时把 addSource / inviterUserId 同步写入 {@link ImGroupMemberDO}
  • + *
+ * + * @author 芋道源码 + */ +@TableName("im_group_request") +@KeySequence("im_group_request_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增;MySQL 等可不写 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupRequestDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 群编号 + *

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

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 邀请人用户编号 + *

+ * NULL 表示用户主动申请进群;非 NULL 表示由群成员邀请、待群主 / 管理员审批 + */ + private Long inviterUserId; + + // ========== 申请发起阶段 ========== + /** + * 申请理由 + */ + private String applyContent; + /** + * 加入来源 + *

+ * 枚举 {@link ImGroupAddSourceEnum};同意时同步写入 {@link ImGroupMemberDO#getAddSource()} + */ + private Integer addSource; + + // ========== 申请处理阶段 ========== + /** + * 处理结果 + *

+ * 枚举 {@link ImGroupRequestHandleResultEnum} + */ + private Integer handleResult; + /** + * 处理人用户编号(群主或管理员) + */ + private Long handleUserId; + /** + * 处理理由(拒绝时可选填) + */ + private String handleContent; + /** + * 处理时间 + */ + private LocalDateTime handleTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImChannelMessageDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImChannelMessageDO.java new file mode 100644 index 000000000..2572828d9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImChannelMessageDO.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.message; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 频道消息 DO + *

+ * 业务语义: + * - 一次推送 1 行;{@link #receiverUserIds} 为空表示全员 + * - {@link #channelId} 冗余 {@link ImChannelMaterialDO#getChannelId()} 便于按频道检索 + * - {@link #content} 存推送时 payload 的 JSON 快照(title / coverUrl / summary / url);不含富文本正文 + * + * @author 芋道源码 + */ +@TableName(value = "im_channel_message", autoResultMap = true) +@KeySequence("im_channel_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImChannelMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 频道编号 + *

+ * 关联 {@link ImChannelDO#getId()};冗余便于按频道检索 + */ + private Long channelId; + /** + * 关联素材编号 + *

+ * 关联 {@link ImChannelMaterialDO#getId()} + */ + private Long materialId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容;推送时 payload 的 JSON 快照 + */ + private String content; + /** + * 接收人编号列表;为空表示全员 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List receiverUserIds; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java new file mode 100644 index 000000000..f6c3d609e --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.message; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 群聊消息 DO + * + * @author 芋道源码 + */ +@TableName(value = "im_group_message", autoResultMap = true) +@KeySequence("im_group_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 客户端消息编号,用于发送幂等 + */ + private String clientMessageId; + /** + * 发送人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long senderId; + /** + * 群编号 + * + * 关联 {@link ImGroupDO#getId()} + */ + private Long groupId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容,JSON 格式 + * + * 参考 content 包下的 TextMessage、ImageMessage 等结构化模型 + */ + private String content; + /** + * 消息状态 + *

+ * 枚举 {@link ImMessageStatusEnum} + * + * 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型) + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + /** + * 定向接收用户编号列表,以逗号分隔 + *

+ * 为空表示全员可见 + * + * 关联 AdminUserDO 的 id 字段 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List receiverUserIds; + /** + * @ 目标用户编号列表,以逗号分隔 + * + * 关联 AdminUserDO 的 id 字段 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List atUserIds; + /** + * 回执状态 + *

+ * 枚举 {@link ImGroupMessageReceiptStatusEnum} + */ + private Integer receiptStatus; + + // ========== 非表字段 ========== + + /** + * 离线拉取等场景下回算的已读人数 + */ + @TableField(exist = false) + private Integer readCount; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java new file mode 100644 index 000000000..bb87939eb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.message; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 私聊消息 DO + * + * @author 芋道源码 + */ +@TableName("im_private_message") +@KeySequence("im_private_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImPrivateMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 客户端消息编号,用于发送幂等 + */ + private String clientMessageId; + /** + * 发送人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long senderId; + /** + * 接收人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long receiverId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容,JSON 格式 + * + * 参考 content 包下的 TextMessage、ImageMessage 等结构化模型 + */ + private String content; + /** + * 消息状态 + *

+ * 枚举 {@link ImMessageStatusEnum} + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java new file mode 100644 index 000000000..e6453d1cd --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.rtc; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallMediaTypeEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 通话记录 DO(房间级 / 主表) + *

+ * 一通通话一行;状态机 CREATED → RUNNING → ENDED;和明细表 {@link ImRtcParticipantDO} 通过 {@link #room} 关联 + * + * @author 芋道源码 + */ +@TableName("im_rtc_call") +@KeySequence("im_rtc_call_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增;MySQL 等数据库可不写 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImRtcCallDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 业务通话编号(UUID,同时作为 LiveKit 房间名);唯一 + */ + private String room; + /** + * 会话类型 + * + * 枚举 {@link ImConversationTypeEnum} + */ + private Integer conversationType; + /** + * 媒体类型 + * + * 枚举 {@link ImRtcCallMediaTypeEnum} + */ + private Integer mediaType; + /** + * 发起人用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long inviterUserId; + /** + * 群编号;私聊为 NULL + *

+ * 关联 {@link ImGroupDO#getId()} + */ + private Long groupId; + /** + * 通话状态 + * + * 枚举 {@link ImRtcCallStatusEnum} + */ + private Integer status; + /** + * 结束原因;通话未结束时为 NULL + * + * 枚举 {@link ImRtcCallEndReasonEnum} + */ + private Integer endReason; + /** + * 发起时间 + */ + private LocalDateTime startTime; + /** + * 接通时间 + * + * 首个非发起人加入时写入;未接通保持 NULL + */ + private LocalDateTime acceptTime; + /** + * 通话结束时间 + */ + private LocalDateTime endTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java new file mode 100644 index 000000000..4282baa1a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.rtc; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 通话参与者 DO(用户级 / 明细表) + *

+ * 一通通话每个参与者一行;通过 {@link #room} 关联主表 {@link ImRtcCallDO} + *

+ * 终态闭合:通话 ENDED 时所有明细 status 必属 {LEFT / REJECTED / NO_ANSWER} 之一 + * + * @author 芋道源码 + */ +@TableName("im_rtc_participant") +@KeySequence("im_rtc_participant_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增;MySQL 等数据库可不写 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImRtcParticipantDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 通话编号 + *

+ * 关联 {@link ImRtcCallDO#getId()} + */ + private Long callId; + /** + * 业务通话编号 + *

+ * 关联 {@link ImRtcCallDO#getRoom()} + */ + private String room; + /** + * 参与者用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 参与角色 + * + * 枚举 {@link ImRtcParticipantRoleEnum} + */ + private Integer role; + /** + * 参与状态 + * + * 枚举 {@link ImRtcParticipantStatusEnum} + */ + private Integer status; + /** + * 被邀请时间;发起人取通话 startTime + */ + private LocalDateTime inviteTime; + /** + * 接听时间;未接听 NULL + */ + private LocalDateTime acceptTime; + /** + * 离开时间;未加入 NULL + */ + private LocalDateTime leaveTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java new file mode 100644 index 000000000..4f6d1fcf7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 敏感词 DO + * + * @author 芋道源码 + */ +@TableName("im_sensitive_word") +@KeySequence("im_sensitive_word_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImSensitiveWordDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 敏感词 + */ + private String word; + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMapper.java new file mode 100644 index 000000000..faab2b547 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.dal.mysql.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 频道 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImChannelMapper extends BaseMapperX { + + default ImChannelDO selectByCode(String code) { + return selectOne(ImChannelDO::getCode, code); + } + + default List selectListByStatusOrderBySort(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImChannelDO::getStatus, status) + .orderByAsc(ImChannelDO::getSort)); + } + + default PageResult selectPage(ImChannelPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ImChannelDO::getCode, reqVO.getCode()) + .likeIfPresent(ImChannelDO::getName, reqVO.getName()) + .eqIfPresent(ImChannelDO::getStatus, reqVO.getStatus()) + .orderByAsc(ImChannelDO::getSort) + .orderByDesc(ImChannelDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMaterialMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMaterialMapper.java new file mode 100644 index 000000000..421bae877 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMaterialMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.dal.mysql.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 频道素材 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImChannelMaterialMapper extends BaseMapperX { + + default Long selectCountByChannelId(Long channelId) { + return selectCount(ImChannelMaterialDO::getChannelId, channelId); + } + + default List selectListByChannelId(Long channelId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImChannelMaterialDO::getChannelId, channelId) + .orderByDesc(ImChannelMaterialDO::getId)); + } + + default PageResult selectPage(ImChannelMaterialPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImChannelMaterialDO::getChannelId, reqVO.getChannelId()) + .eqIfPresent(ImChannelMaterialDO::getType, reqVO.getType()) + .likeIfPresent(ImChannelMaterialDO::getTitle, reqVO.getTitle()) + .betweenIfPresent(ImChannelMaterialDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(ImChannelMaterialDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFacePackItemMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFacePackItemMapper.java new file mode 100644 index 000000000..622a319fa --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFacePackItemMapper.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.im.dal.mysql.face; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * IM 表情包项 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImFacePackItemMapper extends BaseMapperX { + + default List selectListByPackIdsAndStatus(Collection packIds, Integer status) { + return selectList(new LambdaQueryWrapperX() + .in(ImFacePackItemDO::getPackId, packIds) + .eq(ImFacePackItemDO::getStatus, status) + .orderByAsc(ImFacePackItemDO::getPackId) + .orderByAsc(ImFacePackItemDO::getSort) + .orderByAsc(ImFacePackItemDO::getId)); + } + + default Long selectCountByPackId(Long packId) { + return selectCount(new LambdaQueryWrapperX() + .eq(ImFacePackItemDO::getPackId, packId)); + } + + default Long selectCountByPackIds(Collection packIds) { + return selectCount(new LambdaQueryWrapperX() + .in(ImFacePackItemDO::getPackId, packIds)); + } + + default PageResult selectPage(ImFacePackItemPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImFacePackItemDO::getPackId, reqVO.getPackId()) + .likeIfPresent(ImFacePackItemDO::getName, reqVO.getName()) + .eqIfPresent(ImFacePackItemDO::getStatus, reqVO.getStatus()) + .orderByAsc(ImFacePackItemDO::getSort) + .orderByDesc(ImFacePackItemDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFacePackMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFacePackMapper.java new file mode 100644 index 000000000..2864d6173 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFacePackMapper.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.im.dal.mysql.face; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 表情包 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImFacePackMapper extends BaseMapperX { + + default List selectListByStatusOrderBySort(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFacePackDO::getStatus, status) + .orderByAsc(ImFacePackDO::getSort) + .orderByAsc(ImFacePackDO::getId)); + } + + default PageResult selectPage(ImFacePackPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ImFacePackDO::getName, reqVO.getName()) + .eqIfPresent(ImFacePackDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(ImFacePackDO::getCreateTime, reqVO.getCreateTime()) + .orderByAsc(ImFacePackDO::getSort) + .orderByDesc(ImFacePackDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFaceUserItemMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFaceUserItemMapper.java new file mode 100644 index 000000000..5eb2dc778 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFaceUserItemMapper.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.im.dal.mysql.face; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 用户私有表情 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImFaceUserItemMapper extends BaseMapperX { + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFaceUserItemDO::getUserId, userId) + .orderByAsc(ImFaceUserItemDO::getSort) + .orderByDesc(ImFaceUserItemDO::getId)); + } + + default ImFaceUserItemDO selectByUserIdAndUrl(Long userId, String url) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImFaceUserItemDO::getUserId, userId) + .eq(ImFaceUserItemDO::getUrl, url)); + } + + default Long selectCountByUserId(Long userId) { + return selectCount(ImFaceUserItemDO::getUserId, userId); + } + + default PageResult selectPage(ImFaceUserItemManagerPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImFaceUserItemDO::getUserId, reqVO.getUserId()) + .likeIfPresent(ImFaceUserItemDO::getName, reqVO.getName()) + .betweenIfPresent(ImFaceUserItemDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(ImFaceUserItemDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java new file mode 100644 index 000000000..83752a9e8 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.im.dal.mysql.friend; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * IM 好友关系 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImFriendMapper extends BaseMapperX { + + default ImFriendDO selectByUserIdAndFriendUserId(Long userId, Long friendUserId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .eq(ImFriendDO::getFriendUserId, friendUserId)); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .orderByDesc(ImFriendDO::getId)); + } + default List selectListByUserIdAndStatus(Long userId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .eq(ImFriendDO::getStatus, status) + .orderByDesc(ImFriendDO::getId)); + } + + default List selectListByUserIdAndFriendUserIdsAndStatus(Long userId, + Collection friendUserIds, + Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .in(ImFriendDO::getFriendUserId, friendUserIds) + .eq(ImFriendDO::getStatus, status)); + } + + default List selectListByUserIdsAndFriendUserIdAndStatus(Collection userIds, + Long friendUserId, + Integer status) { + return selectList(new LambdaQueryWrapperX() + .in(ImFriendDO::getUserId, userIds) + .eq(ImFriendDO::getFriendUserId, friendUserId) + .eq(ImFriendDO::getStatus, status)); + } + + default PageResult selectPage(ImFriendManagerPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImFriendDO::getUserId, reqVO.getUserId()) + .eqIfPresent(ImFriendDO::getFriendUserId, reqVO.getFriendUserId()) + .eqIfPresent(ImFriendDO::getStatus, reqVO.getStatus()) + .eqIfPresent(ImFriendDO::getSilent, reqVO.getSilent()) + .betweenIfPresent(ImFriendDO::getAddTime, reqVO.getAddTime()) + .orderByDesc(ImFriendDO::getId)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateReAddFields(Long id, Integer status, LocalDateTime addTime, + Boolean silent, Boolean pinned, Boolean blocked, + String displayName, Integer addSource) { + return update(null, Wrappers.lambdaUpdate() + .eq(ImFriendDO::getId, id) + .set(ImFriendDO::getStatus, status) + .set(ImFriendDO::getAddTime, addTime) + .set(ImFriendDO::getSilent, silent) + .set(ImFriendDO::getPinned, pinned) + .set(ImFriendDO::getBlocked, blocked) + .set(ImFriendDO::getDisplayName, displayName) + .set(ImFriendDO::getAddSource, addSource) + .set(ImFriendDO::getDeleteTime, null)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapper.java new file mode 100644 index 000000000..e81def0fe --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapper.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.im.dal.mysql.friend; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 好友申请记录 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImFriendRequestMapper extends BaseMapperX { + + default ImFriendRequestDO selectByFromUserIdAndToUserId(Long fromUserId, Long toUserId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImFriendRequestDO::getFromUserId, fromUserId) + .eq(ImFriendRequestDO::getToUserId, toUserId)); + } + + /** + * 拉取「我相关」的好友申请列表 + */ + default List selectMyList(Long userId, LocalDateTime maxRequestUpdateTime, + Long maxId, int limit) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX<>(); + wrapper.and(w -> w.eq(ImFriendRequestDO::getFromUserId, userId) + .or().eq(ImFriendRequestDO::getToUserId, userId)); + if (maxRequestUpdateTime != null && maxId != null) { + wrapper.and(w -> w.lt(ImFriendRequestDO::getUpdateTime, maxRequestUpdateTime) + .or(n -> n.eq(ImFriendRequestDO::getUpdateTime, maxRequestUpdateTime) + .lt(ImFriendRequestDO::getId, maxId))); + } + wrapper.orderByDesc(ImFriendRequestDO::getUpdateTime) + .orderByDesc(ImFriendRequestDO::getId) + .last("LIMIT " + limit); + return selectList(wrapper); + } + + default int updateByIdAndHandleResult(Long id, Integer handleResult, ImFriendRequestDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .eq(ImFriendRequestDO::getId, id).eq(ImFriendRequestDO::getHandleResult, handleResult)); + } + + /** + * 复用 (fromUserId, toUserId) 旧申请记录:覆盖申请理由 / 备注 / 来源,重置为未处理 + 清空旧处理痕迹 + *

+ * handleContent / handleTime 走 LambdaUpdateWrapper.set 显式置 null,updateById 默认会忽略 null 字段 + */ + default int updateByIdReset(Long id, String applyContent, String displayName, Integer addSource, + LocalDateTime updateTime) { + return update(null, new LambdaUpdateWrapper() + .eq(ImFriendRequestDO::getId, id) + .set(ImFriendRequestDO::getApplyContent, applyContent) + .set(ImFriendRequestDO::getDisplayName, displayName) + .set(ImFriendRequestDO::getAddSource, addSource) + .set(ImFriendRequestDO::getHandleResult, ImFriendRequestHandleResultEnum.UNHANDLED.getResult()) + .set(ImFriendRequestDO::getHandleContent, null) + .set(ImFriendRequestDO::getHandleTime, null) + .set(ImFriendRequestDO::getUpdateTime, updateTime)); + } + + default PageResult selectPage(ImFriendRequestManagerPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImFriendRequestDO::getFromUserId, reqVO.getFromUserId()) + .eqIfPresent(ImFriendRequestDO::getToUserId, reqVO.getToUserId()) + .eqIfPresent(ImFriendRequestDO::getHandleResult, reqVO.getHandleResult()) + .eqIfPresent(ImFriendRequestDO::getAddSource, reqVO.getAddSource()) + .betweenIfPresent(ImFriendRequestDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(ImFriendRequestDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java new file mode 100644 index 000000000..7f56a1aa7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.im.dal.mysql.group; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * IM 群 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupMapper extends BaseMapperX { + + default ImGroupDO selectByIdForUpdate(Long id) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupDO::getId, id) + .last("FOR UPDATE")); + } + + default PageResult selectPage(ImGroupManagerPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ImGroupDO::getName, reqVO.getName()) + .eqIfPresent(ImGroupDO::getOwnerUserId, reqVO.getOwnerUserId()) + .eqIfPresent(ImGroupDO::getStatus, reqVO.getStatus()) + .eqIfPresent(ImGroupDO::getBanned, reqVO.getBanned()) + .betweenIfPresent(ImGroupDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(ImGroupDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java new file mode 100644 index 000000000..2ef54da35 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java @@ -0,0 +1,149 @@ +package cn.iocoder.yudao.module.im.dal.mysql.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * IM 群成员 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupMemberMapper extends BaseMapperX { + + default List selectListByGroupId(Long groupId) { + return selectList(new LambdaQueryWrapperX().eq(ImGroupMemberDO::getGroupId, groupId)); + } + + default ImGroupMemberDO selectByGroupIdAndUserId(Long groupId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getUserId, userId)); + } + + default List selectListByGroupIdAndUserIds(Long groupId, Collection userIds) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .in(ImGroupMemberDO::getUserId, userIds)); + } + + default List selectListByGroupIdAndStatus(Long groupId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getStatus, status)); + } + + default List selectListByGroupIdAndStatusAndRoles(Long groupId, Integer status, + Collection roles) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getStatus, status) + .in(ImGroupMemberDO::getRole, roles)); + } + + default List selectListByUserIdAndStatus(Long userId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getUserId, userId) + .eq(ImGroupMemberDO::getStatus, status)); + } + + /** + * 查询用户已退群的成员记录 + *

+ * 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。 + * + * @param userId 用户编号 + * @param minQuitTime 最早退群时间(含),可空 + * @return 已退群成员记录列表 + */ + default List selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getUserId, userId) + .eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus()) + .geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateByGroupIdAndStatus(Long groupId, Integer oldStatus, ImGroupMemberDO updateObj) { + return update(updateObj, new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getStatus, oldStatus)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateByGroupIdAndUserIdsAndStatus(Long groupId, Collection userIds, + Integer oldStatus, ImGroupMemberDO updateObj) { + return update(updateObj, new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .in(ImGroupMemberDO::getUserId, userIds) + .eq(ImGroupMemberDO::getStatus, oldStatus)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateListByGroupIdAndUserIds(Long groupId, Collection userIds, ImGroupMemberDO updateObj) { + return update(updateObj, new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .in(ImGroupMemberDO::getUserId, userIds)); + } + + default Long selectCountByGroupIdAndRoleAndStatus(Long groupId, Integer role, Integer status) { + return selectCount(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getRole, role) + .eq(ImGroupMemberDO::getStatus, status)); + } + + /** + * 批量按 group_id 统计指定状态的成员数 + */ + default Map selectCountMapByGroupIdsAndStatus(Collection groupIds, Integer status) { + if (CollUtil.isEmpty(groupIds)) { + return Collections.emptyMap(); + } + List> rows = selectMaps(Wrappers.query() + .select("group_id AS groupId", "COUNT(*) AS cnt") + .in("group_id", groupIds) + .eq("status", status) + .groupBy("group_id")); + // 转换成 Map + Map result = new HashMap<>(rows.size()); + rows.forEach(row -> result.put( + ((Number) row.get("groupId")).longValue(), + ((Number) row.get("cnt")).longValue())); + return result; + } + + @SuppressWarnings("UnusedReturnValue") + default int updateMuteEndTimeNull(Long id) { + return update(null, Wrappers.lambdaUpdate() + .eq(ImGroupMemberDO::getId, id) + .set(ImGroupMemberDO::getMuteEndTime, null)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateRejoinFields(Long id, Integer status, LocalDateTime joinTime, + Integer role, Integer addSource, Long inviterUserId) { + return update(null, Wrappers.lambdaUpdate() + .eq(ImGroupMemberDO::getId, id) + .set(ImGroupMemberDO::getStatus, status) + .set(ImGroupMemberDO::getJoinTime, joinTime) + .set(ImGroupMemberDO::getRole, role) + .set(ImGroupMemberDO::getAddSource, addSource) + .set(ImGroupMemberDO::getInviterUserId, inviterUserId) + .set(ImGroupMemberDO::getQuitTime, null) + .set(ImGroupMemberDO::getMuteEndTime, null)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupRequestMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupRequestMapper.java new file mode 100644 index 000000000..72fe7562d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupRequestMapper.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.im.dal.mysql.group; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * IM 加群申请记录 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupRequestMapper extends BaseMapperX { + + default ImGroupRequestDO selectByGroupIdAndUserId(Long groupId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupRequestDO::getGroupId, groupId) + .eq(ImGroupRequestDO::getUserId, userId)); + } + + default List selectListByGroupIdsAndHandleResult(Collection groupIds, Integer handleResult) { + return selectList(new LambdaQueryWrapperX() + .in(ImGroupRequestDO::getGroupId, groupIds) + .eq(ImGroupRequestDO::getHandleResult, handleResult) + .orderByDesc(ImGroupRequestDO::getUpdateTime) + .orderByDesc(ImGroupRequestDO::getId)); + } + + default List selectListByGroupId(Long groupId) { + // 同上,update_time 倒序优先于 id + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupRequestDO::getGroupId, groupId) + .orderByDesc(ImGroupRequestDO::getUpdateTime) + .orderByDesc(ImGroupRequestDO::getId)); + } + + default int updateByIdAndHandleResult(Long id, Integer expectedHandleResult, ImGroupRequestDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .eq(ImGroupRequestDO::getId, id).eq(ImGroupRequestDO::getHandleResult, expectedHandleResult)); + } + + /** + * 复用主动申请的旧记录:覆盖申请理由 / 来源,重置为未处理 + 清空旧处理痕迹 + 刷 update_time + *

+ * update_time 显式 set,因为 update(null, wrapper) 不会触发 MetaObjectHandler.updateFill; + * 列表查询按 update_time 倒序,复用记录必须刷这一列才会排到最前 + */ + default int updateApplyByIdReset(Long id, String applyContent, Integer addSource, LocalDateTime updateTime) { + return update(null, new LambdaUpdateWrapper() + .eq(ImGroupRequestDO::getId, id) + .set(ImGroupRequestDO::getApplyContent, applyContent) + .set(ImGroupRequestDO::getAddSource, addSource) + .set(ImGroupRequestDO::getHandleResult, ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) + .set(ImGroupRequestDO::getInviterUserId, null) + .set(ImGroupRequestDO::getHandleUserId, null) + .set(ImGroupRequestDO::getHandleContent, null) + .set(ImGroupRequestDO::getHandleTime, null) + .set(ImGroupRequestDO::getUpdateTime, updateTime)); + } + + default int updateInviteByIdReset(Long id, Long inviterUserId, Integer addSource, LocalDateTime updateTime) { + return update(null, new LambdaUpdateWrapper() + .eq(ImGroupRequestDO::getId, id) + .set(ImGroupRequestDO::getInviterUserId, inviterUserId) + .set(ImGroupRequestDO::getAddSource, addSource) + .set(ImGroupRequestDO::getHandleResult, ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) + .set(ImGroupRequestDO::getHandleUserId, null) + .set(ImGroupRequestDO::getHandleContent, null) + .set(ImGroupRequestDO::getHandleTime, null) + .set(ImGroupRequestDO::getUpdateTime, updateTime)); + } + + default PageResult selectPage(ImGroupRequestManagerPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImGroupRequestDO::getGroupId, reqVO.getGroupId()) + .eqIfPresent(ImGroupRequestDO::getUserId, reqVO.getUserId()) + .eqIfPresent(ImGroupRequestDO::getInviterUserId, reqVO.getInviterUserId()) + .eqIfPresent(ImGroupRequestDO::getHandleResult, reqVO.getHandleResult()) + .eqIfPresent(ImGroupRequestDO::getAddSource, reqVO.getAddSource()) + .betweenIfPresent(ImGroupRequestDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(ImGroupRequestDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImChannelMessageMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImChannelMessageMapper.java new file mode 100644 index 000000000..81f923889 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImChannelMessageMapper.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessagePageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 频道消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImChannelMessageMapper extends BaseMapperX { + + /** + * 拉取指定用户应收的频道消息 + *

+ * 命中条件:id 大于游标 + (receiver_user_ids 为空表示全员 OR 逗号分隔列表里包含当前 userId) + * + * @param userId 当前用户编号 + * @param minId 游标;返回大于此值的消息 + * @param size 返回条数 + * @return 频道消息列表;按 id 升序 + */ + default List selectListByUserAndMinId(Long userId, Long minId, Integer size) { + return selectList(new LambdaQueryWrapperX() + .gt(ImChannelMessageDO::getId, minId) + .and(w -> w.isNull(ImChannelMessageDO::getReceiverUserIds) + .or().eq(ImChannelMessageDO::getReceiverUserIds, "") + .or().apply("FIND_IN_SET({0}, receiver_user_ids)", userId)) + .orderByAsc(ImChannelMessageDO::getId) + .last("LIMIT " + size)); + } + + default Long selectCountByMaterialId(Long materialId) { + return selectCount(ImChannelMessageDO::getMaterialId, materialId); + } + + default PageResult selectPage(ImChannelMessagePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImChannelMessageDO::getChannelId, reqVO.getChannelId()) + .eqIfPresent(ImChannelMessageDO::getMaterialId, reqVO.getMaterialId()) + .betweenIfPresent(ImChannelMessageDO::getSendTime, reqVO.getSendTime()) + .orderByDesc(ImChannelMessageDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java new file mode 100644 index 000000000..84736fdf9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 群聊消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupMessageMapper extends BaseMapperX { + + /** + * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用) + * + * @param groupIds 用户当前仍在群内的群编号列表 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 + * @param size 拉取数量 + * @return 消息列表(按 id 升序) + */ + default List selectListByMinId(List groupIds, Long minId, + LocalDateTime minSendTime, Integer size) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.in("group_id", groupIds) + .gt("id", minId) + .gt("send_time", minSendTime) + .orderByAsc("id"); + wrapper.limitN(size); + return selectList(wrapper); + } + + /** + * 查询"退群前"的离线消息(退群成员使用) + *

+ * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 + *

+ * 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。 + * + * @param groupId 群编号 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含) + * @param quitTime 退群时间(含),仅返回退群当时已存在的消息 + * @param size 拉取数量 + * @return 消息列表(按 id 升序) + */ + default List selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId, + LocalDateTime minSendTime, + LocalDateTime quitTime, + Integer size) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.eq("group_id", groupId) + .gt("id", minId) + .gt("send_time", minSendTime) + .le("send_time", quitTime) + .orderByAsc("id"); + wrapper.limitN(size); + return selectList(wrapper); + } + + /** + * 查询群聊历史消息(游标拉取) + * + * @param groupId 群编号 + * @param maxId 起始消息 id(不含),为空则从最新开始 + * @param limit 拉取数量 + * @param joinTime 入群时间,仅返回入群之后的消息 + * @return 消息列表(按 id 倒序) + */ + default List selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.eq("group_id", groupId) + .lt(maxId != null, "id", maxId) + .ge(joinTime != null, "send_time", joinTime) + .orderByDesc("id"); + wrapper.limitN(limit); + return selectList(wrapper); + } + + default ImGroupMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupMessageDO::getSenderId, senderId) + .eq(ImGroupMessageDO::getClientMessageId, clientMessageId)); + } + + /** + * 查询群内指定范围内待回执的消息 + *

+ * 仅在用户"已读位置前进"时调用,避免全量扫描: + * 只有位于 (minId, maxId] 范围内、且仍处于 PENDING 的回执消息可能因本次已读而状态变化。 + * + * @param groupId 群编号 + * @param minId 起始消息 id(不含,上一次已读位置) + * @param maxId 结束消息 id(含,本次已读位置) + * @return 待回执消息列表 + */ + default List selectListByGroupIdAndPendingReceipt(Long groupId, Long minId, Long maxId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMessageDO::getGroupId, groupId) + .eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .gt(minId != null, ImGroupMessageDO::getId, minId) + .le(ImGroupMessageDO::getId, maxId) + .ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus())); + } + + default PageResult selectPage(ImGroupMessageManagerPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImGroupMessageDO::getGroupId, reqVO.getGroupId()) + .eqIfPresent(ImGroupMessageDO::getSenderId, reqVO.getSenderId()) + .eqIfPresent(ImGroupMessageDO::getType, reqVO.getType()) + .likeIfPresent(ImGroupMessageDO::getContent, reqVO.getContent()) + .eqIfPresent(ImGroupMessageDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(ImGroupMessageDO::getSendTime, reqVO.getSendTime()) + .orderByDesc(ImGroupMessageDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java new file mode 100644 index 000000000..a33f29f92 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 私聊消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImPrivateMessageMapper extends BaseMapperX { + + /** + * 根据 minId + 时间窗口增量拉取私聊消息 + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 + * @param size 拉取数量 + * @return 消息列表 + */ + default List selectListByMinId(Long userId, Long minId, + LocalDateTime minSendTime, Integer size) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.and(w -> w.eq("sender_id", userId) + .or() + .eq("receiver_id", userId)) + .gt("id", minId) + .gt("send_time", minSendTime) + .orderByAsc("id"); + wrapper.limitN(size); + return selectList(wrapper); + } + + /** + * 查询私聊历史消息(游标拉取) + * + * @param userId 当前用户编号 + * @param receiverId 对方用户编号 + * @param maxId 起始消息 id(不含),为空则从最新开始 + * @param limit 拉取数量 + * @return 消息列表(按 id 倒序) + */ + default List selectHistoryList(Long userId, Long receiverId, Long maxId, Integer limit) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.and(w -> w.eq("sender_id", userId).eq("receiver_id", receiverId) + .or() + .eq("sender_id", receiverId).eq("receiver_id", userId)) + .lt(maxId != null, "id", maxId) + .orderByDesc("id"); + wrapper.limitN(limit); + return selectList(wrapper); + } + + default ImPrivateMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, senderId) + .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); + } + + default Long selectMaxIdBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) { + ImPrivateMessageDO message = selectOne(new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, senderId) + .eq(ImPrivateMessageDO::getReceiverId, receiverId) + .eq(ImPrivateMessageDO::getStatus, status) + .orderByDesc(ImPrivateMessageDO::getId) + .last("LIMIT 1")); + return message != null ? message.getId() : null; + } + + default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId, + Integer whereStatus, ImPrivateMessageDO updateObj) { + return update(updateObj, new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, senderId) + .eq(ImPrivateMessageDO::getReceiverId, receiverId) + .le(ImPrivateMessageDO::getId, maxMessageId) + .eq(ImPrivateMessageDO::getStatus, whereStatus)); + } + + default PageResult selectPage(ImPrivateMessageManagerPageReqVO reqVO) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX<>(); + if (reqVO.getSenderId() != null && reqVO.getReceiverId() != null) { + query.and(w -> w.eq(ImPrivateMessageDO::getSenderId, reqVO.getSenderId()) + .eq(ImPrivateMessageDO::getReceiverId, reqVO.getReceiverId()) + .or() + .eq(ImPrivateMessageDO::getSenderId, reqVO.getReceiverId()) + .eq(ImPrivateMessageDO::getReceiverId, reqVO.getSenderId())); + } else { + query.eqIfPresent(ImPrivateMessageDO::getSenderId, reqVO.getSenderId()) + .eqIfPresent(ImPrivateMessageDO::getReceiverId, reqVO.getReceiverId()); + } + return selectPage(reqVO, query + .eqIfPresent(ImPrivateMessageDO::getType, reqVO.getType()) + .likeIfPresent(ImPrivateMessageDO::getContent, reqVO.getContent()) + .eqIfPresent(ImPrivateMessageDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(ImPrivateMessageDO::getSendTime, reqVO.getSendTime()) + .orderByDesc(ImPrivateMessageDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java new file mode 100644 index 000000000..65f21a55b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.im.dal.mysql.rtc; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo.ImRtcCallManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * IM 通话记录 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImRtcCallMapper extends BaseMapperX { + + default ImRtcCallDO selectByRoom(String room) { + return selectOne(ImRtcCallDO::getRoom, room); + } + + default ImRtcCallDO selectLastOneByGroupIdAndStatusIn(Long groupId, Collection statuses) { + return selectLastOne(new LambdaQueryWrapperX() + .eq(ImRtcCallDO::getGroupId, groupId) + .in(ImRtcCallDO::getStatus, statuses)); + } + + default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcCallDO updateObj) { + return update(updateObj, Wrappers.lambdaUpdate() + .eq(ImRtcCallDO::getId, id) + .eq(ImRtcCallDO::getStatus, oldStatus)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateByIdAndStatusIn(Long id, Collection statuses, ImRtcCallDO updateObj) { + return update(updateObj, Wrappers.lambdaUpdate() + .eq(ImRtcCallDO::getId, id) + .in(ImRtcCallDO::getStatus, statuses)); + } + + default List selectListByStatusInAndStartTimeBefore(Collection statuses, + LocalDateTime startTimeBefore) { + return selectList(new LambdaQueryWrapperX() + .in(ImRtcCallDO::getStatus, statuses) + .lt(ImRtcCallDO::getStartTime, startTimeBefore)); + } + + default PageResult selectPage(ImRtcCallManagerPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImRtcCallDO::getInviterUserId, reqVO.getInviterUserId()) + .eqIfPresent(ImRtcCallDO::getConversationType, reqVO.getConversationType()) + .eqIfPresent(ImRtcCallDO::getMediaType, reqVO.getMediaType()) + .eqIfPresent(ImRtcCallDO::getStatus, reqVO.getStatus()) + .eqIfPresent(ImRtcCallDO::getEndReason, reqVO.getEndReason()) + .betweenIfPresent(ImRtcCallDO::getStartTime, reqVO.getStartTime()) + .orderByDesc(ImRtcCallDO::getId)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java new file mode 100644 index 000000000..49d8c12ff --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.im.dal.mysql.rtc; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * IM 通话参与者 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImRtcParticipantMapper extends BaseMapperX { + + default ImRtcParticipantDO selectByRoomAndUserId(String room, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getRoom, room) + .eq(ImRtcParticipantDO::getUserId, userId)); + } + + default List selectListByRoom(String room) { + return selectList(ImRtcParticipantDO::getRoom, room); + } + + default List selectListByCallId(Long callId) { + return selectList(ImRtcParticipantDO::getCallId, callId); + } + + default List selectListByStatusAndInviteTimeBefore(Integer status, LocalDateTime threshold) { + return selectList(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getStatus, status) + .lt(ImRtcParticipantDO::getInviteTime, threshold)); + } + + default List selectListByRoomAndStatusAndInviteTimeBefore(String room, Integer status, LocalDateTime threshold) { + return selectList(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getRoom, room) + .eq(ImRtcParticipantDO::getStatus, status) + .lt(ImRtcParticipantDO::getInviteTime, threshold)); + } + + default ImRtcParticipantDO selectLastOneByUserIdAndStatus(Long userId, Collection statuses) { + return selectLastOne(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getUserId, userId) + .in(ImRtcParticipantDO::getStatus, statuses)); + } + + default ImRtcParticipantDO selectLastOneByUserIdAndStatusInAndRoomNot(Long userId, Collection statuses, String room) { + return selectLastOne(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getUserId, userId) + .in(ImRtcParticipantDO::getStatus, statuses) + .ne(ImRtcParticipantDO::getRoom, room)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcParticipantDO updateObj) { + return update(updateObj, Wrappers.lambdaUpdate() + .eq(ImRtcParticipantDO::getId, id) + .eq(ImRtcParticipantDO::getStatus, oldStatus)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateByRoomAndStatus(String room, Integer oldStatus, ImRtcParticipantDO updateObj) { + return update(updateObj, Wrappers.lambdaUpdate() + .eq(ImRtcParticipantDO::getRoom, room) + .eq(ImRtcParticipantDO::getStatus, oldStatus)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java new file mode 100644 index 000000000..ccf3f2bb4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.im.dal.mysql.sensitiveword; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 敏感词 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImSensitiveWordMapper extends BaseMapperX { + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImSensitiveWordDO::getStatus, status)); + } + + default ImSensitiveWordDO selectByWord(String word) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImSensitiveWordDO::getWord, word)); + } + + default PageResult selectPage(ImSensitiveWordPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ImSensitiveWordDO::getWord, reqVO.getWord()) + .eqIfPresent(ImSensitiveWordDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(ImSensitiveWordDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(ImSensitiveWordDO::getId)); + } + + @Select("SELECT MAX(update_time) FROM im_sensitive_word") + LocalDateTime selectMaxUpdateTime(@Param("tenantId") Long tenantId); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapper.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapper.java new file mode 100644 index 000000000..141f6b8c9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapper.java @@ -0,0 +1,191 @@ +package cn.iocoder.yudao.module.im.dal.mysql.statistics; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * IM 数据看板 Mapper + *

+ * 独立于业务 Mapper:所有统计 SQL 集中在此,仅服务于 manager 看板,不被其它业务调用,保持各业务 Mapper / Service 不受统计需求污染。 + * + * @author 芋道源码 + */ +@Mapper +public interface ImStatisticsManagerMapper { + + String NORMAL_MESSAGE_CONDITION = "type IN (101,102,103,104,105,107,108,115,125) AND status <> 2"; + + // ==================== 用户 ==================== + + /** + * 用户总数(system_users) + */ + @Select("SELECT COUNT(*) FROM system_users WHERE deleted = 0") + Long selectTotalUserCount(); + + /** + * 区间内新增用户数 + */ + @Select("SELECT COUNT(*) FROM system_users " + + "WHERE deleted = 0 AND create_time >= #{beginTime} AND create_time < #{endTime}") + Long selectNewUserCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内活跃用户数:私聊或群聊发过消息的去重用户数 + */ + @Select("SELECT COUNT(DISTINCT user_id) FROM (" + + " SELECT sender_id AS user_id FROM im_private_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + " UNION ALL " + + " SELECT sender_id AS user_id FROM im_group_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + ") t") + Long selectActiveUserCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内每日新增用户数(按天分组) + * + * @return [{date: "yyyy-MM-dd", count: 123}, ...] + */ + @Select("SELECT DATE(create_time) AS date, COUNT(*) AS count FROM system_users " + + "WHERE deleted = 0 AND create_time >= #{beginTime} AND create_time < #{endTime} " + + "GROUP BY DATE(create_time)") + List> selectNewUserDailyCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内每日活跃用户数(按天分组、跨私聊+群聊去重) + */ + @Select("SELECT day AS date, COUNT(DISTINCT user_id) AS count FROM (" + + " SELECT DATE(send_time) AS day, sender_id AS user_id FROM im_private_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + " UNION ALL " + + " SELECT DATE(send_time) AS day, sender_id AS user_id FROM im_group_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + ") t GROUP BY day") + List> selectActiveUserDailyCount(@Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime); + + // ==================== 群 ==================== + + /** + * 当前有效群总数 + */ + @Select("SELECT COUNT(*) FROM im_group WHERE deleted = 0 AND status = 0") + Long selectTotalGroupCount(); + + /** + * 区间内新建群数 + */ + @Select("SELECT COUNT(*) FROM im_group " + + "WHERE deleted = 0 AND create_time >= #{beginTime} AND create_time < #{endTime}") + Long selectNewGroupCount(@Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime); + + /** + * 群规模分布(按群成员数分桶) + * + * @return [{range: "1-9 人", count: 123}, ...] + */ + @Select("SELECT " + + " CASE " + + " WHEN cnt < 10 THEN '1-9 人' " + + " WHEN cnt < 50 THEN '10-49 人' " + + " WHEN cnt < 200 THEN '50-199 人' " + + " ELSE '200+ 人' " + + " END AS `range`, COUNT(*) AS count " + + "FROM (" + + " SELECT g.id, COUNT(m.id) AS cnt " + + " FROM im_group g " + + " LEFT JOIN im_group_member m ON m.group_id = g.id AND m.status = 0 AND m.deleted = 0 " + + " WHERE g.deleted = 0 AND g.status = 0 " + + " GROUP BY g.id" + + ") t " + + "GROUP BY `range`") + List> selectGroupSizeDistribution(); + + // ==================== 消息 ==================== + + /** + * 区间内私聊消息数 + */ + @Select("SELECT COUNT(*) FROM im_private_message " + + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}") + Long selectPrivateMessageCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内群聊消息数 + */ + @Select("SELECT COUNT(*) FROM im_group_message " + + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}") + Long selectGroupMessageCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内每日私聊消息数(按天分组) + */ + @Select("SELECT DATE(send_time) AS date, COUNT(*) AS count FROM im_private_message " + + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime} " + + "GROUP BY DATE(send_time)") + List> selectPrivateMessageDailyCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内每日群聊消息数(按天分组) + */ + @Select("SELECT DATE(send_time) AS date, COUNT(*) AS count FROM im_group_message " + + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime} " + + "GROUP BY DATE(send_time)") + List> selectGroupMessageDailyCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内消息类型分布(私聊+群聊合并) + * + * @return [{type: 0, count: 123}, ...] + */ + @Select("SELECT type, COUNT(*) AS count FROM (" + + " SELECT type FROM im_private_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + " UNION ALL " + + " SELECT type FROM im_group_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + ") t GROUP BY type") + List> selectMessageTypeDistribution(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 区间内 TOP 发送者(私聊+群聊合并,按消息数倒序) + * + * @return [{userId: 1024, messageCount: 1500}, ...] + */ + @Select("SELECT user_id AS userId, COUNT(*) AS messageCount FROM (" + + " SELECT sender_id AS user_id FROM im_private_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + " UNION ALL " + + " SELECT sender_id AS user_id FROM im_group_message " + + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION + + " AND send_time >= #{beginTime} AND send_time < #{endTime}" + + ") t GROUP BY user_id ORDER BY messageCount DESC LIMIT #{limit}") + List> selectTopSenders(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime, + @Param("limit") int limit); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java new file mode 100644 index 000000000..247ad33ce --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.im.dal.redis; + + +/** + * IM Redis Key 枚举类 + * + * @author 芋道源码 + */ +public interface RedisKeyConstants { + + /** + * 群消息已读位置 + * KEY 格式: im:group:message:read:{groupId} + * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId) + */ + String GROUP_MESSAGE_READ = "im:group:message:read:%s"; + + /** + * 频道消息已读位置 + * KEY 格式: im:channel:message:read:{channelId} + * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId) + */ + String CHANNEL_MESSAGE_READ = "im:channel:message:read:%s"; + + /** + * 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态) + *

+ * KEY 格式:friend_state:{userId}_{friendUserId} + * VALUE 数据类型:{@link cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum} + */ + String FRIEND_STATE = "friend_state"; + + /** + * 群信息缓存 + *

+ * KEY 格式:group:{groupId} + * VALUE 数据类型:ImGroupDO + */ + String GROUP = "group"; + + /** + * 群有效成员 userId 列表缓存(仅 ENABLE 状态) + *

+ * KEY 格式:group_member_ids:{groupId} + * VALUE 数据类型:List + *

+ * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。 + */ + String GROUP_MEMBER_IDS = "group_member_ids"; + + /** + * 通话同对 / 同群活跃唯一性的分布式锁 + *

+ * KEY 格式:im_rtc_call:{conversationType}:{suffix} + * 私聊(conversationType=1):suffix = {小 userId}_{大 userId} + * 群聊(conversationType=2):suffix = {groupId} + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 + * 过期时间:不固定(lock 时显式传 timeoutMillis) + */ + String IM_RTC_CALL_LOCK = "im_rtc_call:%d:%s"; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java new file mode 100644 index 000000000..bda4b3326 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.im.dal.redis.message; + +import cn.hutool.core.convert.Convert; +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.CHANNEL_MESSAGE_READ; + +/** + * IM 频道消息已读位置 Redis DAO + * + * @author 芋道源码 + */ +@Repository +public class ImChannelMessageReadRedisDAO { + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 更新用户在某频道的最大已读消息编号 + * + * @param channelId 频道编号 + * @param userId 用户编号 + * @param maxMessageId 最大已读消息编号 + */ + public void updateReadMaxMessageId(Long channelId, Long userId, Long maxMessageId) { + String key = formatKey(channelId); + stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); + } + + /** + * 获取用户在某频道的最大已读消息编号 + * + * @param channelId 频道编号 + * @param userId 用户编号 + * @return 最大已读消息编号;不存在则返回 null + */ + public Long getReadMaxMessageId(Long channelId, Long userId) { + String key = formatKey(channelId); + Object val = stringRedisTemplate.opsForHash().get(key, userId.toString()); + return Convert.toLong(val); + } + + private static String formatKey(Long channelId) { + return String.format(CHANNEL_MESSAGE_READ, channelId); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java new file mode 100644 index 000000000..5c76f7906 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.im.dal.redis.message; + +import cn.hutool.core.convert.Convert; +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ; + +/** + * IM 群消息已读位置 Redis DAO + * + * @author 芋道源码 + */ +@Repository +public class ImGroupMessageReadRedisDAO { + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 更新用户在某群的最大已读消息编号 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @param maxMessageId 最大已读消息编号 + */ + public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) { + String key = formatKey(groupId); + stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); + } + + /** + * 获取用户在某群的最大已读消息编号 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 最大已读消息编号,不存在则返回 null + */ + public Long getReadMaxMessageId(Long groupId, Long userId) { + String key = formatKey(groupId); + Object val = stringRedisTemplate.opsForHash().get(key, userId.toString()); + return Convert.toLong(val); + } + + /** + * 获取某群所有用户的已读位置 + * + * @param groupId 群编号 + * @return userId → maxReadMessageId 映射 + */ + public Map getReadMaxMessageIdMap(Long groupId) { + String key = formatKey(groupId); + Map entries = stringRedisTemplate.opsForHash().entries(key); + // 转换为 Long → Long 的 Map + Map result = new HashMap<>(entries.size()); + entries.forEach((k, v) -> result.put(Long.parseLong(k.toString()), Long.parseLong(v.toString()))); + return result; + } + + /** + * 删除用户在某群的已读位置 + * + * @param groupId 群编号 + * @param userId 用户编号 + */ + public void deleteReadMaxMessageId(Long groupId, Long userId) { + String key = formatKey(groupId); + stringRedisTemplate.opsForHash().delete(key, userId.toString()); + } + + /** + * 批量删除用户在某群的已读位置 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { + String key = formatKey(groupId); + Object[] hashKeys = userIds.stream().map(String::valueOf).toArray(); + stringRedisTemplate.opsForHash().delete(key, hashKeys); + } + + /** + * 删除某群所有用户的已读位置(整个 Hash Key) + * + * @param groupId 群编号 + */ + public void deleteReadMaxMessageIdMap(Long groupId) { + String key = formatKey(groupId); + stringRedisTemplate.delete(key); + } + + private static String formatKey(Long groupId) { + return String.format(GROUP_MESSAGE_READ, groupId); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java new file mode 100644 index 000000000..0384bf471 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.im.dal.redis.rtc; + +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.IM_RTC_CALL_LOCK; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_INVITE_BUSY; + +/** + * IM 通话同对 / 同群活跃唯一性的锁 Redis DAO + *

+ * invite 入口包一层;锁内做「SELECT 已有活跃通话 → 命中即加入分支;否则 INSERT 新通话」 + * + * @author 芋道源码 + */ +@Repository +@Slf4j +public class ImRtcCallLockRedisDAO { + + /** + * 等待获取锁的最长时间;超时抛 RTC_INVITE_BUSY + */ + private static final long LOCK_WAIT_MS = 5_000L; + /** + * 持有锁的最长时间;自动释放兜底;给 LiveKit / DB / 推送偶发慢留余地 + */ + private static final long LOCK_LEASE_MS = 30_000L; + + @Resource + private RedissonClient redissonClient; + + /** + * 私聊通话锁;按 userId 排序拼 key 保对称(同对呼叫和反向呼叫共用一把锁) + */ + public V lockPrivate(Long userIdA, Long userIdB, Callable callable) throws Exception { + String key = String.format(IM_RTC_CALL_LOCK, ImConversationTypeEnum.PRIVATE.getType(), + Math.min(userIdA, userIdB)+ "_" + Math.max(userIdA, userIdB)); + return doLock(key, callable); + } + + /** + * 群通话锁;同群所有 invite 串行 + */ + public V lockGroup(Long groupId, Callable callable) throws Exception { + String key = String.format(IM_RTC_CALL_LOCK, ImConversationTypeEnum.GROUP.getType(), groupId); + return doLock(key, callable); + } + + /** + * tryLock(waitTime, leaseTime, unit):waitTime 内拿不到锁直接抛繁忙;拿到后 leaseTime 自动释放 + *

+ * unlock 前用 isHeldByCurrentThread 兜底;业务超过 leaseTime 时锁已自动释放,不再抛 IllegalMonitorStateException + */ + private V doLock(String lockKey, Callable callable) throws Exception { + RLock lock = redissonClient.getLock(lockKey); + boolean acquired = lock.tryLock(LOCK_WAIT_MS, LOCK_LEASE_MS, TimeUnit.MILLISECONDS); + if (!acquired) { + log.error("[doLock][lockKey={} 等待 {}ms 仍未获取到锁]", lockKey, LOCK_WAIT_MS); + throw exception(RTC_INVITE_BUSY); + } + try { + return callable.call(); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } else { + log.error("[doLock][lockKey={} 业务超过 leaseTime={}ms,锁已被 Redisson 自动释放]", lockKey, LOCK_LEASE_MS); + } + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java new file mode 100644 index 000000000..eb34dabc0 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java @@ -0,0 +1,108 @@ +package cn.iocoder.yudao.module.im.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * IM 错误码枚举类 + *

+ * im 系统,使用 1-040-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== 消息 (1-040-300-000) ========== + ErrorCode MESSAGE_NOT_EXISTS = new ErrorCode(1_040_300_000, "消息不存在"); + ErrorCode MESSAGE_RECALL_DENIED = new ErrorCode(1_040_300_002, "只能撤回自己发送的消息"); + ErrorCode MESSAGE_ALREADY_RECALLED = new ErrorCode(1_040_300_003, "消息已撤回"); + ErrorCode MESSAGE_SENSITIVE_WORD_BLOCKED = new ErrorCode(1_040_300_004, "消息包含敏感词,无法发送"); + ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条"); + ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); + ErrorCode MESSAGE_QUOTE_INVALID = new ErrorCode(1_040_300_008, "引用的消息不可用"); + ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); + ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭"); + ErrorCode MESSAGE_GROUP_READ_DISABLED = new ErrorCode(1_040_300_011, "群聊已读功能已关闭"); + ErrorCode MESSAGE_CONTENT_INVALID = new ErrorCode(1_040_300_013, "消息内容格式不正确"); + + // ========== 群 (1-040-400-000) ========== + ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); + ErrorCode GROUP_BANNED = new ErrorCode(1_040_400_001, "群已被封禁"); + ErrorCode GROUP_DISSOLVED = new ErrorCode(1_040_400_002, "群已解散"); + ErrorCode GROUP_NOT_OWNER = new ErrorCode(1_040_400_003, "仅群主可执行该操作"); + ErrorCode GROUP_NOT_OWNER_OR_ADMIN = new ErrorCode(1_040_400_004, "仅群主或管理员可执行该操作"); + ErrorCode GROUP_TRANSFER_OWNER_TO_SELF = new ErrorCode(1_040_400_005, "不能将群主转让给自己"); + ErrorCode GROUP_MESSAGE_PIN_MAX_LIMIT = new ErrorCode(1_040_400_006, "群置顶消息数量不能超过 {} 条"); + ErrorCode GROUP_MESSAGE_ALREADY_PINNED = new ErrorCode(1_040_400_007, "该消息已置顶"); + ErrorCode GROUP_MESSAGE_NOT_PINNED = new ErrorCode(1_040_400_008, "该消息未置顶"); + ErrorCode GROUP_MESSAGE_PIN_DIRECTED_DENIED = new ErrorCode(1_040_400_009, "定向消息不支持置顶"); + + // ========== 群成员 (1-040-500-000) ========== + ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); + ErrorCode GROUP_OWNER_CANNOT_QUIT = new ErrorCode(1_040_500_003, "群主不能退出群聊,请先转让群主或解散群聊"); + ErrorCode GROUP_CANNOT_REMOVE_SELF = new ErrorCode(1_040_500_004, "不能将自己移出群聊"); + ErrorCode GROUP_MEMBER_EXCEED = new ErrorCode(1_040_500_005, "群聊人数不能超过 {} 人"); + ErrorCode GROUP_INVITE_NOT_FRIEND = new ErrorCode(1_040_500_006, "'{}' 不是您的好友,邀请失败"); + ErrorCode GROUP_ADMIN_TARGET_NOT_IN_GROUP = new ErrorCode(1_040_500_007, "目标用户已不在该群中"); + ErrorCode GROUP_ADMIN_TARGET_IS_OWNER = new ErrorCode(1_040_500_008, "群主无法被设为或撤销管理员"); + ErrorCode GROUP_ADMIN_MAX_LIMIT = new ErrorCode(1_040_500_009, "群管理员数量不能超过 {} 人"); + ErrorCode GROUP_REMOVE_OWNER_DENIED = new ErrorCode(1_040_500_010, "群主无法被移出群聊"); + ErrorCode GROUP_REMOVE_ADMIN_DENIED = new ErrorCode(1_040_500_011, "管理员无法移出其他管理员,请先由群主撤销其管理员身份"); + ErrorCode GROUP_MUTED_CANNOT_SEND = new ErrorCode(1_040_500_012, "群已全局禁言,仅群主和管理员可发送消息"); + ErrorCode GROUP_MEMBER_MUTED_CANNOT_SEND = new ErrorCode(1_040_500_013, "您已被禁言,解除时间:{}"); + ErrorCode GROUP_MUTE_MEMBER_SELF = new ErrorCode(1_040_500_014, "不能禁言自己"); + ErrorCode GROUP_MUTE_OWNER_DENIED = new ErrorCode(1_040_500_015, "群主无法被禁言"); + ErrorCode GROUP_MUTE_ADMIN_DENIED = new ErrorCode(1_040_500_016, "管理员无法禁言其他管理员"); + + // ========== 好友 (1-040-600-000) ========== + ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友"); + ErrorCode FRIEND_ADD_SELF = new ErrorCode(1_040_600_002, "不允许添加自己为好友"); + ErrorCode FRIEND_NOT_BLOCKED = new ErrorCode(1_040_600_003, "对方未在黑名单中"); + ErrorCode FRIEND_BLOCKED_BY_PEER = new ErrorCode(1_040_600_004, "您已被对方拉入黑名单,无法发送消息"); + + // ========== 加群申请 (1-040-510-000) ========== + ErrorCode GROUP_REQUEST_NOT_EXISTS = new ErrorCode(1_040_510_001, "加群申请不存在"); + ErrorCode GROUP_REQUEST_HANDLED = new ErrorCode(1_040_510_002, "加群申请已处理"); + ErrorCode GROUP_REQUEST_NOT_TO_ME = new ErrorCode(1_040_510_003, "仅群主或管理员可处理加群申请"); + ErrorCode GROUP_REQUEST_ALREADY_MEMBER = new ErrorCode(1_040_510_004, "您已在该群中,无需重复申请"); + + // ========== 好友申请 (1-040-610-000) ========== + ErrorCode FRIEND_REQUEST_NOT_EXISTS = new ErrorCode(1_040_610_001, "好友申请不存在"); + ErrorCode FRIEND_REQUEST_HANDLED = new ErrorCode(1_040_610_002, "好友申请已处理"); + ErrorCode FRIEND_REQUEST_NOT_TO_ME = new ErrorCode(1_040_610_003, "不能处理别人的好友申请"); + ErrorCode FRIEND_REQUEST_ALREADY_FRIEND = new ErrorCode(1_040_610_005, "您已是 TA 的好友,无需重复添加"); + ErrorCode FRIEND_REQUEST_BLOCKED_BY_PEER = new ErrorCode(1_040_610_006, "您已被对方拉入黑名单,无法添加为好友"); + + // ========== 敏感词 (1-040-700-000) ========== + ErrorCode SENSITIVE_WORD_NOT_EXISTS = new ErrorCode(1_040_700_000, "敏感词不存在"); + ErrorCode SENSITIVE_WORD_DUPLICATED = new ErrorCode(1_040_700_001, "敏感词 '{}' 已存在"); + + // ========== 表情包 (1-040-800-000) ========== + ErrorCode FACE_PACK_NOT_EXISTS = new ErrorCode(1_040_800_000, "表情包不存在"); + ErrorCode FACE_PACK_HAS_ITEMS = new ErrorCode(1_040_800_001, "表情包下还有表情,无法删除"); + ErrorCode FACE_PACK_ITEM_NOT_EXISTS = new ErrorCode(1_040_800_002, "表情不存在"); + ErrorCode FACE_USER_ITEM_NOT_EXISTS = new ErrorCode(1_040_800_010, "个人表情不存在"); + ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情"); + ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情"); + ErrorCode FACE_USER_ITEM_MAX_LIMIT = new ErrorCode(1_040_800_014, "个人表情数量不能超过 {} 个"); + + // ========== 频道 (1-040-810-000) ========== + ErrorCode IM_CHANNEL_NOT_EXISTS = new ErrorCode(1_040_810_000, "频道不存在"); + ErrorCode IM_CHANNEL_CODE_DUPLICATED = new ErrorCode(1_040_810_001, "频道编码 '{}' 已存在"); + ErrorCode IM_CHANNEL_HAS_MATERIAL = new ErrorCode(1_040_810_002, "频道下还有素材,无法删除"); + ErrorCode IM_CHANNEL_MATERIAL_NOT_EXISTS = new ErrorCode(1_040_810_010, "素材不存在"); + ErrorCode IM_CHANNEL_MATERIAL_USED = new ErrorCode(1_040_810_011, "素材已被推送过,无法删除"); + ErrorCode IM_CHANNEL_MESSAGE_NOT_EXISTS = new ErrorCode(1_040_810_020, "频道消息不存在"); + + // ========== 实时通话 (1-040-900-000) ========== + ErrorCode RTC_NOT_ENABLED = new ErrorCode(1_040_900_000, "通话功能未开启"); + ErrorCode RTC_SESSION_NOT_EXISTS = new ErrorCode(1_040_900_001, "通话已结束"); + ErrorCode RTC_PEER_BUSY = new ErrorCode(1_040_900_002, "对方正在通话中"); + ErrorCode RTC_SELF_BUSY = new ErrorCode(1_040_900_003, "您正在通话中"); + ErrorCode RTC_NOT_PARTICIPANT = new ErrorCode(1_040_900_004, "您不在该通话中"); + ErrorCode RTC_INVITE_SELF = new ErrorCode(1_040_900_005, "不能呼叫自己"); + ErrorCode RTC_PRIVATE_INVITEE_REQUIRED = new ErrorCode(1_040_900_006, "私聊通话必须指定对方"); + ErrorCode RTC_GROUP_REQUIRED = new ErrorCode(1_040_900_007, "群聊通话必须指定群编号"); + ErrorCode RTC_INVITE_BUSY = new ErrorCode(1_040_900_008, "通话发起繁忙,请稍后再试"); + ErrorCode RTC_GROUP_CALL_ACTIVE = new ErrorCode(1_040_900_009, "该群已有进行中通话,请通过胶囊条加入"); + ErrorCode RTC_GROUP_INVITEE_OVER_LIMIT = new ErrorCode(1_040_900_010, "群通话邀请人数超过最大值"); + ErrorCode RTC_GROUP_INVITEE_REQUIRED = new ErrorCode(1_040_900_011, "群通话必须选择被邀请人"); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java new file mode 100644 index 000000000..404d2fb33 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.im.enums; + +/** + * IM 通用常量 + * + * @author 芋道源码 + */ +public interface ImCommonConstants { + + /** + * 群消息 @ 所有人的特殊用户编号 + *

+ * 前后端协议契约值;atUserIds 数组里出现该值表示 @ 全体成员 + */ + long AT_USER_ID_ALL = -1L; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java new file mode 100644 index 000000000..0f3f446ec --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.im.enums; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Objects; + +/** + * IM 会话类型枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImConversationTypeEnum implements ArrayValuable { + + PRIVATE(1, "私聊"), // 私聊 + GROUP(2, "群聊"), // 群聊 + CHANNEL(3, "频道"); // 频道 / 公众号 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImConversationTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isPrivate(Integer type) { + return Objects.equals(PRIVATE.type, type); + } + + public static boolean isGroup(Integer type) { + return Objects.equals(GROUP.type, type); + } + + public static boolean isChannel(Integer type) { + return Objects.equals(CHANNEL.type, type); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/channel/ImChannelMaterialTypeEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/channel/ImChannelMaterialTypeEnum.java new file mode 100644 index 000000000..55982c752 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/channel/ImChannelMaterialTypeEnum.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.enums.channel; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IM 频道素材内容类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImChannelMaterialTypeEnum implements ArrayValuable { + + /** + * 站内富文本;点击素材在客户端内置详情页拉 content 渲染 + */ + CONTENT(1, "站内富文本"), + /** + * 外链;点击素材跳 url 打开浏览器 + */ + LINK(2, "外链"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImChannelMaterialTypeEnum::getType).toArray(Integer[]::new); + + private final Integer type; + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendAddSourceEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendAddSourceEnum.java new file mode 100644 index 000000000..55ad7ac00 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendAddSourceEnum.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.im.enums.friend; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 好友添加来源枚举 + *

+ * 由发起方调用 apply 接口时传入;同意后同步写入 im_friend.add_source(双向) + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImFriendAddSourceEnum implements ArrayValuable { + + SEARCH(1, "搜索"), // FriendAddDialog 搜索流程 + GROUP(2, "群聊"), // 群成员主页 → UserInfo「加为好友」入口 + QR_CODE(3, "扫码"), // TODO @芋艿:后续实现扫码加好友 + CARD(4, "名片"); // TODO @芋艿:后续实现通过名片加好友,类似微信的「扫一扫 - 名片」功能,或者「通讯录 - 推荐好友」功能 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImFriendAddSourceEnum::getSource).toArray(Integer[]::new); + + /** + * 来源 + */ + private final Integer source; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendRequestHandleResultEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendRequestHandleResultEnum.java new file mode 100644 index 000000000..d5d2bf8a1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendRequestHandleResultEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.im.enums.friend; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Objects; + +/** + * IM 好友申请处理结果枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImFriendRequestHandleResultEnum implements ArrayValuable { + + UNHANDLED(0, "未处理"), + AGREED(1, "同意"), + REFUSED(2, "拒绝"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImFriendRequestHandleResultEnum::getResult).toArray(Integer[]::new); + + /** + * 结果 + */ + private final Integer result; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + /** + * 判断申请是否还未处理 + */ + public static boolean isUnhandled(Integer result) { + return Objects.equals(UNHANDLED.result, result); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendStateEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendStateEnum.java new file mode 100644 index 000000000..888f6a9dc --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendStateEnum.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.im.enums.friend; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Objects; + +/** + * IM 好友关系状态(合并「是否好友」+「是否拉黑」两态) + *

+ * 用 state 而非 status:避免和 ImFriendDO 的 status 物理字段(CommonStatusEnum:ENABLE / DISABLE)混淆。 + *

+ * 用于 ImFriendService.getFriendState 返回值与 FRIEND_STATE 缓存值:私聊发送热点路径下,sender 调缓存即可同时判定「能否发」和「是否屏蔽接收方」 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImFriendStateEnum implements ArrayValuable { + + NONE(0, "非好友 / 已删除"), + FRIEND(1, "好友"), + BLOCKED(2, "好友且已被对方拉黑"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImFriendStateEnum::getState).toArray(Integer[]::new); + + /** + * 状态值 + */ + private final Integer state; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isNone(Integer state) { + return Objects.equals(NONE.state, state); + } + + public static boolean isFriend(Integer state) { + return Objects.equals(FRIEND.state, state); + } + + public static boolean isBlocked(Integer state) { + return Objects.equals(BLOCKED.state, state); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupAddSourceEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupAddSourceEnum.java new file mode 100644 index 000000000..d12e098c2 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupAddSourceEnum.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.im.enums.group; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 加入群聊来源枚举 + *

+ * 由发起方在申请 / 邀请时传入;同意后同步写入 ImGroupMemberDO 的 addSource 字段 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImGroupAddSourceEnum implements ArrayValuable { + + SEARCH(1, "搜索"), // TODO @芋艿:SEARCH 暂未实现,原因 - 搜群入口尚未开发 + INVITE(2, "邀请"), + QR_CODE(3, "扫码"), // TODO @芋艿:QR_CODE 暂未实现,原因 - 群二维码扫码进群入口尚未开发 + SHARE_LINK(4, "分享链接"); // TODO @芋艿:SHARE_LINK 暂未实现,原因 - 群分享链接进群入口尚未开发 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupAddSourceEnum::getSource).toArray(Integer[]::new); + + /** + * 来源 + */ + private final Integer source; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupMemberRoleEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupMemberRoleEnum.java new file mode 100644 index 000000000..48a4e790f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupMemberRoleEnum.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.im.enums.group; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Objects; + +/** + * IM 群成员角色枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImGroupMemberRoleEnum implements ArrayValuable { + + OWNER(1, "群主"), + ADMIN(2, "管理员"), + NORMAL(3, "普通成员"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMemberRoleEnum::getRole).toArray(Integer[]::new); + + /** + * 角色 + */ + private final Integer role; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isOwner(Integer role) { + return Objects.equals(OWNER.role, role); + } + + public static boolean isAdmin(Integer role) { + return Objects.equals(ADMIN.role, role); + } + + public static boolean isOwnerOrAdmin(Integer role) { + return isOwner(role) || isAdmin(role); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupRequestHandleResultEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupRequestHandleResultEnum.java new file mode 100644 index 000000000..1be6df533 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/group/ImGroupRequestHandleResultEnum.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.im.enums.group; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Objects; + +/** + * IM 加群申请处理结果枚举 + *

+ * 取值与 {@link cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum} 平行,便于复用心智模型 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImGroupRequestHandleResultEnum implements ArrayValuable { + + UNHANDLED(0, "未处理"), + AGREED(1, "同意"), + REFUSED(2, "拒绝"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupRequestHandleResultEnum::getResult).toArray(Integer[]::new); + + /** + * 结果 + */ + private final Integer result; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + /** + * 判断申请是否还未处理 + */ + public static boolean isUnhandled(Integer result) { + return Objects.equals(UNHANDLED.result, result); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java new file mode 100644 index 000000000..256560cd6 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IM 群消息回执状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable { + + NO_RECEIPT(0, "不需要回执"), + PENDING(1, "待完成"), + DONE(2, "已完成"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java new file mode 100644 index 000000000..79cb8d82a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 消息状态枚举 + *

+ * 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3) + * 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2) + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImMessageStatusEnum implements ArrayValuable { + + SENDING(-1, "发送中"), // 仅客户端使用 + UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态) + RECALL(2, "已撤回"), + READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java new file mode 100644 index 000000000..5b1b44667 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java @@ -0,0 +1,425 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Set; + +/** + * IM 消息类型枚举 + * + * @author 芋道源码 + */ +@Getter +@RequiredArgsConstructor +public enum ImMessageTypeEnum implements ArrayValuable { + + // ========== 用户聊天消息(101-105 直接复用 OpenIM 段位编号) ========== + /** + * 对应 OpenIM:Text 101 + * 对应自己的类:TextMessage + */ + TEXT(101, "文本", true, true), + /** + * 对应 OpenIM:Picture 102 + * 对应自己的类:ImageMessage + */ + IMAGE(102, "图片", true, true), + /** + * 对应 OpenIM:Sound 103 + * 对应自己的类:AudioMessage + */ + VOICE(103, "语音", true, true), + /** + * 对应 OpenIM:Video 104 + * 对应自己的类:VideoMessage + */ + VIDEO(104, "视频", true, true), + /** + * 对应 OpenIM:File 105 + * 对应自己的类:FileMessage + */ + FILE(105, "文件", true, true), + /** + * 对应 OpenIM:Merger 107 + * 对应自己的类:MergeMessage + */ + MERGE(107, "合并转发", true, true), + /** + * 对应 OpenIM:Card 108(OpenIM 仅用户名片;本系统扩展为用户 / 群双类型,按 targetType 区分) + * 对应自己的类:CardMessage + * 场景:把用户名片 / 群名片推荐给其他会话;用户名片点击打开 UserInfoCard,群名片点击「已加群跳会话 / 未加群弹申请加群」 + */ + CARD(108, "名片", true, true), + /** + * 对应 OpenIM:Face 115 + * 对应自己的类:FaceMessage + * 场景:表情贴图(运营配置的系统表情包 + 用户私有表情包);Unicode emoji 仍走 TEXT + */ + FACE(115, "表情", true, true), + + // ========== 频道消息扩展段(125+;OpenIM 122 之后未占用,本系统在 125 起步给频道 / 公众号类消息扩展) ========== + /** + * 对应 OpenIM:无(125 段位 OpenIM 未占用,作为频道消息扩展起始位) + * 对应自己的类:MaterialMessage + * 场景:频道运营推送的素材消息;当前形态为图文卡片(title + coverUrl + summary + url) + * 详情:url 非空跳 url;url 为空时客户端按 materialId 拉 /get-content 渲染富文本正文 + */ + MATERIAL(125, "素材", true, true), + + // ========== 信号类(2101 / 2200 直接复用 OpenIM 段位编号;2201 自有扩展) ========== + /** + * 对应 OpenIM:RevokeNotification 2101 + * 对应自己的类:RecallMessage + */ + RECALL(2101, "撤回", true, false), + /** + * 对应 OpenIM:HasReadReceipt 2200 + * 对应自己的类:无(payload 走 ImXxxMessageDTO 顶层字段) + */ + RECEIPT(2200, "回执", false, false), + /** + * 对应 OpenIM:无(自有扩展,OpenIM 走 ConversationChangeNotification 1300 路径) + * 对应自己的类:无(payload 走 ImXxxMessageDTO 顶层字段) + */ + READ(2201, "已读", false, false), + + // ========== 实时通话信令(1601-1605 段位与 OpenIM 对齐;1610+ 自有扩展) ========== + /** + * 对应 OpenIM:SignalingNotification 1601(通话信令统一入口) + * 对应自己的类:ImRtcCallNotification + * 场景:通话信令;不入库,走 imWebSocketService 仅推参与方;status 复用参与者状态枚举区分 INVITING / JOINED / REJECTED / NO_ANSWER / LEFT + */ + RTC_CALL(1601, "通话信令", false, false), + /** + * 对应 OpenIM:RoomParticipantsConnectedNotification 1602 + * 对应自己的类:ImRtcParticipantConnectedNotification + * 场景:通话参与者加入;LiveKit webhook participant_joined 触发;私聊推 peer 多端 + inviter 多端,群聊全群广播;不入库 + */ + RTC_PARTICIPANT_CONNECTED(1602, "通话参与者加入", false, false), + /** + * 对应 OpenIM:RoomParticipantsDisconnectedNotification 1603 + * 对应自己的类:ImRtcParticipantDisconnectedNotification + * 场景:通话参与者离开;LiveKit webhook participant_left 触发;推送范围同 1602;不入库 + */ + RTC_PARTICIPANT_DISCONNECTED(1603, "通话参与者离开", false, false), + // 1604-1609 OpenIM 已用 / 留作扩展,本系统暂不使用 + /** + * 对应 OpenIM:无(自有扩展,OpenIM 通话事件不入消息流) + * 对应自己的类:ImRtcCallStartNotification + * 场景:通话开始;群聊入 im_group_message 全群广播,前端渲染聊天 tip「{inviterNickname} 发起了语音通话」; + * 私聊入 im_private_message 定向给被叫,仅用于会话列表预览展示「[语音通话]」(不渲染聊天 tip) + *

+ * 与 RTC_CALL_END(1611) 两段式配对:START 一定先于 END 入库(START 在 invite 接口事务里、END 在 cancel/leave 接口事务里,自然按请求顺序串行) + */ + RTC_CALL_START(1610, "通话开始", true, false), + /** + * 对应 OpenIM:无(自有扩展,OpenIM 通话事件不入消息流) + * 对应自己的类:ImRtcCallEndNotification + * 场景:通话结束;入 im_private_message / im_group_message;私聊渲染准气泡,群聊渲染 tip「语音通话已经结束」 + *

+ * 与 RTC_CALL_START(1610) 两段式配对 + */ + RTC_CALL_END(1611, "通话结束", true, false), + + // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ========== + // TODO @芋艿:FRIEND_REQUEST_* 与 GROUP_REQUEST_* 都是 persistent=false 的 SysMsg,离线 pull 拉不到, + // 目前可能丢失「实时 toast 提醒」体验(业务状态不丢,前端上线 fetch{Friend,Group}RequestList 能补回来); + // 未来思考下怎么优化:候选方案 1)改 persistent=true 入私聊消息流 + 让客户端按 type 自渲染; + // 2)服务端补一个「未读通知拉取」接口给前端冷启动调用。 + /** + * 对应 OpenIM:FriendApplicationApprovedNotification 1201 + * 对应自己的类:FriendRequestApprovedNotification + * 场景:B 同意 A 的好友申请,推给 A 多端 + */ + FRIEND_REQUEST_APPROVED(1201, "好友申请被同意", false, false), + /** + * 对应 OpenIM:FriendApplicationRejectedNotification 1202 + * 对应自己的类:FriendRequestRejectedNotification + * 场景:B 拒绝 A 的好友申请,推给 A 多端 + */ + FRIEND_REQUEST_REJECTED(1202, "好友申请被拒绝", false, false), + /** + * 对应 OpenIM:FriendApplicationNotification 1203 + * 对应自己的类:FriendRequestNotification + * 场景:A 申请加 B,推给 B 多端,前端落到「新的朋友」列表 + */ + FRIEND_REQUEST_RECEIVED(1203, "收到新的好友申请", false, false), + /** + * 对应 OpenIM:FriendAddedNotification 1204(OpenIM friendAdded.isSendMsg=false 默认不入消息流;本系统改为入库当会话气泡) + * 对应自己的类:FriendAddNotification + * 场景:双方建立好友关系,单条入库(sender=fromUserId, receiver=toUserId);双向 WebSocket 自动覆盖双方多端 + * 注意:silentReAddFriend 单边语义场景,发送时显式 setPersistent(false) 覆盖默认值 + */ + FRIEND_ADD(1204, "新增好友", true, false), + /** + * 对应 OpenIM:FriendDeletedNotification 1205 + * 对应自己的类:FriendDeleteNotification + * 场景:A 删除 B,推给 A、B 双方多端 + */ + FRIEND_DELETE(1205, "好友被删除", false, false), + // 1206 对应 OpenIM FriendRemarkSetNotification;本系统并入 FRIEND_UPDATE(1210) 统一推送,单一字段变更不再独立通道 + /** + * 对应 OpenIM:BlackAddedNotification 1207 + * 对应自己的类:FriendBlockNotification + * 场景:A 拉黑 B,仅推 A 多端 + */ + FRIEND_BLOCK(1207, "加入黑名单", false, false), + /** + * 对应 OpenIM:BlackDeletedNotification 1208 + * 对应自己的类:FriendUnblockNotification + * 场景:A 移出 B 的黑名单,仅推 A 多端 + */ + FRIEND_UNBLOCK(1208, "移出黑名单", false, false), + /** + * 对应 OpenIM:FriendInfoUpdatedNotification 1209 + * 对应自己的类:FriendInfoUpdatedNotification + * 场景:B 改了昵称 / 头像后,推给 B 的所有好友 + * 触发:system 模块发 AdminUserProfileUpdateMessage,IM 消费者 AdminUserProfileUpdateConsumer 批量推此通知 + */ + FRIEND_INFO_UPDATED(1209, "好友资料变更", false, false), + /** + * 对应 OpenIM:FriendsInfoUpdateNotification 1210(窄化到 silent / pinned 单边属性) + * 对应自己的类:FriendUpdateNotification + * 场景:A 改了 silent / pinned 等单边属性,推 A 多端 + */ + FRIEND_UPDATE(1210, "好友信息批量更新", false, false), + + // ========== 群事件(1501-1520 直接复用 OpenIM 段位编号;1530+ 我们独有扩展) ========== + // 1500 对应 OpenIM GroupNotificationBegin 起始位,仅作占位,不使用 + /** + * 对应 OpenIM:sdkws.GroupCreatedTips(GroupCreatedNotification 1501) + * 对应自己的类:GroupCreateNotification + * 场景:用户创建群(同时邀请初始成员),全员广播(含创建者多端同步 + 初始成员) + */ + GROUP_CREATE(1501, "群创建", true, false), + /** + * 对应 OpenIM:sdkws.GroupInfoSetTips(GroupInfoSetNotification 1502,NAME / NOTICE 之外字段的 generic 兜底) + * 对应自己的类:GroupInfoUpdateNotification + * 场景:群主修改群头像 / 简介等字段后全员广播 + */ + GROUP_INFO_UPDATE(1502, "群信息变更", true, false), + /** + * 对应 OpenIM:sdkws.JoinGroupApplicationTips(JoinGroupApplicationNotification 1503) + * 对应自己的类:GroupRequestReceivedNotification + * 场景:用户申请加群 / 普通成员邀请待审批,定向私聊推送给群主 + 全部管理员(多端同步);不入群消息流 + */ + GROUP_REQUEST_RECEIVED(1503, "收到新的入群申请", false, false), + /** + * 对应 OpenIM:sdkws.MemberQuitTips(MemberQuitNotification 1504) + * 对应自己的类:GroupMemberQuitNotification + * 场景:成员主动退群(send-before-remove),全员广播(含 quitter);quitter 自判 operatorUserId === self → removeGroup + */ + GROUP_MEMBER_QUIT(1504, "成员退群", true, false), + /** + * 对应 OpenIM:sdkws.GroupApplicationAcceptedTips(GroupApplicationAcceptedNotification 1505) + * 对应自己的类:GroupRequestApprovedNotification + * 场景:群主 / 管理员同意申请,定向私聊推送给申请人 + 群主 + 全部管理员;申请人侧弹 toast,admin 侧 pendingRequestCount-1;不入群消息流 + */ + GROUP_REQUEST_APPROVED(1505, "入群申请被同意", false, false), + /** + * 对应 OpenIM:sdkws.GroupApplicationRejectedTips(GroupApplicationRejectedNotification 1506) + * 对应自己的类:GroupRequestRejectedNotification + * 场景:群主 / 管理员拒绝申请,定向私聊推送给申请人 + 群主 + 全部管理员;不入群消息流 + */ + GROUP_REQUEST_REJECTED(1506, "入群申请被拒绝", false, false), + /** + * 对应 OpenIM:sdkws.GroupOwnerTransferredTips(GroupOwnerTransferredNotification 1507) + * 对应自己的类:GroupOwnerTransferNotification + * 场景:群主转让,全员广播;前端 transferOwner 把 ownerUserId 切到新值 + 旧群主 role → NORMAL / 新群主 role → OWNER + */ + GROUP_OWNER_TRANSFER(1507, "群主转让", true, false), + /** + * 对应 OpenIM:sdkws.MemberKickedTips(MemberKickedNotification 1508) + * 对应自己的类:GroupMemberKickNotification + * 场景:群主 / 管理员移出成员(send-before-remove),全员广播(含被踢者);被踢者自判 memberUserIds 含 self → removeGroup + */ + GROUP_MEMBER_KICK(1508, "成员被移出", true, false), + /** + * 对应 OpenIM:sdkws.MemberInvitedTips(MemberInvitedNotification 1509) + * 对应自己的类:GroupMemberInviteNotification + * 场景:成员邀请新人入群,全员广播(含被邀请者);被邀请人前端按 memberUserIds 含自己自判,初次拉取 fetchGroupInfo + fetchGroupMembers + */ + GROUP_MEMBER_INVITE(1509, "成员加入", true, false), + /** + * 对应 OpenIM:sdkws.MemberEnterTips(MemberEnterNotification 1510) + * 对应自己的类:GroupMemberEnterNotification + * 场景:用户经搜索 / 二维码 / 分享链接自由进群(FREE 模式或审批通过后),全员广播;前端按 entrantUserId 局部添加成员 + */ + GROUP_MEMBER_ENTER(1510, "自由进群", true, false), + /** + * 对应 OpenIM:sdkws.GroupDismissedTips(GroupDismissedNotification 1511) + * 对应自己的类:GroupDissolveNotification + * 场景:群主解散群(send-before-remove),全员广播(含群主多端同步);前端 removeGroup 清群;离场用户离线 pull 通过 quit 路径(send_time < quit_time)也能拉到 + */ + GROUP_DISSOLVE(1511, "群解散", true, false), + /** + * 对应 OpenIM:sdkws.GroupMemberMutedTips(GroupMemberMutedNotification 1512) + * 对应自己的类:GroupMemberMutedNotification + * 场景:群主 / 管理员禁言某成员,全员广播 + */ + GROUP_MEMBER_MUTED(1512, "成员禁言", true, false), + /** + * 对应 OpenIM:sdkws.GroupMemberCancelMutedTips(GroupMemberCancelMutedNotification 1513) + * 对应自己的类:GroupMemberCancelMutedNotification + * 场景:群主 / 管理员取消某成员禁言,全员广播 + */ + GROUP_MEMBER_CANCEL_MUTED(1513, "成员取消禁言", true, false), + /** + * 对应 OpenIM:sdkws.GroupMutedTips(GroupMutedNotification 1514) + * 对应自己的类:GroupMutedNotification + * 场景:群主 / 管理员开启全群禁言,全员广播 + */ + GROUP_MUTED(1514, "全群禁言", true, false), + /** + * 对应 OpenIM:sdkws.GroupCancelMutedTips(GroupCancelMutedNotification 1515) + * 对应自己的类:GroupCancelMutedNotification + * 场景:群主 / 管理员取消全群禁言,全员广播 + */ + GROUP_CANCEL_MUTED(1515, "全群取消禁言", true, false), + /** + * 对应 OpenIM:sdkws.GroupMemberInfoSetTips(GroupMemberInfoSetNotification 1516,窄化到 displayUserName) + * 对应自己的类:GroupMemberNicknameUpdateNotification + * 场景:成员修改自己在群里的昵称,全员广播;前端按 displayUserName 局部更新对应 member + */ + GROUP_MEMBER_NICKNAME_UPDATE(1516, "成员昵称变更", true, false), + /** + * 对应 OpenIM:GroupMemberSetToAdminNotification 1517 + * 对应自己的类:GroupAdminAddNotification + * 场景:群主设置管理员,全员广播;前端 updateMembersRole 把对应成员 role 提升为 ADMIN + */ + GROUP_ADMIN_ADD(1517, "添加管理员", true, false), + /** + * 对应 OpenIM:GroupMemberSetToOrdinaryUserNotification 1518 + * 对应自己的类:GroupAdminRemoveNotification + * 场景:群主撤销管理员,全员广播;前端 updateMembersRole 把对应成员 role 降级为 NORMAL + */ + GROUP_ADMIN_REMOVE(1518, "撤销管理员", true, false), + /** + * 对应 OpenIM:sdkws.GroupInfoSetAnnouncementTips(GroupInfoSetAnnouncementNotification 1519) + * 对应自己的类:GroupNoticeUpdateNotification + * 场景:群主修改群公告后全员广播 + */ + GROUP_NOTICE_UPDATE(1519, "群公告变更", true, false), + /** + * 对应 OpenIM:sdkws.GroupInfoSetNameTips(GroupInfoSetNameNotification 1520) + * 对应自己的类:GroupNameUpdateNotification + * 场景:群主修改群名后全员广播 + */ + GROUP_NAME_UPDATE(1520, "群名变更", true, false), + + // 1530+ 我们独有扩展段(OpenIM 1500-1520 段位无对应物) + /** + * 对应 OpenIM:无直接对应(OpenIM 走 ConversationChangeNotification 1300 单聊路径) + * 对应自己的类:GroupMemberSettingUpdateNotification + * 场景:用户改自己的群免打扰 / 群备注,仅推该用户其他在线终端做多端同步 + */ + GROUP_MEMBER_SETTING_UPDATE(1530, "群成员个人设置变更", false, false), + /** + * 对应 OpenIM:无(OpenIM 无群消息置顶功能,自有扩展) + * 对应自己的类:GroupMessagePinNotification + * 场景:群主 / 管理员置顶一条群消息,全员广播;payload 直接带消息对象,前端把 message push 进 group.pinnedMessages + */ + GROUP_MESSAGE_PIN(1531, "群消息置顶", true, false), + /** + * 对应 OpenIM:无(OpenIM 无群消息置顶功能,自有扩展) + * 对应自己的类:GroupMessageUnpinNotification + * 场景:群主 / 管理员取消置顶,全员广播;前端按 messageId 从 group.pinnedMessages 移除 + */ + GROUP_MESSAGE_UNPIN(1532, "群消息取消置顶", true, false), + /** + * 对应 OpenIM:无(OpenIM 无群封禁概念,自有扩展) + * 对应自己的类:GroupBannedNotification + * 场景:管理后台封禁 / 解封群,全员广播;前端按 banned 字段切换输入栏封禁覆盖层 + */ + GROUP_BANNED(1533, "群封禁变更", true, false); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); + + private static final Set FRIEND_NOTIFICATION_TYPES = Set.of( + FRIEND_REQUEST_APPROVED.type, + FRIEND_REQUEST_REJECTED.type, + FRIEND_REQUEST_RECEIVED.type, + FRIEND_ADD.type, + FRIEND_DELETE.type, + FRIEND_BLOCK.type, + FRIEND_UNBLOCK.type, + FRIEND_INFO_UPDATED.type, + FRIEND_UPDATE.type); + + private static final Set GROUP_REQUEST_NOTIFICATION_TYPES = Set.of( + GROUP_REQUEST_RECEIVED.type, + GROUP_REQUEST_APPROVED.type, + GROUP_REQUEST_REJECTED.type); + + private static final Set RTC_NOTIFICATION_TYPES = Set.of( + RTC_CALL.type, + RTC_PARTICIPANT_CONNECTED.type, + RTC_PARTICIPANT_DISCONNECTED.type); + + /** + * 类型 + */ + private final Integer type; + /** + * 名字 + */ + private final String name; + /** + * 是否入库 + *

+ * true:插入 im_xxx_message 消息表,离线 pull 能拉到; + * false:仅 WebSocket 推送,离线丢弃;状态由专用存储维护(如 READ 走 Redis 游标) + */ + private final boolean persistent; + /** + * 是不是用户聊天消息(normal vs event 二分) + *

+ * true:用户主动发的聊天消息,计入会话未读数(接收方非激活会话时 unreadCount + 1);用户发送入口仅允许这类; + * false:系统事件 / 信号 / 提示,不参与未读计数 + */ + private final boolean normal; + + @Override + public Integer[] array() { + return ARRAYS; + } + + /** + * 校验 type 已注册,并返回对应枚举;未注册立刻抛异常,避免新增 type 时漏配 persistent / normal 属性 + * + * @param type 消息类型 + * @return 枚举实例 + */ + public static ImMessageTypeEnum validate(Integer type) { + ImMessageTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values()); + Assert.notNull(result, "未注册的消息类型 type={}", type); + return result; + } + + /** + * 判断是否为好友通知 + */ + public static boolean isFriendNotification(Integer type) { + return type != null && FRIEND_NOTIFICATION_TYPES.contains(type); + } + + /** + * 判断是否为群申请定向通知 + */ + public static boolean isGroupRequestNotification(Integer type) { + return type != null && GROUP_REQUEST_NOTIFICATION_TYPES.contains(type); + } + + /** + * 判断是否为通话信令通知 + */ + public static boolean isRtcNotification(Integer type) { + return type != null && RTC_NOTIFICATION_TYPES.contains(type); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java new file mode 100644 index 000000000..51099b4f4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 通话结束原因枚举;落历史消息时计算 content 文案 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImRtcCallEndReasonEnum implements ArrayValuable { + + HANGUP(1, "通话结束"), // 接通后任一方主动挂断 + REJECT(2, "已拒绝"), // 被叫接通前点拒接 + CANCEL(3, "已取消"), // 主叫接通前主动取消 + NO_ANSWER(4, "无人接听"), // 振铃超时未接通;由参与者超时 Job 触发 + BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话 + ERROR(9, "通话异常"); // 网络中断、设备失败等 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcCallEndReasonEnum::getReason).toArray(Integer[]::new); + + /** + * 原因值 + */ + private final Integer reason; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java new file mode 100644 index 000000000..9f767b90d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Objects; + +/** + * IM 通话媒体类型枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImRtcCallMediaTypeEnum implements ArrayValuable { + + VOICE(1, "语音"), // 仅音频 + VIDEO(2, "视频"); // 音频 + 视频 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcCallMediaTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isVoice(Integer type) { + return Objects.equals(VOICE.type, type); + } + + public static boolean isVideo(Integer type) { + return Objects.equals(VIDEO.type, type); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java new file mode 100644 index 000000000..42b3cdaa4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.hutool.core.collection.ListUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * IM 通话状态枚举(主表 status) + *

+ * 状态机:CREATED → RUNNING → ENDED;CREATED 直接到 ENDED 表示无人接听 / 主叫取消 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImRtcCallStatusEnum implements ArrayValuable { + + CREATED(10, "创建"), // 通话已创建;私聊:等被叫接听;群聊:发起人已进房,等其他人加入 + RUNNING(20, "进行中"), // 第一个非发起人接通后进入 + ENDED(30, "已结束"); // 任一方挂断 / 主叫 cancel / Webhook 兜底 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcCallStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 活跃状态集合;通话未结束(CREATED / RUNNING) + */ + public static final List ACTIVE_STATUSES = ListUtil.of(CREATED.getStatus(), RUNNING.getStatus()); + + /** + * 状态值 + */ + private final Integer status; + /** + * 状态名 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isCreated(Integer status) { + return Objects.equals(CREATED.status, status); + } + + public static boolean isRunning(Integer status) { + return Objects.equals(RUNNING.status, status); + } + + public static boolean isEnded(Integer status) { + return Objects.equals(ENDED.status, status); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java new file mode 100644 index 000000000..125c5f0b4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 通话参与者角色枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImRtcParticipantRoleEnum implements ArrayValuable { + + INVITER(1, "发起人"), + INVITEE(2, "被邀请者"), + JOINER(3, "主动加入者"); // 仅群通话场景:旁观者点胶囊条加入已有通话 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcParticipantRoleEnum::getRole).toArray(Integer[]::new); + + /** + * 角色 + */ + private final Integer role; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java new file mode 100644 index 000000000..3f3acc4a2 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.hutool.core.collection.ListUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * IM 通话参与者状态枚举(明细表 status) + *

+ * 终态闭合:通话 ENDED 时所有明细 status 必属 {LEFT / REJECTED / NO_ANSWER} + *

+ * 1、INVITING → JOINED → LEFT(接通后挂断 / 离开); + * 2、INVITING → REJECTED(接通前点拒接); + * 3、INVITING → NO_ANSWER(通话结束仍未应答) + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImRtcParticipantStatusEnum implements ArrayValuable { + + // TODO @芋艿:hand up 要不要也搞下。 + INVITING(10, "邀请中"), // 已发出 invite,等被叫响应 + JOINED(20, "已加入"), // 已 connect 进 LiveKit 房间 + REJECTED(30, "已拒绝"), // 接通前点拒接 + NO_ANSWER(40, "未应答"), // 通话已结束仍未应答;endSession 批量改 + LEFT(50, "已离开"); // 接通后挂断 / Webhook 兜底 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcParticipantStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 活跃状态集合;尚未离开通话(INVITING / JOINED) + */ + public static final List ACTIVE_STATUSES = ListUtil.of(INVITING.getStatus(), JOINED.getStatus()); + + /** + * 状态值 + */ + private final Integer status; + /** + * 状态名 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isInviting(Integer status) { + return Objects.equals(INVITING.status, status); + } + + public static boolean isJoined(Integer status) { + return Objects.equals(JOINED.status, status); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java new file mode 100644 index 000000000..8a8192f3e --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java @@ -0,0 +1,195 @@ +package cn.iocoder.yudao.module.im.framework.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +/** + * IM 模块全局配置 + *

+ * 各子模块用嵌套 inner class 区分(friend / group / face / message / rtc 等), + * yaml 路径保持 yudao.im.{module}.{key} 与原有部署保持兼容 + * + * @author 芋道源码 + */ +@Component +@ConfigurationProperties(prefix = "yudao.im") +@Validated +@Data +public class ImProperties { + + private Friend friend = new Friend(); + + private Group group = new Group(); + + private Face face = new Face(); + + private Message message = new Message(); + + @Valid + private Rtc rtc = new Rtc(); + + /** + * 好友模块配置 + */ + @Data + public static class Friend { + + /** + * 是否自动通过所有好友申请(全局开关) + *

+ * 默认 false,普通用户必须走申请-审批流程;开启后所有用户的好友申请会立即同意,主要用于全员开放型 IM 部署。 + * 如需细化到「仅特定用户自动通过」(如机器人 / AI 账号),请在 system 用户表加字段,并在 applyFriend 内按用户级开关短路 + */ + private boolean autoAccept = false; + + } + + /** + * 群模块配置 + */ + @Data + public static class Group { + + /** + * 群最大成员人数 + */ + private int maxMember = 500; + + /** + * 单群管理员人数上限 + */ + private int adminMaxCount = 3; + + /** + * 单群置顶消息条数上限 + */ + private int pinMaxCount = 5; + + } + + /** + * 表情模块配置 + */ + @Data + public static class Face { + + /** + * 个人表情数量上限 + */ + private int userItemMaxCount = 200; + + } + + /** + * 消息模块配置 + */ + @Data + public static class Message { + + /** + * 是否启用私聊已读功能 + *

+ * 关闭后:private read 接口直接抛业务异常;服务端不再下发私聊 READ / RECEIPT 事件信号。 + * 客户端侧需镜像此开关,隐藏私聊气泡的「已读 / 未读」标签 + */ + private boolean privateReadEnabled = true; + + /** + * 是否启用群聊已读功能(含群消息回执) + *

+ * 关闭后:group read 接口直接抛业务异常;服务端不再下发群 READ / RECEIPT 事件信号; + * 群消息回执 receiptStatus 一并停用(即使发送方传 receipt=true 也强制落 NO_RECEIPT,不再算「N 人已读」)。 + * 客户端侧需镜像此开关,隐藏群回执 popover 与「发送回执消息」入口 + */ + private boolean groupReadEnabled = true; + + /** + * pull 最大拉取数量 + */ + private int maxPullSize = 1000; + + /** + * 消息撤回时间限制(分钟) + */ + private int recallTimeoutMinutes = 5; + + /** + * 私聊离线消息最大拉取天数 + *

+ * 客户端通过 pull 接口增量拉取私聊离线消息时,仅返回最近 N 天内产生的消息, + * 超过该窗口的老消息不再主动推送(可通过历史消息接口按需倒翻)。 + */ + private int privatePullMaxDays = 30; + + /** + * 群聊离线消息最大拉取天数 + *

+ * 客户端通过 pull 接口增量拉取群聊离线消息时,仅返回最近 N 天内产生的消息; + * 退群前消息的补齐也以该窗口为基准(早于窗口的退群群不再扫描),避免老用户首次 + * 拉取时对历史退群群做大量查询。 + */ + private int groupPullMaxDays = 30; + + } + + /** + * 实时通话模块配置 + *

+ * 媒体走 LiveKit SFU;后端只签 Token + 通过 IM 长连接推送来电 / 接通 / 结束三种信令。 + * 关闭后所有 RTC 接口直接抛 RTC_NOT_ENABLED;前端可据此隐藏通话按钮。 + */ + @Data + public static class Rtc { + + /** + * 是否启用实时通话功能 + */ + private boolean enabled = true; + + /** + * LiveKit Server WebSocket 地址;客户端 connect 时使用,通常 ws://host:7880 或 wss://host + */ + @NotBlank(message = "LiveKit URL 不能为空") + private String livekitUrl = "ws://127.0.0.1:7880"; + + /** + * LiveKit API Key + */ + @NotBlank(message = "LiveKit API Key 不能为空") + private String apiKey = "devkey"; + + /** + * LiveKit API Secret;生产必须改为强随机值 + */ + @NotBlank(message = "LiveKit API Secret 不能为空") + @Size(min = 32, message = "LiveKit API Secret 长度需 ≥ 32 位") + private String apiSecret = "secret-poc-key-min-32-chars-required-here"; + + /** + * 单次签发的 Token 有效期(小时) + */ + private int tokenTtlHours = 6; + + /** + * 群通话最大同时在房成员数;超过 invite 直接拒绝 + */ + private int groupMaxParticipants = 16; + + /** + * 僵尸通话清理阈值(分钟);通话创建超过此值仍未结束才纳入扫描,避开「刚发起还在响铃」的合理零人态 + */ + private int cleanupZombieThresholdMinutes = 5; + + /** + * 振铃超时阈值(分钟);被叫 INVITING 超过此值未接通 → 标 NO_ANSWER + 推 RTC_CALL(REJECT) 让 banner 收敛 + */ + private int inviteTimeoutMinutes = 1; + + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java new file mode 100644 index 000000000..61b6dae9e --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java @@ -0,0 +1,6 @@ +/** + * 属于 erp 模块的 framework 封装 + * + * @author 芋道源码 + */ +package cn.iocoder.yudao.module.im.framework; diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java new file mode 100644 index 000000000..12bf2e112 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.framework.rtc.config; + +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * im 模块的 RTC 组件的 Configuration + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class ImRtcConfiguration { + + /** + * LiveKit 客户端 + */ + @Bean + public LiveKitClient liveKitClient(ImProperties imProperties) { + return new LiveKitClient(imProperties); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java new file mode 100644 index 000000000..9aea1839c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java @@ -0,0 +1,244 @@ +package cn.iocoder.yudao.module.im.framework.rtc.core; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONUtil; +import cn.hutool.jwt.JWT; +import cn.hutool.jwt.signers.JWTSignerUtil; +import cn.iocoder.yudao.framework.common.util.http.HttpUtils; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * LiveKit 客户端 + *

+ * 厂商绑定层封装:Token 签发(join / admin)、Server API(DeleteRoom / ListParticipants)、identity 拼接 / 解析 + *

+ * 由 {@link cn.iocoder.yudao.module.im.framework.rtc.config.ImRtcConfiguration} 注册为 Bean,本类不带 {@code @Component} + * + * @author 芋道源码 + */ +@Slf4j +public class LiveKitClient { + + /** + * Twirp 端点路径 + */ + private static final String TWIRP_DELETE_ROOM = "/twirp/livekit.RoomService/DeleteRoom"; + private static final String TWIRP_LIST_PARTICIPANTS = "/twirp/livekit.RoomService/ListParticipants"; + + /** + * 管理 Token 有效期;Server API 一次调用即弃,10 秒足够 + */ + private static final Duration ADMIN_TOKEN_TTL = Duration.ofSeconds(10); + + /** + * Server API HTTP 调用的超时上限;超时后直接报错,避免 LiveKit 异常 / 网络抖动时业务长时间阻塞 + */ + private static final int SERVER_API_TIMEOUT_MS = 10_000; + + private final ImProperties imProperties; + + /** + * @param imProperties IM 全局配置;从中读 livekitUrl / apiKey / apiSecret / tokenTtlHours + */ + public LiveKitClient(ImProperties imProperties) { + this.imProperties = imProperties; + } + + /** + * 签发客户端进房 Token;有效期从 {@link ImProperties.Rtc#getTokenTtlHours()} 读取 + * + * @param identity 用户唯一标识;写入 sub claim;同 identity 重连会踢前一个连接 + * @param displayName 客户端展示名;可空 + * @param room 房间名 + * @return JWT 字符串 + */ + public String signJoinToken(String identity, String displayName, String room) { + Assert.notBlank(identity, "identity 不可为空"); + Assert.notBlank(room, "room 不可为空"); + ImProperties.Rtc cfg = imProperties.getRtc(); + + // video claim:限定客户端能在该房间内做什么 + Map video = new HashMap<>(); + video.put("roomJoin", true); // 允许加入房间 + video.put("room", room); // 限定只能加入这个房间 + video.put("canPublish", true); // 允许发布媒体(推流) + video.put("canSubscribe", true); // 允许订阅媒体(拉流) + video.put("canPublishData", true); // 允许发送 data channel 消息 + + long nowSec = Instant.now().getEpochSecond(); + long ttlSec = Duration.ofHours(cfg.getTokenTtlHours()).getSeconds(); + JWT jwt = JWT.create() + .setIssuer(cfg.getApiKey()) + .setSubject(identity) + .setNotBefore(new Date(nowSec * 1000)) + .setExpiresAt(new Date((nowSec + ttlSec) * 1000)) + .setPayload("video", video) + .setSigner(JWTSignerUtil.hs256(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8))); + if (StrUtil.isNotEmpty(displayName)) { + jwt.setPayload("name", displayName); + } + return jwt.sign(); + } + + /** + * 调用 LiveKit Server API 删除房间:用于通话结束时,强制断开异常残留客户端 + * + * @param room 房间名 + */ + @SuppressWarnings("EmptyTryBlock") + public void deleteRoom(String room) { + try (HttpResponse ignored = postTwirp(TWIRP_DELETE_ROOM, room)) { + // 状态码不区分;调用方失败即兜底 + } + } + + /** + * 调用 LiveKit Server API 查询某房间内的参与者数量:用于定时扫描僵尸通话 + *

+ * 房间不存在 LiveKit 返回 404,视同 0 人 + * + * @param room 房间名 + * @return 参与者数量;HTTP 失败返回 -1 + */ + public int listParticipants(String room) { + try (HttpResponse response = postTwirp(TWIRP_LIST_PARTICIPANTS, room)) { + if (response.getStatus() == 404) { + return 0; + } + if (!response.isOk()) { + log.warn("[listParticipants][LiveKit 返回非 2xx status={} room={} body={}]", + response.getStatus(), room, response.body()); + return -1; + } + JSONArray participants = JSONUtil.parseObj(response.body()).getJSONArray("participants"); + return CollUtil.size(participants); + } + } + + /** + * 拼接 LiveKit identity;当前单端 = userId 字符串 + *

+ * 多端扩展时改 {@code userId + "#" + terminal} 格式,调用方无需改 + * + * @param userId 用户编号 + * @return identity 字符串 + */ + public String buildIdentity(Long userId) { + Assert.notNull(userId, "userId 不可为空"); + return String.valueOf(userId); + } + + /** + * 从 LiveKit identity 解析业务 userId + *

+ * 当前 identity 直接是 userId 字符串;预留 {@code userId#terminal} 多端格式 + * + * @param identity LiveKit identity + * @return 用户编号;解析失败返回 null + */ + public Long parseUserId(String identity) { + if (StrUtil.isBlank(identity)) { + return null; + } + int sep = identity.indexOf('#'); + String idPart = sep >= 0 ? identity.substring(0, sep) : identity; + try { + return Long.parseLong(idPart); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 校验 LiveKit Webhook 签名;流程参见 + * webhook 文档 + *

+ * 校验两步: + * 1.1 JWT HS256 签名验证;密钥使用 LiveKit API Secret + * 1.2 body 的 sha256 与 JWT 内 claim 一致;防止抓到 token 后篡改 body + * + * @param authHeader 请求头 Authorization 原值(含 "Bearer " 前缀) + * @param rawBody 请求原始 body + * @return 是否通过;签名异常一律视为不通过 + */ + public boolean verifyWebhookSignature(String authHeader, String rawBody) { + if (StrUtil.isBlank(authHeader)) { + return false; + } + String token = StrUtil.removePrefix(authHeader, "Bearer ").trim(); + ImProperties.Rtc cfg = imProperties.getRtc(); + try { + JWT jwt = JWT.of(token); + // JWT HS256 签名验证 + if (!jwt.setKey(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8)).verify()) { + return false; + } + // body sha256 一致性校验 + Object expectedSha = jwt.getPayload("sha256"); + if (expectedSha == null) { + return false; + } + // 计算 body 的 sha256,并与 JWT 内 claim 对比 + String actualSha = Base64.encode(DigestUtil.sha256(rawBody)); + return Objects.equals(expectedSha.toString(), actualSha); + } catch (Exception e) { + log.warn("[verifyWebhookSignature][签名解析异常 bodyLength={}]", + rawBody == null ? 0 : rawBody.length(), e); + return false; + } + } + + /** + * 签发管理 Token;用于调 Server API(DeleteRoom / ListParticipants / RemoveParticipant 等) + * + * @return JWT 字符串 + */ + private String signAdminToken() { + ImProperties.Rtc cfg = imProperties.getRtc(); + // roomAdmin claim 给管理类 API 必备 + long nowSec = Instant.now().getEpochSecond(); + return JWT.create() + .setIssuer(cfg.getApiKey()) + .setNotBefore(new Date(nowSec * 1000)) + .setExpiresAt(new Date((nowSec + ADMIN_TOKEN_TTL.getSeconds()) * 1000)) + .setPayload("video", MapUtil.of("roomAdmin", true)) + .setSigner(JWTSignerUtil.hs256(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8))) + .sign(); + } + + /** + * Twirp 协议 POST 调用;统一处理 ws→http 协议切换、签 admin token、Bearer 头、JSON body、超时 + * + * @param path Twirp 端点路径,例如 {@code /twirp/livekit.RoomService/DeleteRoom} + * @param room 房间名;写入 JSON body 的 room 字段 + * @return HTTP 响应;调用方负责 close 与状态码判断 + */ + private HttpResponse postTwirp(String path, String room) { + Assert.notBlank(room, "room 不可为空"); + String token = signAdminToken(); + return HttpRequest.post(HttpUtils.wsUrlToHttp(imProperties.getRtc().getLivekitUrl()) + path) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .body(JSONUtil.toJsonStr(MapUtil.of("room", room))) + .timeout(SERVER_API_TIMEOUT_MS) + .execute(); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java new file mode 100644 index 000000000..bb535017a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.im.framework.rtc.core; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * LiveKit Webhook 事件载荷;只反序列化我们关心的字段,其余忽略 + *

+ * 文档参考:webhook 文档 + * + * @author 芋道源码 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class LiveKitWebhookEventDTO { + + /** + * 房间创建:首个 participant 加入时触发 + */ + public static final String EVENT_ROOM_STARTED = "room_started"; + /** + * 房间销毁:最后一个 participant 离开 / 显式 DeleteRoom 后触发;用于业务侧兜底关房 + */ + public static final String EVENT_ROOM_FINISHED = "room_finished"; + /** + * 参与者加入房间 + */ + public static final String EVENT_PARTICIPANT_JOINED = "participant_joined"; + /** + * 参与者离开房间:关 tab / 网络断 / 显式 disconnect 都会触发;用于业务侧兜底清理 + */ + public static final String EVENT_PARTICIPANT_LEFT = "participant_left"; + /** + * 参与者发布媒体轨道(摄像头 / 麦克风 / 屏幕共享) + */ + public static final String EVENT_TRACK_PUBLISHED = "track_published"; + + /** + * 事件 id:用于幂等去重 + */ + private String id; + + /** + * 事件类型;取值参见本类 EVENT_* 常量 + */ + private String event; + + /** + * 房间元信息:room_started / room_finished 必填 + */ + private RoomInfo room; + + /** + * 参与者元信息:participant_* 事件必填 + */ + private ParticipantInfo participant; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RoomInfo { + + private String sid; + private String name; + private Long creationTime; + private Integer numParticipants; + + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ParticipantInfo { + + private String sid; + /** + * 用户身份:签 token 时写入 userId 字符串,后续支持多端会拼成 userId#terminal + */ + private String identity; + private String name; + private Long joinedAt; + + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java new file mode 100644 index 000000000..7e2b2ed5c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.framework.web.config; + +import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * im 模块的 web 组件的 Configuration + */ +@Configuration(proxyBeanMethods = false) +public class ImWebConfiguration { + + /** + * im 模块的 API 分组 + */ + @Bean + public GroupedOpenApi imGroupedOpenApi() { + return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("im"); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java new file mode 100644 index 000000000..b21d0fe03 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * IM 模块的 web 配置 + */ +package cn.iocoder.yudao.module.im.framework.web; diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java new file mode 100644 index 000000000..275b08153 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.im.job.rtc; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import com.xxl.job.core.context.XxlJobHelper; +import com.xxl.job.core.handler.annotation.XxlJob; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 僵尸通话清理 Job:兜底 LiveKit Webhook 丢失 / 客户端异常关闭等未调 leave 的场景 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class ImRtcCallCleanupJob { + + @Resource + private ImRtcCallService rtcCallService; + + @Resource + private ImProperties imProperties; + + /** + * 执行清理 + * + * @param param 阈值(分钟);为空 / 非法走 {@link ImProperties.Rtc#getCleanupZombieThresholdMinutes()} 默认值 + */ + @XxlJob("imRtcCallCleanupJob") + @TenantJob + public void execute(String param) { + int thresholdMinutes = resolveThresholdMinutes(param); + int cleaned = rtcCallService.cleanupZombieCalls(thresholdMinutes); + log.info("[execute][清理僵尸通话数量 ({}) 个]", cleaned); + XxlJobHelper.handleSuccess(String.format("清理僵尸通话 %s 个", cleaned)); + } + + /** + * 解析 quartz param 为分钟阈值;非法 / 非正数走配置默认值 + * + * @param param quartz 调度入参字符串 + * @return 分钟阈值 + */ + private int resolveThresholdMinutes(String param) { + int defaultMinutes = imProperties.getRtc().getCleanupZombieThresholdMinutes(); + if (StrUtil.isBlank(param) || !NumberUtil.isInteger(param)) { + return defaultMinutes; + } + int minutes = Integer.parseInt(param); + return minutes > 0 ? minutes : defaultMinutes; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcParticipantTimeoutJob.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcParticipantTimeoutJob.java new file mode 100644 index 000000000..90349d765 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcParticipantTimeoutJob.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.im.job.rtc; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import com.xxl.job.core.context.XxlJobHelper; +import com.xxl.job.core.handler.annotation.XxlJob; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 振铃超时 Job:扫 INVITING 超过阈值的参与者,单人粒度标 NO_ANSWER + 推 RTC_CALL(REJECT) 让前端 banner 收敛 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class ImRtcParticipantTimeoutJob { + + @Resource + private ImRtcCallService rtcCallService; + + @Resource + private ImProperties imProperties; + + /** + * 执行超时扫描 + * + * @param param 阈值(分钟);为空 / 非法走 {@link ImProperties.Rtc#getInviteTimeoutMinutes()} 默认值 + */ + @XxlJob("imRtcParticipantTimeoutJob") + @TenantJob + public void execute(String param) { + int thresholdMinutes = resolveThresholdMinutes(param); + int timedOut = rtcCallService.timeoutInvitingParticipants(thresholdMinutes); + log.info("[execute][振铃超时参与者数量 ({}) 个]", timedOut); + XxlJobHelper.handleSuccess(String.format("振铃超时 %s 个", timedOut)); + } + + /** + * 解析 quartz param 为分钟阈值;非法 / 非正数走配置默认值 + * + * @param param quartz 调度入参字符串 + * @return 分钟阈值 + */ + private int resolveThresholdMinutes(String param) { + int defaultMinutes = imProperties.getRtc().getInviteTimeoutMinutes(); + if (StrUtil.isBlank(param) || !NumberUtil.isInteger(param)) { + return defaultMinutes; + } + int minutes = Integer.parseInt(param); + return minutes > 0 ? minutes : defaultMinutes; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/mq/consumer/friend/AdminUserProfileUpdateConsumer.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/mq/consumer/friend/AdminUserProfileUpdateConsumer.java new file mode 100644 index 000000000..b39ce13ec --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/mq/consumer/friend/AdminUserProfileUpdateConsumer.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.im.mq.consumer.friend; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendInfoUpdatedNotification; +import cn.iocoder.yudao.module.system.api.message.user.AdminUserProfileUpdateMessage; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +/** + * 监听 system 模块的 {@link AdminUserProfileUpdateMessage} 消息,向「资料被改的人」的所有好友推送 FRIEND_INFO_UPDATED 通知 + * + * @author 芋道源码 + */ +@Slf4j +@Component +public class AdminUserProfileUpdateConsumer { + + @Resource + private ImFriendService friendService; + @Resource + private ImWebSocketService websocketService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + @Async // Spring Event 默认在 Producer 发送的线程,通过 @Async 实现异步;事务提交后触发,避免回滚误推幽灵通知 / Consumer 抢在 commit 前读旧值 + public void onMessage(AdminUserProfileUpdateMessage message) { + try { + log.info("[onMessage][消息内容({})]", message); + // 1. 过滤双向有效好友 + if (message == null || message.getUserId() == null) { + return; + } + Long userId = message.getUserId(); + List friends = friendService.getMutualEnableFriendList(userId); + if (CollUtil.isEmpty(friends)) { + return; + } + + // 2. 给每个好友的多端推 FRIEND_INFO_UPDATED;payload 里 operatorUserId / friendUserId 都是「资料被改的人」 + int successCount = 0; + for (ImFriendDO friend : friends) { + try { + FriendInfoUpdatedNotification payload = (FriendInfoUpdatedNotification) new FriendInfoUpdatedNotification() + .setOperatorUserId(userId).setFriendUserId(userId); + websocketService.sendPrivateMessageAsync(friend.getFriendUserId(), ImPrivateMessageDTO.ofFriendNotification( + ImMessageTypeEnum.FRIEND_INFO_UPDATED.getType(), userId, friend.getFriendUserId(), payload)); + successCount++; + } catch (Exception e) { + log.warn("[onMessage][userId({}) friendUserId({}) 推送失败]", + userId, friend.getFriendUserId(), e); + } + } + log.info("[onMessage][userId({}) 推送 FRIEND_INFO_UPDATED 给 {} 位好友]", userId, successCount); + } catch (Exception e) { + log.error("[onMessage][消息内容({}) 处理失败]", message, e); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/package-info.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/package-info.java new file mode 100644 index 000000000..9bf6bc368 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/package-info.java @@ -0,0 +1,8 @@ +/** + * im 模块,我们放即时通讯业务。 + * 例如说:单聊、群聊、消息收发、消息撤回、消息已读等等 + * + * 1. Controller URL:以 /im/ 开头,避免和其它 Module 冲突 + * 2. DataObject 表名:以 im_ 开头,方便在数据库中区分 + */ +package cn.iocoder.yudao.module.im; diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialService.java new file mode 100644 index 000000000..44fc20463 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialService.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * IM 频道素材 Service 接口 + * + * @author 芋道源码 + */ +public interface ImChannelMaterialService { + + // ==================== 用户端 ==================== + + /** + * 校验素材存在 + * + * @param id 素材编号 + * @return 素材 DO + */ + ImChannelMaterialDO validateMaterialExists(Long id); + + /** + * 按编号批量查询素材 + * + * @param ids 素材编号列表 + * @return 素材列表 + */ + List getMaterialList(Collection ids); + + /** + * 按编号批量查询素材 Map + * + * @param ids 素材编号列表 + * @return id -> 素材 Map + */ + default Map getMaterialMap(Collection ids) { + return convertMap(getMaterialList(ids), ImChannelMaterialDO::getId); + } + + /** + * 统计指定频道下的素材数量 + * + * @param channelId 频道编号 + * @return 数量 + */ + Long getMaterialCountByChannelId(Long channelId); + + // ==================== 管理后台 ==================== + + /** + * 按频道查询素材精简列表 + * + * @param channelId 频道编号 + * @return 素材列表 + */ + List getMaterialListByChannelId(Long channelId); + + /** + * 分页查询素材 + * + * @param reqVO 分页查询条件 + * @return 素材分页 + */ + PageResult getMaterialPage(ImChannelMaterialPageReqVO reqVO); + + /** + * 获取素材详情(含 content 富文本) + * + * @param id 素材编号 + * @return 素材 DO + */ + ImChannelMaterialDO getMaterial(Long id); + + /** + * 新增素材 + * + * @param reqVO 新增请求 + * @return 新增素材编号 + */ + Long createMaterial(@Valid ImChannelMaterialSaveReqVO reqVO); + + /** + * 修改素材 + * + * @param reqVO 修改请求 + */ + void updateMaterial(@Valid ImChannelMaterialSaveReqVO reqVO); + + /** + * 删除素材;素材已被推送过时拒绝,避免历史消息无法回查 + * + * @param id 素材编号 + */ + void deleteMaterial(Long id); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialServiceImpl.java new file mode 100644 index 000000000..ec1d8c3cf --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialServiceImpl.java @@ -0,0 +1,113 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMaterialMapper; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_NOT_EXISTS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_USED; + +/** + * IM 频道素材 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImChannelMaterialServiceImpl implements ImChannelMaterialService { + + @Resource + private ImChannelMaterialMapper channelMaterialMapper; + @Resource + private ImChannelService channelService; + @Resource + private ImChannelMessageMapper channelMessageMapper; + + // ==================== 用户端 ==================== + + @Override + public ImChannelMaterialDO validateMaterialExists(Long id) { + ImChannelMaterialDO material = channelMaterialMapper.selectById(id); + if (material == null) { + throw exception(IM_CHANNEL_MATERIAL_NOT_EXISTS); + } + return material; + } + + @Override + public List getMaterialList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return channelMaterialMapper.selectByIds(ids); + } + + @Override + public Long getMaterialCountByChannelId(Long channelId) { + return channelMaterialMapper.selectCountByChannelId(channelId); + } + + // ==================== 管理后台 ==================== + + @Override + public List getMaterialListByChannelId(Long channelId) { + return channelMaterialMapper.selectListByChannelId(channelId); + } + + @Override + public PageResult getMaterialPage(ImChannelMaterialPageReqVO reqVO) { + return channelMaterialMapper.selectPage(reqVO); + } + + @Override + public ImChannelMaterialDO getMaterial(Long id) { + return channelMaterialMapper.selectById(id); + } + + @Override + public Long createMaterial(ImChannelMaterialSaveReqVO reqVO) { + // 1. 校验所属频道存在 + channelService.validateChannelExists(reqVO.getChannelId()); + + // 2. 插入素材 + ImChannelMaterialDO material = BeanUtils.toBean(reqVO, ImChannelMaterialDO.class); + channelMaterialMapper.insert(material); + return material.getId(); + } + + @Override + public void updateMaterial(ImChannelMaterialSaveReqVO reqVO) { + // 1.1 校验存在 + validateMaterialExists(reqVO.getId()); + // 1.2 校验所属频道存在 + channelService.validateChannelExists(reqVO.getChannelId()); + + // 2. 更新素材 + ImChannelMaterialDO updateObj = BeanUtils.toBean(reqVO, ImChannelMaterialDO.class); + channelMaterialMapper.updateById(updateObj); + } + + @Override + public void deleteMaterial(Long id) { + validateMaterialExists(id); + // 防止删除素材导致历史 channel_message 反查不到内容 + if (channelMessageMapper.selectCountByMaterialId(id) > 0) { + throw exception(IM_CHANNEL_MATERIAL_USED); + } + channelMaterialMapper.deleteById(id); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelService.java new file mode 100644 index 000000000..b318743b9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelService.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * IM 频道 Service 接口 + * + * @author 芋道源码 + */ +public interface ImChannelService { + + // ==================== 用户端 ==================== + + /** + * 按状态查询频道列表,按 sort 升序 + * + * @param status 状态;对应 CommonStatusEnum + * @return 频道列表 + */ + List getChannelListByStatus(Integer status); + + /** + * 按编号批量查询频道 + * + * @param ids 频道编号列表 + * @return 频道列表 + */ + List getChannelList(Collection ids); + + /** + * 按编号批量查询频道 Map + * + * @param ids 频道编号列表 + * @return id -> 频道 Map + */ + default Map getChannelMap(Collection ids) { + return convertMap(getChannelList(ids), ImChannelDO::getId); + } + + /** + * 校验频道存在 + * + * @param id 频道编号 + * @return 频道 DO + */ + @SuppressWarnings("UnusedReturnValue") + ImChannelDO validateChannelExists(Long id); + + // ==================== 管理后台 ==================== + + /** + * 分页查询频道 + * + * @param reqVO 分页查询条件 + * @return 频道分页 + */ + PageResult getChannelPage(ImChannelPageReqVO reqVO); + + /** + * 获取频道详情 + * + * @param id 频道编号 + * @return 频道 DO + */ + ImChannelDO getChannel(Long id); + + /** + * 新增频道 + * + * @param reqVO 新增请求 + * @return 新增频道编号 + */ + Long createChannel(@Valid ImChannelSaveReqVO reqVO); + + /** + * 修改频道 + * + * @param reqVO 修改请求 + */ + void updateChannel(@Valid ImChannelSaveReqVO reqVO); + + /** + * 删除频道;频道下有素材或消息时拒绝 + * + * @param id 频道编号 + */ + void deleteChannel(Long id); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java new file mode 100644 index 000000000..6df27e1fb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java @@ -0,0 +1,120 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMapper; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * IM 频道 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImChannelServiceImpl implements ImChannelService { + + @Resource + private ImChannelMapper channelMapper; + @Resource + @Lazy // 延迟加载,解决循环依赖 + private ImChannelMaterialService channelMaterialService; + + // ==================== 用户端 ==================== + + @Override + public List getChannelListByStatus(Integer status) { + return channelMapper.selectListByStatusOrderBySort(status); + } + + @Override + public List getChannelList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return channelMapper.selectByIds(ids); + } + + @Override + public ImChannelDO validateChannelExists(Long id) { + ImChannelDO channel = channelMapper.selectById(id); + if (channel == null) { + throw exception(IM_CHANNEL_NOT_EXISTS); + } + return channel; + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getChannelPage(ImChannelPageReqVO reqVO) { + return channelMapper.selectPage(reqVO); + } + + @Override + public ImChannelDO getChannel(Long id) { + return channelMapper.selectById(id); + } + + @Override + public Long createChannel(ImChannelSaveReqVO reqVO) { + // 校验 code 唯一 + validateCodeUnique(null, reqVO.getCode()); + + // 插入 + ImChannelDO channel = BeanUtils.toBean(reqVO, ImChannelDO.class); + channelMapper.insert(channel); + return channel.getId(); + } + + @Override + public void updateChannel(ImChannelSaveReqVO reqVO) { + // 1.1 校验存在 + validateChannelExists(reqVO.getId()); + // 1.2 校验 code 唯一 + validateCodeUnique(reqVO.getId(), reqVO.getCode()); + + // 2. 更新 + ImChannelDO updateObj = BeanUtils.toBean(reqVO, ImChannelDO.class); + channelMapper.updateById(updateObj); + } + + @Override + public void deleteChannel(Long id) { + // 1.1 校验存在 + validateChannelExists(id); + // 1.2 防止误删频道导致历史素材 / 消息回查不到归属 + if (channelMaterialService.getMaterialCountByChannelId(id) > 0) { + throw exception(IM_CHANNEL_HAS_MATERIAL); + } + + // 2. 删除频道 + channelMapper.deleteById(id); + } + + private void validateCodeUnique(Long id, String code) { + ImChannelDO exist = channelMapper.selectByCode(code); + if (exist == null) { + return; + } + if (id == null || ObjUtil.notEqual(exist.getId(), id)) { + throw exception(IM_CHANNEL_CODE_DUPLICATED, code); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemService.java new file mode 100644 index 000000000..c9f913e5a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemService.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * IM 表情包项 Service 接口 + * + * @author 芋道源码 + */ +public interface ImFacePackItemService { + + // ==================== 用户端 ==================== + + /** + * 按 packIds 批量取启用项,给前端聚合接口用 + * + * @param packIds 表情包编号列表 + * @return 启用状态的表情列表 + */ + List getEnabledItemListByPackIds(Collection packIds); + + /** + * 取某个表情包下的表情数量;ImFacePackService 删除前校验「包下无项」用 + * + * @param packId 表情包编号 + * @return 数量 + */ + Long getFacePackItemCount(Long packId); + + /** + * 取多个表情包下的表情数量合计;ImFacePackService 批量删除前校验用 + * + * @param packIds 表情包编号列表 + * @return 数量合计 + */ + Long getFacePackItemCount(Collection packIds); + + // ==================== 管理后台 ==================== + + /** + * 分页查询表情包项 + * + * @param reqVO 分页查询条件 + * @return 表情包项分页 + */ + PageResult getFacePackItemPage(ImFacePackItemPageReqVO reqVO); + + /** + * 获取表情包项详情 + * + * @param id 表情包项编号 + * @return 表情包项 DO + */ + ImFacePackItemDO getFacePackItem(Long id); + + /** + * 新增表情包项 + * + * @param reqVO 新增请求 + * @return 新增表情包项编号 + */ + Long createFacePackItem(@Valid ImFacePackItemSaveReqVO reqVO); + + /** + * 修改表情包项 + * + * @param reqVO 修改请求 + */ + void updateFacePackItem(@Valid ImFacePackItemSaveReqVO reqVO); + + /** + * 删除表情包项 + * + * @param id 表情包项编号 + */ + void deleteFacePackItem(Long id); + + /** + * 批量删除表情包项 + * + * @param ids 表情包项编号列表 + */ + void deleteFacePackItemList(List ids); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImpl.java new file mode 100644 index 000000000..9f8d54563 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImpl.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackItemMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_ITEM_NOT_EXISTS; + +/** + * IM 表情包项 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImFacePackItemServiceImpl implements ImFacePackItemService { + + @Resource + private ImFacePackItemMapper facePackItemMapper; + @Resource + private ImFacePackService facePackService; + + // ==================== 用户端 ==================== + + @Override + public List getEnabledItemListByPackIds(Collection packIds) { + if (CollUtil.isEmpty(packIds)) { + return Collections.emptyList(); + } + return facePackItemMapper.selectListByPackIdsAndStatus(packIds, CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public Long getFacePackItemCount(Long packId) { + return facePackItemMapper.selectCountByPackId(packId); + } + + @Override + public Long getFacePackItemCount(Collection packIds) { + if (CollUtil.isEmpty(packIds)) { + return 0L; + } + return facePackItemMapper.selectCountByPackIds(packIds); + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getFacePackItemPage(ImFacePackItemPageReqVO reqVO) { + return facePackItemMapper.selectPage(reqVO); + } + + @Override + public ImFacePackItemDO getFacePackItem(Long id) { + return facePackItemMapper.selectById(id); + } + + @Override + public Long createFacePackItem(ImFacePackItemSaveReqVO reqVO) { + // 1. 校验所属表情包存在 + facePackService.validateFacePackExists(reqVO.getPackId()); + + // 2. 入库 + ImFacePackItemDO item = BeanUtils.toBean(reqVO, ImFacePackItemDO.class); + facePackItemMapper.insert(item); + return item.getId(); + } + + @Override + public void updateFacePackItem(ImFacePackItemSaveReqVO reqVO) { + // 1.1 校验存在 + validateFacePackItemExists(reqVO.getId()); + // 1.2 校验所属表情包存在 + facePackService.validateFacePackExists(reqVO.getPackId()); + + // 2. 更新 + ImFacePackItemDO updateObj = BeanUtils.toBean(reqVO, ImFacePackItemDO.class); + facePackItemMapper.updateById(updateObj); + } + + @Override + public void deleteFacePackItem(Long id) { + // 1. 校验存在 + validateFacePackItemExists(id); + + // 2. 删除 + facePackItemMapper.deleteById(id); + } + + @Override + public void deleteFacePackItemList(List ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + facePackItemMapper.deleteByIds(ids); + } + + private void validateFacePackItemExists(Long id) { + if (facePackItemMapper.selectById(id) == null) { + throw exception(FACE_PACK_ITEM_NOT_EXISTS); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackService.java new file mode 100644 index 000000000..d579fbfbb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackService.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * IM 表情包 Service 接口 + * + * @author 芋道源码 + */ +public interface ImFacePackService { + + // ==================== 用户端 ==================== + + /** + * 获取所有启用的表情包,按 sort 升序 + * + * @return 启用的表情包列表 + */ + List getEnabledFacePackList(); + + /** + * 校验表情包存在 + * + * @param id 表情包编号 + * @return 表情包 DO + */ + @SuppressWarnings("UnusedReturnValue") + ImFacePackDO validateFacePackExists(Long id); + + // ==================== 管理后台 ==================== + + /** + * 分页查询表情包 + * + * @param reqVO 分页查询条件 + * @return 表情包分页 + */ + PageResult getFacePackPage(ImFacePackPageReqVO reqVO); + + /** + * 获取表情包详情 + * + * @param id 表情包编号 + * @return 表情包 DO + */ + ImFacePackDO getFacePack(Long id); + + /** + * 新增表情包 + * + * @param reqVO 新增请求 + * @return 新增表情包编号 + */ + Long createFacePack(@Valid ImFacePackSaveReqVO reqVO); + + /** + * 修改表情包 + * + * @param reqVO 修改请求 + */ + void updateFacePack(@Valid ImFacePackSaveReqVO reqVO); + + /** + * 删除表情包;包下存在表情时拒绝,避免历史 face 消息无法回查归属 + * + * @param id 表情包编号 + */ + void deleteFacePack(Long id); + + /** + * 批量删除表情包;任一包下存在表情时整批拒绝,避免「只删一半」中间态 + * + * @param ids 表情包编号列表 + */ + void deleteFacePackList(List ids); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImpl.java new file mode 100644 index 000000000..e86a3977d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImpl.java @@ -0,0 +1,113 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackMapper; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_HAS_ITEMS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS; + +/** + * IM 表情包 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImFacePackServiceImpl implements ImFacePackService { + + @Resource + private ImFacePackMapper facePackMapper; + + /** + * @Lazy 解决与 ImFacePackItemServiceImpl 的循环依赖(item.create / update 校验所属包存在 → 反向调用本类) + */ + @Resource + @Lazy + private ImFacePackItemService facePackItemService; + + // ==================== 用户端 ==================== + + @Override + public List getEnabledFacePackList() { + return facePackMapper.selectListByStatusOrderBySort(CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public ImFacePackDO validateFacePackExists(Long id) { + ImFacePackDO pack = facePackMapper.selectById(id); + if (pack == null) { + throw exception(FACE_PACK_NOT_EXISTS); + } + return pack; + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getFacePackPage(ImFacePackPageReqVO reqVO) { + return facePackMapper.selectPage(reqVO); + } + + @Override + public ImFacePackDO getFacePack(Long id) { + return facePackMapper.selectById(id); + } + + @Override + public Long createFacePack(ImFacePackSaveReqVO reqVO) { + ImFacePackDO pack = BeanUtils.toBean(reqVO, ImFacePackDO.class); + facePackMapper.insert(pack); + return pack.getId(); + } + + @Override + public void updateFacePack(ImFacePackSaveReqVO reqVO) { + // 1. 校验存在 + validateFacePackExists(reqVO.getId()); + + // 2. 更新 + ImFacePackDO updateObj = BeanUtils.toBean(reqVO, ImFacePackDO.class); + facePackMapper.updateById(updateObj); + } + + @Override + public void deleteFacePack(Long id) { + // 1.1 校验存在 + validateFacePackExists(id); + // 1.2 校验表情包下没有表情;防止误删表情包导致历史 face 消息无法回查归属 + if (facePackItemService.getFacePackItemCount(id) > 0) { + throw exception(FACE_PACK_HAS_ITEMS); + } + + // 2. 删除 + facePackMapper.deleteById(id); + } + + @Override + public void deleteFacePackList(List ids) { + // 1. 任一存在表情则拒绝整批删除,避免「只删一半」的中间态 + if (CollUtil.isEmpty(ids)) { + return; + } + if (facePackItemService.getFacePackItemCount(ids) > 0) { + throw exception(FACE_PACK_HAS_ITEMS); + } + + // 2. 删除 + facePackMapper.deleteByIds(ids); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemService.java new file mode 100644 index 000000000..68ae78cf1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemService.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * IM 用户私有表情 Service 接口 + * + * @author 芋道源码 + */ +public interface ImFaceUserItemService { + + /** + * 获取指定用户的个人表情列表 + * + * @param userId 用户编号 + * @return 个人表情列表 + */ + List getFaceUserItemList(Long userId); + + /** + * 添加个人表情 + * + * @param userId 用户编号 + * @param reqVO 添加请求 + * @return 新增表情编号 + */ + Long createFaceUserItem(Long userId, @Valid ImFaceUserItemSaveReqVO reqVO); + + /** + * 删除指定用户的某条个人表情 + * + * @param userId 用户编号 + * @param id 表情编号 + */ + void deleteFaceUserItem(Long userId, Long id); + + // ==================== 管理后台 ==================== + + /** + * 分页查询所有用户的个人表情;管理后台审计 / 删除违规图用 + * + * @param reqVO 分页查询条件 + * @return 个人表情分页 + */ + PageResult getFaceUserItemPage(ImFaceUserItemManagerPageReqVO reqVO); + + /** + * 管理后台直接删除某条个人表情;不做归属校验 + * + * @param id 表情编号 + */ + void deleteFaceUserItem(Long id); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImpl.java new file mode 100644 index 000000000..e5598059b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImpl.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_MAX_LIMIT; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN; + +/** + * IM 用户私有表情 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImFaceUserItemServiceImpl implements ImFaceUserItemService { + + @Resource + private ImFaceUserItemMapper faceUserItemMapper; + @Resource + private ImProperties imProperties; + + @Override + public List getFaceUserItemList(Long userId) { + return faceUserItemMapper.selectListByUserId(userId); + } + + @Override + public Long createFaceUserItem(Long userId, ImFaceUserItemSaveReqVO reqVO) { + // 1.1 同 URL 已存在则报错 + if (faceUserItemMapper.selectByUserIdAndUrl(userId, reqVO.getUrl()) != null) { + throw exception(FACE_USER_ITEM_DUPLICATED); + } + // 1.2 超过最大数量限制则报错 + int maxCount = imProperties.getFace().getUserItemMaxCount(); + if (faceUserItemMapper.selectCountByUserId(userId) >= maxCount) { + throw exception(FACE_USER_ITEM_MAX_LIMIT, maxCount); + } + + // 2. 入库 + ImFaceUserItemDO item = BeanUtils.toBean(reqVO, ImFaceUserItemDO.class).setUserId(userId); + try { + faceUserItemMapper.insert(item); + } catch (DuplicateKeyException ex) { + throw exception(FACE_USER_ITEM_DUPLICATED); + } + return item.getId(); + } + + @Override + public void deleteFaceUserItem(Long userId, Long id) { + // 1.1 校验存在 + ImFaceUserItemDO item = faceUserItemMapper.selectById(id); + if (item == null) { + throw exception(FACE_USER_ITEM_NOT_EXISTS); + } + // 1.2 校验归属:防止 A 用户传 B 用户的表情 id 删别人的 + if (ObjectUtil.notEqual(item.getUserId(), userId)) { + throw exception(FACE_USER_ITEM_NOT_OWN); + } + + // 2. 删除 + faceUserItemMapper.deleteById(id); + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getFaceUserItemPage(ImFaceUserItemManagerPageReqVO reqVO) { + return faceUserItemMapper.selectPage(reqVO); + } + + @Override + public void deleteFaceUserItem(Long id) { + // 1. 校验存在 + if (faceUserItemMapper.selectById(id) == null) { + throw exception(FACE_USER_ITEM_NOT_EXISTS); + } + + // 2. 删除 + faceUserItemMapper.deleteById(id); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestService.java new file mode 100644 index 000000000..a78bb38c1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; + +import java.util.List; + +/** + * IM 好友申请 Service 接口 + * + * @author 芋道源码 + */ +public interface ImFriendRequestService { + + /** + * 发起好友申请 + * + * @param fromUserId 发起方用户编号 + * @param reqVO 申请请求 + * @return 申请记录 + */ + ImFriendRequestDO applyFriend(Long fromUserId, ImFriendRequestApplyReqVO reqVO); + + /** + * 同意好友申请 + * + * @param userId 操作人用户编号 + * @param requestId 申请记录编号 + */ + void agreeFriendRequest(Long userId, Long requestId); + + /** + * 拒绝好友申请 + * + * @param userId 操作人用户编号 + * @param requestId 申请记录编号 + * @param handleContent 拒绝理由 + */ + void refuseFriendRequest(Long userId, Long requestId, String handleContent); + + /** + * 查询「我相关」的申请列表(含我发起的、别人加我的);游标分页:传 maxId 拉更早一页 + * + * @param userId 用户编号 + * @param maxId 当前列表最旧记录的 id;首页传 null + * @param limit 单次拉取条数(page size,由前端常量控制) + * @return 申请记录列表,按更新时间、id 倒序 + */ + List getMyFriendRequestList(Long userId, Long maxId, Integer limit); + + /** + * 按 id 单查申请记录;通用读接口,调用方自行做越权过滤 + */ + ImFriendRequestDO getFriendRequest(Long id); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】分页查询好友申请记录 + */ + PageResult getFriendRequestPage(ImFriendRequestManagerPageReqVO reqVO); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java new file mode 100644 index 000000000..7f7f3c76e --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java @@ -0,0 +1,266 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestApprovedNotification; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestNotification; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestRejectedNotification; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * IM 好友申请 Service 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@Service +@Validated +public class ImFriendRequestServiceImpl implements ImFriendRequestService { + + @Resource + private ImFriendRequestMapper friendRequestMapper; + + @Resource + private ImFriendService friendService; + @Resource + private ImWebSocketService websocketService; + + @Resource + private ImProperties imProperties; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public ImFriendRequestDO applyFriend(Long fromUserId, ImFriendRequestApplyReqVO reqVO) { + Long toUserId = reqVO.getToUserId(); + // 1.1 校验:不能加自己 + if (Objects.equals(fromUserId, toUserId)) { + throw exception(FRIEND_ADD_SELF); + } + // 1.2 校验对方存在且启用 + adminUserApi.validateUser(toUserId); + // 1.3 已是好友 / 被对方拉黑:直接报错(state 一次拿到双向状态,省两次单边查询) + // 错误码与 ImFriendService#validateFriend 不同(语义为「申请被拒」),故保留 inline + Integer state = friendService.getFriendState(fromUserId, toUserId); + if (ImFriendStateEnum.isFriend(state)) { + throw exception(FRIEND_REQUEST_ALREADY_FRIEND); + } + if (ImFriendStateEnum.isBlocked(state)) { + throw exception(FRIEND_REQUEST_BLOCKED_BY_PEER); + } + // 1.4 单向好友(我已删 + 对方仍把我当好友):静默重新启用我侧关系,避免对方感知我曾删除 + ImFriendDO peerFriend = friendService.getFriend(toUserId, fromUserId); + if (peerFriend != null && CommonStatusEnum.isEnable(peerFriend.getStatus())) { + // 对方已拉黑:静默恢复等于绕过拉黑回到好友列表,必须先拒掉; + // getFriendState 在我侧 DISABLE 时直接返回 NONE,拿不到 BLOCKED 信号,这里显式补一次校验 + if (BooleanUtil.isTrue(peerFriend.getBlocked())) { + throw exception(FRIEND_REQUEST_BLOCKED_BY_PEER); + } + friendService.silentReAddFriend(fromUserId, toUserId, reqVO.getDisplayName(), reqVO.getAddSource()); + return null; + } + + // 2. 落库:同一申请人和接收人唯一,已有记录覆盖申请内容并重置为未处理 + ImFriendRequestDO request = createOrResetRequest(fromUserId, reqVO); + + // 3. 推送 FRIEND_REQUEST_RECEIVED 给 toUser 多端;payload 携带申请方昵称 / 头像,前端按 requestId 直推 push 进列表 + AdminUserRespDTO fromUser = adminUserApi.getUser(fromUserId).getCheckedData(); + FriendRequestNotification payload = (FriendRequestNotification) new FriendRequestNotification() + .setRequestId(request.getId()).setApplyContent(request.getApplyContent()).setAddSource(request.getAddSource()) + .setOperatorUserId(fromUserId).setFriendUserId(fromUserId); + if (fromUser != null) { + payload.setFromNickname(fromUser.getNickname()).setFromAvatar(fromUser.getAvatar()); + } + websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification( + ImMessageTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), fromUserId, toUserId, payload)); + + // 4. 全局自动通过开关:注册 afterCommit 回调,事务提交后再走同意流程 + // 回调内 try/catch 兜底 —— afterCommit 异常会被 Spring 静默吞掉,否则同意失败时申请方永远等不到 APPROVED + if (imProperties.getFriend().isAutoAccept()) { + Long requestId = request.getId(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + try { + getSelf().agreeFriendRequest(toUserId, requestId); + } catch (Exception e) { + log.error("[applyFriend][autoAccept fromUserId={} toUserId={} requestId={} 自动通过失败]", + fromUserId, toUserId, requestId, e); + } + } + + }); + } + return request; + } + + /** + * 创建或重置好友申请 + * + * @param fromUserId 申请人用户编号 + * @param reqVO 申请请求 + * @return 申请记录 + */ + private ImFriendRequestDO createOrResetRequest(Long fromUserId, ImFriendRequestApplyReqVO reqVO) { + Long toUserId = reqVO.getToUserId(); + ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId); + if (request == null) { + // 1. 无旧申请:创建新申请;唯一键冲突时回查并复用并发写入的记录 + request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class) + .setFromUserId(fromUserId).setToUserId(toUserId) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + try { + friendRequestMapper.insert(request); + return request; + } catch (DuplicateKeyException ex) { + request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId); + if (request == null) { + throw ex; + } + } + } + + // 2. 复用旧申请:覆盖本次申请内容,并重置为未处理 + LocalDateTime now = LocalDateTime.now(); + friendRequestMapper.updateByIdReset(request.getId(), + reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource(), now); + // 同步内存对象,后续通知和自动通过直接复用 + request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName()) + .setAddSource(reqVO.getAddSource()) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()) + .setHandleContent(null).setHandleTime(null).setUpdateTime(now); + return request; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void agreeFriendRequest(Long userId, Long requestId) { + // 1.1 校验申请存在、未处理、操作人是接收方 + ImFriendRequestDO request = validateRequestForHandle(userId, requestId); + // 1.2 复验双方用户有效 + adminUserApi.validateUserList(List.of(request.getFromUserId(), request.getToUserId())); + + // 2. 乐观锁更新申请处理结果 + ImFriendRequestDO updateObj = new ImFriendRequestDO() + .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()).setHandleTime(LocalDateTime.now()); + int affected = friendRequestMapper.updateByIdAndHandleResult(request.getId(), + ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), updateObj); + if (affected == 0) { + throw exception(FRIEND_REQUEST_HANDLED); + } + request.setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()).setHandleTime(updateObj.getHandleTime()); + + // 3. 双向建立好友关系 + friendService.becomeFriends(request); + + // 4. 推 FRIEND_REQUEST_APPROVED 给 fromUser 多端 + FriendRequestApprovedNotification payload = (FriendRequestApprovedNotification) + new FriendRequestApprovedNotification().setRequestId(request.getId()) + .setOperatorUserId(userId).setFriendUserId(userId); + websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification( + ImMessageTypeEnum.FRIEND_REQUEST_APPROVED.getType(), userId, request.getFromUserId(), payload)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void refuseFriendRequest(Long userId, Long requestId, String handleContent) { + // 1. 校验申请存在 + 未处理 + 操作人是接收方(fail-fast;并发场景仍由下面的乐观锁兜底) + ImFriendRequestDO request = validateRequestForHandle(userId, requestId); + + // 2. 乐观锁更新申请:handleResult=REFUSED + handleContent + handleTime;并发拒绝会有一方 affectedRows=0 + ImFriendRequestDO updateObj = new ImFriendRequestDO() + .setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult()) + .setHandleContent(handleContent).setHandleTime(LocalDateTime.now()); + int affected = friendRequestMapper.updateByIdAndHandleResult(request.getId(), + ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), updateObj); + if (affected == 0) { + throw exception(FRIEND_REQUEST_HANDLED); + } + + // 3. 推 FRIEND_REQUEST_REJECTED 给 fromUser 多端 + FriendRequestRejectedNotification payload = (FriendRequestRejectedNotification) + new FriendRequestRejectedNotification().setRequestId(request.getId()) + .setHandleContent(handleContent) + .setOperatorUserId(userId).setFriendUserId(userId); + websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification( + ImMessageTypeEnum.FRIEND_REQUEST_REJECTED.getType(), userId, request.getFromUserId(), payload)); + } + + @Override + public List getMyFriendRequestList(Long userId, Long maxId, Integer limit) { + ImFriendRequestDO maxRequest = maxId != null ? friendRequestMapper.selectById(maxId) : null; + if (maxId != null && maxRequest == null) { + return List.of(); + } + return friendRequestMapper.selectMyList(userId, + maxRequest != null ? maxRequest.getUpdateTime() : null, + maxRequest != null ? maxRequest.getId() : null, + limit); + } + + @Override + public ImFriendRequestDO getFriendRequest(Long id) { + return friendRequestMapper.selectById(id); + } + + @Override + public PageResult getFriendRequestPage(ImFriendRequestManagerPageReqVO reqVO) { + return friendRequestMapper.selectPage(reqVO); + } + + /** + * 校验申请可被「当前用户」处理:申请存在 + 未处理 + 操作人 = 接收方 + */ + private ImFriendRequestDO validateRequestForHandle(Long userId, Long requestId) { + ImFriendRequestDO request = friendRequestMapper.selectById(requestId); + if (request == null) { + throw exception(FRIEND_REQUEST_NOT_EXISTS); + } + if (ObjUtil.notEqual(request.getToUserId(), userId)) { + throw exception(FRIEND_REQUEST_NOT_TO_ME); + } + if (!ImFriendRequestHandleResultEnum.isUnhandled(request.getHandleResult())) { + throw exception(FRIEND_REQUEST_HANDLED); + } + return request; + } + + private ImFriendRequestServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java new file mode 100644 index 000000000..84a6de9bc --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java @@ -0,0 +1,124 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; + +import java.util.Collection; +import java.util.List; + +/** + * IM 好友关系 Service 接口 + *

+ * 注意:用户端「加好友」走 {@link ImFriendRequestService#applyFriend} 申请-审批流程, + * 不再开放直接 add 接口;只有 {@link #becomeFriends} 是内部入口(被 agree 同意 / 管理员 import 触发)。 + * + * @author 芋道源码 + */ +public interface ImFriendService { + + /** + * 获取 userId 视角下与 friendUserId 的好友关系状态(私聊发送热点路径) + *

+ * 参见 {@link ImFriendStateEnum} 枚举类 + */ + Integer getFriendState(Long userId, Long friendUserId); + + /** + * 校验「能否对 peerUserId 发起私聊语义动作」(消息发送 / RTC 邀请) + *

+ * 好友 / 黑名单校验:和私聊消息发送同一套语义;NONE 已删 / 未加,BLOCKED 被对方拉黑 + * + * @param userId 当前用户编号 + * @param peerUserId 对方用户编号 + */ + void validateFriend(Long userId, Long peerUserId); + + /** + * 获得当前用户的好友列表(含已删除状态) + */ + List getFriendList(Long userId); + + /** + * 获得当前用户的有效好友列表(仅 ENABLE 状态) + */ + List getEnableFriendList(Long userId); + + /** + * 获得当前用户的双向有效好友列表(双方均 ENABLE 状态) + */ + List getMutualEnableFriendList(Long userId); + + /** + * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态) + */ + List getActiveFriendList(Long userId, Collection friendUserIds); + + /** + * 查询一个好友关系记录 + */ + ImFriendDO getFriend(Long userId, Long friendUserId); + + // ==================== 内部入口 ==================== + + /** + * 双向建立好友关系(内部入口) + *

+ * 由 {@link ImFriendRequestService#agreeFriendRequest} 同意申请 / 管理后台导入触发; + * A 侧 displayName / addSource 取自申请记录;B 侧 displayName 为空、addSource 同来源。 + * 写库后推送 FRIEND_ADD 通知给 A、B 双方多端,并下发 TIP 系统消息。 + * + * @param request 已同意的申请记录(决定 fromUserId / toUserId / addSource / displayName) + */ + void becomeFriends(ImFriendRequestDO request); + + /** + * 单向静默重新建立好友关系 + *

+ * 仅用于 {@link ImFriendRequestService#applyFriend} 在「我已删除 + 对方仍把我当好友」场景: + * 直接恢复 userId 这边的 friend 记录,不走申请审批;不下发 TIP / 不通知对方,仅 FRIEND_ADD 给 userId 多端,避免对方感知我曾删除。 + * + * @param userId 当前用户编号 + * @param friendUserId 对方用户编号 + * @param displayName 备注(取自申请 VO) + * @param addSource 添加来源(取自申请 VO) + */ + void silentReAddFriend(Long userId, Long friendUserId, String displayName, Integer addSource); + + // ==================== 用户端 ==================== + + /** + * 删除好友(单向软删除) + *

+ * 仅删除 userId 视角下的好友关系;对端 friendUserId 的视角不受影响(单边删除语义) + * + * @param clear 是否级联清理本端相关数据(当前包含私聊会话;通过 FRIEND_DELETE 通知透传给多端) + */ + void deleteFriend(Long userId, Long friendUserId, Boolean clear); + + /** + * 更新好友单边属性(备注 / 免打扰 / 联系人置顶) + */ + void updateFriend(Long userId, ImFriendUpdateReqVO reqVO); + + /** + * 拉黑好友(必须先是好友) + */ + void blockFriend(Long userId, Long friendUserId); + + /** + * 移出黑名单 + */ + void unblockFriend(Long userId, Long friendUserId); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】分页查询好友关系 + */ + PageResult getFriendPage(ImFriendManagerPageReqVO reqVO); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java new file mode 100644 index 000000000..1919aaea1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java @@ -0,0 +1,333 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.*; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STATE; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_BLOCKED_BY_PEER; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; + +/** + * IM 好友关系 Service 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@Service +@Validated +public class ImFriendServiceImpl implements ImFriendService { + + @Resource + private ImFriendMapper friendMapper; + + @Resource + private ImWebSocketService websocketService; + @Resource + @Lazy + private ImPrivateMessageService privateMessageService; + + @Override + @Cacheable(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId", unless = "#result == null") + public Integer getFriendState(Long userId, Long friendUserId) { + // 1.1 我侧记录:我方删了,都算非好友 + ImFriendDO mine = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (mine == null || !CommonStatusEnum.isEnable(mine.getStatus())) { + return ImFriendStateEnum.NONE.getState(); + } + // 1.2 对方侧记录:对方删了 = 不是好友 + ImFriendDO peer = friendMapper.selectByUserIdAndFriendUserId(friendUserId, userId); + if (peer == null || !CommonStatusEnum.isEnable(peer.getStatus())) { + return ImFriendStateEnum.NONE.getState(); + } + // 2. 仅当双方都是 ENABLE 状态,才算好友关系;此时对方拉黑我,则是 BLOCKED + return BooleanUtil.isTrue(peer.getBlocked()) ? ImFriendStateEnum.BLOCKED.getState() : ImFriendStateEnum.FRIEND.getState(); + } + + @Override + public void validateFriend(Long userId, Long peerUserId) { + // 好友 / 黑名单校验:和私聊消息发送同一套语义;NONE 已删 / 未加,BLOCKED 被对方拉黑 + Integer state = getSelf().getFriendState(userId, peerUserId); + if (ImFriendStateEnum.isNone(state)) { + throw exception(FRIEND_NOT_FRIEND); + } + if (ImFriendStateEnum.isBlocked(state)) { + throw exception(FRIEND_BLOCKED_BY_PEER); + } + } + + private ImFriendService getSelf() { + return SpringUtil.getBean(getClass()); + } + + @Override + public List getFriendList(Long userId) { + return friendMapper.selectListByUserId(userId); + } + + @Override + public List getEnableFriendList(Long userId) { + return friendMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public List getMutualEnableFriendList(Long userId) { + // 1. 查询本端启用好友 + List friends = getEnableFriendList(userId); + if (CollUtil.isEmpty(friends)) { + return Collections.emptyList(); + } + + // 2. 查询对端启用好友关系 + Set friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId); + List mutualFriends = friendMapper.selectListByUserIdsAndFriendUserIdAndStatus(friendUserIds, userId, + CommonStatusEnum.ENABLE.getStatus()); + Set mutualUserIds = convertSet(mutualFriends, ImFriendDO::getUserId); + + // 3. 过滤双向启用好友 + return filterList(friends, friend -> mutualUserIds.contains(friend.getFriendUserId())); + } + + @Override + public List getActiveFriendList(Long userId, Collection friendUserIds) { + if (CollUtil.isEmpty(friendUserIds)) { + return Collections.emptyList(); + } + return friendMapper.selectListByUserIdAndFriendUserIdsAndStatus(userId, friendUserIds, + CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public ImFriendDO getFriend(Long userId, Long friendUserId) { + return friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + } + + @Override + public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) { + // 1.1 校验:至少改一个字段(无字段变更,直接结束) + if (reqVO.getDisplayName() == null && reqVO.getSilent() == null && reqVO.getPinned() == null) { + return; + } + // 1.2 校验好友关系启用 + ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId()); + if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) { + throw exception(FRIEND_NOT_FRIEND); + } + + // 2. 更新好友属性(备注 / 免打扰 / 联系人置顶) + friendMapper.updateById(new ImFriendDO().setId(friend.getId()) + .setSilent(reqVO.getSilent()).setDisplayName(reqVO.getDisplayName()).setPinned(reqVO.getPinned())); + + // 3. 推 FRIEND_UPDATE 给 A 多端:所有单边属性变更合并为单条通知,避免多通知顺序竞争 + FriendUpdateNotification payload = (FriendUpdateNotification) new FriendUpdateNotification() + .setDisplayName(reqVO.getDisplayName()).setSilent(reqVO.getSilent()).setPinned(reqVO.getPinned()) + .setOperatorUserId(userId).setFriendUserId(reqVO.getFriendUserId()); + websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification( + ImMessageTypeEnum.FRIEND_UPDATE.getType(), userId, userId, payload)); + } + + @Override + @Caching(evict = { + @CacheEvict(cacheNames = FRIEND_STATE, key = "#request.fromUserId + '_' + #request.toUserId"), + @CacheEvict(cacheNames = FRIEND_STATE, key = "#request.toUserId + '_' + #request.fromUserId") + }) + @Transactional(rollbackFor = Exception.class) + public void becomeFriends(ImFriendRequestDO request) { + Long fromUserId = request.getFromUserId(); + Long toUserId = request.getToUserId(); + // 1. 双向建立关系:A 侧带申请的 displayName / addSource;B 侧 displayName 为空、addSource 同来源 + // FRIEND_STATE 双向失效由方法上的 @Caching 注解处理;framework 已开 transactionAware 自动延迟到 afterCommit + addFriend0(fromUserId, toUserId, request.getDisplayName(), request.getAddSource()); + addFriend0(toUserId, fromUserId, null, request.getAddSource()); + + // 2. 发送 FRIEND_ADD 入库(双方拉历史都能看到「你们已成为好友」会话气泡)+ 双向 WebSocket 自动覆盖双方多端 + // operatorUserId=fromUserId 标记申请发起方;前端按 (currentUserId === operatorUserId) 区分视角,文案固定不依赖此字段 + FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification() + .setOperatorUserId(fromUserId).setFriendUserId(toUserId); + privateMessageService.sendPrivateMessage(fromUserId, new ImPrivateMessageSendDTO() + .setReceiverId(toUserId).setType(ImMessageTypeEnum.FRIEND_ADD.getType()).setContent(payload)); + } + + @Override + @Caching(evict = { + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"), + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId") + }) + @Transactional(rollbackFor = Exception.class) + public void silentReAddFriend(Long userId, Long friendUserId, String displayName, Integer addSource) { + // 1. 单边重新启用我侧好友关系 + addFriend0(userId, friendUserId, displayName, addSource); + + // 2. 走 sendPrivateMessage + persistent=false:不入库 + 仅推 userId 多端(对方完全不感知,保持「对方一直把我当好友」错觉) + // operatorUserId 填 friendUserId(对方):让 userId 多端 UI 呈现「对方加了我」视角,与 silent 语义对齐 + // 前端按 type=FRIEND_ADD 渲染会话气泡(瞬时,不入库刷新即消失) + FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification() + .setOperatorUserId(friendUserId).setFriendUserId(friendUserId); + privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO() + .setReceiverId(friendUserId).setType(ImMessageTypeEnum.FRIEND_ADD.getType()) + .setContent(payload).setPersistent(false)); + } + + @Override + @Caching(evict = { + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"), + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId") + }) + @Transactional(rollbackFor = Exception.class) + public void deleteFriend(Long userId, Long friendUserId, Boolean clear) { + // 1. 单边软删:仅 userId 视角的关系置 DISABLE;friendUserId 视角不动 + if (!deleteFriend0(userId, friendUserId)) { + return; + } + + // 2. 走 sendPrivateMessage + persistent=false:不入库 + 仅推 userId 多端(friendUserId 不感知);clear 透传让多端清理动作一致 + // clear=false 时前端按 type=FRIEND_DELETE 渲染「你已删除好友」会话气泡(瞬时); + // clear=true 时前端按 clear 字段直接清会话,跳过气泡渲染 + FriendDeleteNotification payload = ((FriendDeleteNotification) new FriendDeleteNotification() + .setOperatorUserId(userId).setFriendUserId(friendUserId)).setClear(clear); + privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO() + .setReceiverId(friendUserId).setType(ImMessageTypeEnum.FRIEND_DELETE.getType()) + .setContent(payload).setPersistent(false)); + } + + @Override + @Caching(evict = { + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"), + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId") + }) + public void blockFriend(Long userId, Long friendUserId) { + // 1.1 校验是好友 + ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) { + throw exception(FRIEND_NOT_FRIEND); + } + // 1.2 已拉黑直接返回,幂等 + if (BooleanUtil.isTrue(friend.getBlocked())) { + return; + } + + // 2. 单边更新 + friendMapper.updateById(new ImFriendDO().setId(friend.getId()).setBlocked(true)); + + // 3. 推 FRIEND_BLOCK 给 A 多端 + FriendBlockNotification payload = (FriendBlockNotification) new FriendBlockNotification() + .setOperatorUserId(userId).setFriendUserId(friendUserId); + websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification( + ImMessageTypeEnum.FRIEND_BLOCK.getType(), userId, userId, payload)); + } + + @Override + @Caching(evict = { + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"), + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId") + }) + public void unblockFriend(Long userId, Long friendUserId) { + // 1.1 校验是好友 + ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) { + throw exception(FRIEND_NOT_FRIEND); + } + // 1.2 未拉黑则报错 + if (!BooleanUtil.isTrue(friend.getBlocked())) { + throw exception(FRIEND_NOT_BLOCKED); + } + + // 2. 单边更新 + friendMapper.updateById(new ImFriendDO().setId(friend.getId()).setBlocked(false)); + + // 3. 推 FRIEND_UNBLOCK 给 A 多端 + FriendUnblockNotification payload = (FriendUnblockNotification) new FriendUnblockNotification() + .setOperatorUserId(userId).setFriendUserId(friendUserId); + websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification( + ImMessageTypeEnum.FRIEND_UNBLOCK.getType(), userId, userId, payload)); + } + + /** + * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} / {@link #silentReAddFriend} 调用): + * - 情况一:已存在 ENABLE 记录 → 已是好友,幂等跳过;不重置 silent / pinned / blocked,避免历史未处理申请再被同意时清掉用户的拉黑 / 置顶 / 免打扰设置 + * - 情况二:已存在 DISABLE 记录 → 复用并恢复 ENABLE,silent / pinned / blocked 一并重置为 false,对齐"重新加好友"语义 + * - 情况三:不存在记录 → 直接插入新记录 + *

+ * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进; + * 极端并发下若插入唯一键冲突,让 DuplicateKeyException 向外抛出,外层事务回滚。 + *

+ * FRIEND_STATE 缓存失效由调用方的 @Caching 注解统一处理,本方法不主动 evict + */ + public void addFriend0(Long userId, Long friendUserId, String displayName, Integer addSource) { + ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + // 情况一:已是 ENABLE 好友,幂等跳过;防御历史未处理申请被二次同意时把 blocked / silent / pinned 清回 false + if (exists != null && CommonStatusEnum.isEnable(exists.getStatus())) { + return; + } + // 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义 + if (exists != null) { + friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.now(), + false, false, false, displayName, addSource); + return; + } + // 情况三:不存在记录 → 直接插入新记录 + ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId) + .silent(false).pinned(false).blocked(false) + .displayName(displayName).addSource(addSource) + .status(CommonStatusEnum.ENABLE.getStatus()).addTime(LocalDateTime.now()).build(); + friendMapper.insert(friend); + } + + /** + * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime) + *

+ * blocked 不主动重置:删好友期间保留拉黑状态;如果未来再 addFriend0,由 addFriend0 统一重置 + *

+ * FRIEND_STATE 缓存失效由调用方的 @Caching 注解统一处理,本方法不主动 evict + */ + public boolean deleteFriend0(Long userId, Long friendUserId) { + ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) { + return false; + } + friendMapper.updateById(new ImFriendDO().setId(exists.getId()) + .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDeleteTime(LocalDateTime.now())); + return true; + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getFriendPage(ImFriendManagerPageReqVO reqVO) { + return friendMapper.selectPage(reqVO); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java new file mode 100644 index 000000000..d9a8000ff --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java @@ -0,0 +1,245 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import jakarta.validation.Valid; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 群成员 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupMemberService { + + /** + * 获得群成员 + * + * @param id 编号 + * @return 群成员 + */ + ImGroupMemberDO getGroupMember(Long id); + + /** + * 获得群成员 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 群成员 + */ + ImGroupMemberDO getGroupMember(Long groupId, Long userId); + + /** + * 批量查询群成员(包含所有状态) + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + * @return 群成员列表 + */ + List getGroupMembers(Long groupId, Collection userIds); + + /** + * 根据群组 id 查询群成员(包含所有状态) + * + * @param groupId 群组id + * @return 群成员列表 + */ + List getGroupMemberListByGroupId(Long groupId); + + /** + * 根据群编号查询有效成员列表(仅 ENABLE 状态) + * + * @param groupId 群编号 + * @return 有效群成员列表 + */ + List getActiveGroupMemberListByGroupId(Long groupId); + + /** + * 获取群里活跃的群主 + 管理员;用于审批通知定向推送 + * + * @param groupId 群编号 + * @return 群主 / 管理员的成员记录列表(按入群时间倒序的天然成员表顺序) + */ + List getGroupMemberListByOwnerAndAdmin(Long groupId); + + /** + * 根据群编号查询有效成员的 userId 列表(仅 ENABLE 状态) + *

+ * 相比 {@link #getActiveGroupMemberListByGroupId(Long)},只返回 userId,结果体积小,并带有 Redis 缓存。 + * 适用于"群消息推送目标"等只需要 userId 的场景; + * 需要 joinTime/quitTime/status 等字段做历史消息可见性判断时,仍应使用完整列表方法。 + * + * @param groupId 群编号 + * @return 有效群成员 userId 列表 + */ + List getActiveGroupMemberUserIdsByGroupId(Long groupId); + + /** + * 查询用户所在的所有群的有效成员记录(仅 ENABLE 状态) + * + * @param userId 用户编号 + * @return 有效群成员记录列表 + */ + List getActiveGroupMemberListByUserId(Long userId); + + /** + * 查询用户已退群的群成员记录(DISABLE 状态) + * + * @param userId 用户编号,必传 + * @param minQuitTime 最早退群时间(含),可空 + * @return 已退群成员记录列表 + */ + List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime); + + /** + * 添加群成员(入群),角色默认 MEMBER + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 群成员记录 + */ + @SuppressWarnings("UnusedReturnValue") + ImGroupMemberDO addGroupMember(Long groupId, Long userId); + + /** + * 添加群成员(入群),并指定角色 + *

+ * 重置旧成员行也会强制重置 role,避免离群期间残留管理员身份被复用。 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @param role 成员角色,见 {@link cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum} + * @return 群成员记录 + */ + @SuppressWarnings("UnusedReturnValue") + ImGroupMemberDO addGroupMember(Long groupId, Long userId, Integer role); + + /** + * 添加群成员(入群),并指定角色 / 加入来源 / 邀请人 + *

+ * 重置旧成员行也会强制重置 role / addSource / inviterUserId,确保留痕反映「本次入群」事件 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @param role 成员角色 + * @param addSource 加入来源,见 {@link cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum} + * @param inviterUserId 邀请人用户编号;NULL 表示主动申请 + * @return 群成员记录 + */ + @SuppressWarnings("UnusedReturnValue") + ImGroupMemberDO addGroupMember(Long groupId, Long userId, Integer role, Integer addSource, Long inviterUserId); + + /** + * 批量添加群成员(入群) + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + void addGroupMembers(Long groupId, Collection userIds); + + /** + * 批量添加群成员(入群),统一携带加入来源 / 邀请人 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + * @param addSource 加入来源 + * @param inviterUserId 邀请人用户编号;NULL 表示主动申请 + */ + void addGroupMembers(Long groupId, Collection userIds, Integer addSource, Long inviterUserId); + + /** + * 校验用户是否为群的有效成员 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 群成员记录 + */ + ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId); + + /** + * 批量校验用户都是该群的有效成员;任一缺失 / 禁用即抛 {@code GROUP_MEMBER_NOT_IN_GROUP} + * + * @param groupId 群编号 + * @param userIds 用户编号集合;为空直接返回 + */ + void validateMembersInGroup(Long groupId, Collection userIds); + + /** + * 更新群成员信息(群内昵称、群名备注、免打扰等) + *

+ * 内部会校验用户是否为群的有效成员 + * + * @param userId 当前登录用户编号 + * @param updateReqVO 更新信息 + */ + void updateGroupMember(Long userId, @Valid ImGroupMemberUpdateReqVO updateReqVO); + + /** + * 批量更新群成员角色 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + * @param role 新角色,见 {@link cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum} + */ + int updateGroupMemberRole(Long groupId, Collection userIds, Integer role); + + /** + * 统计群内指定 role 的活跃成员数量 + * + * @param groupId 群编号 + * @param role 成员角色 + * @return 数量 + */ + Long getGroupMemberCountByRole(Long groupId, Integer role); + + /** + * 移除指定群成员(设置为 DISABLE 状态) + *

+ * 用于退群、踢出场景 + * + * @param groupId 群编号 + * @param userId 用户编号 + */ + void removeGroupMember(Long groupId, Long userId); + + /** + * 批量移除指定群成员(设置为 DISABLE 状态) + *

+ * 用于批量踢出场景 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + void removeGroupMembers(Long groupId, Collection userIds); + + /** + * 移除群的全部成员(设置为 DISABLE 状态) + *

+ * 用于群解散场景 + * + * @param groupId 群编号 + */ + void removeGroupMembersByGroupId(Long groupId); + + /** + * 批量按 group 统计活跃成员数:(group_id → count) + * + * @param groupIds 群编号集合 + * @return 群成员数 Map + */ + Map getActiveMemberCountMap(Collection groupIds); + + /** + * 更新成员禁言到期时间 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @param muteEndTime 禁言到期时间;null 表示取消禁言 + */ + void updateGroupMemberMuteEndTime(Long groupId, Long userId, LocalDateTime muteEndTime); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java new file mode 100644 index 000000000..523841eb4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java @@ -0,0 +1,323 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; + +/** + * 群成员 Service 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@Service +@Validated +public class ImGroupMemberServiceImpl implements ImGroupMemberService { + + @Resource + private ImGroupMemberMapper groupMemberMapper; + + @Resource + @Lazy // 避免循环依赖 + private ImGroupMessageService groupMessageService; + + @Override + public ImGroupMemberDO getGroupMember(Long id) { + return groupMemberMapper.selectById(id); + } + + @Override + public ImGroupMemberDO getGroupMember(Long groupId, Long userId) { + return groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + } + + @Override + public List getGroupMembers(Long groupId, Collection userIds) { + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyList(); + } + return groupMemberMapper.selectListByGroupIdAndUserIds(groupId, userIds); + } + + @Override + public List getGroupMemberListByGroupId(Long groupId) { + return groupMemberMapper.selectListByGroupId(groupId); + } + + @Override + public List getActiveGroupMemberListByGroupId(Long groupId) { + return groupMemberMapper.selectListByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public List getGroupMemberListByOwnerAndAdmin(Long groupId) { + // TODO DONE @AI:把条件往下传;这样减少加载数据量! + return groupMemberMapper.selectListByGroupIdAndStatusAndRoles(groupId, CommonStatusEnum.ENABLE.getStatus(), + List.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole())); + } + + /** + * 只缓存 userId 列表而非整个 {@link ImGroupMemberDO},理由: + *

    + *
  • 体积小:500 人群约 4KB,失效/序列化成本低;
  • + *
  • 失效面窄:仅 {@link #addGroupMember}/{@link #addGroupMembers}/ + * {@link #removeGroupMember}/{@link #removeGroupMembers}/{@link #removeGroupMembersByGroupId} + * 这类影响成员集合的写操作需要失效; + * {@link #updateGroupMember}(昵称/备注/免打扰)不修改集合成员,不需要失效。
  • + *
+ */ + @Override + @Cacheable(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public List getActiveGroupMemberUserIdsByGroupId(Long groupId) { + List members = groupMemberMapper.selectListByGroupIdAndStatus( + groupId, CommonStatusEnum.ENABLE.getStatus()); + return convertList(members, ImGroupMemberDO::getUserId); + } + + @Override + public List getActiveGroupMemberListByUserId(Long userId) { + return groupMemberMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) { + return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime); + } + + @Override + public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { + return addGroupMember(groupId, userId, ImGroupMemberRoleEnum.NORMAL.getRole(), null, null); + } + + @Override + public ImGroupMemberDO addGroupMember(Long groupId, Long userId, Integer role) { + return addGroupMember(groupId, userId, role, null, null); + } + + /** + * 并发安全:依靠 im_group_member 表的唯一索引 uk_im_group_member_group_user(group_id, user_id) 保证幂等, + * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 + *

+ * 重置旧成员行时强制重置 role / addSource / inviterUserId / quitTime / muteEndTime + */ + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public ImGroupMemberDO addGroupMember(Long groupId, Long userId, Integer role, + Integer addSource, Long inviterUserId) { + LocalDateTime now = LocalDateTime.now(); + // 情况一:已存在记录 → 重置或跳过 + ImGroupMemberDO exists = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + if (exists != null) { + if (CommonStatusEnum.isDisable(exists.getStatus())) { + groupMemberMapper.updateRejoinFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), now, + role, addSource, inviterUserId); + exists.setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now).setRole(role) + .setAddSource(addSource).setInviterUserId(inviterUserId) + .setQuitTime(null).setMuteEndTime(null); + } + return exists; + } + // 情况二:新增成员 + ImGroupMemberDO member = new ImGroupMemberDO() + .setGroupId(groupId).setUserId(userId) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now) + .setRole(role).setAddSource(addSource).setInviterUserId(inviterUserId); + try { + groupMemberMapper.insert(member); + return member; + } catch (DuplicateKeyException e) { + // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),查询返回 + log.warn("[addGroupMember][groupId({}) userId({}) 并发插入冲突,查询返回]", groupId, userId); + return groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + } + } + + @Override + public void addGroupMembers(Long groupId, Collection userIds) { + addGroupMembers(groupId, userIds, null, null); + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void addGroupMembers(Long groupId, Collection userIds, Integer addSource, Long inviterUserId) { + LocalDateTime now = LocalDateTime.now(); + Integer role = ImGroupMemberRoleEnum.NORMAL.getRole(); + // 1.1 查询已有记录(含已退群的 DISABLE 记录) + List existMembers = groupMemberMapper.selectListByGroupIdAndUserIds(groupId, userIds); + Map existMap = convertMap(existMembers, ImGroupMemberDO::getUserId); + // 1.2 分类:已有记录 → UPDATE,新成员 → INSERT + List inserts = new ArrayList<>(); + List updates = new ArrayList<>(); + for (Long userId : userIds) { + ImGroupMemberDO exist = existMap.get(userId); + if (exist == null) { + inserts.add(new ImGroupMemberDO().setGroupId(groupId).setUserId(userId) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setRole(role).setJoinTime(now) + .setAddSource(addSource).setInviterUserId(inviterUserId)); + } else if (CommonStatusEnum.DISABLE.getStatus().equals(exist.getStatus())) { + updates.add(new ImGroupMemberDO().setId(exist.getId()) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setRole(role).setJoinTime(now) + .setAddSource(addSource).setInviterUserId(inviterUserId)); + } + } + + // 2.1 先做 update,update 没有并发冲突风险 + if (CollUtil.isNotEmpty(updates)) { + for (ImGroupMemberDO update : updates) { + groupMemberMapper.updateRejoinFields(update.getId(), update.getStatus(), update.getJoinTime(), + update.getRole(), update.getAddSource(), update.getInviterUserId()); + } + } + // 2.2 批量 insert。并发场景下若其它请求已先一步插入同一 (groupId, userId), + // 会触发唯一索引冲突,此时降级为逐个 addGroupMember(利用其兜底逻辑幂等处理)。 + if (CollUtil.isNotEmpty(inserts)) { + try { + groupMemberMapper.insertBatch(inserts); + } catch (DuplicateKeyException e) { + log.warn("[addGroupMembers][groupId({}) userIds({}) 批量插入冲突,降级为逐个处理]", groupId, userIds); + for (ImGroupMemberDO insert : inserts) { + addGroupMember(groupId, insert.getUserId(), role, addSource, inviterUserId); + } + } + } + } + + @Override + public ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId) { + ImGroupMemberDO member = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + if (member == null || CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + return member; + } + + @Override + public void validateMembersInGroup(Long groupId, Collection userIds) { + if (CollUtil.isEmpty(userIds)) { + return; + } + // 一次性拉取目标 userId 的成员记录,仅保留活跃状态 + List members = groupMemberMapper.selectListByGroupIdAndUserIds(groupId, userIds); + Set activeUserIds = convertSet(members, ImGroupMemberDO::getUserId, + member -> CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus())); + // 任一 userId 不在活跃集合即抛 + for (Long userId : userIds) { + if (!activeUserIds.contains(userId)) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + } + } + + @Override + public int updateGroupMemberRole(Long groupId, Collection userIds, Integer role) { + if (CollUtil.isEmpty(userIds)) { + return 0; + } + return groupMemberMapper.updateListByGroupIdAndUserIds(groupId, userIds, new ImGroupMemberDO().setRole(role)); + } + + @Override + public Long getGroupMemberCountByRole(Long groupId, Integer role) { + return groupMemberMapper.selectCountByGroupIdAndRoleAndStatus( + groupId, role, CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public void updateGroupMember(Long userId, ImGroupMemberUpdateReqVO updateReqVO) { + Long groupId = updateReqVO.getGroupId(); + // 1. 校验是群的有效成员 + ImGroupMemberDO member = validateMemberInGroup(groupId, userId); + + // 2. 更新群成员信息 + ImGroupMemberDO updateObj = BeanUtils.toBean(updateReqVO, ImGroupMemberDO.class) + .setId(member.getId()); + groupMemberMapper.updateById(updateObj); + + // 3.1 displayUserName 是公开字段,单独走 GROUP_MEMBER_NICKNAME_UPDATE 广播给全员;空串视为「清空昵称」也要广播;与旧值相同跳过 + if (updateReqVO.getDisplayUserName() != null + && ObjUtil.notEqual(updateReqVO.getDisplayUserName(), member.getDisplayUserName())) { + groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberNicknameUpdate( + groupId, userId, updateReqVO.getDisplayUserName())); + } + // 3.2 silent / groupRemark 是个人字段,仅推自己做多端同步;与旧值都相同跳过 + boolean silentChanged = updateReqVO.getSilent() != null + && ObjUtil.notEqual(updateReqVO.getSilent(), member.getSilent()); + boolean groupRemarkChanged = updateReqVO.getGroupRemark() != null + && ObjUtil.notEqual(updateReqVO.getGroupRemark(), member.getGroupRemark()); + if (silentChanged || groupRemarkChanged) { + groupMessageService.sendGroupMessage(userId, List.of(userId), ImGroupMessageSendDTO.ofGroupMemberSettingUpdate( + groupId, userId, updateReqVO.getSilent(), updateReqVO.getGroupRemark())); + } + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void removeGroupMember(Long groupId, Long userId) { + // 1. 校验是群的有效成员 + ImGroupMemberDO member = validateMemberInGroup(groupId, userId); + // 2. 更新为退群状态 + groupMemberMapper.updateById(new ImGroupMemberDO().setId(member.getId()) + .setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void removeGroupMembers(Long groupId, Collection userIds) { + groupMemberMapper.updateByGroupIdAndUserIdsAndStatus(groupId, userIds, + CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void removeGroupMembersByGroupId(Long groupId) { + groupMemberMapper.updateByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + } + + @Override + public Map getActiveMemberCountMap(Collection groupIds) { + return groupMemberMapper.selectCountMapByGroupIdsAndStatus(groupIds, CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public void updateGroupMemberMuteEndTime(Long groupId, Long userId, LocalDateTime muteEndTime) { + ImGroupMemberDO member = validateMemberInGroup(groupId, userId); + if (muteEndTime != null) { + // 禁言:直接更新到期时间 + groupMemberMapper.updateById(new ImGroupMemberDO().setId(member.getId()).setMuteEndTime(muteEndTime)); + } else { + // 取消禁言 + groupMemberMapper.updateMuteEndTimeNull(member.getId()); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestService.java new file mode 100644 index 000000000..bd604f6f4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestService.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.request.ImGroupRequestApplyReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * IM 加群申请 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupRequestService { + + /** + * 用户主动申请加群 + *

+ * 群未开启审批时直接入群 + 1510 全员广播;开启审批则创建或复用一条待审批记录 + 1503 推送 + * + * @param userId 申请人用户编号 + * @param reqVO 申请请求 + * @return 申请记录;自由进群直进时返回 null + */ + ImGroupRequestDO applyJoinGroup(Long userId, @Valid ImGroupRequestApplyReqVO reqVO); + + /** + * 同意加群申请(群主或管理员);处理前校验入群人数上限 + * + * @param userId 操作人用户编号 + * @param requestId 申请记录编号 + */ + void agreeGroupRequest(Long userId, Long requestId); + + /** + * 拒绝加群申请(群主或管理员) + * + * @param userId 操作人用户编号 + * @param requestId 申请记录编号 + * @param handleContent 拒绝理由 + */ + void refuseGroupRequest(Long userId, Long requestId, String handleContent); + + /** + * 邀请创建审批申请;inviteGroupMember 在群开启审批时调用,每个被邀请人创建或复用一条待审批记录 + * + * @param groupId 群编号 + * @param inviterUserId 邀请人用户编号 + * @param invitedUserIds 被邀请人用户编号集合 + */ + void createInviteRequestList(Long groupId, Long inviterUserId, Collection invitedUserIds); + + /** + * 拉取「我管理的所有群」下的未处理申请列表 + *

+ * 前端 store 据此派生:每个群的未处理总数(用于群顶部横幅红点)+ 列表内容(用于 Drawer) + * + * @param userId 当前用户编号;后端按 ImGroupMember.role 过滤出我作为 OWNER / ADMIN 的群 + * @return 未处理申请列表(不分页) + */ + List getUnhandledRequestListByOwnerOrAdmin(Long userId); + + /** + * 拉取指定群下的全部加群申请(含已处理);仅群主 / 管理员可查 + *

+ * 用于群「进群申请」子页:最新一条卡片化突出 + 历史申请按 id 倒序 + * + * @param userId 当前用户编号;用于校验 owner / admin 身份 + * @param groupId 群编号 + * @return 申请记录列表,按 id 倒序 + */ + List getGroupRequestListByGroupId(Long userId, Long groupId); + + /** + * 按 id 单查申请记录;通用读接口,越权过滤交由调用方 + * + * @param id 申请记录编号 + * @return 申请记录 + */ + ImGroupRequestDO getGroupRequest(Long id); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】分页查询加群申请记录 + */ + PageResult getGroupRequestPage(ImGroupRequestManagerPageReqVO reqVO); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImpl.java new file mode 100644 index 000000000..4b6093504 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImpl.java @@ -0,0 +1,413 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.request.ImGroupRequestApplyReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupRequestManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper; +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.BaseGroupNotification; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestApprovedNotification; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestReceivedNotification; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestRejectedNotification; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * IM 加群申请 Service 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@Service +@Validated +public class ImGroupRequestServiceImpl implements ImGroupRequestService { + + @Resource + private ImGroupRequestMapper groupRequestMapper; + + @Resource + @Lazy // 避免循环依赖 + private ImGroupService groupService; + @Resource + @Lazy // 避免循环依赖 + private ImGroupMemberService groupMemberService; + @Resource + @Lazy // 避免循环依赖 + private ImGroupMessageService groupMessageService; + + @Resource + private ImWebSocketService websocketService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public ImGroupRequestDO applyJoinGroup(Long userId, ImGroupRequestApplyReqVO reqVO) { + Long groupId = reqVO.getGroupId(); + // 1.1 校验群存在 + 未封禁 / 未解散 + ImGroupDO group = groupService.validateGroupExists(groupId); + // 1.2 校验未在群中 + ImGroupMemberDO member = groupMemberService.getGroupMember(groupId, userId); + if (member != null && !CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { + throw exception(GROUP_REQUEST_ALREADY_MEMBER); + } + + // 2. 情况一:群未开启审批,直接入群;写群成员留痕 + 推 1510 全员广播;不落申请记录 + if (!Boolean.TRUE.equals(group.getJoinApproval())) { + // 入群前校验人数上限 + groupService.validateMemberCountLimit(groupId, 1); + // 写群成员;addSource 来自前端入口(搜索 / 二维码 / 分享链接),inviterUserId=null(主动申请) + groupMemberService.addGroupMember(groupId, userId, + ImGroupMemberRoleEnum.NORMAL.getRole(), reqVO.getAddSource(), null); + // 推 1510 给全员;payload 含进群者 + 来源,前端按 entrantUserId 局部插入新成员 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMemberEnter(groupId, userId, reqVO.getAddSource())); + return null; + } + + // 3. 情况二:群开启了审批,创建或复用一条主动申请记录 + ImGroupRequestDO request = createOrResetApplyRequest(groupId, userId, reqVO); + + // 4. 1503 私聊定向推群主 + 全部管理员(多端同步);payload 携带申请方昵称 / 头像 + AdminUserRespDTO applyUser = adminUserApi.getUser(userId).getCheckedData(); + GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser); + for (Long receiverUserId : getGroupMemberListByOwnerAndAdminUserIds(group)) { + websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification( + ImMessageTypeEnum.GROUP_REQUEST_RECEIVED.getType(), userId, receiverUserId, payload)); + } + return request; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void agreeGroupRequest(Long userId, Long requestId) { + // 1.1 校验申请存在 + 未处理 + 操作人是 owner / admin + ImGroupRequestDO request = validateRequestForHandle(userId, requestId); + // 1.2 复核群当前状态:拒绝在封禁 / 解散的群继续放人 + groupService.validateGroupExists(request.getGroupId()); + // 1.3 复核申请人是否已在群中;幂等避免重复广播 1509 / 1510 入群事件 + ImGroupMemberDO applicant = groupMemberService.getGroupMember(request.getGroupId(), request.getUserId()); + if (applicant != null && CommonStatusEnum.ENABLE.getStatus().equals(applicant.getStatus())) { + throw exception(GROUP_REQUEST_ALREADY_MEMBER); + } + // 2. 入群前校验人数上限;群已满抛错让操作人选择拒绝 + groupService.validateMemberCountLimit(request.getGroupId(), 1); + + // 3. 乐观锁推进状态 + LocalDateTime now = LocalDateTime.now(); + ImGroupRequestDO updateObj = new ImGroupRequestDO() + .setHandleResult(ImGroupRequestHandleResultEnum.AGREED.getResult()) + .setHandleUserId(userId).setHandleTime(now); + int affected = groupRequestMapper.updateByIdAndHandleResult(request.getId(), + ImGroupRequestHandleResultEnum.UNHANDLED.getResult(), updateObj); + if (affected == 0) { + throw exception(GROUP_REQUEST_HANDLED); + } + request.setHandleResult(ImGroupRequestHandleResultEnum.AGREED.getResult()) + .setHandleUserId(userId).setHandleTime(now); + + // 4. 写群成员;addSource / inviterUserId 沿用申请记录上的来源信息 + groupMemberService.addGroupMember(request.getGroupId(), request.getUserId(), + ImGroupMemberRoleEnum.NORMAL.getRole(), request.getAddSource(), request.getInviterUserId()); + + // 5.1 1505 私聊推送给申请人 + 群主 + 全部管理员(每端单推) + GroupRequestApprovedNotification payload = (GroupRequestApprovedNotification) new GroupRequestApprovedNotification() + .setRequestId(request.getId()).setGroupId(request.getGroupId()).setUserId(request.getUserId()) + .setOperatorUserId(userId); + broadcastToOwnerAdminsAndApplicant(request.getGroupId(), request.getUserId(), payload, + ImMessageTypeEnum.GROUP_REQUEST_APPROVED.getType(), userId); + // 5.2 群事件:主动申请 → 1510 自由进群;被邀请 → 1509 成员加入 + if (request.getInviterUserId() == null) { + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMemberEnter(request.getGroupId(), + request.getUserId(), request.getAddSource())); + } else { + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMemberInvite(request.getGroupId(), + request.getInviterUserId(), Collections.singleton(request.getUserId()))); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void refuseGroupRequest(Long userId, Long requestId, String handleContent) { + // 1. 校验 + ImGroupRequestDO request = validateRequestForHandle(userId, requestId); + + // 2. 乐观锁推进 + ImGroupRequestDO updateObj = new ImGroupRequestDO() + .setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult()) + .setHandleContent(handleContent) + .setHandleUserId(userId).setHandleTime(LocalDateTime.now()); + int affected = groupRequestMapper.updateByIdAndHandleResult(request.getId(), + ImGroupRequestHandleResultEnum.UNHANDLED.getResult(), updateObj); + if (affected == 0) { + throw exception(GROUP_REQUEST_HANDLED); + } + + // 3. 1506 私聊推送给申请人 + 群主 + 全部管理员 + GroupRequestRejectedNotification payload = (GroupRequestRejectedNotification) new GroupRequestRejectedNotification() + .setRequestId(request.getId()).setGroupId(request.getGroupId()).setUserId(request.getUserId()) + .setHandleContent(handleContent).setOperatorUserId(userId); + broadcastToOwnerAdminsAndApplicant(request.getGroupId(), request.getUserId(), payload, + ImMessageTypeEnum.GROUP_REQUEST_REJECTED.getType(), userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createInviteRequestList(Long groupId, Long inviterUserId, Collection invitedUserIds) { + if (CollUtil.isEmpty(invitedUserIds)) { + return; + } + ImGroupDO group = groupService.validateGroupExists(groupId); + Integer inviteSource = ImGroupAddSourceEnum.INVITE.getSource(); + // 1. 逐条创建或复用邀请申请 + List requests = convertList(invitedUserIds, userId -> + createOrResetInviteRequest(groupId, inviterUserId, userId, inviteSource)); + + // 2. 推 1503 给群主 + 全部管理员;多端同步;每条申请单独推一帧 + Map userMap = adminUserApi.getUserMap(invitedUserIds); + List ownerAndAdmins = getGroupMemberListByOwnerAndAdminUserIds(group); + for (ImGroupRequestDO request : requests) { + AdminUserRespDTO applyUser = userMap.get(request.getUserId()); + GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser); + for (Long receiverUserId : ownerAndAdmins) { + websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification( + ImMessageTypeEnum.GROUP_REQUEST_RECEIVED.getType(), inviterUserId, receiverUserId, payload)); + } + } + } + + @Override + public List getUnhandledRequestListByOwnerOrAdmin(Long userId) { + // 1. 找出当前用户作为 OWNER / ADMIN 的所有群 + List myMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); + Set ownerOrAdminGroupIds = convertSet(myMembers, + ImGroupMemberDO::getGroupId, + m -> ImGroupMemberRoleEnum.isOwnerOrAdmin(m.getRole())); + if (CollUtil.isEmpty(ownerOrAdminGroupIds)) { + return Collections.emptyList(); + } + // 2. 一次拉所有群的未处理申请 + return groupRequestMapper.selectListByGroupIdsAndHandleResult( + ownerOrAdminGroupIds, ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); + } + + @Override + public List getGroupRequestListByGroupId(Long userId, Long groupId) { + // 1. 校验群存在 + 当前用户是群主 / 管理员 + groupService.validateGroupExists(groupId); + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole())) { + throw exception(GROUP_REQUEST_NOT_TO_ME); + } + // 2. 拉取该群下全部申请(含已处理);按 id 倒序,前端首条卡片化展示 + return groupRequestMapper.selectListByGroupId(groupId); + } + + @Override + public ImGroupRequestDO getGroupRequest(Long id) { + return groupRequestMapper.selectById(id); + } + + @Override + public PageResult getGroupRequestPage(ImGroupRequestManagerPageReqVO reqVO) { + return groupRequestMapper.selectPage(reqVO); + } + + /** + * 创建或重置主动加群申请 + * + * @param groupId 群编号 + * @param userId 申请人用户编号 + * @param reqVO 申请请求 + * @return 申请记录 + */ + private ImGroupRequestDO createOrResetApplyRequest(Long groupId, Long userId, ImGroupRequestApplyReqVO reqVO) { + // 1. 已有申请:覆盖本次申请内容,并重置为未处理 + ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); + if (request != null) { + resetApplyRequest(request, reqVO); + return request; + } + // 2. 无旧申请:创建主动申请记录 + request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class) + .setUserId(userId).setInviterUserId(null) + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); + try { + groupRequestMapper.insert(request); + return request; + } catch (DuplicateKeyException ex) { + // 3. 唯一键冲突:回查并复用并发写入的记录 + request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); + if (request == null) { + throw ex; + } + resetApplyRequest(request, reqVO); + return request; + } + } + + /** + * 创建或重置邀请加群申请 + * + * @param groupId 群编号 + * @param inviterUserId 邀请人用户编号 + * @param userId 被邀请人用户编号 + * @param inviteSource 邀请来源 + * @return 申请记录 + */ + private ImGroupRequestDO createOrResetInviteRequest(Long groupId, Long inviterUserId, + Long userId, Integer inviteSource) { + // 1. 已有申请:覆盖邀请人和来源,并重置为未处理 + ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); + if (request != null) { + resetInviteRequest(request, inviterUserId, inviteSource); + return request; + } + // 2. 无旧申请:创建邀请申请记录 + request = new ImGroupRequestDO().setGroupId(groupId).setUserId(userId).setInviterUserId(inviterUserId) + .setAddSource(inviteSource).setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); + try { + groupRequestMapper.insert(request); + return request; + } catch (DuplicateKeyException ex) { + // 3. 唯一键冲突:回查并复用并发写入的记录 + request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); + if (request == null) { + throw ex; + } + resetInviteRequest(request, inviterUserId, inviteSource); + return request; + } + } + + /** + * 重置主动加群申请 + * + * @param request 申请记录 + * @param reqVO 申请请求 + */ + private void resetApplyRequest(ImGroupRequestDO request, ImGroupRequestApplyReqVO reqVO) { + // 1. 更新申请内容、来源和处理状态 + LocalDateTime now = LocalDateTime.now(); + groupRequestMapper.updateApplyByIdReset(request.getId(), + reqVO.getApplyContent(), reqVO.getAddSource(), now); + // 2. 同步内存对象,后续通知构建直接复用 + request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource()) + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) + .setInviterUserId(null).setHandleUserId(null) + .setHandleContent(null).setHandleTime(null).setUpdateTime(now); + } + + /** + * 重置邀请加群申请 + * + * @param request 申请记录 + * @param inviterUserId 邀请人用户编号 + * @param inviteSource 邀请来源 + */ + private void resetInviteRequest(ImGroupRequestDO request, Long inviterUserId, Integer inviteSource) { + // 1. 更新邀请人、来源和处理状态 + LocalDateTime now = LocalDateTime.now(); + groupRequestMapper.updateInviteByIdReset(request.getId(), inviterUserId, inviteSource, now); + // 2. 同步内存对象,后续通知构建直接复用 + request.setInviterUserId(inviterUserId).setAddSource(inviteSource) + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) + .setHandleUserId(null).setHandleContent(null).setHandleTime(null) + .setUpdateTime(now); + } + + /** + * 校验申请可被「当前用户」处理:申请存在 + 未处理 + 操作人是群主 / 管理员 + */ + private ImGroupRequestDO validateRequestForHandle(Long userId, Long requestId) { + ImGroupRequestDO request = groupRequestMapper.selectById(requestId); + if (request == null) { + throw exception(GROUP_REQUEST_NOT_EXISTS); + } + if (!ImGroupRequestHandleResultEnum.isUnhandled(request.getHandleResult())) { + throw exception(GROUP_REQUEST_HANDLED); + } + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(request.getGroupId(), userId); + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole())) { + throw exception(GROUP_REQUEST_NOT_TO_ME); + } + return request; + } + + /** + * 构建 1503 通知 payload;聚合申请方昵称 / 头像供前端直接渲染 + */ + private GroupRequestReceivedNotification buildRequestNotification(ImGroupDO group, ImGroupRequestDO request, + AdminUserRespDTO applyUser) { + Long operatorUserId = request.getInviterUserId() != null ? request.getInviterUserId() : request.getUserId(); + GroupRequestReceivedNotification payload = (GroupRequestReceivedNotification) new GroupRequestReceivedNotification() + .setRequestId(request.getId()).setGroupId(group.getId()).setUserId(request.getUserId()) + .setInviterUserId(request.getInviterUserId()) + .setApplyContent(request.getApplyContent()).setAddSource(request.getAddSource()) + .setOperatorUserId(operatorUserId); + if (applyUser != null) { + payload.setUserNickname(applyUser.getNickname()).setUserAvatar(applyUser.getAvatar()); + } + return payload; + } + + /** + * 1505 / 1506 受众:申请人 + 群主 + 全部管理员;每端单独推一帧,前端按 receiver 是否申请人区分文案 + */ + private void broadcastToOwnerAdminsAndApplicant(Long groupId, Long applicantUserId, BaseGroupNotification payload, + Integer messageType, Long operatorUserId) { + ImGroupDO group = groupService.getGroup(groupId); + if (group == null) { + return; + } + Set receivers = new LinkedHashSet<>(getGroupMemberListByOwnerAndAdminUserIds(group)); + receivers.add(applicantUserId); + for (Long receiverUserId : receivers) { + websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification( + messageType, operatorUserId, receiverUserId, payload)); + } + } + + /** + * 列出群主 + 全部管理员的用户编号 + * + * @param group 群信息 + * @return 群主 + 全部管理员的用户编号列表 + */ + private List getGroupMemberListByOwnerAndAdminUserIds(ImGroupDO group) { + return convertList(groupMemberService.getGroupMemberListByOwnerAndAdmin(group.getId()), + ImGroupMemberDO::getUserId); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java new file mode 100644 index 000000000..d3b4fd48c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java @@ -0,0 +1,251 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminAddReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminRemoveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCancelMuteMemberReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupMuteAllReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupMuteMemberReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupTransferOwnerReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerBanReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 用户群群 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupService { + + // ==================== 群的写操作 ==================== + + /** + * 创建群 + *

+ * 同时将当前登录用户设置为群主,并插入群主的群成员记录 + * + * @param createReqVO 创建信息 + * @param userId 当前登录用户编号(群主) + * @return 创建后的群信息 + */ + ImGroupDO createGroup(@Valid ImGroupCreateReqVO createReqVO, Long userId); + + /** + * 更新群信息 + * + * @param updateReqVO 更新信息 + * @param userId 当前登录用户编号 + * @return 更新后的群信息 + */ + ImGroupDO updateGroup(@Valid ImGroupUpdateReqVO updateReqVO, Long userId); + + /** + * 解散群 + *

+ * 仅群主可执行 + * + * @param id 群编号 + * @param userId 当前登录用户编号 + */ + void dissolveGroup(Long id, Long userId); + + // ==================== 群的读操作 ==================== + + /** + * 获得群 + * + * @param id 编号 + * @return 群 + */ + ImGroupDO getGroup(Long id); + + /** + * 批量获得群 Map + * + * @param ids 群编号集合 + * @return 群 Map(key = 群编号) + */ + Map getGroupMap(Collection ids); + + /** + * 校验群存在且未封禁、未解散 + * + * @param groupId 群编号 + * @return 群信息 + */ + ImGroupDO validateGroupExists(Long groupId); + + /** + * 校验入群人数上限 + *

+ * 调用方场景:自由进群 / 审批通过等不经 inviteGroupMember 的入群路径,需在写群成员前主动校验 + * + * @param groupId 群编号 + * @param addCount 即将新增的人数 + * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 群已满抛 GROUP_MEMBER_EXCEED + */ + void validateMemberCountLimit(Long groupId, int addCount); + + /** + * 获取指定用户的群列表 + *

+ * 返回用户当前仍有效的群,以及最近 yudao.im.message.group-pull-max-days 天内退群的群 + * —— 退群前可能还有离线消息需要展示,前端需要把这些群信息作为缓存。 + * + * @param userId 用户编号 + * @return 群列表 + */ + List getMyGroupList(Long userId); + + // ==================== 群成员的写操作 ==================== + // 说明:群成员的写操作统一放在 ImGroupService,而非 ImGroupMemberService, + // 保持 ImGroupMemberService 无 WebSocket 推送等外部依赖,职责更单一。 + + /** + * 邀请用户加入群 + *

+ * 群成员即可执行,支持批量。 + * 校验群人数上限,邀请后推送提示消息和群创建事件给被邀请人。 + * + * @param userId 当前登录用户编号 + * @param inviteReqVO 邀请信息 + */ + void inviteGroupMember(Long userId, @Valid ImGroupMemberInviteReqVO inviteReqVO); + + /** + * 退群 + *

+ * 群主不可退群(只能解散) + * + * @param groupId 群编号 + * @param userId 当前登录用户编号 + */ + void quitGroup(Long groupId, Long userId); + + /** + * 移除群成员(踢人) + *

+ * 群主可踢管理员和普通成员;管理员仅能踢普通成员;群主不可被踢。 + * + * @param userId 当前登录用户编号 + * @param removeReqVO 移除信息 + */ + void removeGroupMember(Long userId, @Valid ImGroupMemberRemoveReqVO removeReqVO); + + /** + * 添加群管理员(仅群主可执行) + * + * @param userId 当前登录用户编号(群主) + * @param reqVO 添加信息(含群编号、目标用户编号列表) + */ + void addGroupAdmin(Long userId, @Valid ImGroupAdminAddReqVO reqVO); + + /** + * 撤销群管理员(仅群主可执行) + * + * @param userId 当前登录用户编号(群主) + * @param reqVO 撤销信息(含群编号、目标用户编号列表) + */ + void removeGroupAdmin(Long userId, @Valid ImGroupAdminRemoveReqVO reqVO); + + /** + * 转让群主(仅老群主可执行) + *

+ * 转让后:旧群主 role 降为 MEMBER,新群主 role 升为 OWNER + * + * @param userId 当前登录用户编号(旧群主) + * @param transferReqVO 转让信息 + */ + void transferGroupOwner(Long userId, @Valid ImGroupTransferOwnerReqVO transferReqVO); + + /** + * 置顶群消息(仅群主或管理员可执行) + *

+ * 上限由 yudao.im.group.pin-max-count 控制;幂等失败时抛业务异常 + * + * @param userId 当前登录用户编号 + * @param groupId 群编号 + * @param messageId 被置顶的消息编号 + */ + void pinGroupMessage(Long userId, Long groupId, Long messageId); + + /** + * 取消置顶群消息(仅群主或管理员可执行) + * + * @param userId 当前登录用户编号 + * @param groupId 群编号 + * @param messageId 被取消置顶的消息编号 + */ + void unpinGroupMessage(Long userId, Long groupId, Long messageId); + + // ==================== 群禁言 ==================== + + /** + * 全群禁言 / 取消(仅群主或管理员可执行) + * + * @param userId 当前登录用户编号 + * @param reqVO 禁言信息 + */ + void muteAll(Long userId, @Valid ImGroupMuteAllReqVO reqVO); + + /** + * 禁言单个成员(三档分层权限) + * + * @param userId 当前登录用户编号 + * @param reqVO 禁言信息 + */ + void muteMember(Long userId, @Valid ImGroupMuteMemberReqVO reqVO); + + /** + * 取消成员禁言(三档分层权限) + * + * @param userId 当前登录用户编号 + * @param reqVO 取消禁言信息 + */ + void cancelMuteMember(Long userId, @Valid ImGroupCancelMuteMemberReqVO reqVO); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】分页查询群列表 + * + * @param pageReqVO 分页查询条件 + * @return 群分页列表 + */ + PageResult getGroupPage(ImGroupManagerPageReqVO pageReqVO); + + /** + * 【管理后台】封禁群 + * + * @param operatorUserId 操作人用户编号 + * @param banReqVO 封禁信息(含群编号、封禁原因) + */ + void banGroup(Long operatorUserId, @Valid ImGroupManagerBanReqVO banReqVO); + + /** + * 【管理后台】解封群 + * + * @param operatorUserId 操作人用户编号 + * @param id 群编号 + */ + void unbanGroup(Long operatorUserId, Long id); + + /** + * 【管理后台】解散群 + * + * @param operatorUserId 操作人用户编号 + * @param id 群编号 + */ + void dissolveGroupByManager(Long operatorUserId, Long id); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java new file mode 100644 index 000000000..541aaf32c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java @@ -0,0 +1,776 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.bean.copier.CopyOptions; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminAddReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminRemoveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCancelMuteMemberReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupMuteAllReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupMuteMemberReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupTransferOwnerReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerBanReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * 用户群 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImGroupServiceImpl implements ImGroupService { + + @Resource + private ImGroupMapper groupMapper; + + @Resource + @Lazy // 避免循环依赖 + private ImGroupMemberService groupMemberService; + @Resource + @Lazy // 避免循环依赖 + private ImGroupMessageService groupMessageService; + @Resource + @Lazy // 避免循环依赖 + private ImGroupRequestService groupRequestService; + + @Resource + private ImFriendService friendService; + + @Resource + private AdminUserApi adminUserApi; + + @Resource + private ImProperties imProperties; + + // ==================== 群的写操作 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public ImGroupDO createGroup(ImGroupCreateReqVO createReqVO, Long userId) { + // 1.1 处理初始成员列表(去重 + 排除创建者自己) + Set initialMemberUserIds = createReqVO.getMemberUserIds() == null + ? new HashSet<>() : new HashSet<>(createReqVO.getMemberUserIds()); + initialMemberUserIds.remove(userId); + // 1.2 校验初始成员都是创建者的好友 + if (CollUtil.isNotEmpty(initialMemberUserIds)) { + List friends = friendService.getActiveFriendList(userId, initialMemberUserIds); + Set friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId); + Collection notFriendUserIds = CollUtil.subtract(initialMemberUserIds, friendUserIds); + if (CollUtil.isNotEmpty(notFriendUserIds)) { + throw exception(GROUP_INVITE_NOT_FRIEND, getUserNicknames(notFriendUserIds)); + } + } + // 1.3 校验群人数上限(创建者 + 初始成员 ≤ 群成员上限) + int maxMember = imProperties.getGroup().getMaxMember(); + if (initialMemberUserIds.size() + 1 > maxMember) { + throw exception(GROUP_MEMBER_EXCEED, maxMember); + } + + // 2.1 插入群记录 + ImGroupDO group = BeanUtils.toBean(createReqVO, ImGroupDO.class) + .setOwnerUserId(userId).setStatus(CommonStatusEnum.ENABLE.getStatus()); + groupMapper.insert(group); + // 2.2 创建者作为 OWNER 入群 + groupMemberService.addGroupMember(group.getId(), userId, ImGroupMemberRoleEnum.OWNER.getRole()); + // 2.3 批量添加初始成员;标记 addSource=INVITE / inviter=创建者 + if (CollUtil.isNotEmpty(initialMemberUserIds)) { + groupMemberService.addGroupMembers(group.getId(), initialMemberUserIds, + ImGroupAddSourceEnum.INVITE.getSource(), userId); + } + + // 3. 推送 GROUP_CREATE 通知给全员(含创建者多端同步 + 初始成员) + List allMemberUserIds = CollectionUtils.of(userId, initialMemberUserIds); + groupMessageService.sendGroupMessage(userId, allMemberUserIds, + ImGroupMessageSendDTO.ofGroupCreate(group.getId(), userId, allMemberUserIds)); + return group; + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#updateReqVO.id") + @Transactional(rollbackFor = Exception.class) + public ImGroupDO updateGroup(ImGroupUpdateReqVO updateReqVO, Long userId) { + // 1.1 校验群存在:group 留作老值备份,通知里 oldXXX 字段从这里取 + ImGroupDO group = validateGroupExists(updateReqVO.getId()); + // 1.2 校验操作人是群主 + if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { + throw exception(GROUP_NOT_OWNER); + } + + // 2. 更新数据库(newGroup 仅含变更字段) + ImGroupDO newGroup = BeanUtils.toBean(updateReqVO, ImGroupDO.class); + groupMapper.updateById(newGroup); + + // 3. 按变更字段分别推送 GROUP_NAME / NOTICE / INFO_UPDATE 通知;活跃成员只查一次复用,避免 3 次 Redis GET + // name / avatar 不允许空串(业务上必须非空),notice 允许空串(清空公告也是有效操作) + Long groupId = group.getId(); + boolean nameChanged = StrUtil.isNotEmpty(updateReqVO.getName()); + boolean noticeChanged = updateReqVO.getNotice() != null; + boolean avatarChanged = StrUtil.isNotEmpty(updateReqVO.getAvatar()); + if (nameChanged || noticeChanged || avatarChanged) { + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId); + if (nameChanged) { + groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupNameUpdate( + groupId, userId, group.getName(), updateReqVO.getName())); + } + if (noticeChanged) { + groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupNoticeUpdate( + groupId, userId, group.getNotice(), updateReqVO.getNotice())); + } + if (avatarChanged) { + groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupInfoUpdate( + groupId, userId, group.getAvatar(), updateReqVO.getAvatar())); + } + } + + // 4. 返回合并后的新群信息(updateReqVO 非空字段覆盖 group) + BeanUtil.copyProperties(updateReqVO, group, CopyOptions.create().ignoreNullValue()); + return group; + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#id") + @Transactional(rollbackFor = Exception.class) + public void dissolveGroup(Long id, Long userId) { + // 1. 校验群存在 + 当前用户是群主 + ImGroupDO group = validateGroupNotDissolved(id); + if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { + throw exception(GROUP_NOT_OWNER); + } + + // 2. 解散群 + dissolveGroup0(id, userId); + } + + private void dissolveGroup0(Long id, Long userId) { + // 1. 先发 GROUP_DISSOLVE 通知:放在成员移除前,sendGroupMessage 才能查到全员 + groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupDissolve(id, userId)); + + // 2.1 更新群状态为已解散 + groupMapper.updateById(new ImGroupDO().setId(id) + .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now())); + // 2.2 移除全部群成员 + groupMemberService.removeGroupMembersByGroupId(id); + // 2.3 清理已读缓存 + groupMessageService.deleteReadMaxMessageIdMap(id); + } + + // ==================== 群成员的写操作 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void inviteGroupMember(Long userId, ImGroupMemberInviteReqVO inviteReqVO) { + Long groupId = inviteReqVO.getGroupId(); + // 1.1 校验群存在 + 当前用户是群成员;同时拿到 role 供下面审批分支判断 + ImGroupDO group = validateGroupExists(groupId); + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); + // 1.2 入参去重 + 排除已在群中的用户 + List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); + Set activeMemberUserIds = convertSet(activeMembers, ImGroupMemberDO::getUserId); + List memberUserIds = CollUtil.subtractToList( + CollUtil.distinct(inviteReqVO.getMemberUserIds()), activeMemberUserIds); + if (CollUtil.isEmpty(memberUserIds)) { + return; + } + // 1.3 校验被邀请人都是当前用户的好友 + List friends = friendService.getActiveFriendList(userId, memberUserIds); + Set friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId); + Collection notFriendUserIds = CollUtil.subtract(memberUserIds, friendUserIds); + if (CollUtil.isNotEmpty(notFriendUserIds)) { + throw exception(GROUP_INVITE_NOT_FRIEND, getUserNicknames(notFriendUserIds)); + } + // 1.4 校验群人数上限 + int maxMember = imProperties.getGroup().getMaxMember(); + if (activeMembers.size() + memberUserIds.size() > maxMember) { + throw exception(GROUP_MEMBER_EXCEED, maxMember); + } + + // 2. 情况一:群开启审批 + 邀请人是普通成员,落 group_request 等群主 / 管理员处理 + // 群主 / 管理员邀请,直接拉人进群 + if (Boolean.TRUE.equals(group.getJoinApproval()) + && !ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole())) { + groupRequestService.createInviteRequestList(groupId, userId, memberUserIds); + return; + } + + // 3. 情况二:未开审批 / 群主 / 管理员邀请,直进;批量添加群成员,写 addSource=INVITE / inviterUserId=操作人 留痕 + groupMemberService.addGroupMembers(groupId, memberUserIds, + ImGroupAddSourceEnum.INVITE.getSource(), userId); + + // 4. 发 GROUP_MEMBER_INVITE 通知给全员;本地拼 receivers(已查的 active + 新邀请)避免缓存刚 evict 后强制走 DB + Set allReceivers = new HashSet<>(memberUserIds); + allReceivers.addAll(activeMemberUserIds); + groupMessageService.sendGroupMessage(userId, allReceivers, + ImGroupMessageSendDTO.ofGroupMemberInvite(groupId, userId, memberUserIds)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void quitGroup(Long groupId, Long userId) { + // 1.1 校验群存在且未解散 + ImGroupDO group = getSelf().getGroup(groupId); + if (group == null) { + throw exception(GROUP_NOT_EXISTS); + } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + // 1.2 群主不可退群 + if (ObjUtil.equal(group.getOwnerUserId(), userId)) { + throw exception(GROUP_OWNER_CANNOT_QUIT); + } + // 1.3 校验当前用户是有效群成员;防止非成员触发广播 + 后续 remove 失败时无法回滚已推送的事件 + groupMemberService.validateMemberInGroup(groupId, userId); + + // 2. 先发广播,后移成员(见类 javadoc) + groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId)); + + // 3.1 移除群成员 + groupMemberService.removeGroupMember(groupId, userId); + // 3.2 清理已读缓存 + groupMessageService.deleteReadMaxMessageId(groupId, userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeGroupMember(Long userId, ImGroupMemberRemoveReqVO removeReqVO) { + Long groupId = removeReqVO.getGroupId(); + Set targetUserIds = new HashSet<>(removeReqVO.getMemberUserIds()); + // 1.1 校验群存在 + 操作者是群主或管理员 + ImGroupMemberDO operator = validateGroupOwnerOrAdmin(groupId, userId); + // 1.2 不能移除自己 + if (targetUserIds.contains(userId)) { + throw exception(GROUP_CANNOT_REMOVE_SELF); + } + // 1.3 仅保留仍有效的成员;已退群(DISABLE)/ 查无记录的目标直接跳过,只踢有效成员,不让整批失败 + List targets = filterList(groupMemberService.getGroupMembers(groupId, targetUserIds), + target -> CommonStatusEnum.ENABLE.getStatus().equals(target.getStatus())); + Set validTargetUserIds = convertSet(targets, ImGroupMemberDO::getUserId); + // 1.4 目标全部已不在群:无人可踢,直接返回 + if (CollUtil.isEmpty(validTargetUserIds)) { + return; + } + // 1.5 三档权限校验:群主不可被移出;管理员不能移出管理员 + boolean operatorIsAdmin = ImGroupMemberRoleEnum.isAdmin(operator.getRole()); + for (ImGroupMemberDO target : targets) { + if (ImGroupMemberRoleEnum.isOwner(target.getRole())) { + throw exception(GROUP_REMOVE_OWNER_DENIED); + } + if (operatorIsAdmin && ImGroupMemberRoleEnum.isAdmin(target.getRole())) { + throw exception(GROUP_REMOVE_ADMIN_DENIED); + } + } + + // 2. 先发 GROUP_MEMBER_KICK 通知:放在被踢者移除前,sendGroupMessage 才能查到全员(含被踢者) + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds)); + + // 3.1 批量移除群成员 + groupMemberService.removeGroupMembers(groupId, validTargetUserIds); + // 3.2 批量清理已读缓存 + groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void addGroupAdmin(Long userId, ImGroupAdminAddReqVO reqVO) { + Long groupId = reqVO.getId(); + Set targetUserIds = new HashSet<>(reqVO.getUserIds()); + // 1.1 仅群主可操作 + validateGroupOwnerForUpdate(groupId, userId); + // 1.2 校验目标都是有效成员且非群主 + Map targetMap = convertMap( + groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId); + validateAdminTargets(targetUserIds, targetMap); + // 1.3 幂等过滤:跳过已是 ADMIN + Set changedUserIds = convertSet(targetUserIds, + id -> id, + id -> !ImGroupMemberRoleEnum.isAdmin(targetMap.get(id).getRole())); + if (CollUtil.isEmpty(changedUserIds)) { + return; + } + // 1.4 校验上限 + Long existAdminCount = groupMemberService.getGroupMemberCountByRole( + groupId, ImGroupMemberRoleEnum.ADMIN.getRole()); + int adminMaxCount = imProperties.getGroup().getAdminMaxCount(); + if (existAdminCount + changedUserIds.size() > adminMaxCount) { + throw exception(GROUP_ADMIN_MAX_LIMIT, adminMaxCount); + } + + // 2. 批量更新角色 + int affected = groupMemberService.updateGroupMemberRole(groupId, changedUserIds, + ImGroupMemberRoleEnum.ADMIN.getRole()); + if (affected != changedUserIds.size()) { + throw exception(GROUP_ADMIN_TARGET_NOT_IN_GROUP); + } + + // 3. 推送 GROUP_ADMIN_ADD 通知给全员 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupAdminAdd(groupId, userId, changedUserIds)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeGroupAdmin(Long userId, ImGroupAdminRemoveReqVO reqVO) { + Long groupId = reqVO.getId(); + Set targetUserIds = new HashSet<>(reqVO.getUserIds()); + // 1.1 仅群主可操作 + validateGroupOwnerForUpdate(groupId, userId); + // 1.2 校验目标都是有效成员且非群主 + Map targetMap = convertMap( + groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId); + validateAdminTargets(targetUserIds, targetMap); + // 1.3 幂等过滤:跳过已是 MEMBER + Set changedUserIds = convertSet(targetUserIds, + id -> id, + id -> ImGroupMemberRoleEnum.isAdmin(targetMap.get(id).getRole())); + if (CollUtil.isEmpty(changedUserIds)) { + return; + } + + // 2. 批量更新角色 + int affected = groupMemberService.updateGroupMemberRole(groupId, changedUserIds, + ImGroupMemberRoleEnum.NORMAL.getRole()); + if (affected != changedUserIds.size()) { + throw exception(GROUP_ADMIN_TARGET_NOT_IN_GROUP); + } + + // 3. 推送 GROUP_ADMIN_REMOVE 通知给全员 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupAdminRemove(groupId, userId, changedUserIds)); + } + + /** + * 校验管理员变更目标都是当前群的有效成员(status=ENABLE)且非群主 + */ + private void validateAdminTargets(Set targetUserIds, Map targetMap) { + for (Long targetUserId : targetUserIds) { + ImGroupMemberDO target = targetMap.get(targetUserId); + if (target == null || CommonStatusEnum.DISABLE.getStatus().equals(target.getStatus())) { + throw exception(GROUP_ADMIN_TARGET_NOT_IN_GROUP); + } + if (ImGroupMemberRoleEnum.isOwner(target.getRole())) { + throw exception(GROUP_ADMIN_TARGET_IS_OWNER); + } + } + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#transferReqVO.id") + @Transactional(rollbackFor = Exception.class) + public void transferGroupOwner(Long userId, ImGroupTransferOwnerReqVO transferReqVO) { + Long groupId = transferReqVO.getId(); + Long newOwnerUserId = transferReqVO.getNewOwnerUserId(); + // 1.1 仅老群主可执行 + validateGroupOwnerForUpdate(groupId, userId); + // 1.2 不能转让给自己 + if (ObjUtil.equal(userId, newOwnerUserId)) { + throw exception(GROUP_TRANSFER_OWNER_TO_SELF); + } + // 1.3 新群主必须是群的有效成员 + ImGroupMemberDO newOwner = groupMemberService.validateMemberInGroup(groupId, newOwnerUserId); + + // 2.1 更新成员角色 + int newOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, Set.of(newOwner.getUserId()), + ImGroupMemberRoleEnum.OWNER.getRole()); + if (newOwnerAffected != 1) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + int oldOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, Set.of(userId), + ImGroupMemberRoleEnum.NORMAL.getRole()); + if (oldOwnerAffected != 1) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + // 2.2 更新群主编号 + groupMapper.updateById(new ImGroupDO().setId(groupId).setOwnerUserId(newOwnerUserId)); + + // 3. 推送 GROUP_OWNER_TRANSFER 通知给全员 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupOwnerTransfer(groupId, userId, newOwnerUserId)); + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#groupId") + @Transactional(rollbackFor = Exception.class) + public void pinGroupMessage(Long userId, Long groupId, Long messageId) { + // 1.1 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable + ImGroupDO group = validateOwnerOrAdminAndGetGroupForUpdate(groupId, userId); + // 1.2 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回 + ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId); + if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { + throw exception(MESSAGE_NOT_IN_GROUP); + } + if (!ImMessageTypeEnum.validate(message.getType()).isNormal() + || ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_NOT_IN_GROUP); + } + // 1.3 定向消息(receiverUserIds 非空)不允许置顶:置顶会向全群广播,泄露原本仅部分人可见的内容 + if (CollUtil.isNotEmpty(message.getReceiverUserIds())) { + throw exception(GROUP_MESSAGE_PIN_DIRECTED_DENIED); + } + + // 2. 幂等 + 上限校验 + List pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); + if (pinned.contains(messageId)) { + throw exception(GROUP_MESSAGE_ALREADY_PINNED); + } + int pinMaxCount = imProperties.getGroup().getPinMaxCount(); + if (pinned.size() >= pinMaxCount) { + throw exception(GROUP_MESSAGE_PIN_MAX_LIMIT, pinMaxCount); + } + pinned.add(messageId); + groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned)); + + // 3. 推送 GROUP_MESSAGE_PIN 通知给全员 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message)); + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#groupId") + @Transactional(rollbackFor = Exception.class) + public void unpinGroupMessage(Long userId, Long groupId, Long messageId) { + // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable + ImGroupDO group = validateOwnerOrAdminAndGetGroupForUpdate(groupId, userId); + // 2. 幂等校验 + List pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); + if (!pinned.contains(messageId)) { + throw exception(GROUP_MESSAGE_NOT_PINNED); + } + pinned.remove(messageId); + groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned)); + + // 3. 推送 GROUP_MESSAGE_UNPIN 通知给全员 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMessageUnpin(groupId, userId, messageId)); + } + + /** + * 校验登录用户是群主或管理员,同时返回群信息 + */ + private ImGroupDO validateOwnerOrAdminAndGetGroup(Long groupId, Long userId) { + ImGroupDO group = validateGroupExists(groupId); + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole())) { + throw exception(GROUP_NOT_OWNER_OR_ADMIN); + } + return group; + } + + /** + * 校验登录用户是群主或管理员,同时锁定群信息 + */ + private ImGroupDO validateOwnerOrAdminAndGetGroupForUpdate(Long groupId, Long userId) { + ImGroupDO group = validateGroupExistsForUpdate(groupId); + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole())) { + throw exception(GROUP_NOT_OWNER_OR_ADMIN); + } + return group; + } + + // ==================== 群的读操作 ==================== + + @Override + public ImGroupDO validateGroupExists(Long id) { + ImGroupDO group = getSelf().getGroup(id); + if (group == null) { + throw exception(GROUP_NOT_EXISTS); + } + if (Boolean.TRUE.equals(group.getBanned())) { + throw exception(GROUP_BANNED); + } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + return group; + } + + @Override + public void validateMemberCountLimit(Long groupId, int addCount) { + // 1. 锁定群记录 + validateGroupExistsForUpdate(groupId); + // 2. 校验成员数量上限 + int activeCount = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId).size(); + int maxMember = imProperties.getGroup().getMaxMember(); + if (activeCount + addCount > maxMember) { + throw exception(GROUP_MEMBER_EXCEED, maxMember); + } + } + + private ImGroupDO validateGroupOwnerForUpdate(Long groupId, Long userId) { + ImGroupDO group = validateGroupExistsForUpdate(groupId); + if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { + throw exception(GROUP_NOT_OWNER); + } + return group; + } + + private ImGroupDO validateGroupExistsForUpdate(Long groupId) { + ImGroupDO group = groupMapper.selectByIdForUpdate(groupId); + if (group == null) { + throw exception(GROUP_NOT_EXISTS); + } + if (Boolean.TRUE.equals(group.getBanned())) { + throw exception(GROUP_BANNED); + } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + return group; + } + + private ImGroupDO validateGroupNotDissolved(Long id) { + ImGroupDO group = getSelf().getGroup(id); + if (group == null) { + throw exception(GROUP_NOT_EXISTS); + } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + return group; + } + + /** + * 校验当前用户是群主或管理员,否则抛 GROUP_NOT_OWNER_OR_ADMIN + */ + private ImGroupMemberDO validateGroupOwnerOrAdmin(Long groupId, Long userId) { + validateGroupExists(groupId); + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole())) { + throw exception(GROUP_NOT_OWNER_OR_ADMIN); + } + return member; + } + + @Override + @Cacheable(cacheNames = GROUP, key = "#id", unless = "#result == null") + public ImGroupDO getGroup(Long id) { + return groupMapper.selectById(id); + } + + @Override + public Map getGroupMap(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyMap(); + } + return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); + } + + @Override + public List getMyGroupList(Long userId) { + // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) + List members = groupMemberService.getActiveGroupMemberListByUserId(userId); + // 1.2 再查最近 N 天(与群消息离线拉取窗口一致)内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存) + LocalDateTime minQuitTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); + members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime)); + if (CollUtil.isEmpty(members)) { + return Collections.emptyList(); + } + + // 2. 批量查询群信息(不按 status / banned 过滤,已解散 / 封禁的群也要返回,供前端展示历史消息的群名 / 头像) + Set groupIds = convertSet(members, ImGroupMemberDO::getGroupId); + return groupMapper.selectByIds(groupIds); + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getGroupPage(ImGroupManagerPageReqVO pageReqVO) { + return groupMapper.selectPage(pageReqVO); + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#banReqVO.id") + public void banGroup(Long operatorUserId, ImGroupManagerBanReqVO banReqVO) { + // 1. 校验群存在且未解散 + ImGroupDO group = getSelf().getGroup(banReqVO.getId()); + if (group == null) { + throw exception(GROUP_NOT_EXISTS); + } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + // 2. 幂等:已封禁直接返回,避免重复广播封禁通知 + if (Boolean.TRUE.equals(group.getBanned())) { + return; + } + + // 3. 更新封禁状态 + groupMapper.updateById(new ImGroupDO().setId(banReqVO.getId()) + .setBanned(true).setBannedReason(banReqVO.getReason()).setBannedTime(LocalDateTime.now())); + + // 4. 广播通知 + groupMessageService.sendGroupMessage(operatorUserId, + ImGroupMessageSendDTO.ofGroupBanned(banReqVO.getId(), operatorUserId, true)); + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#id") + public void unbanGroup(Long operatorUserId, Long id) { + // 1. 校验群存在 + if (getSelf().getGroup(id) == null) { + throw exception(GROUP_NOT_EXISTS); + } + + // 2. 解封(保留 bannedReason / bannedTime 作为历史记录) + groupMapper.updateById(new ImGroupDO().setId(id).setBanned(false)); + + // 3. 广播通知 + groupMessageService.sendGroupMessage(operatorUserId, + ImGroupMessageSendDTO.ofGroupBanned(id, operatorUserId, false)); + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#id") + @Transactional(rollbackFor = Exception.class) + public void dissolveGroupByManager(Long operatorUserId, Long id) { + // 1. 校验群存在且未解散 + validateGroupNotDissolved(id); + + // 2. 解散群 + dissolveGroup0(id, operatorUserId); + } + + // ==================== 群禁言 ==================== + + @Override + @CacheEvict(cacheNames = GROUP, key = "#reqVO.id") + @Transactional(rollbackFor = Exception.class) + public void muteAll(Long userId, ImGroupMuteAllReqVO reqVO) { + // 1. 校验群主或管理员 + validateGroupOwnerOrAdmin(reqVO.getId(), userId); + + // 2. 更新 mutedAll + groupMapper.updateById(new ImGroupDO().setId(reqVO.getId()).setMutedAll(reqVO.getMutedAll())); + + // 3. 广播通知 + ImGroupMessageSendDTO messageSendDTO = Boolean.TRUE.equals(reqVO.getMutedAll()) + ? ImGroupMessageSendDTO.ofGroupMuted(reqVO.getId(), userId) + : ImGroupMessageSendDTO.ofGroupCancelMuted(reqVO.getId(), userId); + groupMessageService.sendGroupMessage(userId, messageSendDTO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void muteMember(Long userId, ImGroupMuteMemberReqVO reqVO) { + // 1.1 不能禁言自己 + if (ObjUtil.equal(userId, reqVO.getUserId())) { + throw exception(GROUP_MUTE_MEMBER_SELF); + } + // 1.2 校验群存在且未封禁 + validateGroupExists(reqVO.getId()); + // 1.3 校验操作人和目标都在群中 + ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getId(), userId); + ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getId(), reqVO.getUserId()); + // 1.4 三档权限校验 + validateMutePermission(operatorMember, targetMember); + + // 2. 设置 muteEndTime + LocalDateTime muteEndTime = reqVO.getMutedSeconds() == 0 + ? ImGroupMemberDO.PERMANENT_MUTE_END_TIME : LocalDateTime.now().plusSeconds(reqVO.getMutedSeconds()); + groupMemberService.updateGroupMemberMuteEndTime(reqVO.getId(), reqVO.getUserId(), muteEndTime); + + // 3. 广播通知 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMemberMuted(reqVO.getId(), userId, + reqVO.getUserId(), muteEndTime)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelMuteMember(Long userId, ImGroupCancelMuteMemberReqVO reqVO) { + // 1.1 校验群存在且未封禁 + validateGroupExists(reqVO.getId()); + // 1.2 校验操作人和目标都在群中 + ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getId(), userId); + ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getId(), reqVO.getUserId()); + // 1.3 三档权限校验 + validateMutePermission(operatorMember, targetMember); + + // 2. 取消禁言(清空 muteEndTime) + groupMemberService.updateGroupMemberMuteEndTime(reqVO.getId(), reqVO.getUserId(), null); + + // 3. 广播通知 + groupMessageService.sendGroupMessage(userId, + ImGroupMessageSendDTO.ofGroupMemberCancelMuted(reqVO.getId(), userId, reqVO.getUserId())); + } + + /** + * 三档分层禁言权限校验 + */ + private void validateMutePermission(ImGroupMemberDO operator, ImGroupMemberDO target) { + // 普通成员不能禁言任何人 + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole())) { + throw exception(GROUP_NOT_OWNER_OR_ADMIN); + } + // 群主不可被禁言 + if (ImGroupMemberRoleEnum.isOwner(target.getRole())) { + throw exception(GROUP_MUTE_OWNER_DENIED); + } + // 管理员不能禁言其他管理员 + if (ImGroupMemberRoleEnum.isAdmin(target.getRole()) && !ImGroupMemberRoleEnum.isOwner(operator.getRole())) { + throw exception(GROUP_MUTE_ADMIN_DENIED); + } + } + + private ImGroupServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + + /** + * 根据用户编号集合,拼接用户昵称字符串(逗号分隔) + * + * @param userIds 用户编号集合 + * @return 昵称字符串,如 "张三,李四" + */ + private String getUserNicknames(Collection userIds) { + Map userMap = adminUserApi.getUserMap(userIds); + return userIds.stream() + .map(id -> userMap.containsKey(id) ? userMap.get(id).getNickname() : String.valueOf(id)) + .collect(Collectors.joining(",")); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageService.java new file mode 100644 index 000000000..8b05161ba --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageService.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessagePageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * IM 频道消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImChannelMessageService { + + // ==================== 用户端 ==================== + + /** + * 拉取当前用户应收的频道消息(离线增量) + * + * @param userId 当前用户编号 + * @param minId 游标;返回大于此值的消息 + * @param size 返回条数 + * @return 频道消息列表;按 id 升序 + */ + List getMessageListForPull(Long userId, Long minId, Integer size); + + /** + * 上报频道消息已读位置;同步推 READ 事件给自己多端 + * + * @param userId 当前用户编号 + * @param channelId 频道编号 + * @param messageId 已读到的最大消息编号 + */ + void readChannelMessages(Long userId, Long channelId, Long messageId); + + /** + * 批量查询用户在多个频道下的已读游标 + * + * @param userId 当前用户编号 + * @param channelIds 频道编号集合 + * @return channelId → 已读到的最大消息编号(未读到的频道不出现在返回 map 中) + */ + Map getChannelReadMaxMessageIdMap(Long userId, Collection channelIds); + + // ==================== 管理后台 ==================== + + /** + * 立即推送频道消息 + * + * @param reqVO 推送请求 + * @return 消息编号 + */ + Long sendMessage(@Valid ImChannelMessageSendReqVO reqVO); + + /** + * 分页查询消息 + * + * @param reqVO 分页查询条件 + * @return 消息分页 + */ + PageResult getMessagePage(ImChannelMessagePageReqVO reqVO); + + /** + * 删除消息 + * + * @param id 消息编号 + */ + void deleteMessage(Long id); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java new file mode 100644 index 000000000..cbdf710ec --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java @@ -0,0 +1,142 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessagePageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper; +import cn.iocoder.yudao.module.im.dal.redis.message.ImChannelMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage; +import jakarta.annotation.Resource; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MESSAGE_NOT_EXISTS; + +/** + * IM 频道消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImChannelMessageServiceImpl implements ImChannelMessageService { + + @Resource + private ImChannelMessageMapper channelMessageMapper; + + @Resource + private ImChannelMaterialService channelMaterialService; + @Resource + private ImWebSocketService webSocketService; + + @Resource + private ImChannelMessageReadRedisDAO channelMessageReadRedisDAO; + + // ==================== 用户端 ==================== + + @Override + public List getMessageListForPull(Long userId, Long minId, Integer size) { + return channelMessageMapper.selectListByUserAndMinId(userId, minId, size); + } + + @Override + public Map getChannelReadMaxMessageIdMap(Long userId, Collection channelIds) { + Map result = new HashMap<>(channelIds.size()); + for (Long channelId : channelIds) { + Long max = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId); + if (max != null) { + result.put(channelId, max); + } + } + return result; + } + + @Override + public void readChannelMessages(Long userId, Long channelId, Long messageId) { + Assert.notNull(channelId, "频道编号不能为空"); + Assert.notNull(messageId, "已读消息编号不能为空"); + // 1. 已读位置未前进,直接返回 + Long prevMaxMessageId = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId); + if (prevMaxMessageId != null && prevMaxMessageId >= messageId) { + return; + } + + // 2. 更新 Redis 频道已读位置 + channelMessageReadRedisDAO.updateReadMaxMessageId(channelId, userId, messageId); + + // 3. 异步推 READ 事件给自己多端同步 + getSelf().readChannelMessageEvent(userId, channelId, messageId); + } + + /** + * 发送频道已读 READ 事件给自己其他终端;频道无「给发送方刷回执」概念,不广播 + */ + @Async + public void readChannelMessageEvent(Long userId, Long channelId, Long readId) { + webSocketService.sendChannelMessageAsync(userId, ImChannelMessageDTO.ofRead(channelId, readId)); + } + + private ImChannelMessageServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + + // ==================== 管理后台 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public Long sendMessage(ImChannelMessageSendReqVO reqVO) { + // 1. 校验素材存在 + ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(reqVO.getMaterialId()); + + // 2.1 组装 payload(不带富文本正文);字段同名直接 BeanUtils 拷贝,materialId 单独 set 以兼容转发场景 + MaterialMessage payload = BeanUtils.toBean(material, MaterialMessage.class).setMaterialId(material.getId()); + String payloadJson = JsonUtils.toJsonString(payload); + // 2.2 落库 1 行 message;reqVO 同名字段(materialId / receiverUserIds)自动拷贝,剩余字段补 set + ImChannelMessageDO message = BeanUtils.toBean(reqVO, ImChannelMessageDO.class).setChannelId(material.getChannelId()) + .setType(ImMessageTypeEnum.MATERIAL.getType()).setContent(payloadJson).setSendTime(LocalDateTime.now()); + channelMessageMapper.insert(message); + + // 3. 异步推 WebSocket:指定用户走点对点;全员(receiverUserIds 为空)走广播 + ImChannelMessageDTO dto = ImChannelMessageDTO.ofSend(message); + if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) { + webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(), dto); + } else { + webSocketService.broadcastChannelMessageAsync(dto); + } + return message.getId(); + } + + @Override + public PageResult getMessagePage(ImChannelMessagePageReqVO reqVO) { + return channelMessageMapper.selectPage(reqVO); + } + + @Override + public void deleteMessage(Long id) { + if (channelMessageMapper.selectById(id) == null) { + throw exception(IM_CHANNEL_MESSAGE_NOT_EXISTS); + } + channelMessageMapper.deleteById(id); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java new file mode 100644 index 000000000..51a28f1c7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java @@ -0,0 +1,159 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * IM 群聊消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupMessageService { + + /** + * 【用户调用】发送群聊消息 + *

+ * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、敏感词、quote、@ 解析等业务校验。 + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。 + * + * @param senderId 发送人编号 + * @param reqVO 发送请求 + * @return 消息 + */ + ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO); + + /** + * 【系统调用】发送群聊消息:内部查 active 成员 + 推送 + *

+ * 调用方批量调用会触发多次 active 成员查询;如果调用方已经持有成员快照,优先使用 {@link #sendGroupMessage(Long, Collection, ImGroupMessageSendDTO)} 方法,避免重复查询。 + * + * @param senderId 发送人编号 + * @param dto 消息 DTO + * @return 消息 + */ + ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto); + + /** + * 【系统调用】发送群聊消息:显式指定推送目标 + * + * @param senderId 发送人编号 + * @param targetUserIds 推送目标用户编号集合(调用方在变更成员状态前抓取的快照) + * @param dto 消息 DTO + * @return 构造的消息 DO(持久化时 id 已回填) + */ + ImGroupMessageDO sendGroupMessage(Long senderId, Collection targetUserIds, ImGroupMessageSendDTO dto); + + /** + * 【用户调用】撤回群聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + * @return 撤回后的提示消息 + */ + ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); + + /** + * 拉取群聊消息(增量) + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + List pullGroupMessageList(Long userId, Long minId, Integer size); + + /** + * 标记群聊消息已读 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param messageId 已读到的消息编号 + */ + void readGroupMessages(Long userId, Long groupId, Long messageId); + + /** + * 获取群消息的已读用户列表 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param messageId 消息编号 + * @return 已读用户编号列表 + */ + List getGroupReadUserIds(Long userId, Long groupId, Long messageId); + + /** + * 查询群聊历史消息(游标拉取) + * + * @param userId 当前用户编号 + * @param reqVO 拉取请求 + * @return 消息列表(按 id 倒序) + */ + List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO); + + /** + * 清理用户在某群的已读位置缓存 + *

+ * 用于成员退群场景 + * + * @param groupId 群编号 + * @param userId 用户编号 + */ + void deleteReadMaxMessageId(Long groupId, Long userId); + + /** + * 批量清理用户在某群的已读位置缓存 + *

+ * 用于批量踢出场景 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + void deleteReadMaxMessageIds(Long groupId, Collection userIds); + + /** + * 清理某群所有用户的已读位置缓存 + *

+ * 用于群解散场景 + * + * @param groupId 群编号 + */ + void deleteReadMaxMessageIdMap(Long groupId); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】分页查询群聊消息 + */ + PageResult getGroupMessagePage(ImGroupMessageManagerPageReqVO reqVO); + + /** + * 【管理后台】获取群聊消息详情 + */ + ImGroupMessageDO getGroupMessage(Long id); + + /** + * 批量按消息编号查询群聊消息 + * + * @param ids 消息编号集合 + * @return 消息列表 + */ + List getGroupMessageList(Collection ids); + + /** + * 批量按消息编号查询群聊消息,返回 messageId → DO 映射 + * + * @param ids 消息编号集合 + * @return 消息 Map(key = 消息编号) + */ + Map getGroupMessageMap(Collection ids); + +} + diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java new file mode 100644 index 000000000..7291bde0e --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -0,0 +1,676 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; +import cn.iocoder.yudao.module.im.util.ImMessageUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * IM 群聊消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImGroupMessageServiceImpl implements ImGroupMessageService { + + /** + * 仅用于规避群消息 pull 的"假空页": + * 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。 + * 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。 + */ + private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3; + + @Resource + private ImGroupMessageMapper groupMessageMapper; + @Resource + private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO; + + @Resource + private ImGroupService groupService; + @Resource + private ImGroupMemberService groupMemberService; + @Resource + private ImSensitiveWordService sensitiveWordService; + + @Resource + private ImWebSocketService imWebSocketService; + + @Resource + private ImProperties imProperties; + + @Override + public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO) { + // 1.1 幂等校验:根据 senderId + clientMessageId 查重 + ImGroupMessageDO existing = groupMessageMapper.selectBySenderIdAndClientMessageId( + senderId, reqVO.getClientMessageId()); + if (existing != null) { + log.info("[sendGroupMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]", + senderId, reqVO.getClientMessageId(), existing.getId()); + return existing; + } + // 1.2 消息内容校验 + ImMessageUtils.validateUserMessageContent(reqVO.getType(), reqVO.getContent()); + // 1.3 校验群存在、发送人仍在群中 + ImGroupDO group = groupService.validateGroupExists(reqVO.getGroupId()); + ImGroupMemberDO senderMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId); + // 1.4 禁言校验 + validateMuteStatus(group, senderMember); + // 1.5 文本消息敏感词过滤 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { + sensitiveWordService.validateText(reqVO.getContent()); + } + + // 2.1 引用 quote 消息规范化 + reqVO.setContent(normalizeQuoteContent(reqVO, senderMember)); + // 2.2 构建并保存消息 + ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m + .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt()))); + groupMessageMapper.insert(message); + + // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步) + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); + Set targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds); + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); + return message; + } + + @Override + public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) { + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId()); + Set targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds); + return sendGroupMessage(senderId, targetUserIds, dto); + } + + @Override + public ImGroupMessageDO sendGroupMessage(Long senderId, Collection targetUserIds, ImGroupMessageSendDTO dto) { + // 1.1 content 序列化:null / String 透传,POJO 走 JSON + Object payload = dto.getContent(); + String contentString = payload == null || payload instanceof String + ? (String) payload + : JsonUtils.toJsonString(payload); + // 1.2 构建并保存消息 + ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) + .setSenderId(senderId).setGroupId(dto.getGroupId()) + .setType(dto.getType()).setContent(contentString) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) + .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())); + // 1.3 按 type.persistent 决定是否入库 + if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { + groupMessageMapper.insert(message); + } + + // 2. WebSocket 异步推送 + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); + return message; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { + // 1.1 校验消息存在 + ImGroupMessageDO message = validateGroupMessageExists(messageId); + // 1.2 校验撤回人仍在群中,并取角色用于权限判断 + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(message.getGroupId(), userId); + boolean isOwnerOrAdmin = ImGroupMemberRoleEnum.isOwnerOrAdmin(operator.getRole()); + // 1.3 普通成员只能撤回自己发的消息;群主 / 管理员可撤回他人违规消息 + if (ObjUtil.notEqual(message.getSenderId(), userId) && !isOwnerOrAdmin) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 1.4 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + // 1.5 撤回时间窗仅约束撤回自己的消息;群主 / 管理员治理他人违规消息不受时间限制 + if (ObjUtil.equal(message.getSenderId(), userId)) { + int recallTimeoutMinutes = imProperties.getMessage().getRecallTimeoutMinutes(); + if (message.getSendTime().plusMinutes(recallTimeoutMinutes).isBefore(LocalDateTime.now())) { + throw exception(MESSAGE_RECALL_TIMEOUT, recallTimeoutMinutes); + } + } + + // 2. 更新原消息状态为撤回 + groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); + + // 3. 发送撤回事件 + return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId()) + .setType(ImMessageTypeEnum.RECALL.getType()) + .setReceiverUserIds(message.getReceiverUserIds()) + .setContent(new RecallMessage().setMessageId(messageId))); + } + + @Override + public List pullGroupMessageList(Long userId, Long minId, Integer size) { + int maxPullSize = imProperties.getMessage().getMaxPullSize(); + if (size > maxPullSize) { + throw exception(MESSAGE_PULL_SIZE_EXCEEDED, maxPullSize); + } + + // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); + + // 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试 + List activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); + Map memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId); + List messages = new ArrayList<>(); + if (CollUtil.isNotEmpty(activeMembers)) { + List groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); + messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap)); + } + // 1.2 补齐"退群前"的消息: + // - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进; + // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,避免把客户端已拥有的老消息再次推送。 + messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap)); + + // 2. 合并后统一过滤,得到当前用户可见的结果 + List result = filterGroupMessageList(messages, memberMap, userId, size); + + // 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 + appendMessageStatusAndReceipt(userId, result); + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size()); + return result; + } + + /** + * 拉取当前仍在群的主路径消息。 + * + * 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测, + * 直到命中可见消息或确认该来源已耗尽。 + */ + private List pullActiveGroupMessageList(Long userId, List groupIds, Long minId, + Integer size, LocalDateTime minSendTime, + Map memberMap) { + // 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界 + Long activeMinId = minId; + for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) { + // 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束 + List messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId, + minSendTime, size); + if (CollUtil.isEmpty(messages)) { + return Collections.emptyList(); + } + boolean hasVisibleMessage = CollUtil.anyMatch(messages, + message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId)); + boolean sourceExhausted = messages.size() < size; + // 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息 + if (hasVisibleMessage || sourceExhausted) { + return messages; + } + + // 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止 + Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId); + if (maxMessageId == null || maxMessageId <= activeMinId) { + return Collections.emptyList(); + } + activeMinId = maxMessageId; + } + return Collections.emptyList(); + } + + /** + * 拉取已离开群路径的消息(退群前消息) + */ + private List pullQuitGroupMessageList(Long userId, Long minId, Integer size, + LocalDateTime minSendTime, + Map memberMap) { + // 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤 + LocalDateTime minQuitTime = minSendTime; + if (minId != null && minId > 0) { + ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId); + if (minMessage != null && minMessage.getSendTime() != null + && minMessage.getSendTime().isAfter(minSendTime)) { + minQuitTime = minMessage.getSendTime(); + } + } + // 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限 + List messages = new ArrayList<>(); + List quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime); + for (ImGroupMemberDO quitMember : quitMembers) { + // 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用 + List quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); + if (CollUtil.isEmpty(quitGroupMessages)) { + continue; + } + messages.addAll(quitGroupMessages); + memberMap.put(quitMember.getGroupId(), quitMember); + } + return messages; + } + + /** + * 过滤一批原始群消息,得到当前用户可见的返回结果 + */ + private List filterGroupMessageList(List messages, + Map memberMap, + Long userId, Integer size) { + // 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条, + // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息 + return messages.stream() + .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) + .sorted(Comparator.comparing(ImGroupMessageDO::getId)) + .limit(size) + .toList(); + } + + /** + * 补全消息已读态和回执已读人数 + * + * 1. 消息已读态(status):根据 Redis 已读游标判断 READ / UNREAD + * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数 + */ + @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) + private void appendMessageStatusAndReceipt(Long userId, List messages) { + if (CollUtil.isEmpty(messages)) { + return; + } + // 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐 + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + return; + } + Map readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 + Map> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) + Map> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 + for (ImGroupMessageDO message : messages) { + // 消息已读态(status):撤回 > 已读 > 未读 + Long groupId = message.getGroupId(); + long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> { + Long readMaxMsgId = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId); + return readMaxMsgId != null ? readMaxMsgId : -1L; + }); + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + // 保持撤回态 + } else if (readMaxMessageId >= message.getId()) { + message.setStatus(ImMessageStatusEnum.READ.getStatus()); + } else { + message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); + } + + // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) + if (ObjUtil.notEqual(message.getSenderId(), userId) + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { + continue; + } + Map positions = readPositionsByGroup.computeIfAbsent(groupId, + gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid)); + List allMembers = membersByGroup.computeIfAbsent(groupId, + groupMemberService::getGroupMemberListByGroupId); + Set visibleUserIds = getVisibleUserIds(message, allMembers); + visibleUserIds.remove(message.getSenderId()); + int readCount = getSumValue(visibleUserIds, + uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, + Integer::sum, 0); + message.setReadCount(readCount); + } + } + + @Override + public void readGroupMessages(Long userId, Long groupId, Long messageId) { + // 0. 全局开关校验 + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + throw exception(MESSAGE_GROUP_READ_DISABLED); + } + Assert.notNull(messageId, "已读消息编号不能为空"); + // 1.1 校验用户在群中(权限校验) + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); + // 1.2 校验消息属于当前群,且对当前用户可见 + ImGroupMessageDO message = groupMessageMapper.selectById(messageId); + if (message == null + || ObjUtil.notEqual(message.getGroupId(), groupId) + || !isMessageVisible(message, member, userId)) { + throw exception(MESSAGE_NOT_IN_GROUP); + } + + // 2. 已读位置未前进,直接返回 + Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId); + if (prevMaxMessageId != null && prevMaxMessageId >= messageId) { + return; + } + // 3. 更新 Redis 群已读位置 + groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId); + + // 4. 异步发送 READ 事件 + 刷新范围内的群回执 + getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId); + } + + @Override + public List getGroupReadUserIds(Long userId, Long groupId, Long messageId) { + // 0. 全局开关校验 + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + throw exception(MESSAGE_GROUP_READ_DISABLED); + } + // 1.1 校验用户在群中(权限校验) + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); + // 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方 + ImGroupMessageDO message = groupMessageMapper.selectById(messageId); + if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { + return Collections.emptyList(); + } + if (!isMessageVisible(message, operator, userId)) { + return Collections.emptyList(); + } + // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 + if (ObjUtil.notEqual(message.getSenderId(), userId)) { + return Collections.emptyList(); + } + + // 2. 获取所有成员和已读位置 + List allMembers = groupMemberService.getGroupMemberListByGroupId(groupId); + Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); + + // 3. 计算该消息的可见成员集合(排除发送者自己) + Set visibleUserIds = getVisibleUserIds(message, allMembers); + visibleUserIds.remove(message.getSenderId()); + + // 4. 只返回在可见范围内且已读位置 >= messageId 的用户 + List readUserIds = new ArrayList<>(); + allPositions.forEach((uid, readMaxMessageId) -> { + if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) { + readUserIds.add(uid); + } + }); + return readUserIds; + } + + @Override + public List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) { + // 1. 校验用户在群中 + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); + + // 2. 查询历史消息(仅入群之后) + List messages = groupMessageMapper.selectHistoryList( + reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime()); + + // 3. 过滤定向消息:仅保留当前用户可见的(receiverUserIds 为空 / 含当前用户 / 本人发送) + return filterList(messages, message -> isMessageVisible(message, member, userId)); + } + + // ========== 异步 WebSocket 推送 ========== + + /** + * 发送已读 + 刷新群回执 WebSocket 事件 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param prevMaxMessageId 上次已读位置 + * @param newMaxMessageId 本次已读位置 + */ + @Async + @SuppressWarnings("DataFlowIssue") + public void readGroupMessageEvent(Long userId, Long groupId, Long prevMaxMessageId, Long newMaxMessageId) { + // 1. 发送 READ 事件给自己的其他终端(多端同步) + imWebSocketService.sendGroupMessageAsync(userId, + ImGroupMessageDTO.ofRead(userId, groupId, newMaxMessageId)); + + // 2. 刷新 (prevMaxMessageId, newMaxMessageId] 区间内的待回执消息 + List pendingMessages = groupMessageMapper.selectListByGroupIdAndPendingReceipt( + groupId, prevMaxMessageId, newMaxMessageId); + if (CollUtil.isEmpty(pendingMessages)) { + return; + } + List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); + Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); + for (ImGroupMessageDO message : pendingMessages) { + // 2.1.1 统计可见成员中的已读人数 + Set visibleUserIds = getVisibleUserIds(message, activeMembers); + visibleUserIds.remove(message.getSenderId()); // 发送者自己不算已读 + if (CollUtil.isEmpty(visibleUserIds)) { + continue; + } + int readCount = getSumValue(visibleUserIds, + uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, + Integer::sum, 0); + // 2.1.2 全部已读 → 标记回执完成 + Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus(); + if (readCount >= visibleUserIds.size()) { + newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus(); + groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId()) + .setReceiptStatus(newReceiptStatus)); + } + + // 2.2 发送 RECEIPT 事件给消息发送方(只有 ta 关心已读进度) + imWebSocketService.sendGroupMessageAsync(message.getSenderId(), + ImGroupMessageDTO.ofReceipt(message.getId(), groupId, readCount, newReceiptStatus)); + } + } + + // ========== 私有工具方法 ========== + + /** + * 群聊引用消息规范化 + * + * @param reqVO 发送请求 + * @param senderMember 发送人成员 + * @return 规范化后的 content + */ + private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember) { + // 解析客户端 content 里的 quote.messageId + Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent()); + + // 情况一:没有 quoteMessageId,直接 remove 掉 content 里可能伪造的 quote 字段 + if (quoteMessageId == null) { + return ImMessageUtils.removeQuote(reqVO.getContent()); + } + + // 情况二:有 quoteMessageId,加载原消息并校验 + ImGroupMessageDO original = groupMessageMapper.selectById(quoteMessageId); + if (original == null + || ImMessageStatusEnum.RECALL.getStatus().equals(original.getStatus())) { + throw exception(MESSAGE_QUOTE_INVALID); + } + // 校验是同群 + if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) { + throw exception(MESSAGE_QUOTE_INVALID); + } + // 拒绝定向消息(仅发送人可见的内容若被全员广播 quote.content,会泄漏给原本看不到的成员) + if (CollUtil.isNotEmpty(original.getReceiverUserIds())) { + throw exception(MESSAGE_QUOTE_INVALID); + } + // 校验对发送人可见(入群时间 / 退群时间) + if (!isMessageVisible(original, senderMember, senderMember.getUserId())) { + throw exception(MESSAGE_QUOTE_INVALID); + } + // 构建 quote 对象并注入 content + QuoteMessage quote = ImMessageUtils.buildQuote(original.getId(), + original.getSenderId(), original.getType(), original.getContent()); + return ImMessageUtils.appendQuote(reqVO.getContent(), quote); + } + + /** + * 校验群聊消息存在 + * + * @param messageId 消息编号 + * @return 群聊消息 + */ + private ImGroupMessageDO validateGroupMessageExists(Long messageId) { + ImGroupMessageDO message = groupMessageMapper.selectById(messageId); + if (message == null) { + throw exception(MESSAGE_NOT_EXISTS); + } + return message; + } + + /** + * 判断一条群消息对某个群成员是否可见 + * + * @param msg 消息 + * @param member 群成员 + * @param userId 当前用户编号(用于定向消息过滤) + * @return 是否可见 + */ + private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) { + if (member == null) { + return false; + } + // 1. 入群时间晚于消息发送时间 → 不可见 + if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) { + return false; + } + // 2. 已退群且退群时间早于消息发送时间 → 不可见 + if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus()) + && member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) { + return false; + } + // 3.1 无定向接收列表 → 全员可见 + if (CollUtil.isEmpty(msg.getReceiverUserIds())) { + return true; + } + // 3.2 当前用户在定向列表中,或本人即发送者 → 可见 + return msg.getReceiverUserIds().contains(userId) + || ObjUtil.equal(msg.getSenderId(), userId); + } + + /** + * 计算一条群消息的可见成员集合(含发送者) + */ + private Set getVisibleUserIds(ImGroupMessageDO message, List members) { + return convertSet(members, ImGroupMemberDO::getUserId, + member -> isMessageVisible(message, member, member.getUserId())); + } + + /** + * 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。 + *

+ * 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定, + * 只应用 {@code receiverUserIds} 定向过滤;语义与 + * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 + */ + private Set getVisibleUserIds(List receiverUserIds, Long senderId, Collection memberUserIds) { + if (CollUtil.isEmpty(memberUserIds)) { + return new HashSet<>(); + } + // 无定向接收列表 → 全员可见 + if (CollUtil.isEmpty(receiverUserIds)) { + return new HashSet<>(memberUserIds); + } + // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) + Set allowed = new HashSet<>(receiverUserIds); + if (senderId != null) { + allowed.add(senderId); + } + Set result = new HashSet<>(); + for (Long userId : memberUserIds) { + if (allowed.contains(userId)) { + result.add(userId); + } + } + return result; + } + + @Override + public void deleteReadMaxMessageId(Long groupId, Long userId) { + groupMessageReadRedisDAO.deleteReadMaxMessageId(groupId, userId); + } + + @Override + public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { + groupMessageReadRedisDAO.deleteReadMaxMessageIds(groupId, userIds); + } + + @Override + public void deleteReadMaxMessageIdMap(Long groupId) { + groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId); + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getGroupMessagePage(ImGroupMessageManagerPageReqVO reqVO) { + return groupMessageMapper.selectPage(reqVO); + } + + @Override + public ImGroupMessageDO getGroupMessage(Long id) { + return groupMessageMapper.selectById(id); + } + + @Override + public List getGroupMessageList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return groupMessageMapper.selectByIds(ids); + } + + @Override + public Map getGroupMessageMap(Collection ids) { + return convertMap(getGroupMessageList(ids), ImGroupMessageDO::getId); + } + + private ImGroupMessageServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + + /** + * 计算群消息回执 status:群已读关闭时强制 NO_RECEIPT,忽略发送方传入的 receipt(receipt 为 null 等价 false) + */ + private Integer resolveReceiptStatus(Boolean receipt) { + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + return ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + } + return BooleanUtil.isTrue(receipt) + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + } + + /** + * 禁言状态校验:全群禁言 → 成员禁言;群主 / 管理员豁免全群禁言 + */ + private void validateMuteStatus(ImGroupDO group, ImGroupMemberDO senderMember) { + boolean isOwnerOrAdmin = ImGroupMemberRoleEnum.isOwnerOrAdmin(senderMember.getRole()); + // 1. 全群禁言:群主 / 管理员豁免 + if (Boolean.TRUE.equals(group.getMutedAll()) && !isOwnerOrAdmin) { + throw exception(GROUP_MUTED_CANNOT_SEND); + } + // 2. 成员禁言 + if (senderMember.getMuteEndTime() != null && senderMember.getMuteEndTime().isAfter(LocalDateTime.now())) { + throw exception(GROUP_MEMBER_MUTED_CANNOT_SEND, senderMember.getMuteEndTime()); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java new file mode 100644 index 000000000..b16257f6a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; + +import java.util.List; + +/** + * IM 私聊消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImPrivateMessageService { + + /** + * 【用户调用】发送私聊消息 + *

+ * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、好友校验、敏感词、quote 解析等业务校验。 + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。 + * + * @param senderId 发送人编号 + * @param reqVO 发送请求 + * @return 消息 + */ + ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO); + + /** + * 【系统调用】发送私聊消息 + * + * @param senderId 发送人编号 + * @param dto 消息 DTO + * @return 构造的消息 DO(持久化时 id 已回填) + */ + ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto); + + /** + * 【用户调用】撤回私聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + * @return 撤回后的消息 + */ + ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId); + + /** + * 拉取私聊消息(增量) + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + List pullPrivateMessageList(Long userId, Long minId, Integer size); + + /** + * 标记私聊消息已读 + *

+ * 语义:将「对方发给当前用户、id <= messageId 的未读消息」一次性翻转为已读, + * 与群聊 readGroupMessages 对称,避免"select-then-update"两步式带来的竞态。 + * + * @param userId 当前用户编号 + * @param receiverId 接收方用户编号(对方) + * @param messageId 已读位置(含),通常是前端会话内最大消息 id + */ + void readPrivateMessages(Long userId, Long receiverId, Long messageId); + + /** + * 查询对方已读到我发的最大消息 id + *

+ * 用于多端 / 离线场景下的已读位置补齐:客户端进入会话或断线重连后, + * 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 翻转本地自发消息为已读,弥补离线期间错过的 RECEIPT 推送事件。 + * + * @param userId 当前用户编号 + * @param peerId 对方用户编号 + * @return 对方已读到的最大消息 id;对方一条都没读过时返回 null + */ + Long getMaxReadMessageId(Long userId, Long peerId); + + /** + * 查询私聊历史消息(游标拉取) + * + * @param userId 当前用户编号 + * @param reqVO 拉取请求 + * @return 消息列表(按 id 倒序) + */ + List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】分页查询私聊消息 + */ + PageResult getPrivateMessagePage(ImPrivateMessageManagerPageReqVO reqVO); + + /** + * 【管理后台】获取私聊消息详情 + */ + ImPrivateMessageDO getPrivateMessage(Long id); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java new file mode 100644 index 000000000..cd83a2d74 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java @@ -0,0 +1,256 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; +import cn.iocoder.yudao.module.im.util.ImMessageUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * IM 私聊消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { + + @Resource + private ImPrivateMessageMapper privateMessageMapper; + + @Resource + private ImFriendService friendService; + @Resource + private ImSensitiveWordService sensitiveWordService; + + @Resource + private ImWebSocketService imWebSocketService; + + @Resource + private ImProperties imProperties; + + @Override + public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) { + // 1.1 幂等校验:根据 senderId + clientMessageId 查重 + ImPrivateMessageDO existing = privateMessageMapper.selectBySenderIdAndClientMessageId( + senderId, reqVO.getClientMessageId()); + if (existing != null) { + log.info("[sendPrivateMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]", + senderId, reqVO.getClientMessageId(), existing.getId()); + return existing; + } + // 1.2 消息内容校验 + ImMessageUtils.validateUserMessageContent(reqVO.getType(), reqVO.getContent()); + // 1.3 好友校验 + friendService.validateFriend(senderId, reqVO.getReceiverId()); + // 1.4 文本消息敏感词过滤 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { + sensitiveWordService.validateText(reqVO.getContent()); + } + + // 2.1 引用 quote 消息规范化 + reqVO.setContent(normalizeQuoteContent(reqVO, senderId)); + // 2.2 构建并保存消息 + ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m + .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); + privateMessageMapper.insert(message); + + // 3. WebSocket 异步推送:接收方 + 发送方多端同步 + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); + imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage); + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); + return message; + } + + @Override + public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto) { + // 1.1 content 序列化:null / String 透传,POJO 走 JSON + Object payload = dto.getContent(); + String contentString = payload == null || payload instanceof String + ? (String) payload + : JsonUtils.toJsonString(payload); + // 1.2 构建消息 + ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) + .setSenderId(senderId).setReceiverId(dto.getReceiverId()) + .setType(dto.getType()).setContent(contentString) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); + // 1.3 决定是否持久化:dto.persistent 优先;为 null 时按 type 默认 + boolean persistent = dto.getPersistent() != null + ? dto.getPersistent() + : ImMessageTypeEnum.validate(dto.getType()).isPersistent(); + if (persistent) { + privateMessageMapper.insert(message); + } + + // 2. WebSocket 异步推送:双向(默认);单边语义(persistent=false)下仅推 sender 多端,对方不感知 + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); + if (persistent) { + imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage); + } + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); + return message; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { + // 1.1 校验消息存在 + ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); + if (message == null) { + throw exception(MESSAGE_NOT_EXISTS); + } + // 1.2 只能撤回自己发送的消息 + if (ObjUtil.notEqual(message.getSenderId(), userId)) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 1.3 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + // 1.4 只允许撤回限定时间内的消息 + int recallTimeoutMinutes = imProperties.getMessage().getRecallTimeoutMinutes(); + if (message.getSendTime().plusMinutes(recallTimeoutMinutes).isBefore(LocalDateTime.now())) { + throw exception(MESSAGE_RECALL_TIMEOUT, recallTimeoutMinutes); + } + + // 2. 更新原消息状态为撤回 + privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); + + // 3. 发送撤回事件 + return sendPrivateMessage(userId, new ImPrivateMessageSendDTO().setReceiverId(message.getReceiverId()) + .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId))); + } + + /** + * 私聊引用消息规范化 + * + * @param reqVO 发送请求 + * @param senderId 发送人编号 + * @return 规范化后的 content + */ + private String normalizeQuoteContent(ImPrivateMessageSendReqVO reqVO, Long senderId) { + // 解析客户端 content 里的 quote.messageId + Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent()); + + // 情况一:没有 quoteMessageId,直接 remove 掉 content 里可能伪造的 quote 字段 + if (quoteMessageId == null) { + return ImMessageUtils.removeQuote(reqVO.getContent()); + } + + // 情况二:有 quoteMessageId,加载原消息并校验 + ImPrivateMessageDO original = privateMessageMapper.selectById(quoteMessageId); + if (original == null + || ImMessageStatusEnum.RECALL.getStatus().equals(original.getStatus())) { + throw exception(MESSAGE_QUOTE_INVALID); + } + // 校验是同对话 + boolean sameConversation = (ObjUtil.equal(original.getSenderId(), senderId) // 发送人是当前用户,接收人是对方 + && ObjUtil.equal(original.getReceiverId(), reqVO.getReceiverId())) + || (ObjUtil.equal(original.getSenderId(), reqVO.getReceiverId()) // 发送人是对方,接收人是当前用户 + && ObjUtil.equal(original.getReceiverId(), senderId)); + if (!sameConversation) { + throw exception(MESSAGE_QUOTE_INVALID); + } + // 构建 quote 对象并注入 content + QuoteMessage quote = ImMessageUtils.buildQuote(original.getId(), + original.getSenderId(), original.getType(), original.getContent()); + return ImMessageUtils.appendQuote(reqVO.getContent(), quote); + } + + @Override + public List pullPrivateMessageList(Long userId, Long minId, Integer size) { + int maxPullSize = imProperties.getMessage().getMaxPullSize(); + if (size > maxPullSize) { + throw exception(MESSAGE_PULL_SIZE_EXCEEDED, maxPullSize); + } + // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getPrivatePullMaxDays()); + + // 根据 minId 和 minSendTime 拉取消息,避免 minId 恰好被发出后才拉取,导致漏消息 + List messages = privateMessageMapper.selectListByMinId(userId, minId, minSendTime, size); + log.info("[pullPrivateMessageList][userId({}) minId({}) size({}) result({})]", + userId, minId, size, messages.size()); + return messages; + } + + @Override + public void readPrivateMessages(Long userId, Long receiverId, Long messageId) { + // 1. 全局开关校验 + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { + throw exception(MESSAGE_PRIVATE_READ_DISABLED); + } + Assert.notNull(messageId, "已读消息编号不能为空"); + // 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读 + // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口 + int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( + receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(), + new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); + if (updated == 0) { + return; + } + + // 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) + imWebSocketService.sendPrivateMessageAsync(userId, + ImPrivateMessageDTO.ofRead(userId, receiverId, messageId)); + imWebSocketService.sendPrivateMessageAsync(receiverId, + ImPrivateMessageDTO.ofReceipt(userId, receiverId, messageId)); + } + + @Override + public Long getMaxReadMessageId(Long userId, Long peerId) { + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { + throw exception(MESSAGE_PRIVATE_READ_DISABLED); + } + return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( + userId, peerId, ImMessageStatusEnum.READ.getStatus()); + } + + @Override + public List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) { + return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit()); + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getPrivateMessagePage(ImPrivateMessageManagerPageReqVO reqVO) { + return privateMessageMapper.selectPage(reqVO); + } + + @Override + public ImPrivateMessageDO getPrivateMessage(Long id) { + return privateMessageMapper.selectById(id); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java new file mode 100644 index 000000000..95f423468 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java @@ -0,0 +1,231 @@ +package cn.iocoder.yudao.module.im.service.message.dto; + +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.*; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * IM 群聊消息发送 DTO + * + * @author 芋道源码 + */ +@Data +public class ImGroupMessageSendDTO { + + /** + * 群编号 + */ + @NotNull(message = "群编号不能为空") + private Long groupId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + @NotNull(message = "消息类型不能为空") + private Integer type; + /** + * 消息内容 + *

+ * 支持 String / POJO;非 String 时由 service 序列化为 JSON + */ + private Object content; + /** + * @ 目标用户编号列表 + */ + private List atUserIds; + /** + * 定向接收用户编号列表 + *

+ * 为空表示全员可见 + */ + private List receiverUserIds; + /** + * 是否需要回执 + *

+ * 缺省视为无需回执(false) + */ + private Boolean receipt; + + // ========== 群广播事件静态工厂(对应 ImMessageTypeEnum 群事件) ========== + + public static ImGroupMessageSendDTO ofGroupCreate(Long groupId, Long operatorUserId, Collection memberUserIds) { + GroupCreateNotification notification = new GroupCreateNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMemberUserIds(new ArrayList<>(memberUserIds)); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_CREATE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupInfoUpdate(Long groupId, Long operatorUserId, String oldAvatar, String newAvatar) { + GroupInfoUpdateNotification notification = new GroupInfoUpdateNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setOldAvatar(oldAvatar); + notification.setNewAvatar(newAvatar); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_INFO_UPDATE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberQuit(Long groupId, Long operatorUserId) { + GroupMemberQuitNotification notification = new GroupMemberQuitNotification(); + notification.setOperatorUserId(operatorUserId); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_QUIT.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupOwnerTransfer(Long groupId, Long operatorUserId, Long newOwnerUserId) { + GroupOwnerTransferNotification notification = new GroupOwnerTransferNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setNewOwnerUserId(newOwnerUserId); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_OWNER_TRANSFER.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberKick(Long groupId, Long operatorUserId, Collection memberUserIds) { + GroupMemberKickNotification notification = new GroupMemberKickNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMemberUserIds(new ArrayList<>(memberUserIds)); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_KICK.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberInvite(Long groupId, Long operatorUserId, Collection memberUserIds) { + GroupMemberInviteNotification notification = new GroupMemberInviteNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMemberUserIds(new ArrayList<>(memberUserIds)); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_INVITE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberEnter(Long groupId, Long entrantUserId, Integer addSource) { + GroupMemberEnterNotification notification = new GroupMemberEnterNotification(); + notification.setOperatorUserId(entrantUserId); + notification.setEntrantUserId(entrantUserId); + notification.setAddSource(addSource); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_ENTER.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupDissolve(Long groupId, Long operatorUserId) { + GroupDissolveNotification notification = new GroupDissolveNotification(); + notification.setOperatorUserId(operatorUserId); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberNicknameUpdate(Long groupId, Long operatorUserId, String displayUserName) { + GroupMemberNicknameUpdateNotification notification = new GroupMemberNicknameUpdateNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setDisplayUserName(displayUserName); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_NICKNAME_UPDATE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupAdminAdd(Long groupId, Long operatorUserId, Collection memberUserIds) { + GroupAdminAddNotification notification = new GroupAdminAddNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMemberUserIds(new ArrayList<>(memberUserIds)); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_ADMIN_ADD.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupAdminRemove(Long groupId, Long operatorUserId, Collection memberUserIds) { + GroupAdminRemoveNotification notification = new GroupAdminRemoveNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMemberUserIds(new ArrayList<>(memberUserIds)); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_ADMIN_REMOVE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupNoticeUpdate(Long groupId, Long operatorUserId, String oldNotice, String newNotice) { + GroupNoticeUpdateNotification notification = new GroupNoticeUpdateNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setOldNotice(oldNotice).setNewNotice(newNotice); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_NOTICE_UPDATE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupNameUpdate(Long groupId, Long operatorUserId, String oldName, String newName) { + GroupNameUpdateNotification notification = new GroupNameUpdateNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setOldName(oldName).setNewName(newName); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_NAME_UPDATE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberSettingUpdate(Long groupId, Long operatorUserId, Boolean silent, String groupRemark) { + GroupMemberSettingUpdateNotification notification = new GroupMemberSettingUpdateNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setSilent(silent).setGroupRemark(groupRemark); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMessagePin(Long groupId, Long operatorUserId, ImGroupMessageDO message) { + GroupMessagePinNotification notification = new GroupMessagePinNotification(); + GroupMessagePinNotification.PinnedMessage pinnedMessage = new GroupMessagePinNotification.PinnedMessage() + .setId(message.getId()).setSenderId(message.getSenderId()).setGroupId(message.getGroupId()) + .setType(message.getType()).setContent(message.getContent()).setSendTime(message.getSendTime()) + .setAtUserIds(message.getAtUserIds()).setReceiverUserIds(message.getReceiverUserIds()); + notification.setOperatorUserId(operatorUserId); + notification.setMessageId(message.getId()).setMessage(pinnedMessage); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMessageUnpin(Long groupId, Long operatorUserId, Long messageId) { + GroupMessageUnpinNotification notification = new GroupMessageUnpinNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMessageId(messageId); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MESSAGE_UNPIN.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberMuted(Long groupId, Long operatorUserId, + Long mutedUserId, + java.time.LocalDateTime muteEndTime) { + GroupMemberMutedNotification notification = new GroupMemberMutedNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMutedUserId(mutedUserId); + notification.setMuteEndTime(muteEndTime); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_MUTED.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMemberCancelMuted(Long groupId, Long operatorUserId, Long mutedUserId) { + GroupMemberCancelMutedNotification notification = new GroupMemberCancelMutedNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setMutedUserId(mutedUserId); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MEMBER_CANCEL_MUTED.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupMuted(Long groupId, Long operatorUserId) { + GroupMutedNotification notification = new GroupMutedNotification(); + notification.setOperatorUserId(operatorUserId); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_MUTED.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupCancelMuted(Long groupId, Long operatorUserId) { + GroupCancelMutedNotification notification = new GroupCancelMutedNotification(); + notification.setOperatorUserId(operatorUserId); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_CANCEL_MUTED.getType()).setContent(notification); + } + + public static ImGroupMessageSendDTO ofGroupBanned(Long groupId, Long operatorUserId, boolean banned) { + GroupBannedNotification notification = new GroupBannedNotification(); + notification.setOperatorUserId(operatorUserId); + notification.setBanned(banned); + return new ImGroupMessageSendDTO().setGroupId(groupId) + .setType(ImMessageTypeEnum.GROUP_BANNED.getType()).setContent(notification); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java new file mode 100644 index 000000000..f1c5375b7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.service.message.dto; + +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; + +/** + * IM 私聊消息发送 DTO + * + * @author 芋道源码 + */ +@Data +public class ImPrivateMessageSendDTO { + + /** + * 接收人编号 + */ + private Long receiverId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容 + *

+ * 支持 String / POJO;非 String 时由 service 序列化为 JSON + */ + private Object content; + /** + * 是否持久化 + 推送给接收方(单边语义开关) + *

+ * null:默认按 {@link ImMessageTypeEnum#isPersistent()} 决定是否入库 + 双向 WS 推送(保持原行为)
+ * false:覆盖为单边——不入库、仅推 sender 多端,对方不感知(如「你已删除 XXX」这类仅自己可见的 TIP)
+ * true:覆盖为双向 + 入库 + */ + private Boolean persistent; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java new file mode 100644 index 000000000..5be2c7c11 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java @@ -0,0 +1,169 @@ +package cn.iocoder.yudao.module.im.service.rtc; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo.ImRtcCallManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; + +import java.util.List; + +/** + * IM 实时通话 Service + * + * @author 芋道源码 + */ +public interface ImRtcCallService { + + /** + * 创建新通话;同好友对 / 同群已有进行中通话直接抛错(群场景应改走 {@link #inviteCall} 追加邀请,或 {@link #joinCall} 加入旁观) + * + * @param userId 发起人编号;通常是当前登录用户 + * @param reqVO 请求参数(scene / mediaType / peerUserId 或 groupId + inviteeIds) + * @return 通话主表 + */ + ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO); + + /** + * 通话中追加邀请:仅群通话场景可用;本人必须是房内 JOINED 参与者;给新邀请人推 RTC_CALL(INVITE) + * + * @param userId 操作人编号;必须是当前会话参与者 + * @param reqVO room + 新邀请的用户编号集合 + */ + void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO); + + /** + * 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 JOINER 加入,邀请池内成员转 JOINED + * + * @param userId 加入者用户编号 + * @param room 业务通话编号;从胶囊条 activeCall 拿 + * @return 通话主表 + */ + ImRtcCallDO joinCall(Long userId, String room); + + /** + * 接听通话:参与者 INVITING → JOINED;主表 CREATED → RUNNING(首次有非发起人接通时) + * + * @param userId 接听者用户编号 + * @param room 业务通话编号 + * @return 通话主表 + */ + ImRtcCallDO acceptCall(Long userId, String room); + + /** + * 拒绝通话;仅 INVITING 状态可拒;群通话拒绝等同于不参与,房间仍存在 + * + * @param userId 拒接者用户编号 + * @param room 业务通话编号 + */ + void rejectCall(Long userId, String room); + + /** + * 取消邀请;主叫在 INVITING 状态主动取消 + * + * @param userId 取消者用户编号(必须是主叫) + * @param room 业务通话编号 + */ + void cancelCall(Long userId, String room); + + /** + * 离开通话;RUNNING 状态下离开;私聊任一方离开 = 通话结束;群通话最后一人离开才结束 + * + * @param userId 离开者用户编号 + * @param room 业务通话编号 + */ + void leaveCall(Long userId, String room); + + /** + * 查询当前正在进行的通话;目前仅群聊场景(胶囊条),私聊未来扩展再补 peerUserId 参数 + *

+ * 鉴权:仅群活跃成员可查;防止任意用户探测群通话状态 / 拿到 inviter / inviteeIds 等敏感信息 + * + * @param userId 操作人编号;通常是当前登录用户 + * @param groupId 群编号 + * @return 通话主表;不存在返回 null + */ + ImRtcCallDO getActiveCall(Long userId, Long groupId); + + /** + * 查询某通话的全部参与者明细;交给 Controller 拼装 inviteeIds / joinedUserIds + * + * @param room 业务通话编号 + * @return 参与者明细列表 + */ + List getCallParticipantList(String room); + + /** + * 签发指定用户进入该通话的 LiveKit Token;供 Controller 拼接到响应 VO + * + * @param userId 进房用户编号;token 内 displayName 取该用户昵称 + * @param room 业务通话编号 + * @return JWT 字符串 + */ + String signCallToken(Long userId, String room); + + /** + * 处理 LiveKit Webhook 事件;用于关 tab / 强杀 / 网络断开等异常退出场景的兜底清理 + *

+ * 关键事件:participant_left(成员离开) / room_finished(房间结束)。 + * 前端正常 leave 时,也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。 + * + * @param event LiveKit Webhook 事件 + */ + void handleLiveKitEvent(cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitWebhookEventDTO event); + + /** + * 【定时任务调用】清理僵尸通话 + * + * @param thresholdMinutes 通话创建超过此分钟数才纳入扫描;调用方保证 > 0 + * @return 清理数量 + */ + int cleanupZombieCalls(int thresholdMinutes); + + /** + * 【定时任务调用】超时未接通的 INVITING 参与者:单人粒度标 NO_ANSWER + 推 RTC_CALL(NO_ANSWER) 让前端 banner 收敛; + * 若导致通话只剩主叫,由 endSessionIfTerminal 级联关房 + * + * @param thresholdMinutes 邀请时间超过此分钟数才纳入扫描;调用方保证 > 0 + * @return 超时处理数量 + */ + int timeoutInvitingParticipants(int thresholdMinutes); + + /** + * 前端 RUNNING 端 timer 兜底;立即扫描指定 room 内超时的 INVITING 参与者,等同 Job 但限定单 room; + * 实际超时阈值由后端 {@link cn.iocoder.yudao.module.im.framework.config.ImProperties.Rtc#getInviteTimeoutMinutes()} 决定, + * 避免前后端配置不一致;接口静默,所有边界(room 不存在 / 鉴权失败 / 无超时候选)都返回 false 不抛异常 + * + * @param userId 调用者用户编号;必须是该 room 的参与者 + * @param room 业务通话编号 + */ + void noAnswerCallCheck(Long userId, String room); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】获得通话记录分页 + * + * @param reqVO 分页查询条件 + * @return 通话记录分页 + */ + PageResult getCallPage(ImRtcCallManagerPageReqVO reqVO); + + /** + * 【管理后台】获得通话记录 + * + * @param id 通话编号 + * @return 通话记录 + */ + ImRtcCallDO getCall(Long id); + + /** + * 【管理后台】按通话编号查询参与者明细 + * + * @param id 通话编号 + * @return 参与者明细列表;通话不存在时返回空集合 + */ + List getCallParticipantListByCallId(Long id); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java new file mode 100644 index 000000000..695921fbb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java @@ -0,0 +1,1189 @@ +package cn.iocoder.yudao.module.im.service.rtc; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.rtc.vo.ImRtcCallManagerPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper; +import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitClient; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitWebhookEventDTO; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * IM 实时通话 Service 实现 + *

+ * 存储模型:DB 单一存储(im_rtc_call 主表 + im_rtc_participant 明细表) + *

+ * 并发幂等:同好友对 / 同群活跃唯一性走 {@link ImRtcCallLockRedisDAO} 分布式锁 + 锁内 SELECT 兜底;webhook 兜底走条件 UPDATE; + *

+ * 推送通道分流: + * 1601 RTC_CALL(INVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendPrivateMessageAsync} 仅推参与方; + * 1602 / 1603 PARTICIPANT_CONNECTED / DISCONNECTED → {@link ImWebSocketService} 推参与方 + 群通话场景广播全群; + * 1610 RTC_CALL_START + 1611 RTC_CALL_END → {@link ImPrivateMessageService} / {@link ImGroupMessageService} 入消息流当聊天 tip + * (START 仅群通话;两者分别在 invite / cancel(leave) 事务里 INSERT,自增 id 自然保证顺序) + *

+ * 职责边界:媒体协商完全交给 LiveKit;后端只做会话状态机、Token 签发、来电信令推送、通话历史落消息流;房内媒体流变化交给 LiveKit 客户端事件(TrackSubscribed 等),后端不重复推 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImRtcCallServiceImpl implements ImRtcCallService { + + @Resource + private ImRtcCallMapper rtcCallMapper; + @Resource + private ImRtcParticipantMapper rtcParticipantMapper; + @Resource + private ImRtcCallLockRedisDAO rtcCallLockRedisDAO; + + @Resource + private ImGroupMemberService groupMemberService; + @Resource + private ImFriendService friendService; + @Resource + private ImWebSocketService webSocketService; + @Resource + private ImPrivateMessageService privateMessageService; + @Resource + private ImGroupMessageService groupMessageService; + + @Resource + private LiveKitClient liveKitClient; + + @Resource + private AdminUserApi adminUserApi; + + @Resource + private ImProperties imProperties; + + // ========== 业务接口 ========== + + @Override + @SneakyThrows + @Transactional(rollbackFor = Exception.class) + public ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO) { + validateEnabled(); + // 1. 校验入参与场景 + validateCreateCall(userId, reqVO); + + // 2. 加锁后跑业务主体;同好友对 / 同群串行,避免并发各开一通的竞态 + if (ImConversationTypeEnum.isGroup(reqVO.getConversationType())) { + return rtcCallLockRedisDAO.lockGroup(reqVO.getGroupId(), () -> createGroupCall(userId, reqVO)); + } + Long peerUserId = CollUtil.getFirst(reqVO.getInviteeIds()); + return rtcCallLockRedisDAO.lockPrivate(userId, peerUserId, () -> createPrivateCall(userId, reqVO, peerUserId)); + } + + /** + * 群通话创建锁内主体:群有活跃通话直接抛(引导走 inviteCall / joinCall); + * 否则走完整生命周期,若发起人自身忙线立即 end(BUSY) 留下通话记录 + * + * @param userId 发起人编号 + * @param reqVO 创建请求 + * @return 通话主表(可能 status=ENDED 表示自身忙线) + */ + private ImRtcCallDO createGroupCall(Long userId, ImRtcCallCreateReqVO reqVO) { + // 1.1 同群有活跃通话 → 直接抛异常(UI 应已拦截),引导用户走 inviteCall / joinCall;避免重复开通 + ImRtcCallDO active = rtcCallMapper.selectLastOneByGroupIdAndStatusIn( + reqVO.getGroupId(), ImRtcCallStatusEnum.ACTIVE_STATUSES); + if (active != null) { + throw exception(RTC_GROUP_CALL_ACTIVE); + } + // 1.2 先检测发起人忙线状态;不抛,留给下方 end 决定 + boolean selfBusy = rtcParticipantMapper.selectLastOneByUserIdAndStatus( + userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES) != null; + + // 2. 完整生命周期:INSERT + INVITE × N + START 全推 + ImRtcCallDO call = createCall0(userId, reqVO); + + // 3. 自身忙线立即 end(BUSY);END 推送给群,群成员看到完整 START + END + if (selfBusy) { + endSession(call, userId, ImRtcCallEndReasonEnum.BUSY); + } + return call; + } + + /** + * 私聊创建锁内主体:双方忙线时仍走完整生命周期(create + 立即 end(BUSY)), + * 同一对正在通话视作数据异常直接抛 + * + * @param userId 发起人编号 + * @param reqVO 创建请求 + * @param peerUserId 对端编号;来自 reqVO.inviteeIds 的唯一元素 + * @return 通话主表(可能 status=ENDED 表示忙线) + */ + private ImRtcCallDO createPrivateCall(Long userId, ImRtcCallCreateReqVO reqVO, Long peerUserId) { + // 1.1 双方已在同一通话 → 数据异常(UI 应已拦截),直接抛 + if (getActivePrivateCallByPair(userId, peerUserId) != null) { + throw exception(RTC_SELF_BUSY); + } + // 1.2 忙线检测:self 优先(更可执行的提示);不抛,留给下方 end 决定 + boolean selfBusy = rtcParticipantMapper.selectLastOneByUserIdAndStatus( + userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES) != null; + boolean peerBusy = !selfBusy && rtcParticipantMapper.selectLastOneByUserIdAndStatus( + peerUserId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES) != null; + + // 2. 完整生命周期:INSERT + INVITE 全推 + ImRtcCallDO call = createCall0(userId, reqVO); + + // 3. 忙线立即 end(BUSY);operator 决定两端看到的文案(self busy → operator=自己;peer busy → operator=对端) + if (selfBusy) { + endSession(call, userId, ImRtcCallEndReasonEnum.BUSY); + } else if (peerBusy) { + endSession(call, peerUserId, ImRtcCallEndReasonEnum.BUSY); + } + return call; + } + + @Override + @SneakyThrows + @Transactional(rollbackFor = Exception.class) + public void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) { + validateEnabled(); + // 1.1 校验通话存在且活跃 + ImRtcCallDO call = validateCallActive(reqVO.getRoom()); + // 1.2 仅群通话支持追加邀请 + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { + throw exception(RTC_GROUP_REQUIRED); + } + // 1.3 操作者必须是房内 JOINED 参与者 + ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); + if (operator == null || !ImRtcParticipantStatusEnum.isJoined(operator.getStatus())) { + throw exception(RTC_NOT_PARTICIPANT); + } + // 2. 加群锁后执行追加邀请;避免与新建 / 重复追加的竞态 + rtcCallLockRedisDAO.lockGroup(call.getGroupId(), () -> { + addInvitees(call, userId, reqVO.getInviteeIds()); + return null; + }); + } + + /** + * 新建通话实体;INSERT 主表 + 参与表 + 推送 INVITE / START + * + * @param inviterId 发起人编号 + * @param reqVO 创建请求 + * @return 通话主表 + */ + private ImRtcCallDO createCall0(Long inviterId, ImRtcCallCreateReqVO reqVO) { + // 1. 构造参数:room 用 UUID;解析被邀请池 + String room = IdUtil.fastSimpleUUID(); + LocalDateTime now = LocalDateTime.now(); + Set invitees = resolveInvitees(reqVO, inviterId); + + // 2.1 INSERT 主表;群聊发起人即时 JOINED 但通话仍处 CREATED,等首个非发起人接通才切 RUNNING + ImRtcCallDO call = new ImRtcCallDO().setRoom(room) + .setConversationType(reqVO.getConversationType()).setMediaType(reqVO.getMediaType()) + .setInviterUserId(inviterId).setGroupId(reqVO.getGroupId()) + .setStatus(ImRtcCallStatusEnum.CREATED.getStatus()).setStartTime(now); + rtcCallMapper.insert(call); + // 2.2 批量 INSERT 参与表:发起人即时 JOINED,被邀请人 INVITING 等接通 + List participants = new ArrayList<>(invitees.size() + 1); + participants.add(new ImRtcParticipantDO().setCallId(call.getId()).setRoom(room).setUserId(inviterId) + .setRole(ImRtcParticipantRoleEnum.INVITER.getRole()) + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()) + .setInviteTime(now).setAcceptTime(now)); + for (Long inviteeId : invitees) { + participants.add(new ImRtcParticipantDO() + .setCallId(call.getId()).setRoom(room).setUserId(inviteeId) + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) + .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus()) + .setInviteTime(now)); + } + rtcParticipantMapper.insertBatch(participants); + + // 3.1 推送通知:RTC_CALL(INVITE) 给每个被邀请人 + AdminUserRespDTO inviterUser = adminUserApi.getUser(inviterId).getCheckedData(); + Map inviteeMap = adminUserApi.getUserMap(invitees); + for (Long inviteeId : invitees) { + pushCallInviteNotification(call, inviterUser, inviteeId, inviteeMap.get(inviteeId), invitees); + } + // 3.2 向消息流写入 RTC_CALL_START;群聊全群广播,私聊定向给被叫,作为会话列表预览的依据 + pushCallStartNotification(call, inviterUser, invitees); + return call; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImRtcCallDO joinCall(Long userId, String room) { + validateEnabled(); + // 1.1 校验通话存在且活跃 + ImRtcCallDO call = validateCallActive(room); + // 1.2 仅群通话支持「旁观者加入」 + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { + throw exception(RTC_GROUP_REQUIRED); + } + // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会 + groupMemberService.validateMemberInGroup(call.getGroupId(), userId); + // 1.4 校验当前用户没有其它活跃通话 + validateUserNotInOtherCall(userId, call.getRoom()); + + // 2. 入参与表:已有记录切回 JOINED;不在记录则以 JOINER 角色 INSERT + LocalDateTime now = LocalDateTime.now(); + joinParticipant(call, userId, now); + + // 3. 主表 CREATED → RUNNING(首次有非发起人加入) + maybeMarkOngoing(call, userId, now); + return call; + } + + /** + * 给已存在的活跃群通话追加邀请;批量校验群成员 + 去重已在通话池 + 推送 INVITE + * + * @param call 活跃通话主表 + * @param inviterId 本次追加邀请的发起人;已是房内 JOINED 参与者 + * @param inviteeIds 本次追加的被邀请人编号 + */ + private void addInvitees(ImRtcCallDO call, Long inviterId, Collection inviteeIds) { + // 1.1 校验被邀请人都是群活跃成员 + groupMemberService.validateMembersInGroup(call.getGroupId(), inviteeIds); + // 1.2 排除已在通话池的;剩余即本次新邀请 + List existingParticipants = rtcParticipantMapper.selectListByRoom(call.getRoom()); + Set existingUserIds = CollectionUtils.convertSet(existingParticipants, ImRtcParticipantDO::getUserId); + Set incomingUserIds = new LinkedHashSet<>(inviteeIds); + incomingUserIds.removeAll(existingUserIds); + if (CollUtil.isEmpty(incomingUserIds)) { + return; + } + long activeCount = existingParticipants.stream() + .filter(participant -> ImRtcParticipantStatusEnum.ACTIVE_STATUSES.contains(participant.getStatus())) + .count(); + if (activeCount + incomingUserIds.size() > imProperties.getRtc().getGroupMaxParticipants()) { + throw exception(RTC_GROUP_INVITEE_OVER_LIMIT); + } + + // 2. 批量 INSERT 新邀请人 + LocalDateTime now = LocalDateTime.now(); + List participants = CollectionUtils.convertList(incomingUserIds, inviteeId -> + new ImRtcParticipantDO().setCallId(call.getId()).setRoom(call.getRoom()).setUserId(inviteeId) + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) + .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus()).setInviteTime(now)); + rtcParticipantMapper.insertBatch(participants); + + // 3. 推送通知:RTC_CALL(INVITE) 给每个新邀请人 + AdminUserRespDTO inviter = adminUserApi.getUser(inviterId).getCheckedData(); + Map inviteeMap = adminUserApi.getUserMap(incomingUserIds); + for (Long inviteeId : incomingUserIds) { + pushCallInviteNotification(call, inviter, inviteeId, inviteeMap.get(inviteeId), incomingUserIds); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImRtcCallDO acceptCall(Long userId, String room) { + validateEnabled(); + // 1.1 校验通话存在且活跃 + ImRtcCallDO call = validateCallActive(room); + // 1.2 校验本人是该通话的参与者 + ImRtcParticipantDO participant = validateParticipant(call, userId); + + // 2.1 已 JOINED 直接幂等返回 + if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { + return call; + } + validateUserNotInOtherCall(userId, call.getRoom()); + // 2.2 仅 INVITING → JOINED;其它状态拒 + if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + LocalDateTime now = LocalDateTime.now(); + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setId(participant.getId()).setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now)); + if (updated == 0) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + + // 3. 主表 CREATED → RUNNING(首次有非发起人接通) + maybeMarkOngoing(call, userId, now); + return call; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void rejectCall(Long userId, String room) { + validateEnabled(); + // 1.1 校验通话存在且活跃 + ImRtcCallDO call = validateCallActive(room); + // 1.2 校验本人是该通话的参与者 + ImRtcParticipantDO participant = validateParticipant(call, userId); + // 1.3 仅 INVITING 状态可拒 + if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + + // 2. INVITING → REJECTED;并发已变更则忽略 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setId(participant.getId()).setStatus(ImRtcParticipantStatusEnum.REJECTED.getStatus()).setLeaveTime(LocalDateTime.now())); + if (updated == 0) { + return; + } + + // 3.1 群通话拒绝:推 RTC_CALL(REJECT) 给主叫,再判定是否要关房(全员拒接 / 只剩主叫一人时收敛) + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + pushCallRejectNotification(call, userId); + endSessionIfTerminal(call, userId); + return; + } + // 3.2 私聊拒绝:走 endSession(推 RTC_CALL_END(reason=REJECT)) + endSession(call, userId, ImRtcCallEndReasonEnum.REJECT); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelCall(Long userId, String room) { + validateEnabled(); + // 1.1 校验通话存在且活跃 + ImRtcCallDO call = validateCallActive(room); + // 1.2 仅主叫可取消 + if (ObjUtil.notEqual(call.getInviterUserId(), userId)) { + throw exception(RTC_NOT_PARTICIPANT); + } + // 1.3 仅 CREATED 状态可取消(RUNNING 应走 leave) + if (!ImRtcCallStatusEnum.isCreated(call.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + + // 2. 关会话并推 RTC_CALL_END(reason=CANCEL) + endSession(call, userId, ImRtcCallEndReasonEnum.CANCEL); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void leaveCall(Long userId, String room) { + validateEnabled(); + // 1.1 校验通话存在且活跃 + ImRtcCallDO call = validateCallActive(room); + // 1.2 校验本人是该通话的参与者 + ImRtcParticipantDO participant = validateParticipant(call, userId); + + // 2. 当前状态 → LEFT;条件 UPDATE 防并发反复改 + LocalDateTime now = LocalDateTime.now(); + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), participant.getStatus(), + new ImRtcParticipantDO().setId(participant.getId()).setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now)); + if (updated == 0) { + return; + } + + // 3. 群通话已入会参与者离开时推送离线通知 + if (ImConversationTypeEnum.isGroup(call.getConversationType()) + && ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { + pushParticipantDisconnectedNotification(call, userId); + } + + // 4. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 + endSessionIfTerminal(call, userId); + } + + /** + * 群通话是否应该关闭 + *

+ * 两种终态: + * 1. JOINED 数 = 0(房内已没人) + * 2. JOINED 数 = 1 且 INVITING 数 = 0(只剩 1 人独守,无后续可加入者) + *

+ * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛 + * + * @param room 业务通话编号 + * @return true 表示无法继续通话,应关房 + */ + private boolean shouldCloseGroupRoom(String room) { + int joined = 0; + int inviting = 0; + for (ImRtcParticipantDO p : rtcParticipantMapper.selectListByRoom(room)) { + if (ImRtcParticipantStatusEnum.isJoined(p.getStatus())) { + joined++; + } else if (ImRtcParticipantStatusEnum.isInviting(p.getStatus())) { + inviting++; + } + } + return joined == 0 || (joined == 1 && inviting == 0); + } + + /** + * 查询两人共同所在的活跃私聊通话 + *

+ * 拆成 3 段简单查询:拿 A 的活跃 participant → 拿主表判私聊未结束 → 看 B 是否在同 call 活跃 + * + * @param userIdA 用户 A 编号 + * @param userIdB 用户 B 编号 + * @return 活跃私聊通话;不存在返回 null + */ + private ImRtcCallDO getActivePrivateCallByPair(Long userIdA, Long userIdB) { + ImRtcParticipantDO participantA = rtcParticipantMapper.selectLastOneByUserIdAndStatus(userIdA, ImRtcParticipantStatusEnum.ACTIVE_STATUSES); + if (participantA == null) { + return null; + } + ImRtcCallDO call = rtcCallMapper.selectByRoom(participantA.getRoom()); + if (call == null + || !ImConversationTypeEnum.isPrivate(call.getConversationType()) + || ImRtcCallStatusEnum.isEnded(call.getStatus())) { + return null; + } + ImRtcParticipantDO participantB = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userIdB); + if (participantB == null + || !ImRtcParticipantStatusEnum.ACTIVE_STATUSES.contains(participantB.getStatus())) { + return null; + } + return call; + } + + @Override + public ImRtcCallDO getActiveCall(Long userId, Long groupId) { + validateEnabled(); + // 1. 鉴权:仅群活跃成员能查(走单行 SQL,不依赖成员列表缓存) + groupMemberService.validateMemberInGroup(groupId, userId); + // 2. 查询活跃通话 + return rtcCallMapper.selectLastOneByGroupIdAndStatusIn(groupId, ImRtcCallStatusEnum.ACTIVE_STATUSES); + } + + @Override + public List getCallParticipantList(String room) { + return rtcParticipantMapper.selectListByRoom(room); + } + + @Override + public String signCallToken(Long userId, String room) { + validateEnabled(); + ImRtcCallDO call = validateCallActive(room); + ImRtcParticipantDO participant = validateParticipant(call, userId); + if (!ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { + throw exception(RTC_NOT_PARTICIPANT); + } + return signToken(userId, resolveDisplayName(adminUserApi.getUser(userId).getCheckedData(), userId), room); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void handleLiveKitEvent(LiveKitWebhookEventDTO event) { + if (event == null || event.getEvent() == null) { + return; + } + switch (event.getEvent()) { + case LiveKitWebhookEventDTO.EVENT_PARTICIPANT_JOINED: + handleParticipantJoined(event); + break; + case LiveKitWebhookEventDTO.EVENT_PARTICIPANT_LEFT: + handleParticipantLeft(event); + break; + case LiveKitWebhookEventDTO.EVENT_ROOM_FINISHED: + handleRoomFinished(event); + break; + default: + // 其它事件忽略;track_published 等业务态由 LiveKit 自身分发驱动 + } + } + + /** + * 处理 LiveKit 「成员加入」事件:DB 接通态由 {@link #acceptCall(Long, String)} 接口写入,此处仅做 1602 转推 + * + * @param event 事件 + */ + private void handleParticipantJoined(LiveKitWebhookEventDTO event) { + if (ObjUtil.hasNull(event.getRoom(), event.getParticipant())) { + return; + } + // 1. 前置检查:通话存在且活跃 + String room = event.getRoom().getName(); + ImRtcCallDO call = rtcCallMapper.selectByRoom(room); + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) { + return; + } + Long userId = liveKitClient.parseUserId(event.getParticipant().getIdentity()); + if (userId == null) { + return; + } + + // 2. 推 1602 通知参与方 / 全群参与方 + pushParticipantConnectedNotification(call, userId); + } + + /** + * 处理 LiveKit 「成员离开」事件:正常 {@link #leaveCall(Long, String)} 接口已经清理过的话,条件 UPDATE 自动幂等 + * + * @param event 事件 + */ + private void handleParticipantLeft(LiveKitWebhookEventDTO event) { + if (ObjUtil.hasNull(event.getRoom(), event.getParticipant())) { + return; + } + // 1. 前置检查:通话存在且活跃 + 参与者仍在 JOINED + String room = event.getRoom().getName(); + ImRtcCallDO call = rtcCallMapper.selectByRoom(room); + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) { + return; + } + Long userId = liveKitClient.parseUserId(event.getParticipant().getIdentity()); + if (userId == null) { + return; + } + ImRtcParticipantDO participant = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); + if (participant == null || !ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { + return; + } + + // 2. JOINED → LEFT;正常 leave 接口已改过则 update 影响 0 行直接退出 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), ImRtcParticipantStatusEnum.JOINED.getStatus(), + new ImRtcParticipantDO().setId(participant.getId()).setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(LocalDateTime.now())); + if (updated == 0) { + return; + } + log.info("[handleParticipantLeft][room={} userId={} 由 LiveKit Webhook 兜底]", room, userId); + + // 3. 推 1603 通知参与方 / 全群参与方 + pushParticipantDisconnectedNotification(call, userId); + // 4. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 + endSessionIfTerminal(call, userId); + } + + /** + * 处理 LiveKit 「房间结束」事件:兜底把 call 推到 ENDED + * + * @param event 事件 + */ + private void handleRoomFinished(LiveKitWebhookEventDTO event) { + if (event.getRoom() == null) { + return; + } + // 1. 前置检查:通话存在且活跃 + String room = event.getRoom().getName(); + ImRtcCallDO call = rtcCallMapper.selectByRoom(room); + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) { + return; + } + // 2. 关会话 + log.info("[handleRoomFinished][room={} 由 LiveKit Webhook 兜底]", room); + endSession(call, null, ImRtcCallEndReasonEnum.HANGUP); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int cleanupZombieCalls(int thresholdMinutes) { + // 阈值由调用方(Job)保证 > 0;低于 1 分钟会误杀刚发起的合理零人态 + LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes); + List candidates = rtcCallMapper.selectListByStatusInAndStartTimeBefore( + ImRtcCallStatusEnum.ACTIVE_STATUSES, threshold); + if (CollUtil.isEmpty(candidates)) { + return 0; + } + + // 2. 逐个查 LiveKit 房间真实 participant 数 + int cleaned = 0; + for (ImRtcCallDO call : candidates) { + int count; + try { + count = liveKitClient.listParticipants(call.getRoom()); + } catch (Exception e) { + log.warn("[cleanupZombieCalls][查询 LiveKit 失败 room={}]", call.getRoom(), e); + continue; + } + if (count != 0) { + continue; + } + log.info("[cleanupZombieCalls][清理僵尸通话 room={}]", call.getRoom()); + endSession(call, null, ImRtcCallEndReasonEnum.HANGUP); + cleaned++; + } + return cleaned; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int timeoutInvitingParticipants(int thresholdMinutes) { + // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态 + LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes); + return noAnswerCallCheck0(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore( + ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void noAnswerCallCheck(Long userId, String room) { + // 鉴权:仅该 room 参与者可触发;失败静默,不暴露错误 + ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(room, userId); + if (operator == null) { + return; + } + // 阈值取后端配置,避免前后端配置不一致;前端 timer 仅是触发时机 + LocalDateTime threshold = LocalDateTime.now() + .minusMinutes(imProperties.getRtc().getInviteTimeoutMinutes()); + List candidates = rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore( + room, ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold); + noAnswerCallCheck0(candidates); + } + + /** + * 批量超时处理:循环单参与者;同 room 复用 call、批量预查 user 避免 N+1;返回成功处理数 + * + * @param candidates 已过滤的超时 INVITING 候选 + * @return 成功处理(CAS 抢占)的数量 + */ + private int noAnswerCallCheck0(List candidates) { + if (CollUtil.isEmpty(candidates)) { + return 0; + } + Map callCache = new HashMap<>(); + Map userMap = adminUserApi.getUserMap( + CollectionUtils.convertSet(candidates, ImRtcParticipantDO::getUserId)); + int timedOut = 0; + for (ImRtcParticipantDO participant : candidates) { + if (timeoutInvitingParticipant(participant, callCache, userMap)) { + timedOut++; + } + } + return timedOut; + } + + /** + * 单参与者振铃超时:CAS 把 INVITING 切到 NO_ANSWER,群通话推 RTC_CALL(NO_ANSWER) + 级联关房判定; + * 私聊场景被叫超时即整通话结束,走 endSession(NO_ANSWER) 推 RTC_CALL_END + * + * @param participant INVITING 状态的超时候选 + * @param callCache 按 room 缓存 call 对象,避免同批次多人重复查询 + * @param userMap 候选用户预查 map;避免逐个 adminUserApi.getUser 走 N+1 + * @return 是否成功处理(CAS 失败 / 通话主表缺失等场景返回 false) + */ + private boolean timeoutInvitingParticipant(ImRtcParticipantDO participant, + Map callCache, + Map userMap) { + // 1. CAS:INVITING → NO_ANSWER;并发已变(用户刚接 / 拒,或 endSession 整体改)跳过 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setId(participant.getId()) + .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()) + .setLeaveTime(LocalDateTime.now())); + if (updated == 0) { + return false; + } + Long userId = participant.getUserId(); + log.info("[timeoutInvitingParticipant][参与者振铃超时 room={} userId={}]", participant.getRoom(), userId); + + // 2. 查询通话主表;同 room 复用 callCache,避免同批次多人重复查询 + ImRtcCallDO call = callCache.computeIfAbsent(participant.getRoom(), rtcCallMapper::selectByRoom); + if (call == null) { + log.warn("[timeoutInvitingParticipant][通话主表缺失 room={} userId={}]", participant.getRoom(), userId); + return false; + } + + // 3.1 群通话:推 RTC_CALL(NO_ANSWER) 让前端 banner 移除该人 + 级联关房判定 + // TODO DONE @AI:拆分独立 NO_ANSWER 信令,不再复用 REJECT + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + pushCallNoAnswerNotification(call, userId, userMap.get(userId)); + endSessionIfTerminal(call, userId); + return true; + } + // 3.2 私聊:被叫超时 = 整通话无人接听,走 endSession 推 RTC_CALL_END(NO_ANSWER) + endSession(call, userId, ImRtcCallEndReasonEnum.NO_ANSWER); + return true; + } + + // ========== 内部辅助 ========== + + private void validateEnabled() { + if (!imProperties.getRtc().isEnabled()) { + throw exception(RTC_NOT_ENABLED); + } + } + + /** + * 校验通话存在且活跃:不存在 / 已 ENDED 直接抛 + * + * @param room 业务通话编号 + * @return 通话主表 + */ + private ImRtcCallDO validateCallActive(String room) { + ImRtcCallDO call = rtcCallMapper.selectByRoom(room); + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + return call; + } + + /** + * 校验本人是该通话的参与者:accept / reject / leave / refreshToken 的共用前置 + * + * @param call 通话主表 + * @param userId 用户编号 + * @return 参与者记录 + */ + private ImRtcParticipantDO validateParticipant(ImRtcCallDO call, Long userId) { + ImRtcParticipantDO participant = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); + if (participant == null) { + throw exception(RTC_NOT_PARTICIPANT); + } + return participant; + } + + /** + * 校验用户不在其它活跃通话中 + * + * @param userId 用户编号 + * @param room 当前房间标识 + */ + private void validateUserNotInOtherCall(Long userId, String room) { + // 查询当前房间外的活跃参与记录 + ImRtcParticipantDO participant = rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot( + userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES, room); + // 存在活跃参与记录,则当前用户忙线 + if (participant != null) { + throw exception(RTC_SELF_BUSY); + } + } + + /** + * 加入群通话参与者列表 + * + * @param call 通话主表 + * @param userId 用户编号 + * @param now 当前时间 + */ + private void joinParticipant(ImRtcCallDO call, Long userId, LocalDateTime now) { + // 1. 已有参与记录:切回 JOINED + ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); + if (existing != null) { + updateParticipantJoined(existing, now); + return; + } + + // 2. 无参与记录:以主动加入者身份新增 + try { + rtcParticipantMapper.insert(new ImRtcParticipantDO() + .setCallId(call.getId()).setRoom(call.getRoom()) + .setUserId(userId).setRole(ImRtcParticipantRoleEnum.JOINER.getRole()) + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setInviteTime(now).setAcceptTime(now)); + } catch (DuplicateKeyException ex) { + // 3. 唯一键冲突:回查并复用并发写入的记录 + existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); + if (existing == null) { + throw ex; + } + updateParticipantJoined(existing, now); + } + } + + /** + * 将参与者更新为已加入 + * + * @param participant 参与者记录 + * @param now 当前时间 + */ + private void updateParticipantJoined(ImRtcParticipantDO participant, LocalDateTime now) { + // 已是 JOINED 直接返回 + if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { + return; + } + // 更新状态和接听时间 + rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(participant.getId()) + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now)); + } + + /** + * 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 + *

+ * end reason 按 call.status 自动推:CREATED(没人接通过)= CANCEL;RUNNING(有人接通过)= HANGUP + * + * @param call 通话主表 + * @param operatorId 操作者用户编号;webhook 兜底场景可空 + */ + private void endSessionIfTerminal(ImRtcCallDO call, Long operatorId) { + if (!ImConversationTypeEnum.isPrivate(call.getConversationType()) + && !shouldCloseGroupRoom(call.getRoom())) { + return; + } + ImRtcCallEndReasonEnum reason = ImRtcCallStatusEnum.isCreated(call.getStatus()) + ? ImRtcCallEndReasonEnum.CANCEL : ImRtcCallEndReasonEnum.HANGUP; + endSession(call, operatorId, reason); + } + + /** + * 校验创建通话入参;按场景区分必填字段,私聊补好友校验,群聊补群成员校验 + * + * @param userId 发起人编号 + * @param reqVO 创建请求 + */ + private void validateCreateCall(Long userId, ImRtcCallCreateReqVO reqVO) { + Integer conversationType = reqVO.getConversationType(); + if (ImConversationTypeEnum.isPrivate(conversationType)) { + // 私聊必须 1 个对端 + if (CollUtil.size(reqVO.getInviteeIds()) != 1) { + throw exception(RTC_PRIVATE_INVITEE_REQUIRED); + } + Long peerUserId = CollUtil.getFirst(reqVO.getInviteeIds()); + if (ObjUtil.equal(userId, peerUserId)) { + throw exception(RTC_INVITE_SELF); + } + friendService.validateFriend(userId, peerUserId); + return; + } + if (ImConversationTypeEnum.isGroup(conversationType)) { + if (reqVO.getGroupId() == null) { + throw exception(RTC_GROUP_REQUIRED); + } + // 群通话必须前端选中被邀请人(对齐微信);空集合直接拒 + if (CollUtil.isEmpty(reqVO.getInviteeIds())) { + throw exception(RTC_GROUP_INVITEE_REQUIRED); + } + groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); + return; + } + throw new IllegalArgumentException("非法的 conversationType: " + conversationType); + } + + /** + * 解析被邀请池:私聊为 peerUserId 单元素;群聊为前端选中子集(超量抛错) + * + * @param reqVO 创建请求 + * @param inviterId 发起人编号;自己不进被邀请池 + * @return 被邀请人 userId 集合 + */ + private Set resolveInvitees(ImRtcCallCreateReqVO reqVO, Long inviterId) { + // 1. 私聊:inviteeIds 已在 validateCreateCall 校验仅 1 个对端,直接复用 + if (ImConversationTypeEnum.isPrivate(reqVO.getConversationType())) { + return new LinkedHashSet<>(reqVO.getInviteeIds()); + } + + // 2. 群聊校验:被邀请人必须是该群活跃成员,防止恶意客户端塞任意 userId + groupMemberService.validateMembersInGroup(reqVO.getGroupId(), reqVO.getInviteeIds()); + Set initial = new LinkedHashSet<>(reqVO.getInviteeIds()); + // 发起人本人不进被邀请池 + initial.remove(inviterId); + if (CollUtil.isEmpty(initial)) { + throw exception(RTC_GROUP_INVITEE_REQUIRED); + } + int max = imProperties.getRtc().getGroupMaxParticipants(); + if (initial.size() + 1 > max) { + throw exception(RTC_GROUP_INVITEE_OVER_LIMIT); + } + return initial; + } + + /** + * 主表 CREATED → RUNNING;仅当首个非发起人加入时推进;条件 UPDATE 保幂等 + * + * @param call 通话主表;推进成功后会同步内存字段 + * @param acceptorId 加入者用户编号;发起人加入不算「首次接通」 + * @param now 当前时间 + */ + private void maybeMarkOngoing(ImRtcCallDO call, Long acceptorId, LocalDateTime now) { + // 1. 已 RUNNING / ENDED 直接退出;发起人加入不算「首次有人接通」 + if (!ImRtcCallStatusEnum.isCreated(call.getStatus())) { + return; + } + if (ObjUtil.equal(call.getInviterUserId(), acceptorId)) { + return; + } + + // 2. CREATED → RUNNING 条件 UPDATE;多人并发只有一人成功 + int updated = rtcCallMapper.updateByIdAndStatus(call.getId(), ImRtcCallStatusEnum.CREATED.getStatus(), + new ImRtcCallDO().setStatus(ImRtcCallStatusEnum.RUNNING.getStatus()).setAcceptTime(now)); + if (updated == 0) { + // 3. 竞争失败:reload 看真实终态;已 ENDED 抛错,否则同步内存 + ImRtcCallDO latest = rtcCallMapper.selectById(call.getId()); + if (latest == null || ImRtcCallStatusEnum.isEnded(latest.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + call.setStatus(latest.getStatus()).setAcceptTime(latest.getAcceptTime()); + return; + } + // 4. 推进成功:同步内存给后续判断 + call.setStatus(ImRtcCallStatusEnum.RUNNING.getStatus()).setAcceptTime(now); + } + + /** + * 关闭会话:主表条件 UPDATE 推到 ENDED → 残留 INVITING 批量改 NO_ANSWER → 推 RTC_CALL_END + * + * @param call 通话主表 + * @param operatorId 操作者用户编号;webhook 兜底 / Job 清理场景可空 + * @param reason 结束原因 + */ + private void endSession(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) { + // 1.1 更新通话主表为已结束;条件 UPDATE 仅在 status 还活跃时生效 + LocalDateTime now = LocalDateTime.now(); + int updated = rtcCallMapper.updateByIdAndStatusIn(call.getId(), ImRtcCallStatusEnum.ACTIVE_STATUSES, + new ImRtcCallDO().setStatus(ImRtcCallStatusEnum.ENDED.getStatus()) + .setEndReason(reason.getReason()).setEndTime(now)); + if (updated == 0) { + log.info("[endSession][已被另一路径终结,跳过 room={} operator={} reason={}]", + call.getRoom(), operatorId, reason); + return; + } + // 【特殊】同步内存 call:让 createCall 这类调用方拿到的 DO 立即反映 ENDED 终态,Controller 拼 RespVO 能直接用 + call.setStatus(ImRtcCallStatusEnum.ENDED.getStatus()).setEndReason(reason.getReason()).setEndTime(now); + // 1.2 更新参与表为已结束:残留 INVITING 改 NO_ANSWER;残留 JOINED 改 LEFT 并写 leaveTime + rtcParticipantMapper.updateByRoomAndStatus(call.getRoom(), ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())); + rtcParticipantMapper.updateByRoomAndStatus(call.getRoom(), ImRtcParticipantStatusEnum.JOINED.getStatus(), + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now)); + + // 2. 推 RTC_CALL_END;先于 deleteRoom 异步发出,让前端按业务语义 reset(NO_ANSWER / CANCEL 等), + // 避免随后 LiveKit Disconnected 事件抢先触发前端 "通话已断开" 兜底 toast + Long durationSeconds = call.getAcceptTime() != null ? + Duration.between(call.getAcceptTime(), now).getSeconds() : null; + pushCallEndNotification(call, operatorId, reason, durationSeconds); + + // 3. 兜底删除 LiveKit 房间,强制断开异常残留客户端;失败仅记日志,不阻断业务 + try { + liveKitClient.deleteRoom(call.getRoom()); + } catch (Exception e) { + log.warn("[endSession][删除 LiveKit 房间失败 room={} operator={} reason={}]", + call.getRoom(), operatorId, reason, e); + } + log.info("[endSession][room={} operator={} reason={}]", call.getRoom(), operatorId, reason); + } + + /** + * 通话事件的收件人池:1)私聊为双方参与者;2)群聊为群活跃成员 + * + * @param call 通话主表 + * @return 收件人 userId 集合 + */ + private Collection getCallAudienceUserIdList(ImRtcCallDO call) { + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + return groupMemberService.getActiveGroupMemberUserIdsByGroupId(call.getGroupId()); + } + return CollectionUtils.convertSet( + rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId); + } + + // ========== 通知推送 ========== + + /** + * RTC_CALL(INVITE):走 webSocketService 直推到被邀请人;persistent=false 不入消息流 + * + * @param call 通话主表 + * @param inviter 发起人;可空,缺失时 payload 的 inviterNickname / Avatar 留空 + * @param inviteeId 被邀请人用户编号 + * @param invitee 被邀请人;可空,缺失时 token 内 displayName 降级为 userId + * @param inviteeIds 本次被邀请人列表;前端来电界面展示「邀请的其他人」用,包含 inviteeId 自身 + */ + private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter, + Long inviteeId, AdminUserRespDTO invitee, + Collection inviteeIds) { + String token = signToken(inviteeId, resolveDisplayName(invitee, inviteeId), call.getRoom()); + ImRtcCallNotification payload = ImRtcCallNotification.ofInvite( + call, inviter, imProperties.getRtc().getLivekitUrl(), token, inviteeIds); + webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification( + ImMessageTypeEnum.RTC_CALL.getType(), call.getInviterUserId(), inviteeId, payload)); + } + + /** + * RTC_CALL(REJECT):仅群通话场景;走 webSocketService 推给群通话受众 + *

+ * 私聊拒绝走 endSession → RTC_CALL_END(reason=REJECT) 入消息流,不在此推 + * + * @param call 通话主表 + * @param operatorUserId 拒接者用户编号 + */ + private void pushCallRejectNotification(ImRtcCallDO call, Long operatorUserId) { + AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId).getCheckedData() : null; + ImRtcCallNotification payload = ImRtcCallNotification.ofReject(call, operatorUserId, operator); + for (Long receiverUserId : getCallAudienceUserIdList(call)) { + webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification( + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload)); + } + } + + /** + * RTC_CALL(NO_ANSWER):仅群通话场景;振铃超时由 Job 触发;走 webSocketService 推给群通话受众 + *

+ * 私聊未接听走 endSession → RTC_CALL_END(reason=NO_ANSWER) 入消息流,不在此推 + * + * @param call 通话主表 + * @param operatorUserId 未接听者用户编号 + * @param operator 未接听者预查结果;调用方批量查避免 N+1,可空 + */ + private void pushCallNoAnswerNotification(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) { + ImRtcCallNotification payload = ImRtcCallNotification.ofNoAnswer(call, operatorUserId, operator); + for (Long receiverUserId : getCallAudienceUserIdList(call)) { + webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification( + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload)); + } + } + + /** + * 通话参与者加入:LiveKit webhook participant_joined 触发;私聊推双方多端、群聊推全群成员(胶囊条 + 1) + * + * @param call 通话主表 + * @param userId 加入的参与者用户编号 + */ + private void pushParticipantConnectedNotification(ImRtcCallDO call, Long userId) { + pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_CONNECTED.getType(), userId, + ImRtcParticipantConnectedNotification.of(call, userId)); + } + + /** + * 通话参与者离开:LiveKit webhook participant_left 触发;推送范围同 {@link #pushParticipantConnectedNotification} + * + * @param call 通话主表 + * @param userId 离开的参与者用户编号 + */ + private void pushParticipantDisconnectedNotification(ImRtcCallDO call, Long userId) { + pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_DISCONNECTED.getType(), userId, + ImRtcParticipantDisconnectedNotification.of(call, userId)); + } + + /** + * 推送参与者事件的公共骨架;按会话类型决定收件人,单次 batch 推送扇出 + * + * @param call 通话主表 + * @param type 消息类型;1602 / 1603 + * @param actorUserId 触发本次事件的用户编号 + * @param payload 业务 payload + */ + private void pushParticipantNotification(ImRtcCallDO call, Integer type, Long actorUserId, Object payload) { + Collection receivers = getCallAudienceUserIdList(call); + if (CollUtil.isEmpty(receivers)) { + return; + } + ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofRtcNotification(type, actorUserId, null, payload); + webSocketService.sendPrivateMessageAsync(receivers, dto); + } + + /** + * RTC_CALL_START:群聊走 imGroupMessageService.send 全群广播;私聊走 imPrivateMessageService.send 定向给被叫 + *

+ * 私聊 peer 直接复用 createCall 解析好的 invitees,避免再查参与表;用于会话列表预览展示「[语音通话]」 + * + * @param call 通话主表 + * @param inviter 发起人;可空 + * @param invitees 本次邀请池;私聊场景取首个作为 peer + */ + private void pushCallStartNotification(ImRtcCallDO call, AdminUserRespDTO inviter, Set invitees) { + ImRtcCallStartNotification payload = ImRtcCallStartNotification.of(call, inviter); + Long peerUserId = ImConversationTypeEnum.isGroup(call.getConversationType()) ? null : CollUtil.getFirst(invitees); + pushCallChatMessage(call, ImMessageTypeEnum.RTC_CALL_START, payload, peerUserId); + } + + /** + * RTC_CALL_END:私聊走 imPrivateMessageService.send;群通话走 imGroupMessageService.send + *

+ * senderId 始终用通话发起人,让前端按「谁发起通话」决定气泡左右;操作者从 payload.operatorUserId 拿 + * + * @param call 通话主表 + * @param operatorId 操作者用户编号;webhook 兜底 / Job 清理场景可空 + * @param reason 结束原因 + * @param durationSeconds 通话时长(秒);未接通时为 null + */ + private void pushCallEndNotification(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason, + Long durationSeconds) { + AdminUserRespDTO operator = operatorId != null ? adminUserApi.getUser(operatorId).getCheckedData() : null; + ImRtcCallEndNotification payload = ImRtcCallEndNotification.of(call, reason, durationSeconds, operatorId, operator); + Long peerUserId = null; + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { + ImRtcParticipantDO peer = CollUtil.findOne( + rtcParticipantMapper.selectListByRoom(call.getRoom()), + p -> ObjUtil.notEqual(p.getUserId(), call.getInviterUserId())); + peerUserId = peer != null ? peer.getUserId() : null; + } + pushCallChatMessage(call, ImMessageTypeEnum.RTC_CALL_END, payload, peerUserId); + } + + /** + * RTC 通话事件入消息流:群聊走 groupMessageService 全群广播,私聊走 privateMessageService 定向给 peer + *

+ * senderId 固定取 inviterUserId,让前端按「谁发起」决定气泡左右;私聊 peer 缺失时降级为发给自己作兜底 + * + * @param call 通话主表 + * @param type 消息类型;RTC_CALL_START / RTC_CALL_END + * @param payload 推送 payload + * @param peerUserId 私聊对端用户编号;群聊忽略,私聊缺失时回退为 senderId + */ + private void pushCallChatMessage(ImRtcCallDO call, ImMessageTypeEnum type, Object payload, Long peerUserId) { + Long senderId = call.getInviterUserId(); + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO().setGroupId(call.getGroupId()) + .setType(type.getType()).setContent(payload); + groupMessageService.sendGroupMessage(senderId, dto); + return; + } + Long receiverId = peerUserId != null ? peerUserId : senderId; + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO().setReceiverId(receiverId) + .setType(type.getType()).setContent(payload); + privateMessageService.sendPrivateMessage(senderId, dto); + } + + // ========== Token / VO ========== + + /** + * 签 LiveKit Token + * + * @param userId 用户编号;token 内 identity 由此派生 + * @param displayName 房内显示名 + * @param room LiveKit 房间名 + * @return JWT 字符串 + */ + private String signToken(Long userId, String displayName, String room) { + return liveKitClient.signJoinToken(liveKitClient.buildIdentity(userId), displayName, room); + } + + /** + * 解析房内显示名:优先取 user.nickname,缺失时降级为 userId 字符串(LiveKit displayName 不可空) + * + * @param user 用户信息;可空 + * @param userId 用户编号;displayName 兜底 + * @return 房内显示名 + */ + private static String resolveDisplayName(AdminUserRespDTO user, Long userId) { + return StrUtil.blankToDefault( + user == null ? null : user.getNickname(), String.valueOf(userId)); + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getCallPage(ImRtcCallManagerPageReqVO reqVO) { + return rtcCallMapper.selectPage(reqVO); + } + + @Override + public ImRtcCallDO getCall(Long id) { + return rtcCallMapper.selectById(id); + } + + @Override + public List getCallParticipantListByCallId(Long id) { + return rtcParticipantMapper.selectListByCallId(id); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java new file mode 100644 index 000000000..1a5c3aa1d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * IM 敏感词 Service 接口 + * + * @author 芋道源码 + */ +public interface ImSensitiveWordService { + + /** + * 校验文本是否包含敏感词 + * + * @param text 待校验文本 + */ + void validateText(String text); + + // ==================== 管理后台 ==================== + + /** + * 【管理后台】分页查询敏感词 + */ + PageResult getSensitiveWordPage(ImSensitiveWordPageReqVO reqVO); + + /** + * 【管理后台】获取敏感词详情 + */ + ImSensitiveWordDO getSensitiveWord(Long id); + + /** + * 【管理后台】新增敏感词,返回新增 id + */ + Long createSensitiveWord(@Valid ImSensitiveWordSaveReqVO reqVO); + + /** + * 【管理后台】修改敏感词 + */ + void updateSensitiveWord(@Valid ImSensitiveWordSaveReqVO reqVO); + + /** + * 【管理后台】删除敏感词 + */ + void deleteSensitiveWord(Long id); + + /** + * 【管理后台】批量删除敏感词 + */ + void deleteSensitiveWordList(List ids); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java new file mode 100644 index 000000000..57ed857ed --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java @@ -0,0 +1,228 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.cache.CacheUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; +import com.github.houbb.sensitive.word.bs.SensitiveWordBs; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.Resource; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_DUPLICATED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_NOT_EXISTS; + +/** + * IM 敏感词 Service 实现类 + *

+ * 词库匹配交给 houbb sensitive-word 库(trie 树 + 全/半角 / 大小写 / 繁简体 / 数字风格规范化) + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { + + @Resource + private ImSensitiveWordMapper sensitiveWordMapper; + + /** + * 缓存条目 + */ + @Data + @AllArgsConstructor + private static class SensitiveWordBsCache { + + /** + * 敏感词检测器 + */ + private SensitiveWordBs bs; + /** + * 构建本实例时数据库里的 max(update_time),作为下次刷新比对的基线 + */ + private LocalDateTime maxUpdateTime; + + } + + /** + * 租户 → SensitiveWordBs 实例的本地缓存 + *

+ * 每分钟触发一次异步 reload,先读 max(update_time),没变就复用旧实例(避免 trie 重建),变了才重新读词库 + 重建。 + * 单实例 CRUD 后另外通过 {@link #invalidateSensitiveWordBsCaches()} 立即让本机失效,多实例靠定时刷新最长 1 分钟内收敛。 + */ + @SuppressWarnings({"Convert2Diamond", "NullableProblems"}) + private final LoadingCache sensitiveWordBsCaches = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 1 分钟过期 + new CacheLoader() { + + @Override + public SensitiveWordBsCache load(Long tenantId) { + return loadFresh(tenantId); + } + + @Override + public ListenableFuture reload(Long tenantId, SensitiveWordBsCache oldValue) { + // 异步刷新线程独立于业务线程,没有租户上下文;必须显式 TenantUtils.execute 设置,否则租户拦截器会按当前线程的空上下文拼 SQL + return Futures.immediateFuture(TenantUtils.execute(tenantId, () -> { + LocalDateTime currentMax = sensitiveWordMapper.selectMaxUpdateTime(tenantId); + // 没变 → 复用旧实例,避免无谓地重建 trie + if (Objects.equals(oldValue.getMaxUpdateTime(), currentMax)) { + return oldValue; + } + // 变了 → 重新读词库并重建 trie + return loadFresh(tenantId); + })); + } + + }); + + private SensitiveWordBsCache loadFresh(Long tenantId) { + return TenantUtils.execute(tenantId, () -> { + // 先取基线时间再读词库:反过来在两次查询之间出现的新插入会被漏感知 + LocalDateTime maxUpdateTime = sensitiveWordMapper.selectMaxUpdateTime(tenantId); + List words = sensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + // 构建敏感词检测器 + SensitiveWordBs bs = SensitiveWordBs.newInstance() + .wordDeny(() -> convertList(words, ImSensitiveWordDO::getWord)) + .ignoreCase(true) + .ignoreWidth(true) // 忽略全/半角 + .ignoreNumStyle(true) // 忽略数字风格(中文/阿拉伯) + .ignoreChineseStyle(true) // 忽略繁简体 + .enableWordCheck(true) + .init(); + return new SensitiveWordBsCache(bs, maxUpdateTime); + }); + } + + /** + * 强制让敏感词缓存失效,下次访问按最新 DB 重建 + *

+ * 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。 + */ + private void invalidateSensitiveWordBsCaches() { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + sensitiveWordBsCaches.invalidate(tenantId); + return; + } + sensitiveWordBsCaches.invalidateAll(); + } + + @Override + public void validateText(String text) { + if (StrUtil.isBlank(text)) { + return; + } + SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(TenantContextHolder.getRequiredTenantId()).getBs(); + if (bs.contains(text)) { + throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED); + } + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getSensitiveWordPage(ImSensitiveWordPageReqVO reqVO) { + return sensitiveWordMapper.selectPage(reqVO); + } + + @Override + public ImSensitiveWordDO getSensitiveWord(Long id) { + return sensitiveWordMapper.selectById(id); + } + + @Override + public Long createSensitiveWord(ImSensitiveWordSaveReqVO reqVO) { + // 1. 校验唯一 + validateWordUnique(null, reqVO.getWord()); + + // 2.1 入库 + ImSensitiveWordDO word = BeanUtils.toBean(reqVO, ImSensitiveWordDO.class); + sensitiveWordMapper.insert(word); + // 2.2 强制失效本机缓存(多实例靠定时刷新收敛) + invalidateSensitiveWordBsCaches(); + return word.getId(); + } + + @Override + public void updateSensitiveWord(ImSensitiveWordSaveReqVO reqVO) { + // 1.1 校验存在 + validateSensitiveWordExists(reqVO.getId()); + // 1.2 校验唯一(排除自身) + validateWordUnique(reqVO.getId(), reqVO.getWord()); + + // 2.1 更新 + ImSensitiveWordDO updateObj = BeanUtils.toBean(reqVO, ImSensitiveWordDO.class); + sensitiveWordMapper.updateById(updateObj); + // 2.2 强制失效本机缓存 + invalidateSensitiveWordBsCaches(); + } + + @Override + public void deleteSensitiveWord(Long id) { + // 1. 校验存在 + validateSensitiveWordExists(id); + + // 2.1 删除 + sensitiveWordMapper.deleteById(id); + // 2.2 强制失效本机缓存 + invalidateSensitiveWordBsCaches(); + } + + + @Override + public void deleteSensitiveWordList(List ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + // 1. 删除 + sensitiveWordMapper.deleteByIds(ids); + // 2. 强制失效本机缓存 + invalidateSensitiveWordBsCaches(); + } + + private void validateSensitiveWordExists(Long id) { + if (sensitiveWordMapper.selectById(id) == null) { + throw exception(SENSITIVE_WORD_NOT_EXISTS); + } + } + + /** + * 校验敏感词唯一(修改时排除自身) + */ + private void validateWordUnique(Long id, String word) { + ImSensitiveWordDO exist = sensitiveWordMapper.selectByWord(word); + if (exist == null) { + return; + } + if (id == null || ObjUtil.notEqual(exist.getId(), id)) { + throw exception(SENSITIVE_WORD_DUPLICATED, word); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerService.java new file mode 100644 index 000000000..5337bff47 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerService.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.im.service.statistics; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * IM 数据看板 Service 接口 + *

+ * 仅服务于 manager 后台统计页,独立于业务 Service,避免污染。 + * 返回的均为聚合后的简单结构,由 Controller 负责 VO 装配与昵称回填。 + * + * @author 芋道源码 + */ +public interface ImStatisticsManagerService { + + // ==================== 用户 ==================== + + /** + * 获取用户总数 + */ + Long getTotalUserCount(); + + /** + * 获取区间内新增用户数 + */ + Long getNewUserCount(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内活跃用户数(私聊+群聊去重) + */ + Long getActiveUserCount(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内每日新增用户数 Map(key 为 yyyy-MM-dd 日期) + */ + Map getNewUserDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内每日活跃用户数 Map + */ + Map getActiveUserDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime); + + // ==================== 群 ==================== + + /** + * 获取群总数 + */ + Long getTotalGroupCount(); + + /** + * 获取区间内新建群数 + */ + Long getNewGroupCount(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取群规模分布 Map(key 为分桶名) + */ + Map getGroupSizeCountMap(); + + // ==================== 消息 ==================== + + /** + * 获取区间内私聊消息数 + */ + Long getPrivateMessageCount(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内群聊消息数 + */ + Long getGroupMessageCount(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内每日私聊消息数 Map + */ + Map getPrivateMessageDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内每日群聊消息数 Map + */ + Map getGroupMessageDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内消息类型分布 Map(key 为消息类型) + */ + Map getMessageTypeCountMap(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取区间内 TOP 发送者 Map(key 为 userId,value 为消息数;按消息数倒序) + */ + Map getTopSenderCountMap(LocalDateTime beginTime, LocalDateTime endTime, int limit); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImpl.java new file mode 100644 index 000000000..52516410c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImpl.java @@ -0,0 +1,126 @@ +package cn.iocoder.yudao.module.im.service.statistics; + +import cn.hutool.core.convert.Convert; +import cn.iocoder.yudao.module.im.dal.mysql.statistics.ImStatisticsManagerMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * IM 数据看板 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImStatisticsManagerServiceImpl implements ImStatisticsManagerService { + + @Resource + private ImStatisticsManagerMapper statisticsMapper; + + // ==================== 用户 ==================== + + @Override + public Long getTotalUserCount() { + return statisticsMapper.selectTotalUserCount(); + } + + @Override + public Long getNewUserCount(LocalDateTime beginTime, LocalDateTime endTime) { + return statisticsMapper.selectNewUserCount(beginTime, endTime); + } + + @Override + public Long getActiveUserCount(LocalDateTime beginTime, LocalDateTime endTime) { + return statisticsMapper.selectActiveUserCount(beginTime, endTime); + } + + @Override + public Map getNewUserDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime) { + List> rows = statisticsMapper.selectNewUserDailyCount(beginTime, endTime); + return toDailyCountMap(rows); + } + + @Override + public Map getActiveUserDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime) { + List> rows = statisticsMapper.selectActiveUserDailyCount(beginTime, endTime); + return toDailyCountMap(rows); + } + + // ==================== 群 ==================== + + @Override + public Long getTotalGroupCount() { + return statisticsMapper.selectTotalGroupCount(); + } + + @Override + public Long getNewGroupCount(LocalDateTime beginTime, LocalDateTime endTime) { + return statisticsMapper.selectNewGroupCount(beginTime, endTime); + } + + @Override + public Map getGroupSizeCountMap() { + List> rows = statisticsMapper.selectGroupSizeDistribution(); + return convertMap(rows, + row -> (String) row.get("range"), + row -> Convert.toLong(row.get("count"))); + } + + // ==================== 消息 ==================== + + @Override + public Long getPrivateMessageCount(LocalDateTime beginTime, LocalDateTime endTime) { + return statisticsMapper.selectPrivateMessageCount(beginTime, endTime); + } + + @Override + public Long getGroupMessageCount(LocalDateTime beginTime, LocalDateTime endTime) { + return statisticsMapper.selectGroupMessageCount(beginTime, endTime); + } + + @Override + public Map getPrivateMessageDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime) { + List> rows = statisticsMapper.selectPrivateMessageDailyCount(beginTime, endTime); + return toDailyCountMap(rows); + } + + @Override + public Map getGroupMessageDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime) { + List> rows = statisticsMapper.selectGroupMessageDailyCount(beginTime, endTime); + return toDailyCountMap(rows); + } + + @Override + public Map getMessageTypeCountMap(LocalDateTime beginTime, LocalDateTime endTime) { + List> rows = statisticsMapper.selectMessageTypeDistribution(beginTime, endTime); + return convertMap(rows, + row -> Convert.toInt(row.get("type")), + row -> Convert.toLong(row.get("count"))); + } + + @Override + public Map getTopSenderCountMap(LocalDateTime beginTime, LocalDateTime endTime, int limit) { + List> rows = statisticsMapper.selectTopSenders(beginTime, endTime, limit); + return convertMap(rows, + row -> Convert.toLong(row.get("userId")), + row -> Convert.toLong(row.get("messageCount"))); + } + + /** + * 把 [{date, count}] 行映射为 {LocalDateTime -> Long}; + */ + private static Map toDailyCountMap(List> rows) { + return convertMap(rows, + row -> Convert.convert(LocalDate.class, row.get("date")).atStartOfDay(), + row -> Convert.toLong(row.get("count"))); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java new file mode 100644 index 000000000..aedcfd3b5 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.im.service.websocket; + +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; + +import java.util.Collection; +import java.util.Collections; + +/** + * IM WebSocket 推送 Service 接口 + *

+ * 统一封装 WebSocket 消息推送,事务内调用时提交后异步执行。 + * + * @author 芋道源码 + */ +public interface ImWebSocketService { + + /** + * 异步推送私聊消息给指定用户 + * + * @param userId 目标用户编号 + * @param dto 私聊消息 DTO + */ + default void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) { + sendPrivateMessageAsync(Collections.singleton(userId), dto); + } + + /** + * 异步批量推送私聊消息给多个用户;用于同一份 DTO 扇出到多个收件人 + *

+ * 相比逐个发送,仅注册一次 afterCommit 回调和异步任务。 + * + * @param userIds 目标用户编号列表 + * @param dto 私聊消息 DTO + */ + void sendPrivateMessageAsync(Collection userIds, ImPrivateMessageDTO dto); + + /** + * 异步推送群聊消息给指定用户 + * + * @param userId 目标用户编号 + * @param dto 群聊消息 DTO + */ + default void sendGroupMessageAsync(Long userId, ImGroupMessageDTO dto) { + sendGroupMessageAsync(Collections.singleton(userId), dto); + } + + /** + * 异步批量推送群聊消息给多个用户 + * + * @param userIds 目标用户编号列表 + * @param dto 群聊消息 DTO + */ + void sendGroupMessageAsync(Collection userIds, ImGroupMessageDTO dto); + + /** + * 异步推送频道消息给指定用户 + * + * @param userId 目标用户编号 + * @param dto 频道消息 DTO + */ + default void sendChannelMessageAsync(Long userId, ImChannelMessageDTO dto) { + sendChannelMessageAsync(Collections.singleton(userId), dto); + } + + /** + * 异步批量推送频道消息给多个用户 + * + * @param userIds 目标用户编号列表 + * @param dto 频道消息 DTO + */ + void sendChannelMessageAsync(Collection userIds, ImChannelMessageDTO dto); + + /** + * 异步广播频道消息给当前所有在线管理端用户;用于全员推送 + * + * @param dto 频道消息 DTO + */ + void broadcastChannelMessageAsync(ImChannelMessageDTO dto); + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java new file mode 100644 index 000000000..2bf32229f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java @@ -0,0 +1,154 @@ +package cn.iocoder.yudao.module.im.service.websocket; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertLinkedSet; + +/** + * IM WebSocket 推送 Service 实现类 + *

+ * 当调用方处于事务中时,推送会延迟到事务提交后再异步执行, + * 避免客户端收到 WebSocket 消息时数据库变更尚未可见。 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImWebSocketServiceImpl implements ImWebSocketService { + + @Resource + private WebSocketMessageSender webSocketMessageSender; + + @Override + public void sendPrivateMessageAsync(Collection userIds, ImPrivateMessageDTO dto) { + // 说明:通过 executeAfterTransaction 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交 + // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理) + executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userIds, dto)); + } + + @Override + public void sendGroupMessageAsync(Collection userIds, ImGroupMessageDTO dto) { + executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto)); + } + + @Override + public void sendChannelMessageAsync(Collection userIds, ImChannelMessageDTO dto) { + executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto)); + } + + @Override + public void broadcastChannelMessageAsync(ImChannelMessageDTO dto) { + executeAfterTransaction(() -> getSelf().doBroadcastChannelMessage(dto)); + } + + /** + * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调 + */ + @Async + public void doSendPrivateMessage(Collection userIds, ImPrivateMessageDTO dto) { + for (Long userId : getDistinctUserIds(userIds)) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImPrivateMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + } + } + } + + /** + * 异步发送群聊 WebSocket 消息 + */ + @Async + public void doSendGroupMessage(Collection userIds, ImGroupMessageDTO dto) { + for (Long userId : getDistinctUserIds(userIds)) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImGroupMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doSendGroupMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + } + } + } + + /** + * 异步发送频道 WebSocket 消息 + */ + @Async + public void doSendChannelMessage(Collection userIds, ImChannelMessageDTO dto) { + for (Long userId : getDistinctUserIds(userIds)) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImChannelMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doSendChannelMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + } + } + } + + /** + * 异步广播频道 WebSocket 消息给当前所有在线管理端用户; + * 依赖 WebSocketMessageSender 按 UserType 广播能力,离线用户由客户端上线 pull 兜底 + */ + @Async + public void doBroadcastChannelMessage(ImChannelMessageDTO dto) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), + ImChannelMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doBroadcastChannelMessage][dto({}) 广播失败]", dto, e); + } + } + + private static Set getDistinctUserIds(Collection userIds) { + return convertLinkedSet(userIds, userId -> userId); + } + + /** + * 事务感知的任务调度: + * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 + * - 无事务:直接执行(如非 @Transactional 方法中的调用) + * + * @param task 待执行的推送任务(内部通过 getSelf() 走 @Async 异步执行) + */ + private void executeAfterTransaction(Runnable task) { + // 情况一:没有事务,直接执行 + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + // 情况二:有事务,注册 afterCommit 事件,在事务提交后执行 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + task.run(); + } + + }); + } + + /** + * 获得自身的代理对象,解决 @Async AOP 代理问题 + */ + private ImWebSocketServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java new file mode 100644 index 000000000..0478cd2ce --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +/** + * IM 频道消息 WebSocket 推送 DTO + *

+ * 单向:服务端运营推送 → C 端用户;C 端不能向频道发消息。 + * 字段分层:顶层是消息元数据 + 检索维度,content 是 MaterialMessage payload 的 JSON 串。 + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class ImChannelMessageDTO { + + public static final String TYPE = "im-channel-message"; + + /** + * 消息编号 + */ + private Long id; + /** + * 频道编号 + */ + private Long channelId; + /** + * 关联素材编号 + */ + private Long materialId; + /** + * 消息类型 + */ + private Integer type; + /** + * 消息内容;payload JSON 串 + */ + private String content; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + + /** + * 由频道消息 DO 构建推送 DTO + */ + public static ImChannelMessageDTO ofSend(ImChannelMessageDO message) { + return BeanUtils.toBean(message, ImChannelMessageDTO.class); + } + + /** + * 构建已读同步 DTO;多端同步:通知自己的其他终端「我已经读了某频道」 + * + * @param channelId 频道编号 + * @param readId 已读位置(最大已读消息编号) + * @return 频道 DTO + */ + public static ImChannelMessageDTO ofRead(Long channelId, Long readId) { + return new ImChannelMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.READ.getType()) + .setChannelId(channelId); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java new file mode 100644 index 000000000..254df98f5 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java @@ -0,0 +1,123 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 群聊消息 WebSocket 统一推送 DTO + *

+ * 发送、已读、回执都通过 {@link #type} 字段区分, + * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 + * + * @author 芋道源码 + */ +@Data +public class ImGroupMessageDTO { + + public static final String TYPE = "im-group-message"; + + /** + * 消息编号 + *

+ * 普通消息:当前消息 id + * READ:无(群已读不需要) + * RECEIPT:需要回执的目标消息 id + */ + private Long id; + /** + * 客户端消息编号 + */ + private String clientMessageId; + /** + * 发送人编号 + */ + private Long senderId; + /** + * 群编号 + */ + private Long groupId; + /** + * 消息类型 + */ + private Integer type; + /** + * 消息内容 + */ + private String content; + /** + * 消息状态 + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + /** + * @ 目标用户编号列表 + */ + private List atUserIds; + /** + * 定向接收用户编号列表 + */ + private List receiverUserIds; + /** + * 群回执状态 + */ + private Integer receiptStatus; + /** + * 群回执已读人数 + */ + private Integer readCount; + /** + * 已读位置 + */ + private Long readId; + + // ========== 静态工厂方法 ========== + + /** + * 构建发送消息 DTO + * + * @param message 群聊消息 DO + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofSend(ImGroupMessageDO message) { + return BeanUtils.toBean(message, ImGroupMessageDTO.class).setReceiverUserIds(null); + } + + /** + * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个群") + * + * @param senderId 当前用户编号 + * @param groupId 群编号 + * @param readId 已读位置(最大已读消息编号) + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) { + return new ImGroupMessageDTO() + .setId(readId).setReadId(readId).setType(ImMessageTypeEnum.READ.getType()) + .setSenderId(senderId).setGroupId(groupId); + } + + /** + * 构建群回执 DTO(广播回执状态和已读人数) + * + * @param messageId 消息编号 + * @param groupId 群编号 + * @param readCount 已读人数 + * @param receiptStatus 回执状态 + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofReceipt(Long messageId, Long groupId, + Integer readCount, Integer receiptStatus) { + return new ImGroupMessageDTO() + .setId(messageId).setType(ImMessageTypeEnum.RECEIPT.getType()) + .setGroupId(groupId).setReadCount(readCount).setReceiptStatus(receiptStatus); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java new file mode 100644 index 000000000..7c708d959 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java @@ -0,0 +1,176 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto; + +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.BaseFriendNotification; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.BaseGroupNotification; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +/** + * IM 私聊消息 WebSocket 统一推送 DTO + *

+ * 发送、已读、回执、撤回都通过 {@link #type} 字段区分, + * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class ImPrivateMessageDTO { + + public static final String TYPE = "im-private-message"; + + /** + * 消息编号 + *

+ * 普通消息:当前消息 id + * READ:已读位置(我已读到这条消息) + * RECEIPT:已读位置(对方已读到这条消息) + * RECALL:被撤回的原消息 id + */ + private Long id; + /** + * 客户端消息编号 + */ + private String clientMessageId; + /** + * 发送人编号 + */ + private Long senderId; + /** + * 接收人编号 + */ + private Long receiverId; + /** + * 消息类型 + */ + private Integer type; + /** + * 消息内容 + */ + private String content; + /** + * 消息状态 + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + + // ========== 静态工厂方法 ========== + + /** + * 构建发送消息 DTO + * + * @param message 私聊消息 DO + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofSend(ImPrivateMessageDO message) { + return BeanUtils.toBean(message, ImPrivateMessageDTO.class); + } + + /** + * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个会话") + * + * @param senderId 当前用户编号 + * @param receiverId 对方用户编号 + * @param readId 已读位置(最大已读消息编号) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofRead(Long senderId, Long receiverId, Long readId) { + return new ImPrivateMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.READ.getType()) + .setSenderId(senderId).setReceiverId(receiverId); + } + + /** + * 构建已读回执 DTO(通知对方"我已读了你的消息") + * + * @param senderId 已读方的用户编号 + * @param receiverId 对方用户编号 + * @param readId 已读位置(最大已读消息编号) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofReceipt(Long senderId, Long receiverId, Long readId) { + return new ImPrivateMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.RECEIPT.getType()) + .setSenderId(senderId).setReceiverId(receiverId); + } + + // ==================== 好友变更相关 ==================== + + /** + * 构建好友通知推送 DTO(统一入口) + * + * @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 FRIEND_* 段 + * @param operatorUserId 操作人用户编号;同时作为帧的 senderId 用于定位接收端 friendUserId + * @param receiverUserId 推送目标用户编号 + * @param payload 好友通知 payload(继承 {@link BaseFriendNotification}) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofFriendNotification(Integer type, Long operatorUserId, + Long receiverUserId, BaseFriendNotification payload) { + validateNotification(type, payload, ImMessageTypeEnum.isFriendNotification(type)); + return new ImPrivateMessageDTO().setType(type) + .setSenderId(operatorUserId).setReceiverId(receiverUserId) + .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); + } + + // ==================== 群定向私聊通知 ==================== + + // TODO DONE @AI:群申请定向通知继续走私聊通道 + /** + * 构建群通知推送 DTO(走私聊通道定向推送,不入群消息流) + *

+ * 用于 GROUP_REQUEST_RECEIVED / GROUP_REQUEST_APPROVED / GROUP_REQUEST_REJECTED 段位; + * 与 {@link cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO} 走 sendGroupMessage 群广播路径不同 + * + * @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 GROUP_REQUEST_* 段 + * @param operatorUserId 操作人用户编号;同时作为帧的 senderId + * @param receiverUserId 推送目标用户编号 + * @param payload 群事件 payload(继承 {@link BaseGroupNotification}) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofGroupNotification(Integer type, Long operatorUserId, + Long receiverUserId, BaseGroupNotification payload) { + validateNotification(type, payload, ImMessageTypeEnum.isGroupRequestNotification(type)); + return new ImPrivateMessageDTO().setType(type) + .setSenderId(operatorUserId).setReceiverId(receiverUserId) + .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); + } + + // ==================== 实时通话信令 ==================== + + /** + * 构建通话信令推送 DTO(走私聊通道仅推参与方,不入消息流) + *

+ * 用于 RTC_CALL(INVITE / REJECT 等)/ RTC_PARTICIPANT_CONNECTED / RTC_PARTICIPANT_DISCONNECTED + * + * @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 RTC_* 段 + * @param senderId 发送人编号;INVITE 时为发起人,REJECT 时为拒绝者,参与者事件时为加入 / 离开者 + * @param receiverUserId 推送目标用户编号 + * @param payload 通话事件 payload(任意 RTC 通知 DTO) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofRtcNotification(Integer type, Long senderId, + Long receiverUserId, Object payload) { + validateNotification(type, payload, ImMessageTypeEnum.isRtcNotification(type)); + return new ImPrivateMessageDTO().setType(type) + .setSenderId(senderId).setReceiverId(receiverUserId) + .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); + } + + private static void validateNotification(Integer type, Object payload, boolean supported) { + Assert.notNull(type, "消息类型不能为空"); + Assert.notNull(payload, "消息内容不能为空"); + Assert.isTrue(supported, "消息类型不匹配 type={}", type); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java new file mode 100644 index 000000000..dd37cc01c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 语音消息内容 + */ +@Data +public class AudioMessage { + + /** + * 语音文件 URL + */ + private String url; + /** + * 语音时长(秒) + */ + private Integer duration; + /** + * 文件大小(字节) + */ + private Long size; + + /** + * 引用消息(可选) + */ + private QuoteMessage quote; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java new file mode 100644 index 000000000..39bef394c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import lombok.Data; + +/** + * 名片消息内容 + *

+ * 支持两类名片,按 targetType 区分: + * 1. 用户名片(targetType = PRIVATE):targetId = 用户编号,name = 用户昵称 + * 2. 群名片(targetType = GROUP):targetId = 群编号,name = 群名,可带 memberCount + */ +@Data +public class CardMessage { + + /** + * 名片对象类型 + *

+ * 枚举 {@link ImConversationTypeEnum} + */ + private Integer targetType; + /** + * 目标对象编号:PRIVATE 时 = 用户编号;GROUP 时 = 群编号 + */ + private Long targetId; + /** + * 显示名快照:PRIVATE 时 = 用户昵称;GROUP 时 = 群名 + */ + private String name; + /** + * 头像(快照) + */ + private String avatar; + /** + * 群成员数(仅 targetType = GROUP;接收端展示「N 人群聊」) + */ + private Integer memberCount; + + /** + * 引用消息(可选) + */ + private QuoteMessage quote; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FaceMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FaceMessage.java new file mode 100644 index 000000000..f5c6b6c4a --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FaceMessage.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 表情消息内容 + *

+ * 承载「贴图 / 自定义表情包」类消息;Unicode emoji(😀😂)仍走 TEXT + */ +@Data +public class FaceMessage { + + /** + * 表情图 URL + */ + private String url; + /** + * 渲染宽度(像素,可选) + *

+ * 客户端渲染时按此尺寸占位,避免图片加载完成后的布局抖动;缺失时由客户端 CSS max-width 兜底 + */ + private Integer width; + /** + * 渲染高度(像素,可选) + */ + private Integer height; + /** + * 表情名(可选) + *

+ * 系统表情包通常带名字(如「狗头」「捂脸」),用户私有表情包通常为空 + */ + private String name; + + /** + * 引用消息(可选) + */ + private QuoteMessage quote; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java new file mode 100644 index 000000000..7feb9ff41 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 文件消息内容 + */ +@Data +public class FileMessage { + + /** + * 文件 URL + */ + private String url; + /** + * 文件名 + */ + private String name; + /** + * 文件大小(字节) + */ + private Long size; + /** + * 文件类型(扩展名) + */ + private String type; + + /** + * 引用消息(可选) + */ + private QuoteMessage quote; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java new file mode 100644 index 000000000..bc8e41ec4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 图片消息内容 + */ +@Data +public class ImageMessage { + + /** + * 图片 URL + */ + private String url; + /** + * 缩略图 URL + */ + private String thumbnailUrl; + /** + * 图片宽度 + */ + private Integer width; + /** + * 图片高度 + */ + private Integer height; + /** + * 图片大小(字节) + */ + private Long size; + + /** + * 引用消息(可选) + */ + private QuoteMessage quote; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java new file mode 100644 index 000000000..8c36d1afa --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 位置消息内容 + */ +@Data +public class LocationMessage { + + /** + * 位置描述 + */ + private String description; + /** + * 经度 + */ + private Double longitude; + /** + * 纬度 + */ + private Double latitude; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MaterialMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MaterialMessage.java new file mode 100644 index 000000000..9ef43087b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MaterialMessage.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 频道素材消息 payload + *

+ * 对应 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum#MATERIAL}(type=125)。 + * 客户端按本字段集渲染图文卡片; + * 富文本正文不在本 payload 中传递,点击详情时另调 /im/channel/material/get-content?id= 按需拉取。 + */ +@Data +@Accessors(chain = true) +public class MaterialMessage { + + /** + * 素材编号 + */ + private Long materialId; + /** + * 频道编号 + *

+ * 冗余到 payload 内;转发到私聊 / 群聊后客户端用本字段定位频道,渲染卡片底部的频道头像 + 名称 + */ + private Long channelId; + /** + * 素材内容类型 + *

+ * 枚举 {@link cn.iocoder.yudao.module.im.enums.channel.ImChannelMaterialTypeEnum} + * 客户端按本字段判定点击行为:CONTENT 走站内详情页拉富文本;LINK 跳 url + */ + private Integer type; + /** + * 标题 + */ + private String title; + /** + * 封面图 + */ + private String coverUrl; + /** + * 摘要 + */ + private String summary; + /** + * 跳转链接 + */ + private String url; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MergeMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MergeMessage.java new file mode 100644 index 000000000..62a051d7b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MergeMessage.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; + +import java.util.List; + +/** + * 合并转发消息内容 + *

+ * payload 内嵌完整快照,原消息撤回 / 删除不影响已转发的合并消息 + */ +@Data +public class MergeMessage { + + /** + * 合并标题 + */ + private String title; + /** + * 内嵌的完整消息快照 + */ + private List messages; + + /** + * 单条内嵌消息快照 + */ + @Data + public static class Item { + + /** + * 原消息编号 + */ + private Long messageId; + /** + * 发送人编号 + */ + private Long senderId; + /** + * 发送人昵称快照;接收方可能不在原会话里,无法实时查到 + */ + private String senderNickname; + /** + * 发送人头像快照 + */ + private String senderAvatar; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 原消息 content(JSON) + */ + private String content; + /** + * 发送时间戳(毫秒) + */ + private Long sendTime; + + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/QuoteMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/QuoteMessage.java new file mode 100644 index 000000000..20fa9f3e1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/QuoteMessage.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 引用消息内容 + *

+ * 客户端在 content.quote 里写入 messageId 表达"引用了哪条消息",服务端反查原消息后重算 QuoteMessage 覆盖客户端写入, + * 避免伪造发送人 / 类型 / 摘要 + * + * @see TextMessage 等等消息内容里 quote 字段 + */ +@Data +@Accessors(chain = true) +public class QuoteMessage { + + /** + * content JSON 里 quote 字段名 + */ + public static final String FIELD_NAME = "quote"; + + /** + * QuoteMessage 里 messageId 字段名 + */ + public static final String FIELD_MESSAGE_ID = "messageId"; + + /** + * 被引用消息编号 + */ + private Long messageId; + /** + * 被引用消息发送人编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long senderId; + /** + * 被引用消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 原消息完整 content(JSON);服务端复制时会 removeQuote 防嵌套 + */ + private String content; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java new file mode 100644 index 000000000..5df78f20b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 撤回提示消息内容 + */ +@Data +@Accessors(chain = true) +public class RecallMessage { + + /** + * 被撤回的原消息编号 + */ + private Long messageId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java new file mode 100644 index 000000000..9d54b7d13 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 文本消息内容 + */ +@Data +public class TextMessage { + + /** + * 文本内容 + */ + private String content; + + /** + * 引用消息(可选) + */ + private QuoteMessage quote; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java new file mode 100644 index 000000000..a5ba53a4c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 视频消息内容 + */ +@Data +public class VideoMessage { + + /** + * 视频文件 URL + */ + private String url; + /** + * 视频封面 URL + */ + private String coverUrl; + /** + * 视频时长(秒) + */ + private Integer duration; + /** + * 视频宽度 + */ + private Integer width; + /** + * 视频高度 + */ + private Integer height; + /** + * 文件大小(字节) + */ + private Long size; + + /** + * 引用消息(可选) + */ + private QuoteMessage quote; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/BaseFriendNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/BaseFriendNotification.java new file mode 100644 index 000000000..4ee8f8716 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/BaseFriendNotification.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 好友事件通知基类 + *

+ * 所有好友事件 payload 共享: + * - operatorUserId 标识"谁触发的",用于多端同步时本端识别"是我自己触发的"还是"对方触发的" + * - friendUserId 标识"好友是谁",前端按它定位 / 更新本地 friend 缓存 + */ +@Data +public abstract class BaseFriendNotification { + + /** + * 操作人用户编号 + */ + private Long operatorUserId; + /** + * 好友用户编号 + */ + private Long friendUserId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendAddNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendAddNotification.java new file mode 100644 index 000000000..589217427 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendAddNotification.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 新增好友通知 + *

+ * 双向建立好友关系后,推送给 A、B 双方多端;前端拉取好友信息入库 + */ +@Data +public class FriendAddNotification extends BaseFriendNotification { + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendBlockNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendBlockNotification.java new file mode 100644 index 000000000..f8cb69e0d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendBlockNotification.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 加入黑名单通知 + *

+ * A 拉黑 B 后仅推 A 多端;B 端不感知(B 那边 blocked=0 不变) + */ +@Data +public class FriendBlockNotification extends BaseFriendNotification { + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendDeleteNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendDeleteNotification.java new file mode 100644 index 000000000..7d8ab9ffe --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendDeleteNotification.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 好友删除通知 + *

+ * 仅推送给操作人多端做同步(对端不感知,与单边删除语义对齐);前端清除本地好友 + 按 clear 决定级联清理 + */ +@Data +public class FriendDeleteNotification extends BaseFriendNotification { + + /** + * 是否级联清理本端相关数据(当前包含私聊会话;未来可能扩展更多 clear 项) + */ + private Boolean clear; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendInfoUpdatedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendInfoUpdatedNotification.java new file mode 100644 index 000000000..8f8148925 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendInfoUpdatedNotification.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 好友资料变更通知(对端改了昵称 / 头像) + *

+ * 由 AdminUserProfileUpdateConsumer 监听 system 模块的 AdminUserProfileUpdateMessage 后,批量推送给资料被改的人的所有好友; + * 前端 dispatcher 收到后调 loadFriendInfo 重拉资料 + *

+ * 此处 friendUserId 表示「资料被更新的那个人」(即对端),与 operatorUserId 一致 + */ +@Data +public class FriendInfoUpdatedNotification extends BaseFriendNotification { + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestApprovedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestApprovedNotification.java new file mode 100644 index 000000000..bc68f4f8c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestApprovedNotification.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 好友申请被同意通知 + *

+ * 推送给原申请发起方多端;前端按 requestId 把对应申请记录 handleResult 更新为「已同意」 + */ +@Data +public class FriendRequestApprovedNotification extends BaseFriendNotification { + + /** + * 已处理的申请记录编号 + */ + private Long requestId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestNotification.java new file mode 100644 index 000000000..924e895eb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestNotification.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 收到新的好友申请通知 + *

+ * 推送给申请的接收方多端;payload 已携带 fromUser 聚合字段,前端按 requestId 直接 push 进列表,无需回拉 + */ +@Data +public class FriendRequestNotification extends BaseFriendNotification { + + /** + * 申请记录编号 + */ + private Long requestId; + /** + * 申请理由 + */ + private String applyContent; + /** + * 添加来源 + */ + private Integer addSource; + + // ========== 聚合自 AdminUser,避免前端再调 system 接口 ========== + + /** + * 申请方昵称 + */ + private String fromNickname; + /** + * 申请方头像 + */ + private String fromAvatar; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestRejectedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestRejectedNotification.java new file mode 100644 index 000000000..afe0a18bd --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestRejectedNotification.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 好友申请被拒绝通知 + *

+ * 推送给原申请发起方多端;前端按 requestId 把对应申请记录 handleResult 更新为「已拒绝」 + */ +@Data +public class FriendRequestRejectedNotification extends BaseFriendNotification { + + /** + * 已处理的申请记录编号 + */ + private Long requestId; + /** + * 拒绝理由(可选) + */ + private String handleContent; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUnblockNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUnblockNotification.java new file mode 100644 index 000000000..a57f00d82 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUnblockNotification.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 移出黑名单通知 + *

+ * A 把 B 移出黑名单后仅推 A 多端 + */ +@Data +public class FriendUnblockNotification extends BaseFriendNotification { + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUpdateNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUpdateNotification.java new file mode 100644 index 000000000..a1a52d6fb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUpdateNotification.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend; + +import lombok.Data; + +/** + * 好友信息批量更新通知(备注 / 免打扰 / 联系人置顶等单边属性) + *

+ * A 改了对 B 的备注 / 免打扰 / 置顶等单边属性后仅推 A 多端做同步; + * 一次 update 涉及多个字段时合并为单条通知,避免多通知顺序竞争。 + */ +@Data +public class FriendUpdateNotification extends BaseFriendNotification { + + /** + * 备注;不为空则更新(空串表示清空) + */ + private String displayName; + /** + * 免打扰;不为空则更新 + */ + private Boolean silent; + /** + * 联系人置顶;不为空则更新 + */ + private Boolean pinned; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/BaseGroupNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/BaseGroupNotification.java new file mode 100644 index 000000000..3e916b1da --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/BaseGroupNotification.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群事件通知基类:所有群事件 payload 共享 operatorUserId + */ +@Data +public abstract class BaseGroupNotification { + + /** + * 操作人用户编号 + */ + private Long operatorUserId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminAddNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminAddNotification.java new file mode 100644 index 000000000..cc31ad7dd --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminAddNotification.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +/** + * 添加管理员事件通知(memberUserIds 为被设管理员的成员) + */ +public class GroupAdminAddNotification extends GroupMemberListNotification { +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminRemoveNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminRemoveNotification.java new file mode 100644 index 000000000..0f0e8d219 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminRemoveNotification.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +/** + * 撤销管理员事件通知(memberUserIds 为被撤销管理员的成员) + */ +public class GroupAdminRemoveNotification extends GroupMemberListNotification { +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupBannedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupBannedNotification.java new file mode 100644 index 000000000..bdfe9d5b9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupBannedNotification.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群封禁 / 解封通知 + */ +@Data +public class GroupBannedNotification extends BaseGroupNotification { + + /** + * 是否封禁 + */ + private Boolean banned; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCancelMutedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCancelMutedNotification.java new file mode 100644 index 000000000..bddf74448 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCancelMutedNotification.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 全群取消禁言通知 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class GroupCancelMutedNotification extends BaseGroupNotification { + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCreateNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCreateNotification.java new file mode 100644 index 000000000..b7e65630b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCreateNotification.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +/** + * 群创建事件通知(memberUserIds 含创建者 + 初始邀请成员) + */ +public class GroupCreateNotification extends GroupMemberListNotification { +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupDissolveNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupDissolveNotification.java new file mode 100644 index 000000000..bec91faad --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupDissolveNotification.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +/** + * 群解散事件通知 + */ +public class GroupDissolveNotification extends BaseGroupNotification { +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupInfoUpdateNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupInfoUpdateNotification.java new file mode 100644 index 000000000..70a073d09 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupInfoUpdateNotification.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群信息变更事件通知(当前承载头像变更,NAME / NOTICE 走独立事件) + */ +@Data +public class GroupInfoUpdateNotification extends BaseGroupNotification { + + /** + * 旧群头像 + */ + private String oldAvatar; + /** + * 新群头像 + */ + private String newAvatar; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberCancelMutedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberCancelMutedNotification.java new file mode 100644 index 000000000..48df8fc36 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberCancelMutedNotification.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 取消成员禁言通知 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class GroupMemberCancelMutedNotification extends BaseGroupNotification { + + /** + * 被取消禁言的用户编号 + */ + private Long mutedUserId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java new file mode 100644 index 000000000..32ff290e1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 自由进群事件通知 + *

+ * 用户经搜索 / 二维码 / 分享链接自由进群(FREE 模式或审批通过后),全员广播; + * 进群者前端按 entrantUserId === self 自判,初次拉取 fetchGroupInfo + fetchGroupMembers;其余成员局部插入新成员 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class GroupMemberEnterNotification extends BaseGroupNotification { + + /** + * 进群者用户编号 + */ + private Long entrantUserId; + /** + * 加入来源 + */ + private Integer addSource; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberInviteNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberInviteNotification.java new file mode 100644 index 000000000..5877ed18b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberInviteNotification.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +/** + * 成员加入事件通知(memberUserIds 为被邀请人) + */ +public class GroupMemberInviteNotification extends GroupMemberListNotification { +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberKickNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberKickNotification.java new file mode 100644 index 000000000..30bbc34fc --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberKickNotification.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +/** + * 成员被移出事件通知(memberUserIds 为被移出成员) + */ +public class GroupMemberKickNotification extends GroupMemberListNotification { +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberListNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberListNotification.java new file mode 100644 index 000000000..46018586c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberListNotification.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +import java.util.List; + +/** + * 群事件成员列表通知基类 + * + * @author 芋道源码 + */ +@Data +public abstract class GroupMemberListNotification extends BaseGroupNotification { + + /** + * 受影响的成员用户编号列表 + */ + private List memberUserIds; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberMutedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberMutedNotification.java new file mode 100644 index 000000000..557d901e9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberMutedNotification.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 成员禁言通知 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class GroupMemberMutedNotification extends BaseGroupNotification { + + /** + * 被禁言的用户编号 + */ + private Long mutedUserId; + /** + * 禁言到期时间 + */ + private LocalDateTime muteEndTime; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberNicknameUpdateNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberNicknameUpdateNotification.java new file mode 100644 index 000000000..0fbe63f8c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberNicknameUpdateNotification.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 成员组内昵称变更事件通知 + */ +@Data +public class GroupMemberNicknameUpdateNotification extends BaseGroupNotification { + + /** + * 群内昵称 + */ + private String displayUserName; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberQuitNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberQuitNotification.java new file mode 100644 index 000000000..cab23a5df --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberQuitNotification.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +/** + * 成员退群事件通知 + */ +public class GroupMemberQuitNotification extends BaseGroupNotification { +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberSettingUpdateNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberSettingUpdateNotification.java new file mode 100644 index 000000000..e7eefcbf0 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberSettingUpdateNotification.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群成员个人设置变更事件通知(个人多端同步) + *

+ * silent / groupRemark 字段 null 表示本次未变更,前端按非 null 局部更新 + */ +@Data +public class GroupMemberSettingUpdateNotification extends BaseGroupNotification { + + /** + * 群免打扰 + */ + private Boolean silent; + /** + * 群备注 + */ + private String groupRemark; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessagePinNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessagePinNotification.java new file mode 100644 index 000000000..6dd5ebc0d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessagePinNotification.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 群消息置顶事件通知 + */ +@Data +public class GroupMessagePinNotification extends BaseGroupNotification { + + /** + * 被置顶的消息编号 + */ + private Long messageId; + /** + * 被置顶的消息展示数据 + */ + private PinnedMessage message; + + /** + * 被置顶的消息展示数据 + */ + @Data + public static class PinnedMessage { + + /** + * 消息编号 + */ + private Long id; + /** + * 发送人编号 + */ + private Long senderId; + /** + * 群编号 + */ + private Long groupId; + /** + * 消息类型 + */ + private Integer type; + /** + * 消息内容 + */ + private String content; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + /** + * @ 目标用户编号列表 + */ + private List atUserIds; + /** + * 定向接收用户编号列表 + */ + private List receiverUserIds; + + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessageUnpinNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessageUnpinNotification.java new file mode 100644 index 000000000..1185b5cd7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessageUnpinNotification.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群消息取消置顶事件通知 + */ +@Data +public class GroupMessageUnpinNotification extends BaseGroupNotification { + + /** + * 被取消置顶的消息编号 + */ + private Long messageId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMutedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMutedNotification.java new file mode 100644 index 000000000..2200766fd --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMutedNotification.java @@ -0,0 +1,11 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 全群禁言通知 + */ +@Data +public class GroupMutedNotification extends BaseGroupNotification { + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNameUpdateNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNameUpdateNotification.java new file mode 100644 index 000000000..689ef2401 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNameUpdateNotification.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群名变更事件通知 + */ +@Data +public class GroupNameUpdateNotification extends BaseGroupNotification { + + /** + * 旧群名 + */ + private String oldName; + /** + * 新群名 + */ + private String newName; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNoticeUpdateNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNoticeUpdateNotification.java new file mode 100644 index 000000000..c2afa6cfb --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNoticeUpdateNotification.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群公告变更事件通知 + */ +@Data +public class GroupNoticeUpdateNotification extends BaseGroupNotification { + + /** + * 旧群公告 + */ + private String oldNotice; + /** + * 新群公告 + */ + private String newNotice; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupOwnerTransferNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupOwnerTransferNotification.java new file mode 100644 index 000000000..612a76406 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupOwnerTransferNotification.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; + +/** + * 群主转让事件通知 + */ +@Data +public class GroupOwnerTransferNotification extends BaseGroupNotification { + + /** + * 新群主用户编号 + */ + private Long newOwnerUserId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestApprovedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestApprovedNotification.java new file mode 100644 index 000000000..79651923d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestApprovedNotification.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 入群申请被同意通知 + *

+ * 走私聊通道定向推送给申请人 + 群主 + 全部管理员;admin 侧据此把 unhandledCount 减 1 并从未处理列表移除 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class GroupRequestApprovedNotification extends BaseGroupNotification { + + /** + * 已处理的申请记录编号 + */ + private Long requestId; + /** + * 群编号 + */ + private Long groupId; + /** + * 申请人 / 被邀请人用户编号 + */ + private Long userId; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestReceivedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestReceivedNotification.java new file mode 100644 index 000000000..a5d1f9053 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestReceivedNotification.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 收到新的入群申请通知 + *

+ * 走私聊通道定向推送给群主 + 全部管理员(多端同步);payload 已携带申请方昵称 / 头像,前端按 requestId 直接 push 进列表 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class GroupRequestReceivedNotification extends BaseGroupNotification { + + /** + * 申请记录编号 + */ + private Long requestId; + /** + * 群编号 + */ + private Long groupId; + /** + * 申请人 / 被邀请人用户编号 + */ + private Long userId; + /** + * 邀请人用户编号;NULL 表示用户主动申请 + */ + private Long inviterUserId; + /** + * 申请理由 + */ + private String applyContent; + /** + * 加入来源 + */ + private Integer addSource; + + // ========== 聚合自 AdminUser,避免前端再调 system 接口 ========== + + /** + * 申请方 / 被邀请人昵称 + */ + private String userNickname; + /** + * 申请方 / 被邀请人头像 + */ + private String userAvatar; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestRejectedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestRejectedNotification.java new file mode 100644 index 000000000..e302085d6 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupRequestRejectedNotification.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 入群申请被拒绝通知 + *

+ * 走私聊通道定向推送给申请人 + 群主 + 全部管理员;admin 侧据此把 unhandledCount 减 1 并从未处理列表移除 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class GroupRequestRejectedNotification extends BaseGroupNotification { + + /** + * 已处理的申请记录编号 + */ + private Long requestId; + /** + * 群编号 + */ + private Long groupId; + /** + * 申请人 / 被邀请人用户编号 + */ + private Long userId; + /** + * 拒绝理由(可选) + */ + private String handleContent; + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java new file mode 100644 index 000000000..f0864e1cf --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc; + +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import lombok.Data; + +/** + * RTC_CALL_END 通话结束通知 + *

+ * 入 im_private_message / im_group_message;接收方关闭通话窗 + 渲染聊天 tip + *

+ * 文案分场景: + * 群聊:「语音通话已经结束」;发起人信息走配对的 {@link ImRtcCallStartNotification} + * 私聊:仿微信准气泡,按 endReason × selfSend 视角转换文案(HANGUP / CANCEL / REJECT / BUSY / ERROR) + *

+ * 与 {@link ImRtcCallStartNotification} 两段式配对:START 在 invite 事务里 INSERT,END 在 cancel / leave 事务里 INSERT; + * 两段位于不同请求 / 事务,自增 id 保证聊天流顺序 + * + * @author 芋道源码 + */ +@Data +public class ImRtcCallEndNotification { + + /** + * 业务通话编号 + */ + private String room; + /** + * 会话类型 + */ + private Integer conversationType; + /** + * 媒体类型 + */ + private Integer mediaType; + /** + * 结束原因 + */ + private Integer endReason; + /** + * 通话时长(秒):接通过为 endTime - acceptTime;未接通为 null + */ + private Long durationSeconds; + /** + * 操作者用户编号:HANGUP / CANCEL / REJECT 是触发结束的人;HANGUP webhook 兜底为 null + *

+ * 用于前端「被某某挂断」类文案;普通文案不依赖此字段 + */ + private Long operatorUserId; + /** + * 操作者昵称:前端按需展示(被某某挂断 / 头像 tip);操作者为空时随之为空 + */ + private String operatorNickname; + /** + * 操作者头像:前端按需展示;操作者为空时随之为空 + */ + private String operatorAvatar; + + public static ImRtcCallEndNotification of(ImRtcCallDO call, ImRtcCallEndReasonEnum reason, Long durationSeconds, + Long operatorId, AdminUserRespDTO operator) { + ImRtcCallEndNotification notification = new ImRtcCallEndNotification(); + notification.room = call.getRoom(); + notification.conversationType = call.getConversationType(); + notification.mediaType = call.getMediaType(); + notification.endReason = reason.getReason(); + notification.durationSeconds = durationSeconds; + notification.operatorUserId = operatorId; + if (operator != null) { + notification.operatorNickname = operator.getNickname(); + notification.operatorAvatar = operator.getAvatar(); + } + return notification; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallNotification.java new file mode 100644 index 000000000..efa8037de --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallNotification.java @@ -0,0 +1,165 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc; + +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import lombok.Data; + +import java.util.Collection; +import java.util.List; + +/** + * RTC_CALL 通话信令通知(通话信令统一入口) + *

+ * 不入库;走 imWebSocketService 仅推参与方 + *

+ * status 字段复用 {@link ImRtcParticipantStatusEnum},表达「本次信令对应的参与者状态变迁」 + * + * @author 芋道源码 + */ +@Data +public class ImRtcCallNotification { + + /** + * 信令对应的参与者状态 + * + * 取值参见 {@link ImRtcParticipantStatusEnum} + */ + private Integer status; + + /** + * 业务通话编号 + */ + private String room; + /** + * 会话类型 + */ + private Integer conversationType; + /** + * 媒体类型 + */ + private Integer mediaType; + /** + * 群编号:群通话场景必填 + */ + private Long groupId; + + // ========== INVITE 专属字段 ========== + + /** + * LiveKit Server WebSocket 地址;INVITE 专属 + */ + private String livekitUrl; + /** + * 该被叫专属的 LiveKit 接听 Token;接通后直接 connect 用;INVITE 专属 + */ + private String token; + /** + * 发起人用户编号;INVITE 专属 + */ + private Long inviterUserId; + /** + * 发起人昵称;INVITE 专属,前端来电界面展示 + */ + private String inviterNickname; + /** + * 发起人头像;INVITE 专属,前端来电界面展示 + */ + private String inviterAvatar; + /** + * 本次被邀请人列表;INVITE 专属,前端来电界面展示「邀请的其他人」 + * + * 注意:包含收件人自身,前端按需过滤 + */ + private List inviteeIds; + + // ========== REJECT 专属字段 ========== + + /** + * 操作者用户编号;REJECT 触发本次状态变迁的人 + */ + private Long operatorUserId; + /** + * 操作者昵称;前端按需展示(被某某拒接);普通文案不依赖 + */ + private String operatorNickname; + /** + * 操作者头像;前端按需展示;普通文案不依赖 + */ + private String operatorAvatar; + + /** + * 构造 INVITE 信令;推被邀请人,invitee 状态变为 INVITING + * + * @param call 通话主表 + * @param inviter 发起人;可空,缺失时 inviterNickname / inviterAvatar 留空 + * @param livekitUrl LiveKit Server WebSocket 地址 + * @param token 被叫的接听 Token;按收件人单独签发 + * @param inviteeIds 本次被邀请人列表;前端来电界面展示「邀请的其他人」用 + * @return INVITE 信令 + */ + public static ImRtcCallNotification ofInvite(ImRtcCallDO call, AdminUserRespDTO inviter, + String livekitUrl, String token, + Collection inviteeIds) { + ImRtcCallNotification notification = baseOf(call, ImRtcParticipantStatusEnum.INVITING.getStatus()); + notification.livekitUrl = livekitUrl; + notification.token = token; + notification.inviterUserId = call.getInviterUserId(); + if (inviter != null) { + notification.inviterNickname = inviter.getNickname(); + notification.inviterAvatar = inviter.getAvatar(); + } + notification.inviteeIds = inviteeIds != null ? new java.util.ArrayList<>(inviteeIds) : null; + return notification; + } + + /** + * 构造 REJECT 信令;仅群通话场景;推主叫 + * + * @param call 通话主表 + * @param operatorUserId 拒接者用户编号 + * @param operator 拒接者;可空,缺失时 operatorNickname / operatorAvatar 留空 + * @return REJECT 信令 + */ + public static ImRtcCallNotification ofReject(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) { + ImRtcCallNotification notification = baseOf(call, ImRtcParticipantStatusEnum.REJECTED.getStatus()); + notification.operatorUserId = operatorUserId; + if (operator != null) { + notification.operatorNickname = operator.getNickname(); + notification.operatorAvatar = operator.getAvatar(); + } + return notification; + } + + /** + * 构造 NO_ANSWER 信令;仅群通话场景;推主叫;超时未接听语义独立于 REJECT + * + * @param call 通话主表 + * @param operatorUserId 未接听者用户编号 + * @param operator 未接听者;可空,缺失时 operatorNickname / operatorAvatar 留空 + * @return NO_ANSWER 信令 + */ + public static ImRtcCallNotification ofNoAnswer(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) { + ImRtcCallNotification notification = baseOf(call, ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()); + notification.operatorUserId = operatorUserId; + if (operator != null) { + notification.operatorNickname = operator.getNickname(); + notification.operatorAvatar = operator.getAvatar(); + } + return notification; + } + + /** + * 公共骨架;填充 call 上下文 + status + */ + private static ImRtcCallNotification baseOf(ImRtcCallDO call, Integer status) { + ImRtcCallNotification notification = new ImRtcCallNotification(); + notification.status = status; + notification.room = call.getRoom(); + notification.conversationType = call.getConversationType(); + notification.mediaType = call.getMediaType(); + notification.groupId = call.getGroupId(); + return notification; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java new file mode 100644 index 000000000..b73346baf --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc; + +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import lombok.Data; + +/** + * RTC_CALL_START 通话开始通知 + *

+ * 群聊入 im_group_message 全群广播;前端渲染聊天 tip「{inviterNickname} 发起了语音通话」 + *

+ * 私聊入 im_private_message 定向给被叫;前端不渲染聊天 tip,仅用于会话列表预览展示「[语音通话]」(刷新后仍可见) + *

+ * 与 {@link ImRtcCallEndNotification} 两段式配对: + * START 在 invite 接口事务里 INSERT,END 在 cancel / leave 接口事务里 INSERT, + * 两段位于不同请求 / 事务,自增 id 保证聊天流顺序;后续如果合并到同一事务里 push,需要额外保证 START 先于 END + * + * @author 芋道源码 + */ +@Data +public class ImRtcCallStartNotification { + + /** + * 业务通话编号 + */ + private String room; + /** + * 会话类型:当前固定 GROUP(私聊无 START) + */ + private Integer conversationType; + /** + * 媒体类型 + */ + private Integer mediaType; + /** + * 发起人用户编号 + */ + private Long inviterUserId; + /** + * 发起人昵称:用于聊天 tip 文案,可空走前端 fallback + */ + private String inviterNickname; + /** + * 发起人头像:可空,预留给点击 tip 展示发起人卡片 + */ + private String inviterAvatar; + + public static ImRtcCallStartNotification of(ImRtcCallDO call, AdminUserRespDTO inviter) { + ImRtcCallStartNotification notification = new ImRtcCallStartNotification(); + notification.room = call.getRoom(); + notification.conversationType = call.getConversationType(); + notification.mediaType = call.getMediaType(); + notification.inviterUserId = call.getInviterUserId(); + if (inviter != null) { + notification.inviterNickname = inviter.getNickname(); + notification.inviterAvatar = inviter.getAvatar(); + } + return notification; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcParticipantConnectedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcParticipantConnectedNotification.java new file mode 100644 index 000000000..4bbfb48e3 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcParticipantConnectedNotification.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc; + +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import lombok.Data; + +/** + * RTC_PARTICIPANT_CONNECTED 通话参与者加入通知 + *

+ * 不入库;LiveKit webhook participant_joined 触发;私聊推 peer 多端 + inviter 多端,群聊全群广播 + *

+ * 前端 callStore 把 userId 追加进 joinedUserIds;胶囊条人数 +1;群聊场景首条 1602 携带通话元信息以便 首次填充胶囊条 + * + * @author 芋道源码 + */ +@Data +public class ImRtcParticipantConnectedNotification { + + /** + * 业务通话编号 + */ + private String room; + /** + * 加入的参与者用户编号 + */ + private Long userId; + /** + * 会话类型 + */ + private Integer conversationType; + /** + * 群编号;群通话场景必填 + */ + private Long groupId; + /** + * 媒体类型;群聊场景的非邀请成员靠这个字段 首次填充胶囊条 + */ + private Integer mediaType; + /** + * 发起人用户编号;群聊场景的非邀请成员靠这个字段 首次填充胶囊条 + */ + private Long inviterUserId; + + /** + * 构造参与者加入通知;按 {@link ImRtcCallDO} 抽通话上下文,仅 userId 是变量 + * + * @param call 通话主表 + * @param userId 加入的参与者用户编号 + * @return 通知载荷 + */ + public static ImRtcParticipantConnectedNotification of(ImRtcCallDO call, Long userId) { + ImRtcParticipantConnectedNotification notification = new ImRtcParticipantConnectedNotification(); + notification.room = call.getRoom(); + notification.userId = userId; + notification.conversationType = call.getConversationType(); + notification.groupId = call.getGroupId(); + notification.mediaType = call.getMediaType(); + notification.inviterUserId = call.getInviterUserId(); + return notification; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcParticipantDisconnectedNotification.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcParticipantDisconnectedNotification.java new file mode 100644 index 000000000..94168c0fd --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcParticipantDisconnectedNotification.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc; + +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import lombok.Data; + +/** + * RTC_PARTICIPANT_DISCONNECTED 通话参与者离开通知 + *

+ * 不入库;LiveKit webhook participant_left 触发;推送范围同 {@link ImRtcParticipantConnectedNotification} + *

+ * 前端 callStore 把 userId 从 joinedUserIds 移除;胶囊条人数 -1 + * + * @author 芋道源码 + */ +@Data +public class ImRtcParticipantDisconnectedNotification { + + /** + * 业务通话编号 + */ + private String room; + /** + * 离开的参与者用户编号 + */ + private Long userId; + /** + * 会话类型 + */ + private Integer conversationType; + /** + * 群编号;群通话场景必填 + */ + private Long groupId; + + /** + * 构造参与者离开通知;按 {@link ImRtcCallDO} 抽通话上下文,仅 userId 是变量 + * + * @param call 通话主表 + * @param userId 离开的参与者用户编号 + * @return 通知载荷 + */ + public static ImRtcParticipantDisconnectedNotification of(ImRtcCallDO call, Long userId) { + ImRtcParticipantDisconnectedNotification notification = new ImRtcParticipantDisconnectedNotification(); + notification.room = call.getRoom(); + notification.userId = userId; + notification.conversationType = call.getConversationType(); + notification.groupId = call.getGroupId(); + return notification; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/util/ImMessageUtils.java b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/util/ImMessageUtils.java new file mode 100644 index 000000000..389f1407c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/java/cn/iocoder/yudao/module/im/util/ImMessageUtils.java @@ -0,0 +1,215 @@ +package cn.iocoder.yudao.module.im.util; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_CONTENT_INVALID; + +/** + * IM 消息内容相关工具类 + */ +public class ImMessageUtils { + + /** + * 文本消息最大长度 + */ + private static final int TEXT_MAX_LENGTH = 4096; + /** + * URL 最大长度 + */ + private static final int URL_MAX_LENGTH = 2048; + /** + * 合并消息最大条数 + */ + private static final int MERGE_MESSAGE_MAX_COUNT = 100; + + /** + * 校验用户发送的消息内容 + * + * @param type 消息类型 + * @param content 消息内容 + */ + public static void validateUserMessageContent(Integer type, String content) { + ImMessageTypeEnum messageType; + try { + messageType = ImMessageTypeEnum.validate(type); + } catch (IllegalArgumentException e) { + throw exception(MESSAGE_CONTENT_INVALID); + } + if (!messageType.isNormal()) { + throw exception(MESSAGE_CONTENT_INVALID); + } + Map map = JsonUtils.parseMap(content); + if (map == null) { + throw exception(MESSAGE_CONTENT_INVALID); + } + if (ImMessageTypeEnum.TEXT == messageType) { + validateTextContent(map); + } else if (ImMessageTypeEnum.IMAGE == messageType || ImMessageTypeEnum.FACE == messageType) { + validateUrl(getString(map, "url")); + } else if (ImMessageTypeEnum.VOICE == messageType) { + validateUrl(getString(map, "url")); + validatePositiveNumber(map.get("duration")); + } else if (ImMessageTypeEnum.VIDEO == messageType) { + validateUrl(getString(map, "url")); + } else if (ImMessageTypeEnum.FILE == messageType) { + validateUrl(getString(map, "url")); + validateNotBlank(getString(map, "name")); + } else if (ImMessageTypeEnum.CARD == messageType) { + validateCardContent(map); + } else if (ImMessageTypeEnum.MERGE == messageType) { + validateMergeContent(map); + } else if (ImMessageTypeEnum.MATERIAL == messageType) { + validateMaterialContent(map); + } + } + + private static void validateTextContent(Map map) { + String text = getString(map, "content"); + validateNotBlank(text); + if (text.length() > TEXT_MAX_LENGTH) { + throw exception(MESSAGE_CONTENT_INVALID); + } + } + + private static void validateCardContent(Map map) { + validateNumber(map.get("targetType")); + validateNumber(map.get("targetId")); + validateNotBlank(getString(map, "name")); + } + + private static void validateMergeContent(Map map) { + validateNotBlank(getString(map, "title")); + Object messages = map.get("messages"); + if (!(messages instanceof Collection messageList) + || CollUtil.isEmpty(messageList) + || messageList.size() > MERGE_MESSAGE_MAX_COUNT) { + throw exception(MESSAGE_CONTENT_INVALID); + } + } + + private static void validateMaterialContent(Map map) { + if (map.get("materialId") == null && StrUtil.isBlank(getString(map, "title"))) { + throw exception(MESSAGE_CONTENT_INVALID); + } + validateUrlIfPresent(getString(map, "url")); + validateUrlIfPresent(getString(map, "coverUrl")); + } + + private static String getString(Map map, String field) { + return Convert.toStr(map.get(field), null); + } + + private static void validateNotBlank(String value) { + if (StrUtil.isBlank(value)) { + throw exception(MESSAGE_CONTENT_INVALID); + } + } + + private static void validateNumber(Object value) { + if (Convert.toLong(value, null) == null) { + throw exception(MESSAGE_CONTENT_INVALID); + } + } + + private static void validatePositiveNumber(Object value) { + Integer number = Convert.toInt(value, null); + if (number == null || number < 0) { + throw exception(MESSAGE_CONTENT_INVALID); + } + } + + private static void validateUrlIfPresent(String url) { + if (StrUtil.isBlank(url)) { + return; + } + validateUrl(url); + } + + private static void validateUrl(String url) { + validateNotBlank(url); + if (url.length() > URL_MAX_LENGTH) { + throw exception(MESSAGE_CONTENT_INVALID); + } + String lowerUrl = StrUtil.trim(url).toLowerCase(); + if (StrUtil.startWithAny(lowerUrl, "javascript:", "data:", "vbscript:", "file:")) { + throw exception(MESSAGE_CONTENT_INVALID); + } + } + + /** + * 从 content 解析客户端写入的 quote.messageId + * + * @param content 原 content(JSON) + * @return messageId,不存在 / 非法返回 null + */ + public static Long parseQuoteMessageId(String content) { + Map map = JsonUtils.parseMap(content); + if (map == null) { + return null; + } + Object quoteRaw = map.get(QuoteMessage.FIELD_NAME); + if (!(quoteRaw instanceof Map)) { + return null; + } + return Convert.toLong(((Map) quoteRaw).get(QuoteMessage.FIELD_MESSAGE_ID)); + } + + /** + * 把 quote 写入 content 的 quote 字段 + * + * @param content 原 content(JSON) + * @param quote QuoteMessage 对象 + * @return 注入 quote 后的 content(JSON) + */ + public static String appendQuote(String content, QuoteMessage quote) { + Map map = JsonUtils.parseMap(content); + if (map == null) { + map = new LinkedHashMap<>(); + } + map.put(QuoteMessage.FIELD_NAME, quote); + return JsonUtils.toJsonString(map); + } + + /** + * 移除 content 里的 quote 字段 + * + * @param content 原 content(JSON) + * @return remove quote 后的 content(JSON) + */ + public static String removeQuote(String content) { + if (StrUtil.isBlank(content) || !StrUtil.contains(content, "\"" + QuoteMessage.FIELD_NAME + "\"")) { + return content; + } + Map map = JsonUtils.parseMap(content); + if (map == null) { + return content; + } + map.remove(QuoteMessage.FIELD_NAME); + return JsonUtils.toJsonString(map); + } + + /** + * 构建 QuoteMessage 对象 + * + * @param messageId 被引用消息编号 + * @param senderId 被引用消息发送人编号 + * @param type 被引用消息类型 + * @param originalContent 被引用消息原 content(JSON),服务端会 removeQuote 防止嵌套 + * @return QuoteMessage 对象 + */ + public static QuoteMessage buildQuote(Long messageId, Long senderId, Integer type, String originalContent) { + return new QuoteMessage().setMessageId(messageId).setSenderId(senderId).setType(type) + .setContent(removeQuote(originalContent)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/main/resources/application-dev.yaml b/yudao-module-im/yudao-module-im-server/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..52ad3f857 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/resources/application-dev.yaml @@ -0,0 +1,119 @@ +--- #################### 注册中心 + 配置中心相关配置 #################### + +spring: + cloud: + nacos: + server-addr: 127.0.0.1:8848 # Nacos 服务器地址 + username: # Nacos 账号 + password: # Nacos 密码 + discovery: # 【配置中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + metadata: + version: 1.0.0 # 服务实例的版本号,可用于灰度发布 + config: # 【注册中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + +--- #################### 数据库相关配置 #################### +spring: + # 数据源配置项 + autoconfigure: + exclude: + datasource: + druid: # Druid 【监控】相关的全局配置 + web-stat-filter: + enabled: true + stat-view-servlet: + enabled: true + allow: # 设置白名单,不填则允许所有访问 + url-pattern: /druid/* + login-username: # 控制台管理用户名和密码 + login-password: + filter: + stat: + enabled: true + log-slow-sql: true # 慢 SQL 记录 + slow-sql-millis: 100 + merge-sql: true + wall: + config: + multi-statement-allow: true + dynamic: # 多数据源配置 + druid: # Druid 【连接池】相关的全局配置 + initial-size: 5 # 初始连接数 + min-idle: 10 # 最小连接池数量 + max-active: 20 # 最大连接池数量 + max-wait: 60000 # 配置获取连接等待超时的时间,单位:毫秒(1 分钟) + time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒(1 分钟) + min-evictable-idle-time-millis: 600000 # 配置一个连接在池中最小生存的时间,单位:毫秒(10 分钟) + max-evictable-idle-time-millis: 1800000 # 配置一个连接在池中最大生存的时间,单位:毫秒(30 分钟) + validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效 + test-while-idle: true + test-on-borrow: false + test-on-return: false + pool-prepared-statements: true # 是否开启 PreparedStatement 缓存 + max-pool-prepared-statement-per-connection-size: 20 # 每个连接缓存的 PreparedStatement 数量 + primary: master + datasource: + master: + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 + username: root + password: 123456 + slave: # 模拟从库,可根据自己需要修改 # 模拟从库,可根据自己需要修改 + lazy: true # 开启懒加载,保证启动速度 + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 + username: root + password: 123456 + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 400-infra.server.iocoder.cn # 地址 + port: 6379 # 端口 + database: 1 # 数据库索引 +# password: 123456 # 密码,建议生产环境开启 + +--- #################### MQ 消息队列相关配置 #################### + +--- #################### 定时任务相关配置 #################### +xxl: + job: + admin: + addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址 + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项 +lock4j: + acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒 + expire: 30000 # 分布式锁的超时时间,默认为 30000 毫秒 + +--- #################### 监控相关配置 #################### + +# Actuator 监控端点的配置项 +management: + endpoints: + web: + base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator + exposure: + include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。 + +# Spring Boot Admin 配置项 +spring: + boot: + admin: + # Spring Boot Admin Client 客户端的相关配置 + client: + instance: + service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME] + username: admin + password: admin + # Spring Boot Admin Server 服务端的相关配置 + context-path: /admin # 配置 Spring + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + demo: true # 开启演示模式 diff --git a/yudao-module-im/yudao-module-im-server/src/main/resources/application-local.yaml b/yudao-module-im/yudao-module-im-server/src/main/resources/application-local.yaml new file mode 100644 index 000000000..b319f2989 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/resources/application-local.yaml @@ -0,0 +1,140 @@ +--- #################### 注册中心 + 配置中心相关配置 #################### + +spring: + cloud: + nacos: + server-addr: 127.0.0.1:8848 # Nacos 服务器地址 + username: # Nacos 账号 + password: # Nacos 密码 + discovery: # 【配置中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + metadata: + version: 1.0.0 # 服务实例的版本号,可用于灰度发布 + config: # 【注册中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + +--- #################### 数据库相关配置 #################### +spring: + # 数据源配置项 + autoconfigure: + exclude: + datasource: + druid: # Druid 【监控】相关的全局配置 + web-stat-filter: + enabled: true + stat-view-servlet: + enabled: true + allow: # 设置白名单,不填则允许所有访问 + url-pattern: /druid/* + login-username: # 控制台管理用户名和密码 + login-password: + filter: + stat: + enabled: true + log-slow-sql: true # 慢 SQL 记录 + slow-sql-millis: 100 + merge-sql: true + wall: + config: + multi-statement-allow: true + dynamic: # 多数据源配置 + druid: # Druid 【连接池】相关的全局配置 + initial-size: 1 # 初始连接数 + min-idle: 1 # 最小连接池数量 + max-active: 20 # 最大连接池数量 + max-wait: 60000 # 配置获取连接等待超时的时间,单位:毫秒(1 分钟) + time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒(1 分钟) + min-evictable-idle-time-millis: 600000 # 配置一个连接在池中最小生存的时间,单位:毫秒(10 分钟) + max-evictable-idle-time-millis: 1800000 # 配置一个连接在池中最大生存的时间,单位:毫秒(30 分钟) + validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效 + test-while-idle: true + test-on-borrow: false + test-on-return: false + pool-prepared-statements: true # 是否开启 PreparedStatement 缓存 + max-pool-prepared-statement-per-connection-size: 20 # 每个连接缓存的 PreparedStatement 数量 + primary: master + datasource: + master: + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 + # url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true # MySQL Connector/J 5.X 连接的示例 + # url: jdbc:postgresql://127.0.0.1:5432/ruoyi-vue-pro # PostgreSQL 连接的示例 + # url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例 + # url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=ruoyi-vue-pro # SQLServer 连接的示例 + # url: jdbc:dm://10.211.55.4:5236?schema=RUOYI_VUE_PRO # DM 连接的示例 + username: root + password: 123456 + # username: sa # SQL Server 连接的示例 + # password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W # SQL Server 连接的示例 + # username: SYSDBA # DM 连接的示例 + # password: SYSDBA # DM 连接的示例 + slave: # 模拟从库,可根据自己需要修改 + lazy: true # 开启懒加载,保证启动速度 + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true + username: root + password: 123456 + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 127.0.0.1 # 地址 + port: 6379 # 端口 + database: 0 # 数据库索引 +# password: 123456 # 密码,建议生产环境开启 + +--- #################### MQ 消息队列相关配置 #################### + +--- #################### 定时任务相关配置 #################### + +xxl: + job: + enabled: false # 是否开启调度中心,默认为 true 开启 + admin: + addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址 + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项 +lock4j: + acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒 + expire: 30000 # 分布式锁的超时时间,默认为 30000 毫秒 + +--- #################### 监控相关配置 #################### + +# Actuator 监控端点的配置项 +management: + endpoints: + web: + base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator + exposure: + include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。 + +# Spring Boot Admin 配置项 +spring: + boot: + admin: + # Spring Boot Admin Client 客户端的相关配置 + client: + instance: + service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME] + username: admin + password: admin + +# 日志文件配置 +logging: + level: + # 配置自己写的 MyBatis Mapper 打印日志 + cn.iocoder.yudao.module.im.dal.mysql: debug + org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR # TODO 芋艿:先禁用,Spring Boot 3.X 存在部分错误的 WARN 提示 + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + env: # 多环境的配置项 + tag: ${HOSTNAME} + security: + mock-enable: true + access-log: # 访问日志的配置项 + enable: false \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-server/src/main/resources/application.yaml b/yudao-module-im/yudao-module-im-server/src/main/resources/application.yaml new file mode 100644 index 000000000..a2774a929 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/resources/application.yaml @@ -0,0 +1,127 @@ +spring: + application: + name: im-server + + profiles: + active: local + + main: + allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。 + allow-bean-definition-overriding: true # 允许 Bean 覆盖,例如说 Feign 等会存在重复定义的服务 + + config: + import: + - optional:classpath:application-${spring.profiles.active}.yaml # 加载【本地】配置 + - optional:nacos:${spring.application.name}-${spring.profiles.active}.yaml # 加载【Nacos】的配置 + + # Servlet 配置 + servlet: + # 文件上传相关配置项 + multipart: + max-file-size: 16MB # 单个文件大小 + max-request-size: 32MB # 设置总上传的文件大小 + + # Jackson 配置项 + jackson: + serialization: + write-dates-as-timestamps: true # 设置 LocalDateTime 的格式,使用时间戳 + write-date-timestamps-as-nanoseconds: false # 设置不使用 nanoseconds 的格式。例如说 1611460870.401,而是直接 1611460870401 + write-durations-as-timestamps: true # 设置 Duration 的格式,使用时间戳 + fail-on-empty-beans: false # 允许序列化无属性的 Bean + + # Cache 配置项 + cache: + type: REDIS + redis: + time-to-live: 1h # 设置过期时间为 1 小时 + +server: + port: 48093 + +logging: + file: + name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径 + +--- #################### 接口文档配置 #################### + +springdoc: + api-docs: + enabled: true # 1. 是否开启 Swagger 接文档的元数据 + path: /v3/api-docs + swagger-ui: + enabled: true # 2.1 是否开启 Swagger 文档的官方 UI 界面 + path: /swagger-ui + default-flat-param-object: true # 参见 https://doc.xiaominfo.com/docs/faq/v4/knife4j-parameterobject-flat-param 文档 + +knife4j: + enable: true + setting: + language: zh_cn + +# MyBatis Plus 的配置项 +mybatis-plus: + configuration: + map-underscore-to-camel-case: true # 虽然默认为 true ,但是还是显示去指定下。 + global-config: + db-config: + id-type: NONE # “智能”模式,基于 IdTypeEnvironmentPostProcessor + 数据源的类型,自动适配成 AUTO、INPUT 模式。 + # id-type: AUTO # 自增 ID,适合 MySQL 等直接自增的数据库 + # id-type: INPUT # 用户输入 ID,适合 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库 + # id-type: ASSIGN_ID # 分配 ID,默认使用雪花算法。注意,Oracle、PostgreSQL、Kingbase、DB2、H2 数据库时,需要去除实体类上的 @KeySequence 注解 + logic-delete-value: 1 # 逻辑已删除值(默认为 1) + logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) + banner: false # 关闭控制台的 Banner 打印 + type-aliases-package: ${yudao.info.base-package}.dal.dataobject + encryptor: + password: XDV71a+xqStEA3WH # 加解密的秘钥,可使用 https://www.imaegoo.com/2020/aes-key-generator/ 网站生成 + +mybatis-plus-join: + banner: false # 关闭控制台的 Banner 打印 + +# Spring Data Redis 配置 +spring: + data: + redis: + repositories: + enabled: false # 项目未使用到 Spring Data Redis 的 Repository,所以直接禁用,保证启动速度 + +# VO 转换(数据翻译)相关 +easy-trans: + is-enable-global: false # 【默认禁用,对性能确认压力大】启用全局翻译(拦截所有 SpringMVC ResponseBody 进行自动翻译 )。如果对于性能要求很高可关闭此配置,或通过 @IgnoreTrans 忽略某个接口 + +--- #################### RPC 远程调用相关配置 #################### + +--- #################### MQ 消息队列相关配置 #################### + +--- #################### 定时任务相关配置 #################### + +xxl: + job: + executor: + appname: ${spring.application.name} # 执行器 AppName + logpath: ${user.home}/logs/xxl-job/${spring.application.name} # 执行器运行日志文件存储磁盘路径 + accessToken: default_token # 执行器通讯TOKEN + +--- #################### 芋道相关配置 #################### + +yudao: + info: + version: 1.0.0 + base-package: cn.iocoder.yudao.module.im + web: + admin-ui: + url: http://dashboard.yudao.iocoder.cn # Admin 管理后台 UI 的地址 + xss: + enable: false + exclude-urls: # 如下 url,仅仅是为了演示,去掉配置也没关系 + - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 + swagger: + title: 管理后台 + description: 提供管理员管理的所有功能 + version: ${yudao.info.version} + tenant: # 多租户相关配置项 + enable: true + ignore-urls: + ignore-tables: + +debug: false diff --git a/yudao-module-im/yudao-module-im-server/src/main/resources/logback-spring.xml b/yudao-module-im/yudao-module-im-server/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..15b28cfdf --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/main/resources/logback-spring.xml @@ -0,0 +1,56 @@ + + + + + + + + +       + + ${CONSOLE_LOG_PATTERN} + + + + + + + + ${FILE_LOG_PATTERN} + + + ${LOG_FILE} + + + ${LOG_FILE}.%d{yyyy-MM-dd}.%i.log + 30 + 10MB + + + + + 0 + 512 + + + + + + + + + + + + + + + diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java new file mode 100644 index 000000000..ed354dfd4 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.im.dal.mysql.friend; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 好友申请记录 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImFriendRequestMapperTest extends BaseDbUnitTest { + + @Resource + private ImFriendRequestMapper mapper; + + // ========== selectMyList ========== + + @Test + public void testSelectMyList_bidirectional() { + // 准备:1 既作为 from 又作为 to;3 是无关用户 + mapper.insert(buildRequest(1L, 2L)); + mapper.insert(buildRequest(2L, 1L)); + mapper.insert(buildRequest(3L, 4L)); + + // 调用:cursor 为空,拉首页 + List list = mapper.selectMyList(1L, null, null, 10); + + // 断言:双向 OR 命中两条,无关用户被排除 + assertEquals(2, list.size()); + list.forEach(r -> assertTrue(r.getFromUserId().equals(1L) || r.getToUserId().equals(1L))); + } + + @Test + public void testSelectMyList_cursorPaging() { + // 准备:三条 1 相关的申请 + ImFriendRequestDO r1 = buildRequest(1L, 2L); + ImFriendRequestDO r2 = buildRequest(1L, 3L); + ImFriendRequestDO r3 = buildRequest(1L, 4L); + mapper.insert(r1); + mapper.insert(r2); + mapper.insert(r3); + + // 调用:cursor = r3,拉比 r3 更早的下一页 + ImFriendRequestDO cursor = mapper.selectById(r3.getId()); + List next = mapper.selectMyList(1L, cursor.getUpdateTime(), cursor.getId(), 10); + + // 断言:仅含 r1 / r2,按 id 倒序 + assertEquals(2, next.size()); + assertEquals(r2.getId(), next.get(0).getId()); + assertEquals(r1.getId(), next.get(1).getId()); + } + + @Test + public void testSelectMyList_limit() { + // 准备:插入 3 条 + for (int i = 0; i < 3; i++) { + mapper.insert(buildRequest(1L, (long) (10 + i))); + } + + // 调用:limit = 2 + List list = mapper.selectMyList(1L, null, null, 2); + + // 断言:手写 LIMIT 真生效 + assertEquals(2, list.size()); + } + + private static ImFriendRequestDO buildRequest(Long fromUserId, Long toUserId) { + return new ImFriendRequestDO() + .setFromUserId(fromUserId).setToUserId(toUserId) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java new file mode 100644 index 000000000..9c71fd2d0 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java @@ -0,0 +1,230 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 群聊消息 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMessageMapperTest extends BaseDbUnitTest { + + @Resource + private ImGroupMessageMapper mapper; + + private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); + + // ========== selectListByMinId ========== + + @Test + public void testSelectListByMinId_onlyReturnsGroupsInIdList() { + // 准备:群 10 的消息 + 群 20 的消息 + ImGroupMessageDO msg10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg10); + ImGroupMessageDO msg20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg20); + + // 调用:只查群 10 + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); + + // 断言:只有 msg10 + assertEquals(1, result.size()); + assertEquals(msg10.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_includesRecall() { + // 准备:一条正常 + 一条撤回;客户端拉离线消息需要拿到撤回状态以渲染「此消息已撤回」占位 + ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(normal); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + mapper.insert(recalled); + + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); + + // 断言:撤回消息一并返回,由客户端按 status 切换渲染 + assertEquals(2, result.size()); + } + + @Test + public void testSelectListByMinId_sendTimeWindow() { + // 准备:一条在窗口内,一条在窗口外 + ImGroupMessageDO inWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + inWindow.setSendTime(LocalDateTime.now().minusDays(1)); + mapper.insert(inWindow); + ImGroupMessageDO outWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + outWindow.setSendTime(LocalDateTime.now().minusDays(40)); + mapper.insert(outWindow); + + List result = mapper.selectListByMinId(List.of(10L), 0L, + LocalDateTime.now().minusDays(30), 100); + + assertEquals(1, result.size()); + assertEquals(inWindow.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_sortAscLimit() { + // 准备:插入 3 条 + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m3); + + // 调用:size=2,返回 id 最小的 2 条 + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 2); + + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() < result.get(1).getId()); + assertEquals(m1.getId(), result.get(0).getId()); + assertEquals(m2.getId(), result.get(1).getId()); + } + + // ========== selectListByGroupIdAndMinIdAndQuitTimeBefore ========== + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_onlyReturnsBeforeQuit() { + // 准备:退群时间 = now-1 day,插入一条"退群前",一条"退群后" + LocalDateTime quitTime = LocalDateTime.now().minusDays(1); + ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + before.setSendTime(LocalDateTime.now().minusDays(2)); + mapper.insert(before); + ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + after.setSendTime(LocalDateTime.now().minusHours(1)); + mapper.insert(after); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + // 断言:只返回退群前消息 + assertEquals(1, result.size()); + assertEquals(before.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_boundaryEqualsQuitTime() { + // 准备:消息发送时间恰好等于退群时间(le 语义应包含该条) + LocalDateTime quitTime = LocalDateTime.now().minusDays(1).withNano(0); + ImGroupMessageDO boundary = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + boundary.setSendTime(quitTime); + mapper.insert(boundary); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + assertEquals(1, result.size()); + } + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_otherGroupExcluded() { + // 准备:群 10 和群 20 各一条消息 + LocalDateTime quitTime = LocalDateTime.now(); + ImGroupMessageDO in10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + in10.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(in10); + ImGroupMessageDO in20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + in20.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(in20); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + assertEquals(1, result.size()); + assertEquals(in10.getId(), result.get(0).getId()); + } + + // ========== selectHistoryList ========== + + @Test + public void testSelectHistoryList_basic() { + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + // 别的群 + ImGroupMessageDO other = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(other); + + List result = mapper.selectHistoryList(10L, null, 100, null); + + // 断言:只返回 10 群的,按 id 倒序 + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() > result.get(1).getId()); + } + + @Test + public void testSelectHistoryList_includeRecall() { + ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(normal); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + mapper.insert(recalled); + + List result = mapper.selectHistoryList(10L, null, 100, null); + + assertEquals(2, result.size()); + assertEquals(recalled.getId(), result.get(0).getId()); + assertEquals(normal.getId(), result.get(1).getId()); + } + + @Test + public void testSelectHistoryList_maxIdCursor() { + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m3); + + List result = mapper.selectHistoryList(10L, m3.getId(), 100, null); + + // 断言:只返回 id < m3.id 的,即 m1、m2 + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId())); + } + + @Test + public void testSelectHistoryList_filterByJoinTime() { + // 准备:一条在入群前,一条在入群后 + LocalDateTime joinTime = LocalDateTime.now().minusHours(1); + ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + before.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(before); + ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + after.setSendTime(LocalDateTime.now().minusMinutes(30)); + mapper.insert(after); + + List result = mapper.selectHistoryList(10L, null, 100, joinTime); + + // 断言:入群前消息不可见 + assertEquals(1, result.size()); + assertEquals(after.getId(), result.get(0).getId()); + } + + // ========== 工具方法 ========== + + private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) { + return ImGroupMessageDO.builder() + .clientMessageId("uuid-" + System.nanoTime()) + .senderId(senderId) + .groupId(groupId) + .type(0) + .content("{\"content\":\"test\"}") + .status(status.getStatus()) + .sendTime(LocalDateTime.now()) + .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) + .build(); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java new file mode 100644 index 000000000..e67b3e5b7 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java @@ -0,0 +1,199 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 私聊消息 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImPrivateMessageMapperTest extends BaseDbUnitTest { + + @Resource + private ImPrivateMessageMapper mapper; + + /** + * 一个足够早的时间作为 minSendTime,避免把测试数据过滤掉 + */ + private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); + + // ========== selectListByMinId ========== + + @Test + public void testSelectListByMinId() { + // 准备:用户 1 发给用户 2 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + // 用户 2 发给用户 1 + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + // 用户 3 发给用户 4(不相关) + ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg3); + + // 调用:用户 1 从 id=0 拉取 + List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 100); + + // 断言:只包含与用户 1 相关的 2 条 + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(m -> + m.getSenderId().equals(1L) || m.getReceiverId().equals(1L))); + } + + @Test + public void testSelectListByMinId_withMinId() { + // 准备 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + + // 调用:从 msg1.id 之后拉取 + List result = mapper.selectListByMinId(1L, msg1.getId(), FAR_PAST, 100); + + // 断言:只有 msg2 + assertEquals(1, result.size()); + assertEquals(msg2.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_limitSize() { + // 准备:插入 3 条 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + + // 调用:limit 2 + List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2); + + // 断言 + assertEquals(2, result.size()); + } + + @Test + public void testSelectListByMinId_sendTimeFilter() { + // 准备:一条落在窗口内,一条落在窗口外 + ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + newMsg.setSendTime(LocalDateTime.now().minusDays(1)); + mapper.insert(newMsg); + ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + oldMsg.setSendTime(LocalDateTime.now().minusDays(40)); + mapper.insert(oldMsg); + + // 调用:窗口起点 = 30 天前 + List result = mapper.selectListByMinId(1L, 0L, + LocalDateTime.now().minusDays(30), 100); + + // 断言:只返回窗口内的消息 + assertEquals(1, result.size()); + assertEquals(newMsg.getId(), result.get(0).getId()); + } + + // ========== selectHistoryList ========== + + @Test + public void testSelectHistoryList_basic() { + // 准备:用户 1 <-> 用户 2 的消息 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + // 用户 1 <-> 用户 3 的消息(不相关) + ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg3); + + // 调用 + List result = mapper.selectHistoryList(1L, 2L, null, 100); + + // 断言:只有与用户 2 对话的 2 条,按 id 倒序 + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() > result.get(1).getId()); + } + + @Test + public void testSelectHistoryList_includeRecall() { + // 准备 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.RECALL); + mapper.insert(msg2); + + // 调用 + List result = mapper.selectHistoryList(1L, 2L, null, 100); + + // 断言:撤回消息一并返回 + assertEquals(2, result.size()); + assertEquals(msg2.getId(), result.get(0).getId()); + assertEquals(msg1.getId(), result.get(1).getId()); + } + + @Test + public void testSelectHistoryList_withMaxId() { + // 准备 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg3); + + // 调用:从 msg3 往前拉 + List result = mapper.selectHistoryList(1L, 2L, msg3.getId(), 100); + + // 断言:只有 msg1 和 msg2 + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(m -> m.getId() < msg3.getId())); + } + + @Test + public void testSelectHistoryList_limit() { + // 准备 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + + // 调用:limit 2 + List result = mapper.selectHistoryList(1L, 2L, null, 2); + + // 断言 + assertEquals(2, result.size()); + } + + @Test + public void testSelectHistoryList_bidirectional() { + // 准备:验证双向查询 + ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); // 对方发的 + mapper.insert(msg1); + + // 调用:以用户 1 的视角查 + List result = mapper.selectHistoryList(1L, 2L, null, 100); + + // 断言:能查到对方发来的消息 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getSenderId()); + } + + // ========== 工具方法 ========== + + private ImPrivateMessageDO buildMessage(Long senderId, Long receiverId, ImMessageStatusEnum status) { + return ImPrivateMessageDO.builder() + .clientMessageId("uuid-" + System.nanoTime()) + .senderId(senderId) + .receiverId(receiverId) + .type(0) + .content("{\"content\":\"test\"}") + .status(status.getStatus()) + .sendTime(LocalDateTime.now()) + .build(); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java new file mode 100644 index 000000000..4808da452 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java @@ -0,0 +1,278 @@ +package cn.iocoder.yudao.module.im.dal.mysql.statistics; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 数据看板 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImStatisticsManagerMapperTest extends BaseDbUnitTest { + + private static final LocalDateTime WINDOW_BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0); + private static final LocalDateTime WINDOW_END = LocalDateTime.of(2026, 2, 1, 0, 0); + + @Resource + private ImStatisticsManagerMapper mapper; + + @Resource + private ImPrivateMessageMapper privateMessageMapper; + @Resource + private ImGroupMessageMapper groupMessageMapper; + @Resource + private ImGroupMapper groupMapper; + @Resource + private ImGroupMemberMapper groupMemberMapper; + @Resource + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void initJdbcTemplate() { + jdbcTemplate = new JdbcTemplate(dataSource); + } + + // ========== 用户 ========== + + @Test + public void testSelectTotalUserCount() { + // 准备:3 个未删除用户 + insertUser(1L, LocalDateTime.now()); + insertUser(2L, LocalDateTime.now()); + insertUser(3L, LocalDateTime.now()); + + // 调用 + 断言 + assertEquals(3L, mapper.selectTotalUserCount()); + } + + @Test + public void testSelectNewUserCount_windowFilter() { + // 准备:1 个在窗口内 + 2 个在窗口外 + insertUser(1L, WINDOW_BEGIN.plusDays(3)); + insertUser(2L, WINDOW_BEGIN.minusDays(1)); + insertUser(3L, WINDOW_END.plusDays(1)); + + // 调用 + 断言:只命中窗口内 + assertEquals(1L, mapper.selectNewUserCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectActiveUserCount_distinctAcrossPrivateAndGroup() { + // 准备:1 私聊;2 群聊;3 在窗口外;1 既发私聊又发群聊(去重) + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(2))); + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(3))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(4))); // 用户 1 已计一次 + privateMessageMapper.insert(buildPrivate(3L, 4L, WINDOW_BEGIN.minusDays(1))); // 窗口外 + + // 调用 + 断言:去重后 = 2 + assertEquals(2L, mapper.selectActiveUserCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectNewUserDailyCount_groupByDay() { + // 准备:第 1 天 2 个;第 2 天 1 个;窗口外 1 个 + insertUser(1L, WINDOW_BEGIN.plusDays(1)); + insertUser(2L, WINDOW_BEGIN.plusDays(1)); + insertUser(3L, WINDOW_BEGIN.plusDays(2)); + insertUser(4L, WINDOW_BEGIN.minusDays(1)); + + // 调用 + List> list = mapper.selectNewUserDailyCount(WINDOW_BEGIN, WINDOW_END); + + // 断言:返回两个分组、总数 = 3 + assertEquals(2, list.size()); + long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum(); + assertEquals(3L, sum); + } + + @Test + public void testSelectActiveUserDailyCount_distinctPerDay() { + // 准备:同一天里用户 1 在私聊与群聊都发了;第二天用户 2 发了 + privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(2))); + + // 调用 + List> list = mapper.selectActiveUserDailyCount(WINDOW_BEGIN, WINDOW_END); + + // 断言:两天合计 2 名活跃用户(同一天 user 1 去重 + 第二天 user 2) + assertEquals(2, list.size()); + long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum(); + assertEquals(2L, sum); + } + + // ========== 群 ========== + + @Test + public void testSelectTotalGroupCount_onlyEnabled() { + // 准备:2 个正常 + 1 个已解散 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + groupMapper.insert(buildGroup(CommonStatusEnum.DISABLE)); + + // 调用 + 断言 + assertEquals(2L, mapper.selectTotalGroupCount()); + } + + @Test + public void testSelectNewGroupCount_windowFilter() { + // 准备:1 个窗口内 + 1 个窗口外 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + // 窗口窄到只覆盖 1 秒 + LocalDateTime narrowBegin = LocalDateTime.now().plusYears(10); + LocalDateTime narrowEnd = narrowBegin.plusSeconds(1); + + // 调用 + 断言:极窄窗口不命中任何群 + assertEquals(0L, mapper.selectNewGroupCount(narrowBegin, narrowEnd)); + } + + @Test + public void testSelectGroupSizeDistribution_bucketing() { + // 准备:一个 5 人群、一个 15 人群 + Long groupSmall = insertGroupAndMembers(5); + Long groupMid = insertGroupAndMembers(15); + assertNotNull(groupSmall); + assertNotNull(groupMid); + + // 调用 + List> dist = mapper.selectGroupSizeDistribution(); + + // 断言:分桶包含「1-9 人」和「10-49 人」 + Map byRange = dist.stream().collect(java.util.stream.Collectors.toMap( + m -> m.get("range"), m -> ((Number) m.get("count")).longValue())); + assertEquals(1L, byRange.get("1-9 人")); + assertEquals(1L, byRange.get("10-49 人")); + } + + // ========== 消息 ========== + + @Test + public void testSelectPrivateMessageCount_windowFilter() { + // 准备:1 在窗口内 + 1 在窗口外 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.minusDays(1))); + + // 调用 + 断言 + assertEquals(1L, mapper.selectPrivateMessageCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectGroupMessageCount_windowFilter() { + // 准备:1 在窗口内 + 1 在窗口外 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.minusDays(1))); + + // 调用 + 断言 + assertEquals(1L, mapper.selectGroupMessageCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectMessageTypeDistribution_mergePrivateAndGroup() { + // 准备:私聊 type=0 ×1,群聊 type=0 ×2、type=1 ×1 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType())); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType())); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType())); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.IMAGE.getType())); + + // 调用 + List> dist = mapper.selectMessageTypeDistribution(WINDOW_BEGIN, WINDOW_END); + + // 断言:type=TEXT(0) → 3;type=IMAGE(1) → 1 + Map byType = dist.stream().collect(java.util.stream.Collectors.toMap( + m -> ((Number) m.get("type")).intValue(), m -> ((Number) m.get("count")).longValue())); + assertEquals(3L, byType.get(ImMessageTypeEnum.TEXT.getType())); + assertEquals(1L, byType.get(ImMessageTypeEnum.IMAGE.getType())); + } + + @Test + public void testSelectTopSenders_orderByCountDescAndLimit() { + // 准备:user 1 共 3 条;user 2 共 2 条;user 3 共 1 条 + privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(2L, 9L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(3L, 9L, WINDOW_BEGIN.plusDays(1))); + + // 调用:取 TOP 2 + List> tops = mapper.selectTopSenders(WINDOW_BEGIN, WINDOW_END, 2); + + // 断言:返回 2 条,按消息数倒序,user1 > user2 + assertEquals(2, tops.size()); + assertEquals(1L, ((Number) tops.get(0).get("userId")).longValue()); + assertEquals(3L, ((Number) tops.get(0).get("messageCount")).longValue()); + assertEquals(2L, ((Number) tops.get(1).get("userId")).longValue()); + assertEquals(2L, ((Number) tops.get(1).get("messageCount")).longValue()); + } + + // ========== 工具方法 ========== + + private void insertUser(Long id, LocalDateTime createTime) { + jdbcTemplate.update( + "INSERT INTO system_users (id, username, password, nickname, status, create_time, update_time, deleted, tenant_id) " + + "VALUES (?, ?, '', ?, 0, ?, ?, FALSE, 0)", + id, "u" + id, "n" + id, createTime, createTime); + } + + private static ImPrivateMessageDO buildPrivate(Long senderId, Long receiverId, LocalDateTime sendTime) { + return ImPrivateMessageDO.builder() + .clientMessageId("uuid-" + System.nanoTime()) + .senderId(senderId).receiverId(receiverId) + .type(ImMessageTypeEnum.TEXT.getType()) + .content("{}") + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(sendTime).build(); + } + + private static ImGroupMessageDO buildGroupMessage(Long senderId, Long groupId, LocalDateTime sendTime) { + return new ImGroupMessageDO() + .setClientMessageId("uuid-" + System.nanoTime()) + .setSenderId(senderId).setGroupId(groupId) + .setType(ImMessageTypeEnum.TEXT.getType()) + .setContent("{}") + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()) + .setSendTime(sendTime); + } + + private static ImGroupDO buildGroup(CommonStatusEnum status) { + return ImGroupDO.builder().name("g" + System.nanoTime()).ownerUserId(1L) + .status(status.getStatus()).build(); + } + + private Long insertGroupAndMembers(int memberCount) { + ImGroupDO group = buildGroup(CommonStatusEnum.ENABLE); + groupMapper.insert(group); + for (int i = 0; i < memberCount; i++) { + groupMemberMapper.insert(new ImGroupMemberDO() + .setGroupId(group.getId()).setUserId((long) (1000 + i)) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setRole(3)); + } + return group.getId(); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java new file mode 100644 index 000000000..d585074d6 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackItemMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_ITEM_NOT_EXISTS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 表情包项 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFacePackItemServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFacePackItemServiceImpl service; + + @Mock + private ImFacePackItemMapper facePackItemMapper; + @Mock + private ImFacePackService facePackService; + + // ========== getEnabledItemListByPackIds ========== + + @Test + public void testGetEnabledItemListByPackIds_emptyShortReturn() { + // 调用 + 断言:空入参直接返回空列表,不查 DB + assertTrue(service.getEnabledItemListByPackIds(Collections.emptyList()).isEmpty()); + verify(facePackItemMapper, never()).selectListByPackIdsAndStatus(any(), any()); + } + + @Test + public void testGetEnabledItemListByPackIds_passesEnableStatus() { + // 准备 + List packIds = Arrays.asList(1L, 2L); + ImFacePackItemDO item = new ImFacePackItemDO(); + when(facePackItemMapper.selectListByPackIdsAndStatus(packIds, CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(item)); + + // 调用 + 断言 + assertEquals(1, service.getEnabledItemListByPackIds(packIds).size()); + } + + // ========== createFacePackItem ========== + + @Test + public void testCreateFacePackItem_success() { + // 准备 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setPackId(10L).setUrl("a.png").setName("dog").setWidth(100).setHeight(100) + .setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO()); + + // 调用 + service.createFacePackItem(reqVO); + + // 断言 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFacePackItemDO.class); + verify(facePackItemMapper).insert(captor.capture()); + assertEquals(10L, captor.getValue().getPackId()); + assertEquals("a.png", captor.getValue().getUrl()); + } + + @Test + public void testCreateFacePackItem_packNotExists() { + // 准备:所属表情包不存在 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setPackId(99L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0) + .setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackService.validateFacePackExists(99L)) + .thenThrow(new ServiceException(FACE_PACK_NOT_EXISTS.getCode(), FACE_PACK_NOT_EXISTS.getMsg())); + + // 调用 + 断言:不落库 + assertThrows(ServiceException.class, () -> service.createFacePackItem(reqVO)); + verify(facePackItemMapper, never()).insert(any(ImFacePackItemDO.class)); + } + + // ========== updateFacePackItem ========== + + @Test + public void testUpdateFacePackItem_success() { + // 准备 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setId(1L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100) + .setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO()); + when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO()); + + // 调用 + service.updateFacePackItem(reqVO); + + // 断言 + verify(facePackItemMapper).updateById(any(ImFacePackItemDO.class)); + } + + @Test + public void testUpdateFacePackItem_itemNotExists() { + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setId(99L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0) + .setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.updateFacePackItem(reqVO)); + assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + verify(facePackItemMapper, never()).updateById(any(ImFacePackItemDO.class)); + } + + // ========== deleteFacePackItem ========== + + @Test + public void testDeleteFacePackItem_success() { + when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO()); + + service.deleteFacePackItem(1L); + + verify(facePackItemMapper).deleteById(1L); + } + + @Test + public void testDeleteFacePackItem_notExists() { + when(facePackItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFacePackItem(99L)); + assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + verify(facePackItemMapper, never()).deleteById(anyLong()); + } + + @Test + public void testDeleteFacePackItemList_emptySkip() { + service.deleteFacePackItemList(Collections.emptyList()); + + verify(facePackItemMapper, never()).deleteByIds(any()); + } + + @Test + public void testDeleteFacePackItemList_success() { + List ids = Arrays.asList(1L, 2L); + when(facePackItemMapper.deleteByIds(ids)).thenReturn(2); + + service.deleteFacePackItemList(ids); + + verify(facePackItemMapper).deleteByIds(ids); + } + + @Test + public void testDeleteFacePackItemList_ignoreMissingIds() { + List ids = Arrays.asList(1L, 2L); + when(facePackItemMapper.deleteByIds(ids)).thenReturn(1); + + service.deleteFacePackItemList(ids); + + verify(facePackItemMapper).deleteByIds(ids); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java new file mode 100644 index 000000000..2cf1f0109 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_HAS_ITEMS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 表情包 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFacePackServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFacePackServiceImpl service; + + @Mock + private ImFacePackMapper facePackMapper; + @Mock + private ImFacePackItemService facePackItemService; + + // ========== validateFacePackExists ========== + + @Test + public void testValidateFacePackExists_notFound() { + when(facePackMapper.selectById(1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.validateFacePackExists(1L)); + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testValidateFacePackExists_returnsDo() { + ImFacePackDO pack = ImFacePackDO.builder().id(1L).name("猫主子").build(); + when(facePackMapper.selectById(1L)).thenReturn(pack); + + assertEquals(pack, service.validateFacePackExists(1L)); + } + + // ========== createFacePack ========== + + @Test + public void testCreateFacePack_insert() { + // 准备 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO(); + reqVO.setName("猫主子").setIcon("icon.png").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 调用 + service.createFacePack(reqVO); + + // 断言:mapper.insert 被调用一次 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFacePackDO.class); + verify(facePackMapper).insert(captor.capture()); + assertEquals("猫主子", captor.getValue().getName()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); + } + + // ========== updateFacePack ========== + + @Test + public void testUpdateFacePack_success() { + // 准备:存在 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO(); + reqVO.setId(1L).setName("新名").setSort(1).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build()); + + // 调用 + service.updateFacePack(reqVO); + + // 断言 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFacePackDO.class); + verify(facePackMapper).updateById(captor.capture()); + assertEquals(1L, captor.getValue().getId()); + assertEquals("新名", captor.getValue().getName()); + } + + @Test + public void testUpdateFacePack_notExists() { + // 准备 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO(); + reqVO.setId(99L).setName("x").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackMapper.selectById(99L)).thenReturn(null); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.updateFacePack(reqVO)); + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode()); + verify(facePackMapper, never()).updateById(any(ImFacePackDO.class)); + } + + // ========== deleteFacePack ========== + + @Test + public void testDeleteFacePack_success() { + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build()); + when(facePackItemService.getFacePackItemCount(1L)).thenReturn(0L); + + service.deleteFacePack(1L); + + verify(facePackMapper).deleteById(1L); + } + + @Test + public void testDeleteFacePack_hasItems() { + // 准备:包下仍有表情 + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build()); + when(facePackItemService.getFacePackItemCount(1L)).thenReturn(3L); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(1L)); + assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode()); + verify(facePackMapper, never()).deleteById(anyLong()); + } + + @Test + public void testDeleteFacePack_notExists() { + when(facePackMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(99L)); + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode()); + } + + // ========== deleteFacePackList ========== + + @Test + public void testDeleteFacePackList_emptySkip() { + // 调用:空 ids 列表直接返回,不查询、不删除 + service.deleteFacePackList(Collections.emptyList()); + + verify(facePackItemService, never()).getFacePackItemCount(any(java.util.Collection.class)); + verify(facePackMapper, never()).deleteByIds(any()); + } + + @Test + public void testDeleteFacePackList_anyHasItemsRejectAll() { + // 准备:批量中存在表情 + when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(1L); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFacePackList(Arrays.asList(1L, 2L))); + assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode()); + verify(facePackMapper, never()).deleteByIds(any()); + } + + @Test + public void testDeleteFacePackList_success() { + // 准备 + when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(0L); + + // 调用 + service.deleteFacePackList(Arrays.asList(1L, 2L)); + + // 断言 + verify(facePackMapper).deleteByIds(Arrays.asList(1L, 2L)); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java new file mode 100644 index 000000000..3cf7f33a5 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java @@ -0,0 +1,164 @@ +package cn.iocoder.yudao.module.im.service.face; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.dao.DuplicateKeyException; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_MAX_LIMIT; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 用户私有表情 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFaceUserItemServiceImpl service; + + @Mock + private ImFaceUserItemMapper faceUserItemMapper; + @Spy + private ImProperties imProperties = new ImProperties(); + + // ========== createFaceUserItem ========== + + @Test + public void testCreateFaceUserItem_success() { + // 准备 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L); + + // 调用 + service.createFaceUserItem(1L, reqVO); + + // 断言:写入用户编号 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFaceUserItemDO.class); + verify(faceUserItemMapper).insert(captor.capture()); + assertEquals(1L, captor.getValue().getUserId()); + assertEquals("a.png", captor.getValue().getUrl()); + } + + @Test + public void testCreateFaceUserItem_duplicateUrl() { + // 准备:相同 (userId, url) 已存在 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setWidth(100).setHeight(100); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")) + .thenReturn(new ImFaceUserItemDO().setUserId(1L)); + + // 调用 + 断言:抛重复异常,不落库 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.createFaceUserItem(1L, reqVO)); + assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class)); + } + + @Test + public void testCreateFaceUserItem_maxLimit() { + // 准备:个人表情已达上限 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setWidth(100).setHeight(100); + imProperties.getFace().setUserItemMaxCount(20); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(20L); + + // 调用 + 断言:抛上限异常,不落库 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.createFaceUserItem(1L, reqVO)); + assertEquals(FACE_USER_ITEM_MAX_LIMIT.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class)); + } + + @Test + public void testCreateFaceUserItem_duplicateKey() { + // 准备:并发插入触发唯一约束 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setWidth(100).setHeight(100); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L); + when(faceUserItemMapper.insert(any(ImFaceUserItemDO.class))).thenThrow(new DuplicateKeyException("duplicate")); + + // 调用 + 断言:数据库唯一约束冲突转业务重复异常 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.createFaceUserItem(1L, reqVO)); + assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode()); + } + + // ========== deleteFaceUserItem(用户端) ========== + + @Test + public void testDeleteFaceUserItem_userOwn_success() { + // 准备:归属当前用户 + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(1L)); + + // 调用 + service.deleteFaceUserItem(1L, 10L); + + // 断言 + verify(faceUserItemMapper).deleteById(10L); + } + + @Test + public void testDeleteFaceUserItem_notExists() { + when(faceUserItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFaceUserItem(1L, 99L)); + assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).deleteById(anyLong()); + } + + @Test + public void testDeleteFaceUserItem_notOwn() { + // 准备:表情归属 user 2,但 user 1 来删 + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFaceUserItem(1L, 10L)); + assertEquals(FACE_USER_ITEM_NOT_OWN.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).deleteById(anyLong()); + } + + // ========== deleteFaceUserItem(管理后台 by id) ========== + + @Test + public void testDeleteFaceUserItemAdmin_success() { + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L)); + + service.deleteFaceUserItem(10L); + + verify(faceUserItemMapper).deleteById(10L); + } + + @Test + public void testDeleteFaceUserItemAdmin_notExists() { + when(faceUserItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFaceUserItem(99L)); + assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java new file mode 100644 index 000000000..4eff98bb1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java @@ -0,0 +1,357 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 好友申请 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFriendRequestServiceImpl friendRequestService; + + @Mock + private ImFriendRequestMapper friendRequestMapper; + @Mock + private ImFriendService friendService; + @Mock + private ImWebSocketService websocketService; + @Mock + private ImProperties imProperties; + @Mock + private AdminUserApi adminUserApi; + + // ========== applyFriend ========== + + @Test + public void testApplyFriend_addSelf() { + // 准备:发起人 = 接收人 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(1L); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.applyFriend(1L, reqVO)); + assertEquals(FRIEND_ADD_SELF.getCode(), exception.getCode()); + } + + @Test + public void testApplyFriend_alreadyFriend() { + // 准备 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.FRIEND.getState()); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.applyFriend(1L, reqVO)); + assertEquals(FRIEND_REQUEST_ALREADY_FRIEND.getCode(), exception.getCode()); + } + + @Test + public void testApplyFriend_blockedByPeer() { + // 准备 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.BLOCKED.getState()); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.applyFriend(1L, reqVO)); + assertEquals(FRIEND_REQUEST_BLOCKED_BY_PEER.getCode(), exception.getCode()); + } + + @Test + public void testApplyFriend_silentReAdd() { + // 准备:单向好友 — 我已删除,但对方仍把我当好友 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setDisplayName("老张").setAddSource(1); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + ImFriendDO peerFriend = ImFriendDO.builder().userId(2L).friendUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(friendService.getFriend(2L, 1L)).thenReturn(peerFriend); + + // 调用 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); + + // 断言:走静默重新加好友,不落申请记录 + assertNull(result); + verify(friendService).silentReAddFriend(eq(1L), eq(2L), eq("老张"), eq(1)); + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class)); + verify(websocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testApplyFriend_blockedByPeerWhenMyselfDeleted() { + // 准备:我侧已删除(getFriendState=NONE),对方仍把我当好友但已拉黑 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setDisplayName("老张").setAddSource(1); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + ImFriendDO peerFriend = ImFriendDO.builder().userId(2L).friendUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).blocked(true).build(); + when(friendService.getFriend(2L, 1L)).thenReturn(peerFriend); + + // 调用 + 断言:必须拒掉,不能走 silentReAddFriend 绕过拉黑 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.applyFriend(1L, reqVO)); + assertEquals(FRIEND_REQUEST_BLOCKED_BY_PEER.getCode(), exception.getCode()); + verify(friendService, never()).silentReAddFriend(anyLong(), anyLong(), anyString(), anyInt()); + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class)); + } + + @Test + public void testApplyFriend_insertNew() { + // 准备:双方都无关系,且无历史申请 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setApplyContent("加个好友").setDisplayName("老张").setAddSource(1); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + when(friendService.getFriend(2L, 1L)).thenReturn(null); + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null); + when(adminUserApi.getUser(1L)).thenReturn(success(new AdminUserRespDTO().setNickname("张三").setAvatar("a.png"))); + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend()); + + // 调用 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); + + // 断言:落新申请记录 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendRequestDO.class); + verify(friendRequestMapper).insert(captor.capture()); + ImFriendRequestDO saved = captor.getValue(); + assertEquals(1L, saved.getFromUserId()); + assertEquals(2L, saved.getToUserId()); + assertEquals("加个好友", saved.getApplyContent()); + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), saved.getHandleResult()); + assertSame(saved, result); + // 断言:推送给接收方 + verify(websocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testApplyFriend_reuseOldRequest() { + // 准备:双方都无关系,但存在历史申请 — 走 reset 复用 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setApplyContent("再来一次").setDisplayName("老张").setAddSource(2); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + when(friendService.getFriend(2L, 1L)).thenReturn(null); + ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult()) + .setHandleContent("旧拒绝").setApplyContent("旧内容"); + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(old); + when(adminUserApi.getUser(1L)).thenReturn(success(null)); + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend()); + + // 调用 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); + + // 断言:复用旧记录,未触发 insert + verify(friendRequestMapper).updateByIdReset(eq(100L), eq("再来一次"), eq("老张"), eq(2), + any(java.time.LocalDateTime.class)); + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class)); + assertEquals(100L, result.getId()); + assertEquals("再来一次", result.getApplyContent()); + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult()); + assertNull(result.getHandleContent()); + assertNull(result.getHandleTime()); + } + + @Test + public void testApplyFriend_insertDuplicateKey_reuseOldRequest() { + // 准备:首次查询不存在,插入时命中唯一键,回查到并发写入的旧申请 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setApplyContent("并发申请").setDisplayName("老张").setAddSource(2); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + when(friendService.getFriend(2L, 1L)).thenReturn(null); + ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult()); + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null, old); + when(friendRequestMapper.insert(any(ImFriendRequestDO.class))).thenThrow(new DuplicateKeyException("dup")); + when(adminUserApi.getUser(1L)).thenReturn(success(null)); + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend()); + + // 调用 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); + + // 断言:复用并重置旧申请,不向上抛数据库异常 + verify(friendRequestMapper).updateByIdReset(eq(100L), eq("并发申请"), eq("老张"), eq(2), + any(LocalDateTime.class)); + assertEquals(100L, result.getId()); + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult()); + verify(websocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + // ========== agreeFriendRequest ========== + + @Test + public void testAgreeFriendRequest_success() { + // 准备 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(eq(100L), + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1); + + // 调用 + friendRequestService.agreeFriendRequest(2L, 100L); + + // 断言:双向建立好友 + 推 APPROVED 给发起方 + verify(adminUserApi).validateUserList(List.of(1L, 2L)); + verify(friendService).becomeFriends(request); + verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testAgreeFriendRequest_notExists() { + // 准备:申请不存在 + when(friendRequestMapper.selectById(100L)).thenReturn(null); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(2L, 100L)); + assertEquals(FRIEND_REQUEST_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testAgreeFriendRequest_notToMe() { + // 准备:操作人不是接收方 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + + // 调用 + 断言:3L 不是接收方 2L + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(3L, 100L)); + assertEquals(FRIEND_REQUEST_NOT_TO_ME.getCode(), exception.getCode()); + } + + @Test + public void testAgreeFriendRequest_alreadyHandled() { + // 准备:申请已被处理 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(2L, 100L)); + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode()); + } + + @Test + public void testAgreeFriendRequest_concurrentCasFail() { + // 准备:fail-fast 校验通过,但乐观锁 CAS 被并发抢先 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0); + + // 调用 + 断言:返回 FRIEND_REQUEST_HANDLED + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(2L, 100L)); + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode()); + verify(friendService, never()).becomeFriends(any(ImFriendRequestDO.class)); + } + + // ========== refuseFriendRequest ========== + + @Test + public void testRefuseFriendRequest_success() { + // 准备 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(eq(100L), + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1); + + // 调用 + friendRequestService.refuseFriendRequest(2L, 100L, "不认识"); + + // 断言:handleResult / handleContent 写入;推 REJECTED 给发起方 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendRequestDO.class); + verify(friendRequestMapper).updateByIdAndHandleResult(eq(100L), + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), captor.capture()); + assertEquals(ImFriendRequestHandleResultEnum.REFUSED.getResult(), captor.getValue().getHandleResult()); + assertEquals("不认识", captor.getValue().getHandleContent()); + verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testRefuseFriendRequest_concurrentCasFail() { + // 准备:CAS 失败 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0); + + // 调用 + 断言:抛 HANDLED,且不推 REJECTED + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.refuseFriendRequest(2L, 100L, "x")); + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode()); + verify(websocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + // ========== getMyFriendRequestList ========== + + @Test + public void testGetMyFriendRequestList_delegate() { + // 准备:mapper 返回 mock 数据 + LocalDateTime updateTime = LocalDateTime.now(); + ImFriendRequestDO cursor = new ImFriendRequestDO(); + cursor.setId(99L); + cursor.setUpdateTime(updateTime); + ImFriendRequestDO one = new ImFriendRequestDO(); + one.setId(1L); + when(friendRequestMapper.selectById(99L)).thenReturn(cursor); + when(friendRequestMapper.selectMyList(1L, updateTime, 99L, 20)) + .thenReturn(java.util.Collections.singletonList(one)); + + // 调用 + 断言:cursor 由 Service 查询后传给 Mapper + assertEquals(1, friendRequestService.getMyFriendRequestList(1L, 99L, 20).size()); + verify(friendRequestMapper).selectMyList(1L, updateTime, 99L, 20); + } + + @Test + public void testGetMyFriendRequestList_cursorNotExists() { + // 准备:cursor 不存在 + when(friendRequestMapper.selectById(99L)).thenReturn(null); + + // 调用 + 断言:返回空列表 + assertTrue(friendRequestService.getMyFriendRequestList(1L, 99L, 20).isEmpty()); + verify(friendRequestMapper, never()).selectMyList(anyLong(), any(), anyLong(), anyInt()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java new file mode 100644 index 000000000..81d03f95b --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java @@ -0,0 +1,291 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 好友关系 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFriendServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFriendServiceImpl friendService; + + @Mock + private ImFriendMapper imFriendMapper; + @Mock + private AdminUserApi adminUserApi; + @Mock + private ImWebSocketService imWebSocketService; + @Mock + private ImPrivateMessageService privateMessageService; + + // ========== updateFriend ========== + + @Test + public void testUpdateFriend_success() { + // 准备 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setSilent(true); + ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) + .silent(false).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); + + // 调用 + friendService.updateFriend(1L, reqVO); + + // 断言:更新了 silent 字段 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(100L, captor.getValue().getId()); + assertTrue(captor.getValue().getSilent()); + // 断言:推送了好友更新通知 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testUpdateFriend_notFriend() { + // 准备 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setSilent(true); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendService.updateFriend(1L, reqVO)); + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); + } + + @Test + public void testUpdateFriend_disabledFriend() { + // 准备 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setSilent(true); + ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendService.updateFriend(1L, reqVO)); + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(any(Long.class), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testUpdateFriend_displayNameOnly() { + // 准备:只传备注、不传 silent —— 走差量更新 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setDisplayName("老张"); + ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) + .silent(false).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); + + // 调用 + friendService.updateFriend(1L, reqVO); + + // 断言:updateById 收到 displayName 但没有 silent(MyBatis-Plus 靠 NOT_NULL 跳过) + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(100L, captor.getValue().getId()); + assertEquals("老张", captor.getValue().getDisplayName()); + assertNull(captor.getValue().getSilent()); + // 断言:推送好友更新通知 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testUpdateFriend_emptyRequest() { + // 准备:silent / displayName / pinned 都不传 —— 进入方法立刻返回,不查 mapper 也不发推送 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + + // 调用 + friendService.updateFriend(1L, reqVO); + + // 断言:没查记录、没触发 SQL 更新 / 没发 WebSocket 推送 + verify(imFriendMapper, never()).selectByUserIdAndFriendUserId(anyLong(), anyLong()); + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(any(Long.class), any(ImPrivateMessageDTO.class)); + } + + // ========== addFriend0(内部方法,被 becomeFriends / silentReAddFriend 调用) ========== + + @Test + public void testAddFriend0_existingEnabledSkip() { + // 准备:已存在且启用 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).silent(true).pinned(true).blocked(true).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + // 调用 + friendService.addFriend0(1L, 2L, null, null); + + // 断言:不插入也不更新 + verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + verify(imFriendMapper, never()).updateReAddFields(anyLong(), anyInt(), any(LocalDateTime.class), + anyBoolean(), anyBoolean(), anyBoolean(), any(), any()); + } + + @Test + public void testAddFriend0_existingDisabledRecovers() { + // 准备:已存在且 DISABLE,应当恢复状态 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + // 调用 + friendService.addFriend0(1L, 2L, null, null); + + // 断言:恢复 ENABLE,并清空 deleteTime + verify(imFriendMapper).updateReAddFields(eq(10L), eq(CommonStatusEnum.ENABLE.getStatus()), + any(LocalDateTime.class), eq(false), eq(false), eq(false), isNull(), isNull()); + verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); + } + + @Test + public void testAddFriend0_duplicateKeyPropagates() { + // 准备:mapper 抛并发冲突;极端并发下让异常向外抛,由外层事务回滚 + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + when(imFriendMapper.insert(any(ImFriendDO.class))) + .thenThrow(new DuplicateKeyException("concurrent insert")); + + // 调用 + 断言:异常向外抛 + assertThrows(DuplicateKeyException.class, () -> friendService.addFriend0(1L, 2L, null, null)); + verify(imFriendMapper).insert(any(ImFriendDO.class)); + } + + // ========== deleteFriend ========== + + @Test + public void testDeleteFriend0_alreadyDisabled() { + // 准备:已经是 DISABLE,不再更新 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + boolean result = friendService.deleteFriend0(1L, 2L); + + assertFalse(result); + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + } + + @Test + public void testDeleteFriend0_enabledGetsDisabled() { + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + boolean result = friendService.deleteFriend0(1L, 2L); + + assertTrue(result); + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getDeleteTime()); + } + + @Test + public void testDeleteFriend_alreadyDisabledSkipNotification() { + // 准备:已经是 DISABLE,不再推本端删除通知 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + friendService.deleteFriend(1L, 2L, true); + + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + verify(privateMessageService, never()).sendPrivateMessage(anyLong(), any(ImPrivateMessageSendDTO.class)); + } + + @Test + public void testDeleteFriend_enabledSendNotification() { + // 准备 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + friendService.deleteFriend(1L, 2L, true); + + verify(imFriendMapper).updateById(any(ImFriendDO.class)); + verify(privateMessageService).sendPrivateMessage(eq(1L), any(ImPrivateMessageSendDTO.class)); + } + + // ========== 其它读方法 ========== + + @Test + public void testGetFriendList() { + List list = List.of( + ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .deleteTime(LocalDateTime.now()).build() + ); + when(imFriendMapper.selectListByUserId(1L)).thenReturn(list); + + List result = friendService.getFriendList(1L); + assertEquals(2, result.size()); + } + + @Test + public void testGetActiveFriendList_emptySkip() { + List result = friendService.getActiveFriendList(1L, Collections.emptyList()); + + assertTrue(result.isEmpty()); + verify(imFriendMapper, never()).selectListByUserIdAndFriendUserIdsAndStatus(anyLong(), anyCollection(), anyInt()); + } + + @Test + public void testGetMutualEnableFriendList_filterSingleSideDeleted() { + ImFriendDO friend2 = ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + ImFriendDO friend3 = ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectListByUserIdAndStatus(1L, CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of(friend2, friend3)); + when(imFriendMapper.selectListByUserIdsAndFriendUserIdAndStatus(anyCollection(), eq(1L), + eq(CommonStatusEnum.ENABLE.getStatus()))).thenReturn(List.of( + ImFriendDO.builder().userId(2L).friendUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + List result = friendService.getMutualEnableFriendList(1L); + + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getFriendUserId()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java new file mode 100644 index 000000000..72537a0f1 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java @@ -0,0 +1,348 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DuplicateKeyException; + +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群成员 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupMemberServiceImpl groupMemberService; + + @Mock + private ImGroupMemberMapper groupMemberMapper; + @Mock + private ImWebSocketService webSocketService; + @Mock + private cn.iocoder.yudao.module.im.service.message.ImGroupMessageService groupMessageService; + + // ========== addGroupMember ========== + + @Test + public void testAddGroupMember_newInsert() { + // 准备:成员记录不存在 + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + // 调用 + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + // 断言:执行了 insert,返回记录的 status 为 ENABLE + assertNotNull(result); + assertEquals(10L, result.getGroupId()); + assertEquals(1L, result.getUserId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + assertNotNull(result.getJoinTime()); + verify(groupMemberMapper).insert(any(ImGroupMemberDO.class)); + } + + @Test + public void testAddGroupMember_existingEnabledReturns() { + // 准备:已存在且 ENABLE,只返回已有记录,不做其它操作 + ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); + + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + assertEquals(50L, result.getId()); + verify(groupMemberMapper, never()).insert(any(ImGroupMemberDO.class)); + verify(groupMemberMapper, never()).updateById(any(ImGroupMemberDO.class)); + } + + @Test + public void testAddGroupMember_existingDisabledRecovers() { + // 准备:已存在且 DISABLE,应重置为 ENABLE 并重置 role 为 MEMBER + ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); + + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + verify(groupMemberMapper).updateRejoinFields(eq(50L), eq(CommonStatusEnum.ENABLE.getStatus()), + any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + assertEquals(ImGroupMemberRoleEnum.NORMAL.getRole(), result.getRole()); + assertNull(result.getQuitTime()); + assertNull(result.getMuteEndTime()); + } + + @Test + public void testAddGroupMember_duplicateKeyFallsBackToSelect() { + // 准备:第一次 select 返回 null,insert 抛 DuplicateKey,再次 select 返回已插入记录 + ImGroupMemberDO inserted = ImGroupMemberDO.builder().id(80L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null).thenReturn(inserted); + when(groupMemberMapper.insert(any(ImGroupMemberDO.class))) + .thenThrow(new DuplicateKeyException("concurrent insert")); + + // 调用:冲突后降级 select + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + assertNotNull(result); + assertEquals(80L, result.getId()); + } + + // ========== addGroupMembers ========== + + @Test + public void testAddGroupMembers_mixedInsertAndUpdate() { + // 准备:用户 2 不存在(新增),用户 3 已存在且 DISABLE(恢复),用户 4 已存在且 ENABLE(跳过) + ImGroupMemberDO exist3 = ImGroupMemberDO.builder().id(30L).groupId(10L).userId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + ImGroupMemberDO exist4 = ImGroupMemberDO.builder().id(40L).groupId(10L).userId(4L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of(exist3, exist4)); + + // 调用 + groupMemberService.addGroupMembers(10L, List.of(2L, 3L, 4L)); + + // 断言:updates 只有用户 3;inserts 只有用户 2 + verify(groupMemberMapper).updateRejoinFields(eq(30L), eq(CommonStatusEnum.ENABLE.getStatus()), + any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull()); + verify(groupMemberMapper).insertBatch(argThat((List list) -> + list.size() == 1 && list.get(0).getUserId().equals(2L))); + } + + @Test + public void testAddGroupMembers_allExisting_onlyUpdates() { + // 准备:传入的 3 个用户都已有记录(全部 DISABLE) → 只做 update,不做 insert + List existing = List.of( + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(), + ImGroupMemberDO.builder().id(2L).groupId(10L).userId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(existing); + + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + verify(groupMemberMapper, times(2)).updateRejoinFields(anyLong(), eq(CommonStatusEnum.ENABLE.getStatus()), + any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull()); + verify(groupMemberMapper, never()).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_allNew_onlyInserts() { + // 准备:都不存在 → 只做 insert + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of()); + + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + verify(groupMemberMapper, never()).updateRejoinFields(anyLong(), anyInt(), any(), anyInt(), any(), any()); + verify(groupMemberMapper).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_allExistingEnabled_nothingHappens() { + // 准备:都已存在且 ENABLE → 既不 update 也不 insert + List existing = List.of( + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(existing); + + groupMemberService.addGroupMembers(10L, List.of(2L)); + + verify(groupMemberMapper, never()).updateRejoinFields(anyLong(), anyInt(), any(), anyInt(), any(), any()); + verify(groupMemberMapper, never()).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_batchInsertDuplicateFallback() { + // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of()); + doThrow(new DuplicateKeyException("concurrent batch insert")) + .when(groupMemberMapper).insertBatch(anyList()); + // addGroupMember 单条兜底逻辑 + when(groupMemberMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null); + + // 调用 + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + // 断言:降级为逐条调用 insert + verify(groupMemberMapper, times(2)).insert(any(ImGroupMemberDO.class)); + } + + // ========== validateMemberInGroup ========== + + @Test + public void testValidateMemberInGroup_notInGroup() { + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.validateMemberInGroup(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testValidateMemberInGroup_disabledEqualsNotInGroup() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.validateMemberInGroup(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testValidateMemberInGroup_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ImGroupMemberDO result = groupMemberService.validateMemberInGroup(10L, 1L); + assertEquals(50L, result.getId()); + } + + // ========== updateGroupMember ========== + + @Test + public void testUpdateGroupMember_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ImGroupMemberUpdateReqVO reqVO = new ImGroupMemberUpdateReqVO() + .setGroupId(10L).setSilent(true).setDisplayUserName("昵称"); + + groupMemberService.updateGroupMember(1L, reqVO); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertTrue(captor.getValue().getSilent()); + // 公开字段昵称变化 → 全员广播 GROUP_MEMBER_NICKNAME_UPDATE + verify(groupMessageService).sendGroupMessage(eq(1L), any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class)); + // 个人字段 silent 变化 → 仅自己多端同步 GROUP_MEMBER_SETTING_UPDATE + verify(groupMessageService).sendGroupMessage(eq(1L), eq(java.util.List.of(1L)), + any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class)); + } + + // ========== removeGroupMember ========== + + @Test + public void testRemoveGroupMember_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + groupMemberService.removeGroupMember(10L, 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + @Test + public void testRemoveGroupMember_notInGroup() { + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.removeGroupMember(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== removeGroupMembers ========== + + @Test + public void testRemoveGroupMembers_batch() { + groupMemberService.removeGroupMembers(10L, List.of(2L, 3L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateByGroupIdAndUserIdsAndStatus(eq(10L), anyCollection(), + eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + @Test + public void testRemoveGroupMembersByGroupId() { + groupMemberService.removeGroupMembersByGroupId(10L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateByGroupIdAndStatus(eq(10L), + eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + // ========== getActiveGroupMemberUserIdsByGroupId ========== + + @Test + public void testGetActiveGroupMemberUserIdsByGroupId_extractsUserIds() { + // 准备:3 个 ENABLE 成员 + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(members); + + // 调用 + List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); + + // 断言:只返回 userId、顺序保留 + assertEquals(List.of(1L, 2L, 3L), userIds); + } + + @Test + public void testGetActiveGroupMemberUserIdsByGroupId_emptyList() { + when(groupMemberMapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of()); + + List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); + + assertTrue(userIds.isEmpty()); + } + + @Test + public void testGetGroupMemberListByOwnerAndAdmin_passesRoles() { + List roles = List.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole()); + List members = List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L).build()); + when(groupMemberMapper.selectListByGroupIdAndStatusAndRoles(10L, CommonStatusEnum.ENABLE.getStatus(), roles)) + .thenReturn(members); + + List result = groupMemberService.getGroupMemberListByOwnerAndAdmin(10L); + + assertEquals(members, result); + verify(groupMemberMapper).selectListByGroupIdAndStatusAndRoles( + 10L, CommonStatusEnum.ENABLE.getStatus(), roles); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImplTest.java new file mode 100644 index 000000000..4207359ab --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImplTest.java @@ -0,0 +1,349 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.request.ImGroupRequestApplyReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper; +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DuplicateKeyException; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 加群申请 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupRequestServiceImpl groupRequestService; + + @Mock + private ImGroupRequestMapper groupRequestMapper; + @Mock + private ImGroupService groupService; + @Mock + private ImGroupMemberService groupMemberService; + @Mock + private ImGroupMessageService groupMessageService; + @Mock + private ImWebSocketService websocketService; + @Mock + private AdminUserApi adminUserApi; + + // ==================== applyJoinGroup ==================== + + @Test + public void testApplyJoinGroup_freeMode_directJoin() { + // 准备:群是 FREE 模式 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .joinApproval(false) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.validateGroupExists(10L)).thenReturn(group); + + ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO(); + reqVO.setGroupId(10L); + reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource()); + + // 调用 + ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO); + + // 断言:FREE 路径直接入群,不落申请记录 + assertNull(result); + verify(groupService).validateMemberCountLimit(10L, 1); + verify(groupMemberService).addGroupMember(eq(10L), eq(1L), + eq(ImGroupMemberRoleEnum.NORMAL.getRole()), + eq(ImGroupAddSourceEnum.SEARCH.getSource()), isNull()); + verify(groupRequestMapper, never()).insert(any(ImGroupRequestDO.class)); + // 推 1510 自由进群 + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_MEMBER_ENTER.getType(), dtoCaptor.getValue().getType()); + } + + @Test + public void testApplyJoinGroup_approvalMode_createsRequest() { + // 准备:群是 APPLY 模式 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .joinApproval(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.validateGroupExists(10L)).thenReturn(group); + // 群里有 owner + 一个 admin,作为 1503 推送目标 + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(99L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(98L) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + when(adminUserApi.getUser(1L)).thenReturn(success(buildUser(1L, "申请人"))); + + ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO(); + reqVO.setGroupId(10L); + reqVO.setApplyContent("我想进群"); + reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource()); + + // 调用 + ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO); + + // 断言:申请记录已落库 + 不直进群 + assertNotNull(result); + verify(groupMemberService, never()).addGroupMember(anyLong(), anyLong(), anyInt(), anyInt(), anyLong()); + verify(groupRequestMapper).insert(any(ImGroupRequestDO.class)); + // 1503 推送给 owner(99) + admin(98),去重后两条 + verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testApplyJoinGroup_insertDuplicateKey_reuseOldRequest() { + // 准备:群是 APPLY 模式,首次查询不存在,插入时命中唯一键 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .joinApproval(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(99L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + when(adminUserApi.getUser(1L)).thenReturn(success(buildUser(1L, "申请人"))); + ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(1L) + .setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult()); + when(groupRequestMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null, old); + when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup")); + + ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO(); + reqVO.setGroupId(10L); + reqVO.setApplyContent("我想进群"); + reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource()); + + // 调用 + ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO); + + // 断言:复用并重置旧申请 + verify(groupRequestMapper).updateApplyByIdReset(eq(50L), eq("我想进群"), + eq(ImGroupAddSourceEnum.SEARCH.getSource()), any()); + assertEquals(50L, result.getId()); + assertEquals(ImGroupRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult()); + verify(websocketService).sendPrivateMessageAsync(eq(99L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testApplyJoinGroup_alreadyMember_throws() { + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .joinApproval(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + + ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO(); + reqVO.setGroupId(10L); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupRequestService.applyJoinGroup(1L, reqVO)); + assertEquals(GROUP_REQUEST_ALREADY_MEMBER.getCode(), exception.getCode()); + } + + // ==================== agreeGroupRequest ==================== + + @Test + public void testAgreeGroupRequest_success_activeApply() { + // 准备:主动申请未处理,操作人是 admin + ImGroupRequestDO request = new ImGroupRequestDO() + .setGroupId(10L).setUserId(2L).setInviterUserId(null) + .setAddSource(ImGroupAddSourceEnum.SEARCH.getSource()) + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); + request.setId(50L); + when(groupRequestMapper.selectById(50L)).thenReturn(request); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupRequestMapper.updateByIdAndHandleResult(eq(50L), + eq(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()), any())).thenReturn(1); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.getGroup(10L)).thenReturn(group); + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(99L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + // 调用 + groupRequestService.agreeGroupRequest(1L, 50L); + + // 断言:人数校验 + 写群成员 + 推 1505 + 推 1510(主动申请) + verify(groupService).validateMemberCountLimit(10L, 1); + verify(groupMemberService).addGroupMember(eq(10L), eq(2L), + eq(ImGroupMemberRoleEnum.NORMAL.getRole()), + eq(ImGroupAddSourceEnum.SEARCH.getSource()), isNull()); + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_MEMBER_ENTER.getType(), dtoCaptor.getValue().getType()); + // 1505 推送给申请人 + owner,去重后两条 + verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testAgreeGroupRequest_concurrent_secondCallFails() { + // 准备:申请存在但乐观锁更新返回 0(被并发处理过) + ImGroupRequestDO request = new ImGroupRequestDO() + .setGroupId(10L).setUserId(2L) + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); + request.setId(50L); + when(groupRequestMapper.selectById(50L)).thenReturn(request); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupRequestMapper.updateByIdAndHandleResult(eq(50L), + eq(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()), any())).thenReturn(0); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupRequestService.agreeGroupRequest(1L, 50L)); + assertEquals(GROUP_REQUEST_HANDLED.getCode(), exception.getCode()); + // 不写群成员 + verify(groupMemberService, never()).addGroupMember(anyLong(), anyLong(), anyInt(), any(), any()); + } + + @Test + public void testAgreeGroupRequest_notOwnerOrAdmin_throws() { + ImGroupRequestDO request = new ImGroupRequestDO() + .setGroupId(10L).setUserId(2L) + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); + request.setId(50L); + when(groupRequestMapper.selectById(50L)).thenReturn(request); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupRequestService.agreeGroupRequest(1L, 50L)); + assertEquals(GROUP_REQUEST_NOT_TO_ME.getCode(), exception.getCode()); + } + + // ==================== refuseGroupRequest ==================== + + @Test + public void testRefuseGroupRequest_success() { + ImGroupRequestDO request = new ImGroupRequestDO() + .setGroupId(10L).setUserId(2L) + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); + request.setId(50L); + when(groupRequestMapper.selectById(50L)).thenReturn(request); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupRequestMapper.updateByIdAndHandleResult(eq(50L), + eq(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()), any())).thenReturn(1); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.getGroup(10L)).thenReturn(group); + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + groupRequestService.refuseGroupRequest(1L, 50L, "暂不通过"); + + // 不写群成员;推 1506 给申请人 + 群主(同一人 1L 时去重为 1 + 申请人 2L = 2 条) + verify(groupMemberService, never()).addGroupMember(anyLong(), anyLong(), anyInt(), any(), any()); + verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + // ==================== createInviteRequestList ==================== + + @Test + public void testCreateInviteRequestList_success() { + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .joinApproval(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupRequestMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null); + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(99L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(java.util.Map.of( + 2L, buildUser(2L, "用户A"), + 3L, buildUser(3L, "用户B"))); + + // 调用:邀请人 1L 邀请 2L、3L(都没有旧记录) + groupRequestService.createInviteRequestList(10L, 1L, List.of(2L, 3L)); + + // 断言:插入 2 条 + 推 1503 给 owner(每条 1 帧)共 2 帧 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupRequestDO.class); + verify(groupRequestMapper, times(2)).insert(captor.capture()); + verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + // 断言:每条记录 inviterUserId=1 + addSource=INVITE,避免审批通过后回写群成员留痕的来源为空 / 脏带旧值 + Collection inserted = captor.getAllValues(); + assertEquals(2, inserted.size()); + inserted.forEach(insert -> { + assertEquals(1L, insert.getInviterUserId()); + assertEquals(ImGroupAddSourceEnum.INVITE.getSource(), insert.getAddSource()); + }); + } + + @Test + public void testCreateInviteRequestList_insertDuplicateKey_reuseOldRequest() { + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .joinApproval(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(2L) + .setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult()); + when(groupRequestMapper.selectByGroupIdAndUserId(10L, 2L)).thenReturn(null, old); + when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup")); + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(99L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(java.util.Map.of(2L, buildUser(2L, "用户A"))); + + // 调用 + groupRequestService.createInviteRequestList(10L, 1L, List.of(2L)); + + // 断言:复用并重置旧邀请申请 + verify(groupRequestMapper).updateInviteByIdReset(eq(50L), eq(1L), + eq(ImGroupAddSourceEnum.INVITE.getSource()), any()); + verify(websocketService).sendPrivateMessageAsync(eq(99L), any(ImPrivateMessageDTO.class)); + } + + private AdminUserRespDTO buildUser(Long id, String nickname) { + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setId(id); + user.setNickname(nickname); + return user; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java new file mode 100644 index 000000000..b430f0bf9 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -0,0 +1,1146 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminAddReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminRemoveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupMuteMemberReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupTransferOwnerReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerBanReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; +import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Spy; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupServiceImpl groupService; + + @Mock + private ImGroupMapper groupMapper; + @Mock + private ImGroupMemberService groupMemberService; + @Mock + private ImGroupMessageService groupMessageService; + @Mock + private ImWebSocketService webSocketService; + @Mock + private ImFriendService friendService; + @Mock + private ImGroupRequestService groupRequestService; + @Mock + private AdminUserApi adminUserApi; + @Spy + private ImProperties imProperties = new ImProperties(); + + // ========== createGroup ========== + + @Test + public void testCreateGroup_success() { + // 准备:仅创建者,无初始成员 + ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); + reqVO.setName("测试群"); + when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> { + ImGroupDO group = invocation.getArgument(0); + group.setId(100L); + return 1; + }); + + // 调用 + ImGroupDO result = groupService.createGroup(reqVO, 1L); + + // 断言:群主 + 状态 + assertEquals(100L, result.getId()); + assertEquals(1L, result.getOwnerUserId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + // 验证:群主加入群(带 OWNER role)+ 不调批量加成员(无初始成员) + verify(groupMemberService).addGroupMember(100L, 1L, ImGroupMemberRoleEnum.OWNER.getRole()); + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); + // 验证:推送 GROUP_CREATE 通知(payload memberUserIds 含创建者自己) + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), anyCollection(), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_CREATE.getType(), dtoCaptor.getValue().getType()); + } + + @Test + public void testCreateGroup_withInitialMembers() { + // 准备:创建者 + 2 个初始成员,都是好友 + ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); + reqVO.setName("测试群"); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> { + ImGroupDO group = invocation.getArgument(0); + group.setId(100L); + return 1; + }); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + // 调用 + ImGroupDO result = groupService.createGroup(reqVO, 1L); + + // 断言:群创建成功 + 创建者 + 初始成员都加入 + assertEquals(100L, result.getId()); + verify(groupMemberService).addGroupMember(100L, 1L, ImGroupMemberRoleEnum.OWNER.getRole()); + verify(groupMemberService).addGroupMembers(eq(100L), anyCollection(), + eq(ImGroupAddSourceEnum.INVITE.getSource()), eq(1L)); + // 验证:推送 GROUP_CREATE 通知,payload memberUserIds 含全员(创建者 + 邀请) + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), anyCollection(), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_CREATE.getType(), dtoCaptor.getValue().getType()); + } + + @Test + public void testCreateGroup_initialMemberNotFriend() { + // 准备:初始成员里有非好友 + ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); + reqVO.setName("测试群"); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + // 只有 2 是好友,3 不是 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + AdminUserRespDTO u3 = new AdminUserRespDTO(); + u3.setId(3L); + u3.setNickname("李四"); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3)); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.createGroup(reqVO, 1L)); + assertEquals(GROUP_INVITE_NOT_FRIEND.getCode(), exception.getCode()); + verify(groupMapper, never()).insert(any(ImGroupDO.class)); + } + + // ========== updateGroup ========== + + @Test + public void testUpdateGroup_notOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:当前用户不是群主 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); + reqVO.setId(10L); + reqVO.setName("新名字"); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.updateGroup(reqVO, 1L)); + assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); + } + } + + @Test + public void testUpdateGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).name("旧名字").ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); + reqVO.setId(10L); + reqVO.setName("新名字"); + + // 调用 + ImGroupDO result = groupService.updateGroup(reqVO, 1L); + + // 断言:更新了数据库 + verify(groupMapper).updateById(any(ImGroupDO.class)); + assertEquals("新名字", result.getName()); + // 推送 GROUP_NAME_UPDATE 通知给全员 + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), anyCollection(), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_NAME_UPDATE.getType(), dtoCaptor.getValue().getType()); + } + } + + // ========== dissolveGroup ========== + + @Test + public void testDissolveGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + // 调用 + groupService.dissolveGroup(10L, 1L); + + // 断言:群状态变为 DISABLE + 群成员全部移除 + 清理已读缓存 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupDO.class); + verify(groupMapper).updateById(captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getDissolvedTime()); + verify(groupMemberService).removeGroupMembersByGroupId(10L); + verify(groupMessageService).deleteReadMaxMessageIdMap(10L); + // 推送 GROUP_DISSOLVE 通知(send-before-remove,sendGroupMessage 内部查 active 自动覆盖全员,含群主多端同步) + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType()); + } + } + + @Test + public void testDissolveGroup_banned_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:已封禁但未解散的群,群主仍可解散 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + // 调用 + groupService.dissolveGroup(10L, 1L); + + // 断言:封禁状态不阻止解散 + verify(groupMapper).updateById(argThat((ImGroupDO update) -> + CommonStatusEnum.DISABLE.getStatus().equals(update.getStatus()))); + verify(groupMemberService).removeGroupMembersByGroupId(10L); + } + } + + @Test + public void testDissolveGroupByManager_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:管理员解散封禁群,不要求管理员是群主 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + // 调用 + groupService.dissolveGroupByManager(99L, 10L); + + // 断言:使用管理员编号发通知并完成清理 + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(99L), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType()); + verify(groupMemberService).removeGroupMembersByGroupId(10L); + verify(groupMessageService).deleteReadMaxMessageIdMap(10L); + } + } + + @Test + public void testDissolveGroup_notOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.dissolveGroup(10L, 1L)); + assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); + } + } + + // ========== inviteGroupMember ========== + + @Test + public void testInviteGroupMember_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:群存在 + joinApproval=false(自由进群) + 当前用户是群主 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .joinApproval(false) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + + // 当前成员只有群主 + List activeMembers = new ArrayList<>(); + activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + + // 被邀请人 2 和 3 都是好友 + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + List friends = List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(friends); + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:校验群成员 + 批量添加成员(带 INVITE 来源 + 邀请人)+ 推送 GROUP_MEMBER_INVITE + verify(groupMemberService).validateMemberInGroup(10L, 1L); + verify(groupMemberService).addGroupMembers(eq(10L), anyCollection(), + eq(ImGroupAddSourceEnum.INVITE.getSource()), eq(1L)); + verify(groupRequestService, never()).createInviteRequestList(anyLong(), anyLong(), anyCollection()); + verify(webSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), anyCollection(), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_MEMBER_INVITE.getType(), dtoCaptor.getValue().getType()); + } + } + + @Test + public void testInviteGroupMember_approval_normalRoutesToApproval() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:joinApproval=true,开启审批;普通成员邀请走审批,落 group_request + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .joinApproval(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(99L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:走审批分支:调 createInviteRequestList;不写群成员、不推 1509 + verify(groupRequestService).createInviteRequestList(eq(10L), eq(1L), anyCollection()); + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection(), any(), any()); + verify(groupMessageService, never()).sendGroupMessage(anyLong(), any(ImGroupMessageSendDTO.class)); + } + } + + @Test + public void testInviteGroupMember_approval_ownerBypassesApproval() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:joinApproval=true,但邀请人是群主;视同已审批,直进群 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .joinApproval(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:绕过审批,直接 addGroupMembers + 推 1509;不落 group_request + verify(groupRequestService, never()).createInviteRequestList(anyLong(), anyLong(), anyCollection()); + verify(groupMemberService).addGroupMembers(eq(10L), anyCollection(), + eq(ImGroupAddSourceEnum.INVITE.getSource()), eq(1L)); + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), anyCollection(), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_MEMBER_INVITE.getType(), dtoCaptor.getValue().getType()); + } + } + + @Test + public void testInviteGroupMember_notFriend() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)) + .thenReturn(List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + // 只有 2 是好友,3 不是 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.inviteGroupMember(1L, reqVO)); + assertEquals(GROUP_INVITE_NOT_FRIEND.getCode(), exception.getCode()); + } + } + + @Test + public void testInviteGroupMember_memberExceed() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:群 10 已有 499 人(逼近 MAX_GROUP_MEMBER=500),再邀请 2 人 → 501 超限 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + List activeMembers = new ArrayList<>(); + for (long i = 1; i <= 499; i++) { + activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(i) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + } + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(600L, 601L))); + // 被邀请人都是好友 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(600L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(601L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.inviteGroupMember(1L, reqVO)); + assertEquals(GROUP_MEMBER_EXCEED.getCode(), exception.getCode()); + // 断言:不加成员、不推送 + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); + } + } + + @Test + public void testInviteGroupMember_skipsMembersAlreadyInGroup() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 用户 2 已在群中 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L))); // 只邀请 2,他已在群中 + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:不会触发添加、不会推送 + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); + verify(webSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== quitGroup ========== + + @Test + public void testQuitGroup_ownerCannotQuit() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.quitGroup(10L, 1L)); + assertEquals(GROUP_OWNER_CANNOT_QUIT.getCode(), exception.getCode()); + } + } + + @Test + public void testQuitGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + groupService.quitGroup(10L, 1L); + + verify(groupMemberService).removeGroupMember(10L, 1L); + verify(groupMessageService).deleteReadMaxMessageId(10L, 1L); + // 推送 GROUP_MEMBER_QUIT 通知给全员(含 quitter,前端自判清群) + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_MEMBER_QUIT.getType(), dtoCaptor.getValue().getType()); + } + } + + @Test + public void testQuitGroup_bannedGroupAllowed() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + groupService.quitGroup(10L, 1L); + + verify(groupMemberService).removeGroupMember(10L, 1L); + verify(groupMessageService).deleteReadMaxMessageId(10L, 1L); + verify(groupMessageService).sendGroupMessage(eq(1L), any(ImGroupMessageSendDTO.class)); + } + } + + // ========== removeGroupMember ========== + + @Test + public void testRemoveGroupMember_cannotRemoveSelf() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(1L, 2L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.removeGroupMember(1L, reqVO)); + assertEquals(GROUP_CANNOT_REMOVE_SELF.getCode(), exception.getCode()); + } + } + + @Test + public void testRemoveGroupMember_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 操作者:群主 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); + // 目标:两个普通成员 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(2L, 3L)); + + groupService.removeGroupMember(1L, reqVO); + + verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection()); + verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), anyCollection()); + // 推送 GROUP_MEMBER_KICK 通知给全员(含被踢者,前端自判清群) + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_MEMBER_KICK.getType(), dtoCaptor.getValue().getType()); + } + } + + @Test + public void testRemoveGroupMember_adminCannotRemoveAdmin() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 操作者:管理员 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()); + // 目标:另一个管理员 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(2L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.removeGroupMember(1L, reqVO)); + assertEquals(GROUP_REMOVE_ADMIN_DENIED.getCode(), exception.getCode()); + verify(groupMemberService, never()).removeGroupMembers(anyLong(), anyCollection()); + } + } + + @Test + public void testRemoveGroupMember_ownerCannotBeRemoved() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 操作者:群主(不能踢自己;这里换成另一个 userId 的群主语义 — 用 ADMIN 操作群主) + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(99L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(99L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.removeGroupMember(1L, reqVO)); + assertEquals(GROUP_REMOVE_OWNER_DENIED.getCode(), exception.getCode()); + } + } + + @Test + public void testRemoveGroupMember_skipInactiveTargets() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 操作者:群主 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()); + // 目标:2L 有效普通成员;3L 已退群(DISABLE)的历史管理员,应被跳过而非拦截整批 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()) + .status(CommonStatusEnum.DISABLE.getStatus()).build())); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(2L, 3L)); + + groupService.removeGroupMember(1L, reqVO); + + // 仅有效成员 2L 进入移除 / 已读清理,已退群的 3L 被跳过 + ArgumentCaptor removeCaptor = ArgumentCaptor.forClass(Collection.class); + verify(groupMemberService).removeGroupMembers(eq(10L), removeCaptor.capture()); + assertEquals(Set.of(2L), Set.copyOf(removeCaptor.getValue())); + ArgumentCaptor readCaptor = ArgumentCaptor.forClass(Collection.class); + verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), readCaptor.capture()); + assertEquals(Set.of(2L), Set.copyOf(readCaptor.getValue())); + } + } + + // ========== addGroupAdmin ========== + + @Test + public void testAddGroupAdmin_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + // 目标 3 是普通成员 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus()) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); + // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限 + when(groupMemberService.getGroupMemberCountByRole(10L, ImGroupMemberRoleEnum.ADMIN.getRole())) + .thenReturn(1L); + when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(), + eq(ImGroupMemberRoleEnum.ADMIN.getRole()))).thenReturn(1); + + ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); + reqVO.setId(10L); + reqVO.setUserIds(List.of(3L)); + + groupService.addGroupAdmin(1L, reqVO); + + verify(groupMemberService).updateGroupMemberRole(eq(10L), argThat((Set ids) -> + ids.size() == 1 && ids.contains(3L)), + eq(ImGroupMemberRoleEnum.ADMIN.getRole())); + } + } + + @Test + public void testAddGroupAdmin_exceedsLimit() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus()) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); + // 群里已有 3 个 ADMIN(达到上限),再加 1 会超 + when(groupMemberService.getGroupMemberCountByRole(10L, ImGroupMemberRoleEnum.ADMIN.getRole())) + .thenReturn(3L); + + ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); + reqVO.setId(10L); + reqVO.setUserIds(List.of(5L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.addGroupAdmin(1L, reqVO)); + assertEquals(GROUP_ADMIN_MAX_LIMIT.getCode(), exception.getCode()); + verify(groupMemberService, never()).updateGroupMemberRole(anyLong(), anyCollection(), anyInt()); + } + } + + @Test + public void testAddGroupAdmin_targetIsOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus()) + .role(ImGroupMemberRoleEnum.OWNER.getRole()).build())); + + ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); + reqVO.setId(10L); + reqVO.setUserIds(List.of(1L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.addGroupAdmin(1L, reqVO)); + assertEquals(GROUP_ADMIN_TARGET_IS_OWNER.getCode(), exception.getCode()); + } + } + + @Test + public void testAddGroupAdmin_idempotentSkipWhenAlreadyAdmin() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + // 目标已是 ADMIN:再加无需操作 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); + + ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); + reqVO.setId(10L); + reqVO.setUserIds(List.of(2L)); + + groupService.addGroupAdmin(1L, reqVO); + + verify(groupMemberService, never()).updateGroupMemberRole(anyLong(), anyCollection(), anyInt()); + verify(groupMemberService, never()).getGroupMemberCountByRole(anyLong(), anyInt()); + } + } + + // ========== removeGroupAdmin ========== + + @Test + public void testRemoveGroupAdmin_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); + when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(), + eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1); + + ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); + reqVO.setId(10L); + reqVO.setUserIds(List.of(2L)); + + groupService.removeGroupAdmin(1L, reqVO); + + verify(groupMemberService).updateGroupMemberRole(eq(10L), argThat((Set ids) -> + ids.size() == 1 && ids.contains(2L)), + eq(ImGroupMemberRoleEnum.NORMAL.getRole())); + } + } + + @Test + public void testRemoveGroupAdmin_idempotentSkipWhenAlreadyMember() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + // 目标已是 MEMBER:撤销无需操作 + when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( + ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); + + ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); + reqVO.setId(10L); + reqVO.setUserIds(List.of(2L)); + + groupService.removeGroupAdmin(1L, reqVO); + + verify(groupMemberService, never()).updateGroupMemberRole(anyLong(), anyCollection(), anyInt()); + } + } + + // ========== transferGroupOwner ========== + + @Test + public void testTransferGroupOwner_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(2L)), + eq(ImGroupMemberRoleEnum.OWNER.getRole()))).thenReturn(1); + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(1L)), + eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1); + + ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); + reqVO.setId(10L); + reqVO.setNewOwnerUserId(2L); + + groupService.transferGroupOwner(1L, reqVO); + + // 群表 owner 切换 + ArgumentCaptor groupCaptor = ArgumentCaptor.forClass(ImGroupDO.class); + verify(groupMapper).updateById(groupCaptor.capture()); + assertEquals(2L, groupCaptor.getValue().getOwnerUserId()); + // 旧群主 → MEMBER;新群主 → OWNER + verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(Set.of(1L)), + eq(ImGroupMemberRoleEnum.NORMAL.getRole())); + verify(groupMemberService).updateGroupMemberRole(eq(10L), eq(Set.of(2L)), + eq(ImGroupMemberRoleEnum.OWNER.getRole())); + } + } + + @Test + public void testTransferGroupOwner_toSelf() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + + ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); + reqVO.setId(10L); + reqVO.setNewOwnerUserId(1L); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.transferGroupOwner(1L, reqVO)); + assertEquals(GROUP_TRANSFER_OWNER_TO_SELF.getCode(), exception.getCode()); + verify(groupMapper, never()).updateById(any(ImGroupDO.class)); + } + } + + @Test + public void testTransferGroupOwner_notOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); + + ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); + reqVO.setId(10L); + reqVO.setNewOwnerUserId(2L); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.transferGroupOwner(1L, reqVO)); + assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); + } + } + + // ========== banGroup ========== + + @Test + public void testBanGroup_dissolved() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupManagerBanReqVO reqVO = new ImGroupManagerBanReqVO(); + reqVO.setId(10L).setReason("违规"); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.banGroup(1L, reqVO)); + assertEquals(GROUP_DISSOLVED.getCode(), exception.getCode()); + verify(groupMapper, never()).updateById(any(ImGroupDO.class)); + verify(groupMessageService, never()).sendGroupMessage(anyLong(), any(ImGroupMessageSendDTO.class)); + } + } + + @Test + public void testBanGroup_alreadyBannedSkip() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).banned(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupManagerBanReqVO reqVO = new ImGroupManagerBanReqVO(); + reqVO.setId(10L).setReason("违规"); + + groupService.banGroup(1L, reqVO); + + verify(groupMapper, never()).updateById(any(ImGroupDO.class)); + verify(groupMessageService, never()).sendGroupMessage(anyLong(), any(ImGroupMessageSendDTO.class)); + } + } + + // ========== muteMember ========== + + @Test + public void testMuteMember_normalCannotMuteAdmin() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); + when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()); + + ImGroupMuteMemberReqVO reqVO = new ImGroupMuteMemberReqVO(); + reqVO.setId(10L).setUserId(2L).setMutedSeconds(60); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.muteMember(1L, reqVO)); + assertEquals(GROUP_NOT_OWNER_OR_ADMIN.getCode(), exception.getCode()); + verify(groupMemberService, never()).updateGroupMemberMuteEndTime(anyLong(), anyLong(), any()); + verify(groupMessageService, never()).sendGroupMessage(anyLong(), any(ImGroupMessageSendDTO.class)); + } + } + + // ========== validateGroupExists ========== + + @Test + public void testValidateGroupExists_notExists() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + when(groupMapper.selectById(10L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_NOT_EXISTS.getCode(), exception.getCode()); + } + } + + @Test + public void testValidateGroupExists_banned() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).banned(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_BANNED.getCode(), exception.getCode()); + } + } + + @Test + public void testValidateGroupExists_dissolved() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_DISSOLVED.getCode(), exception.getCode()); + } + } + + // ========== getMyGroupList ========== + + @Test + public void testGetMyGroupList_noMembers() { + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(new ArrayList<>()); + + List result = groupService.getMyGroupList(1L); + assertTrue(result.isEmpty()); + verify(groupMapper, never()).selectByIds(anyCollection()); + } + + @Test + public void testGetMyGroupList_success() { + // 活跃群成员 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(20L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ))); + // 最近退群成员(最近 30 天内) + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(new ArrayList<>(List.of( + ImGroupMemberDO.builder().groupId(30L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build() + ))); + List groups = List.of( + ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMapper.selectByIds(anyCollection())).thenReturn(groups); + + List result = groupService.getMyGroupList(1L); + assertEquals(3, result.size()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java new file mode 100644 index 000000000..5582fab6f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -0,0 +1,1196 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Spy; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群聊消息 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupMessageServiceImpl groupMessageService; + + @Mock + private ImGroupMessageMapper groupMessageMapper; + @Mock + private ImGroupService groupService; + @Mock + private ImGroupMemberService groupMemberService; + @Mock + private ImSensitiveWordService sensitiveWordService; + @Mock + private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO; + @Mock + private ImWebSocketService imWebSocketService; + + /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true、maxPullSize=1000);个别用例可改字段测分支 */ + @Spy + private ImProperties imProperties = new ImProperties(); + + private ImGroupMessageSendReqVO buildSendReqVO() { + ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); + reqVO.setClientMessageId("test-uuid-group-001"); + reqVO.setGroupId(10L); + reqVO.setType(ImMessageTypeEnum.TEXT.getType()); + reqVO.setContent("{\"content\":\"群聊你好\"}"); + return reqVO; + } + + // ========== 发送测试 ========== + + @Test + public void testSendMessage_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + group.setName("测试群"); + when(groupService.validateGroupExists(10L)).thenReturn(group); + + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L)); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { + ImGroupMessageDO msg = invocation.getArgument(0); + msg.setId(99L); + return 1; + }); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言 + assertNotNull(result); + assertEquals(1L, result.getSenderId()); + assertEquals(10L, result.getGroupId()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); + + // 验证推送给 3 个群成员(含发送者自己,用于多端同步) + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) && ids.contains(3L)), + any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testSendMessage_clientMessageIdIdempotent() { + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + ImGroupMessageDO existing = ImGroupMessageDO.builder() + .id(100L).clientMessageId("test-uuid-group-001").senderId(1L).groupId(10L) + .type(0).content("{\"content\":\"群聊你好\"}").status(0) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(existing); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言 + assertEquals(100L, result.getId()); + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + } + + @Test + public void testSendMessage_notInGroup() { + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.sendGroupMessage(1L, reqVO)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== pull 测试 ========== + + @Test + public void testPullMessages_joinTimeFilter() { + // 准备:消息时间用"最近 1 天内"避免被 30 天窗口过滤 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime joinTime = now.minusHours(2); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + // 入群前 1 条 + 入群后 1 条 + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(3)).build(); + ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(beforeJoin, afterJoin)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:入群前消息不可见 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getId()); + } + + @Test + public void testPullMessages_activeQueryRetriesWhenFirstBatchInvisible() { + // 准备:首批 2 条(入群前 + 定向给别人)全部不可见,第二批命中可见消息 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime joinTime = now.minusHours(1); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(3)).build(); + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(2L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(now.minusHours(2)).build(); + ImGroupMessageDO visibleMsg = ImGroupMessageDO.builder() + .id(3L).groupId(10L).senderId(2L) + .sendTime(now.minusMinutes(30)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(beforeJoin, directedMsg)); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(2L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(visibleMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); + + // 断言:仅返回第二批的可见消息;两批 selectListByMinId 各被调用一次 + assertEquals(1, result.size()); + assertEquals(3L, result.get(0).getId()); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(2)); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(2L), + any(LocalDateTime.class), eq(2)); + } + + @Test + public void testPullMessages_quitMemberLoadsPreQuitMessages() { + // 准备:当前群(在群)+ 一个最近退群 + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); + + ImGroupMessageDO activeMsg = ImGroupMessageDO.builder() + .id(5L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(2)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(activeMsg)); + + // 已退群:退群时间在窗口内(minId=0,minQuitTime 直接用 minSendTime) + LocalDateTime quitTime = now.minusDays(3); + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() + .groupId(20L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(20)) + .quitTime(quitTime).build(); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of(quitMember)); + + ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() + .id(3L).groupId(20L).senderId(99L) + .sendTime(now.minusDays(5)).build(); + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), + any(LocalDateTime.class), eq(quitTime), eq(100))) + .thenReturn(List.of(quitGroupMsg)); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:两条消息均返回,并按 id 升序 + assertEquals(2, result.size()); + assertEquals(3L, result.get(0).getId()); + assertEquals(5L, result.get(1).getId()); + } + + @Test + public void testPullMessages_quitMemberFilteredByBoundarySendTime() { + // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime + LocalDateTime now = LocalDateTime.now(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); + + LocalDateTime boundarySendTime = now.minusDays(2); + ImGroupMessageDO boundary = ImGroupMessageDO.builder() + .id(8L).groupId(10L).senderId(2L).sendTime(boundarySendTime).build(); + when(groupMessageMapper.selectById(8L)).thenReturn(boundary); + when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 8L, 100); + + // 断言:使用边界消息 sendTime 抬升 minQuitTime + assertEquals(0, result.size()); + verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); + } + + @Test + public void testPullMessages_quitQueryKeepsOriginalMinIdWhenActiveQueryRetries() { + // 准备:主查询首批不可见触发重试(游标从 8→101),退群补齐仍使用原始 minId=8 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime joinTime = now.minusHours(1); + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); + + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(3)).build(); + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() + .id(101L).groupId(10L).senderId(2L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(now.minusMinutes(50)).build(); + ImGroupMessageDO visibleActiveMsg = ImGroupMessageDO.builder() + .id(102L).groupId(10L).senderId(2L) + .sendTime(now.minusMinutes(30)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(8L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(beforeJoin, directedMsg)); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(101L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(visibleActiveMsg)); + + LocalDateTime boundarySendTime = now.minusDays(2); + ImGroupMessageDO boundary = ImGroupMessageDO.builder() + .id(8L).groupId(99L).senderId(2L).sendTime(boundarySendTime).build(); + when(groupMessageMapper.selectById(8L)).thenReturn(boundary); + + LocalDateTime quitTime = now.minusHours(12); + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() + .groupId(20L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(10)) + .quitTime(quitTime).build(); + when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) + .thenReturn(List.of(quitMember)); + + ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() + .id(50L).groupId(20L).senderId(99L) + .sendTime(now.minusDays(1)).build(); + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), + any(LocalDateTime.class), eq(quitTime), eq(2))) + .thenReturn(List.of(quitGroupMsg)); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 8L, 2); + + // 断言:退群补齐使用原始 minId=8 而非主查询重试后的 101 + assertEquals(2, result.size()); + assertEquals(50L, result.get(0).getId()); + assertEquals(102L, result.get(1).getId()); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(8L), + any(LocalDateTime.class), eq(2)); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(101L), + any(LocalDateTime.class), eq(2)); + verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); + verify(groupMessageMapper).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), + any(LocalDateTime.class), eq(quitTime), eq(2)); + verify(groupMessageMapper, never()).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(101L), + any(LocalDateTime.class), eq(quitTime), eq(2)); + } + + @Test + public void testPullMessages_receiverUserIdsFilter() { + // 准备 + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + // 定向接收消息:只给用户 2 和 3 + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(5L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(now.minusHours(2)).build(); + // 全员消息 + ImGroupMessageDO allMsg = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(5L) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(directedMsg, allMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:定向接收的消息用户 1 看不到,只能看到全员消息 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getId()); + } + + @Test + public void testPullMessages_atFieldCorrectReturn() { + // 准备 + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO atMsg = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .atUserIds(List.of(1L, 3L)) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(atMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:@ 字段正确返回 + assertEquals(1, result.size()); + assertEquals(List.of(1L, 3L), result.get(0).getAtUserIds()); + } + + @Test + public void testPullMessages_pageTrimmedToSize() { + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); + + ImGroupMessageDO activeMsg1 = ImGroupMessageDO.builder() + .id(1000L).groupId(10L).senderId(2L).sendTime(now.minusHours(2)).build(); + ImGroupMessageDO activeMsg2 = ImGroupMessageDO.builder() + .id(2000L).groupId(10L).senderId(2L).sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(activeMsg1, activeMsg2)); + + LocalDateTime quitTime = now.minusDays(1); + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() + .groupId(20L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(20)) + .quitTime(quitTime).build(); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of(quitMember)); + + ImGroupMessageDO quit1 = ImGroupMessageDO.builder() + .id(150L).groupId(20L).senderId(99L).sendTime(now.minusDays(5)).build(); + ImGroupMessageDO quit2 = ImGroupMessageDO.builder() + .id(160L).groupId(20L).senderId(99L).sendTime(now.minusDays(4)).build(); + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), + any(LocalDateTime.class), eq(quitTime), eq(2))) + .thenReturn(List.of(quit1, quit2)); + + when(groupMessageReadRedisDAO.getReadMaxMessageId(anyLong(), eq(1L))).thenReturn(null); + + List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); + + assertEquals(2, result.size()); + assertEquals(150L, result.get(0).getId()); + assertEquals(160L, result.get(1).getId()); + } + + @Test + public void testPullMessages_pullSetsReadFromRedisCursor() { + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO low = ImGroupMessageDO.builder() + .id(5L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(now.minusHours(2)).build(); + ImGroupMessageDO high = ImGroupMessageDO.builder() + .id(10L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(low, high)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(7L); + + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + assertEquals(2, result.size()); + assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus()); + } + + @Test + public void testPullMessages_receiptReadCountForSender() { + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(receiptMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(100L); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 100L); + positions.put(3L, 50L); + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + List allMembers = List.of( + member, + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(20)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(20)).build() + ); + when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); + + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + assertEquals(1, result.size()); + assertEquals(1, result.get(0).getReadCount()); + } + + // ========== 撤回测试 ========== + + @Test + public void testRecallMessage_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:消息由用户 1 发送,刚发送 5 分钟内 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + // 撤回前需要校验用户仍在群中 + ImGroupMemberDO selfMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(selfMember); + when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); + + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L)); + + // 调用 + ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); + + // 断言:返回撤回消息 + assertNotNull(result); + // 验证:更新原消息状态 + 插入 RecallMessage + verify(groupMessageMapper).updateById(any(ImGroupMessageDO.class)); + verify(groupMessageMapper).insert(any(ImGroupMessageDO.class)); + // 验证:给 2 个活跃成员推送撤回提示 + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 2 && ids.contains(1L) && ids.contains(2L)), + any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testRecallMessage_notOwn() { + // 准备 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(2L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_alreadyRecalled() { + // 准备 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.RECALL.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_timeout() { + // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now().minusMinutes(10)).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); + } + + // ========== 群已读测试 ========== + + @Test + public void testReadMessages_notInGroup() { + // 准备:当前用户不在群中 + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言:越权校验 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testGetReadUserIds_withVisibleScope() { + // 准备:发送者用户 5 是群成员 + ImGroupMemberDO currentMember = ImGroupMemberDO.builder() + .groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); + when(groupMemberService.validateMemberInGroup(10L, 5L)).thenReturn(currentMember); + + // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(80L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); + when(groupMessageMapper.selectById(80L)).thenReturn(message); + + // 准备:群成员一览 + // 用户 1: 正常,入群在消息之前 + // 用户 2: 正常,入群在消息之前 + // 用户 3: 正常,但入群在消息之后 → 不可见 + // 用户 5: 发送者,不计入回执 + List allMembers = List.of( + currentMember, + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 4, 13, 0, 0, 0)).build(), // 消息之后才入群 + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 + ); + when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); + + // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 50L); + positions.put(3L, 200L); + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + // 调用:查询 messageId=80 的已读用户 + List readUsers = groupMessageService.getGroupReadUserIds(5L, 10L, 80L); + + // 断言: + // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ + // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗ + // 用户 3: 入群在消息之后 → 不可见 → 不算 + // 用户 5: 发送者 → 排除 + assertEquals(1, readUsers.size()); + assertTrue(readUsers.contains(1L)); + assertFalse(readUsers.contains(2L)); + assertFalse(readUsers.contains(3L)); + assertFalse(readUsers.contains(5L)); + } + + @Test + public void testGetReadUserIds_notInGroup() { + // 准备:当前用户不在群中 + when(groupMemberService.validateMemberInGroup(10L, 99L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言:越权校验 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getGroupReadUserIds(99L, 10L, 80L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== 发送:补充边界 ========== + + @Test + public void testSendMessage_receiptPending() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:reqVO.receipt=true + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + reqVO.setReceipt(true); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言:receipt=true → 回执状态为 PENDING + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus()); + } + } + + @Test + public void testSendMessage_sensitiveWordBlocked() { + // 准备:文本消息命中敏感词 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) + .when(sensitiveWordService).validateText(reqVO.getContent()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.sendGroupMessage(1L, reqVO)); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + // 断言:不入库、不推送 + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + + // ========== 撤回:补充边界 ========== + + @Test + public void testRecallMessage_notExists() { + when(groupMessageMapper.selectById(50L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_senderNotInGroup() { + // 准备:消息合法、可撤回时间内,但发送人已退群 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + // 断言:不执行更新、不插 tip + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + } + + // ========== 已读:happy path + 跳过 ========== + + @Test + public void testReadGroupMessages_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:用户在群;Redis 游标从 5 前进到 100 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build()); + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); + // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of()); + + // 调用 + groupMessageService.readGroupMessages(1L, 10L, 100L); + + // 断言:Redis 游标更新 + READ 事件 + verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L); + verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testReadGroupMessages_disabled() { + // 准备:关闭群聊已读 + imProperties.getMessage().setGroupReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); + assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); + // 断言:Redis 不写、不推送 + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); + } + + @Test + public void testGetGroupReadUserIds_disabled() { + // 准备:关闭群聊已读 + imProperties.getMessage().setGroupReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getGroupReadUserIds(1L, 10L, 100L)); + assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); + } + + @Test + public void testSendGroupMessage_groupReadDisabled_forcesNoReceipt() { + // 关闭群已读:发送方传 receipt=true 也强制落 NO_RECEIPT + imProperties.getMessage().setGroupReadEnabled(false); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + reqVO.setReceipt(true); // 发送方明确要求回执 + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); + + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus(), + "群已读关闭时即使发送方传 receipt=true 也强制落 NO_RECEIPT"); + } + } + + @Test + public void testReadGroupMessages_cursorAlreadyAhead() { + // 准备:已读游标已 >= 目标,直接返回 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build()); + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); + + // 调用 + groupMessageService.readGroupMessages(1L, 10L, 100L); + + // 断言:不更新、不推送 + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); + } + + @Test + public void testReadGroupMessages_messageNotInGroup() { + // 准备:用户在群,但 messageId 属于其它群 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() + .id(100L).groupId(20L).senderId(2L).sendTime(LocalDateTime.now()).build()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); + assertEquals(MESSAGE_NOT_IN_GROUP.getCode(), exception.getCode()); + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + } + + // ========== 回执:DONE 状态迁移 ========== + + @Test + public void testReadGroupMessageEvent_receiptDoneTransition() { + // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2} + // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE + ImGroupMessageDO pending = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.now().minusMinutes(1)) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) + .thenReturn(List.of(pending)); + List activeMembers = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 100L); + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + // 调用 + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); + + // 断言:消息回执状态更新为 DONE + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).updateById(captor.capture()); + assertEquals(100L, captor.getValue().getId()); + assertEquals(ImGroupMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus()); + // 断言:给消息发送方(5)推送 RECEIPT 事件 + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); + } + + @Test + public void testReadGroupMessageEvent_receiptStaysPendingWhenPartialRead() { + // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB + ImGroupMessageDO pending = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.now().minusMinutes(1)) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) + .thenReturn(List.of(pending)); + List activeMembers = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 50L); // 还没读到 100 + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); + + // 断言:回执状态保持 PENDING,不更新 DB + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); + // 仍然推送 RECEIPT 事件给发送方(携带已读人数) + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); + } + + // ========== sendGroupMessage(senderId, dto):receiverUserIds 定向过滤 ========== + + @Test + public void testSendGroupMessage_noReceiverUserIds_broadcastsToAllMembers() { + // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":1}"); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessage(1L, dto); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 4 && ids.contains(1L) && ids.contains(2L) + && ids.contains(3L) && ids.contains(4L)), + any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessage_withReceiverUserIds_onlyTargetedPlusSender() { + // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} + // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":1}") + .setReceiverUserIds(List.of(2L, 3L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessage(1L, dto); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) + && ids.contains(3L) && !ids.contains(4L)), + any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { + // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) + // 预期:仅定向用户可见 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":1}") + .setReceiverUserIds(List.of(2L, 3L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessage(99L, dto); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), + any(ImGroupMessageDTO.class)); + } + + // ========== sendGroupMessage(senderId, dto):helper 行为 ========== + + @Test + public void testSendGroupMessage_dto_persistsAndSerializesPojoContent() { + // 准备:persistent=true 类型 + POJO content + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent(new RecallMessage().setMessageId(50L)); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); + + groupMessageService.sendGroupMessage(1L, dto); + + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON + receiptStatus=NO_RECEIPT + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).insert(captor.capture()); + ImGroupMessageDO message = captor.getValue(); + assertEquals(1L, message.getSenderId()); + assertEquals(10L, message.getGroupId()); + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); + assertEquals("{\"messageId\":50}", message.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus()); + assertNotNull(message.getClientMessageId()); + assertNotNull(message.getSendTime()); + } + + @Test + public void testSendGroupMessage_dto_receiptPending() { + // 准备:dto.receipt=true → receiptStatus=PENDING + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent("{\"messageId\":50}").setReceipt(true); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); + + groupMessageService.sendGroupMessage(1L, dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).insert(captor.capture()); + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus()); + } + + @Test + public void testSendGroupMessage_dto_nonPersistentTypeNotInserted() { + // 准备:persistent=false 类型(RECEIPT 回执)→ 不入库,仅推送 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.RECEIPT.getType()); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); + + groupMessageService.sendGroupMessage(1L, dto); + + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + verify(imWebSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() { + // 准备:调用方传入显式 targets(解散场景成员已被批量 DISABLE,必须按移除前快照推送) + Set targets = Set.of(1L, 2L, 3L); + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent("{}"); + + groupMessageService.sendGroupMessage(1L, targets, dto); + + // 断言:不读取 active members,按调用方 targets 推送 + verify(groupMemberService, never()).getActiveGroupMemberUserIdsByGroupId(anyLong()); + verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class)); + } + + // ========== getGroupMessageList ========== + + @Test + public void testGetGroupMessageList_delegatesToMapperWithJoinTime() { + // 准备 + LocalDateTime joinTime = LocalDateTime.now().minusDays(5); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); + reqVO.setGroupId(10L); + reqVO.setMaxId(100L); + reqVO.setLimit(20); + + List mockList = List.of( + ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L) + .sendTime(joinTime.plusMinutes(1)).build() + ); + when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList); + + // 调用 + List result = groupMessageService.getGroupMessageList(1L, reqVO); + + // 断言:使用成员的 joinTime 作为历史消息的下限 + assertEquals(1, result.size()); + verify(groupMessageMapper).selectHistoryList(10L, 100L, 20, joinTime); + } + + @Test + public void testGetGroupMessageList_notInGroup() { + // 准备:用户不在群中 + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); + reqVO.setGroupId(10L); + reqVO.setLimit(20); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), + GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言:鉴权失败 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getGroupMessageList(1L, reqVO)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + // 断言:不查询 DB + verify(groupMessageMapper, never()).selectHistoryList(anyLong(), anyLong(), anyInt(), any()); + } + + // ========== delete read cursor 委托 ========== + + @Test + public void testDeleteReadMaxMessageId_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageId(10L, 1L); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageId(10L, 1L); + } + + @Test + public void testDeleteReadMaxMessageIds_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageIds(10L, List.of(1L, 2L)); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIds(10L, List.of(1L, 2L)); + } + + @Test + public void testDeleteReadMaxMessageIdMap_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageIdMap(10L); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIdMap(10L); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java new file mode 100644 index 000000000..cd6465fa5 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -0,0 +1,432 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 私聊消息 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImPrivateMessageServiceImpl privateMessageService; + + @Mock + private ImPrivateMessageMapper privateMessageMapper; + @Mock + private ImFriendService friendService; + @Mock + private ImSensitiveWordService sensitiveWordService; + @Mock + private ImWebSocketService imWebSocketService; + + /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true);个别用例可改字段测分支 */ + @Spy + private ImProperties imProperties = new ImProperties(); + + private ImPrivateMessageSendReqVO buildSendReqVO() { + ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); + reqVO.setClientMessageId("test-uuid-001"); + reqVO.setReceiverId(2L); + reqVO.setType(ImMessageTypeEnum.TEXT.getType()); + reqVO.setContent("{\"content\":\"你好\"}"); + return reqVO; + } + + // ========== 发送测试 ========== + + @Test + public void testSendMessage_success() { + // 准备 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> { + ImPrivateMessageDO msg = invocation.getArgument(0); + msg.setId(99L); + return 1; + }); + + // 调用 + ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); + + // 断言 + assertNotNull(result); + assertEquals(1L, result.getSenderId()); + assertEquals(2L, result.getReceiverId()); + assertEquals(ImMessageTypeEnum.TEXT.getType(), result.getType()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); + assertNotNull(result.getSendTime()); + + // 验证调用 + verify(friendService).validateFriend(1L, 2L); + verify(sensitiveWordService).validateText(reqVO.getContent()); + verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); + // 验证推送给接收方和发送方 + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testSendMessage_clientMessageIdIdempotent() { + // 准备:模拟已存在消息 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + ImPrivateMessageDO existingMessage = ImPrivateMessageDO.builder() + .id(100L).clientMessageId("test-uuid-001").senderId(1L).receiverId(2L) + .type(0).content("{\"content\":\"你好\"}").status(0) + .sendTime(LocalDateTime.now()).build(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(existingMessage); + + // 调用 + ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); + + // 断言:返回已存在的消息 + assertEquals(100L, result.getId()); + // 验证不会重复插入 + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + } + + @Test + public void testSendMessage_notFriend() { + // 准备 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + doThrow(new ServiceException(FRIEND_NOT_FRIEND)) + .when(friendService).validateFriend(1L, 2L); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.sendPrivateMessage(1L, reqVO)); + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); + } + + // ========== pull 测试 ========== + + @Test + public void testPullMessages_success() { + // 准备 + List mockMessages = List.of( + ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(), + ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build() + ); + when(privateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100))) + .thenReturn(mockMessages); + + // 调用 + List result = privateMessageService.pullPrivateMessageList(1L, 0L, 100); + + // 断言 + assertEquals(2, result.size()); + } + + @Test + public void testPullMessages_sizeExceeded() { + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.pullPrivateMessageList(1L, 0L, 1001)); + assertEquals(MESSAGE_PULL_SIZE_EXCEEDED.getCode(), exception.getCode()); + } + + // ========== 已读测试 ========== + + @Test + public void testReadMessages_success() { + // 准备:前端上报已读到 messageId=5;mapper 返回更新行数 2 表示有未读被翻转 + when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( + eq(2L), eq(1L), eq(5L), + eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class))) + .thenReturn(2); + + // 调用 + privateMessageService.readPrivateMessages(1L, 2L, 5L); + + // 断言:更新了消息状态 + verify(privateMessageMapper).updateBySenderIdAndReceiverIdAndIdLeAndStatus( + eq(2L), eq(1L), eq(5L), + eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)); + + // 断言:发送了 READ + RECEIPT 事件,payload 字段正确 + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(ImPrivateMessageDTO.class); + verify(imWebSocketService, times(2)).sendPrivateMessageAsync( + userCaptor.capture(), contentCaptor.capture()); + + // 第一次:发给自己的 READ 事件 + assertEquals(1L, userCaptor.getAllValues().get(0)); + ImPrivateMessageDTO readPayload = contentCaptor.getAllValues().get(0); + assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType()); + assertEquals(1L, readPayload.getSenderId()); + assertEquals(2L, readPayload.getReceiverId()); + assertEquals(5L, readPayload.getId(), "READ id 应为前端上报的 messageId"); + + // 第二次:发给对方的 RECEIPT 事件 + assertEquals(2L, userCaptor.getAllValues().get(1)); + ImPrivateMessageDTO receiptPayload = contentCaptor.getAllValues().get(1); + assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType()); + assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为前端上报的 messageId"); + } + + // ========== 撤回测试 ========== + + @Test + public void testRecallMessage_success() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); // 刚发送,5 分钟内 + when(privateMessageMapper.selectById(10L)).thenReturn(message); + when(privateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1); + when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenReturn(1); + + // 调用 + ImPrivateMessageDO result = privateMessageService.recallPrivateMessage(1L, 10L); + + // 断言:返回撤回消息 + assertNotNull(result); + // 验证:更新原消息状态 + 插入 RecallMessage + verify(privateMessageMapper).updateById(any(ImPrivateMessageDO.class)); + verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); + // 验证推送了消息(给接收方和发送方) + verify(imWebSocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testRecallMessage_notOwn() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(privateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_alreadyRecalled() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.RECALL.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(privateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_notExists() { + // 准备 + when(privateMessageMapper.selectById(10L)).thenReturn(null); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_timeout() { + // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now().minusMinutes(10)).build(); + when(privateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); + // 断言:不推送、不插 tipMessage + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + } + + @Test + public void testSendMessage_sensitiveWordBlocked() { + // 准备:文本消息命中敏感词 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) + .when(sensitiveWordService).validateText(reqVO.getContent()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.sendPrivateMessage(1L, reqVO)); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + // 断言:不入库、不推送 + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testReadMessages_disabled() { + // 准备:关闭私聊已读 + imProperties.getMessage().setPrivateReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.readPrivateMessages(1L, 2L, 5L)); + assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode()); + // 断言:不更新消息状态、不推送 + verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndStatus( + anyLong(), anyLong(), anyLong(), anyInt(), any(ImPrivateMessageDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testReadMessages_noUnread() { + // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息 + when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( + eq(2L), eq(1L), eq(5L), + eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class))) + .thenReturn(0); + + // 调用 + privateMessageService.readPrivateMessages(1L, 2L, 5L); + + // 断言:不推送 READ / RECEIPT + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + // ========== getMaxReadMessageId 测试 ========== + + @Test + public void testGetMaxReadMessageId_hit() { + // 准备:对方读到我发的最大 id=10 + when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( + 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(10L); + + // 调用 + Long result = privateMessageService.getMaxReadMessageId(1L, 2L); + + // 断言 + assertEquals(10L, result); + } + + @Test + public void testGetMaxReadMessageId_miss() { + // 准备:对方一条都没读过 + when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( + 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(null); + + // 调用 + Long result = privateMessageService.getMaxReadMessageId(1L, 2L); + + // 断言:原样返回 null,前端按 falsy 跳过 + assertNull(result); + } + + @Test + public void testGetMaxReadMessageId_disabled() { + // 准备:关闭私聊已读 + imProperties.getMessage().setPrivateReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.getMaxReadMessageId(1L, 2L)); + assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode()); + } + + // ========== sendPrivateMessage(senderId, dto):helper 行为 ========== + + @Test + public void testSendPrivateMessage_dto_persistsAndSerializesPojoContent() { + // 准备:persistent=true 类型 + POJO content + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() + .setReceiverId(2L).setType(ImMessageTypeEnum.RECALL.getType()) + .setContent(new RecallMessage().setMessageId(50L)); + + privateMessageService.sendPrivateMessage(1L, dto); + + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON + ArgumentCaptor captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class); + verify(privateMessageMapper).insert(captor.capture()); + ImPrivateMessageDO message = captor.getValue(); + assertEquals(1L, message.getSenderId()); + assertEquals(2L, message.getReceiverId()); + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); + assertEquals("{\"messageId\":50}", message.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); + assertNotNull(message.getClientMessageId()); + assertNotNull(message.getSendTime()); + // 断言:sender + receiver 双端推送 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() { + // 准备:persistent=false 类型(FRIEND_DELETE 通知)→ 不入库;仅推 sender 多端,receiver 不感知 + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() + .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_DELETE.getType()); + + privateMessageService.sendPrivateMessage(1L, dto); + + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + // ========== getPrivateMessageList ========== + + @Test + public void testGetPrivateMessageList_delegatesToMapper() { + // 准备 + ImPrivateMessageListReqVO reqVO = new ImPrivateMessageListReqVO(); + reqVO.setReceiverId(2L); + reqVO.setMaxId(100L); + reqVO.setLimit(20); + List mockList = List.of( + ImPrivateMessageDO.builder().id(99L).senderId(1L).receiverId(2L).build(), + ImPrivateMessageDO.builder().id(98L).senderId(2L).receiverId(1L).build() + ); + when(privateMessageMapper.selectHistoryList(1L, 2L, 100L, 20)).thenReturn(mockList); + + // 调用 + List result = privateMessageService.getPrivateMessageList(1L, reqVO); + + // 断言:透传到 mapper,参数一致 + assertEquals(2, result.size()); + verify(privateMessageMapper).selectHistoryList(1L, 2L, 100L, 20); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java new file mode 100644 index 000000000..b39c02432 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java @@ -0,0 +1,384 @@ +package cn.iocoder.yudao.module.im.service.rtc; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper; +import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DuplicateKeyException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_REQUIRED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_OVER_LIMIT; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImRtcCallServiceImpl rtcCallService; + + @Mock + private ImRtcParticipantMapper rtcParticipantMapper; + @Mock + private ImRtcCallMapper rtcCallMapper; + @Mock + private ImRtcCallLockRedisDAO rtcCallLockRedisDAO; + @Mock + private AdminUserApi adminUserApi; + @Mock + private ImWebSocketService webSocketService; + @Mock + private ImProperties imProperties; + @Mock + private ImGroupMemberService groupMemberService; + + // ========== timeoutInvitingParticipants(Job 入口)========== + + @Test + public void testTimeoutInvitingParticipants_emptyCandidates_returnsZeroAndNoDownstream() { + // 准备:无超时候选 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore( + eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:返回 0;无候选时不应触发 user 预查 / call 查询 / 推送 + assertEquals(0, result); + verifyNoInteractions(adminUserApi, rtcCallMapper, webSocketService); + } + + @Test + public void testTimeoutInvitingParticipants_thresholdConvertedToCutoff() { + // 准备:阈值 5 分钟;mock 空候选避免触发后续逻辑 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // 调用 + LocalDateTime before = LocalDateTime.now(); + rtcCallService.timeoutInvitingParticipants(5); + + // 断言:cutoff = now - 5 分钟(允许 5 秒漂移) + ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(rtcParticipantMapper).selectListByStatusAndInviteTimeBefore( + eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture()); + LocalDateTime cutoff = cutoffCaptor.getValue(); + LocalDateTime expected = before.minusMinutes(5); + assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5, + "cutoff 应当约等于 now - 5 min;实际:" + cutoff); + } + + @Test + public void testTimeoutInvitingParticipants_casAllFails_noPushNoEndSession() { + // 准备:候选非空但每个 CAS 都失败(并发已变状态) + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any())) + .thenReturn(List.of(p)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(0); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:CAS 全失败时返回 0;不查 call、不推送 + assertEquals(0, result); + verify(rtcCallMapper, never()).selectByRoom(any()); + verifyNoInteractions(webSocketService); + } + + @Test + public void testTimeoutInvitingParticipants_groupCall_pushesNoAnswerSkipsEndSession() { + // 准备:群通话单候选 CAS 成功;shouldCloseGroupRoom 通过 selectListByRoom 多 JOINED 让其返 false,跳过 endSession + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any())) + .thenReturn(List.of(p)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(1); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(List.of(200L, 201L)); + // 房内 2 个 JOINED + 1 个 INVITING → shouldCloseGroupRoom 返 false + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( + buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(22L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING) + )); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:成功 1 个;NO_ANSWER 信令推到主叫;不触发 endSession + assertEquals(1, result); + verify(webSocketService).sendPrivateMessageAsync(eq(200L), any(ImPrivateMessageDTO.class)); + verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any()); + } + + @Test + public void testTimeoutInvitingParticipants_callMissing_silentSkip() { + // 准备:CAS 成功后通话主表缺失(异常兜底场景) + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any())) + .thenReturn(List.of(p)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(1); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(null); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:CAS 已成功但 call 缺失视为部分失败返 0;不应推送 + assertEquals(0, result); + verifyNoInteractions(webSocketService); + } + + // ========== noAnswerCallCheck(前端 timer 入口)========== + + @Test + public void testNoAnswerCallCheck_authFails_silentNoOp() { + // 准备:selectByRoomAndUserId 返 null 覆盖三种鉴权失败场景(非参与者 / 非法 room / null room) + when(rtcParticipantMapper.selectByRoomAndUserId(any(), eq(100L))).thenReturn(null); + + // 调用 + rtcCallService.noAnswerCallCheck(100L, "r1"); + rtcCallService.noAnswerCallCheck(100L, ""); + rtcCallService.noAnswerCallCheck(100L, null); + + // 断言:仅鉴权查询了 3 次;不应进入后续超时扫描 / 推送 + verify(rtcParticipantMapper, times(3)).selectByRoomAndUserId(any(), eq(100L)); + verify(rtcParticipantMapper, never()).selectListByRoomAndStatusAndInviteTimeBefore(any(), any(), any()); + verifyNoInteractions(adminUserApi, webSocketService); + } + + @Test + public void testNoAnswerCallCheck_usesBackendThreshold_notFrontend() { + // 准备:鉴权通过 + 后端配置阈值 2 分钟 + 无候选(避免触发推送) + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING)); + ImProperties.Rtc rtcConfig = new ImProperties.Rtc(); + rtcConfig.setInviteTimeoutMinutes(2); + when(imProperties.getRtc()).thenReturn(rtcConfig); + when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore( + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // 调用 + LocalDateTime before = LocalDateTime.now(); + rtcCallService.noAnswerCallCheck(100L, "r1"); + + // 断言:扫描时使用 cutoff = now - 2 分钟(后端配置),而非前端 60s + ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(rtcParticipantMapper).selectListByRoomAndStatusAndInviteTimeBefore( + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture()); + LocalDateTime cutoff = cutoffCaptor.getValue(); + LocalDateTime expected = before.minusMinutes(2); + assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5, + "cutoff 应当约等于 now - 2 min(后端配置);实际:" + cutoff); + } + + @Test + public void testNoAnswerCallCheck_groupCall_pushesNoAnswer() { + // 准备:鉴权通过 + 单候选 CAS 成功 + 群通话不关房 + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING)); + ImProperties.Rtc rtcConfig = new ImProperties.Rtc(); + rtcConfig.setInviteTimeoutMinutes(1); + when(imProperties.getRtc()).thenReturn(rtcConfig); + ImRtcParticipantDO timeoutTarget = buildParticipant(11L, "r1", 101L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore( + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(List.of(timeoutTarget)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(101L, buildUser(101L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(11L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(1); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(List.of(200L, 201L)); + // 让 shouldCloseGroupRoom 返 false + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( + buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED) + )); + + // 调用 + rtcCallService.noAnswerCallCheck(100L, "r1"); + + // 断言:NO_ANSWER 信令推到主叫 200L;不触发 endSession + verify(webSocketService).sendPrivateMessageAsync(eq(200L), any(ImPrivateMessageDTO.class)); + verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any()); + } + + // ========== createCall ========== + + @Test + public void testCreateCall_groupOnlyInviteSelf_throwInviteeRequired() throws Exception { + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc()); + when(rtcCallLockRedisDAO.lockGroup(eq(10L), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Callable callable = invocation.getArgument(1); + return callable.call(); + }); + ImRtcCallCreateReqVO reqVO = new ImRtcCallCreateReqVO(); + reqVO.setConversationType(ImConversationTypeEnum.GROUP.getType()); + reqVO.setGroupId(10L); + reqVO.setInviteeIds(Set.of(100L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> rtcCallService.createCall(100L, reqVO)); + + assertEquals(RTC_GROUP_INVITEE_REQUIRED.getCode(), exception.getCode()); + verify(rtcParticipantMapper, never()).insertBatch(anyList()); + } + + // ========== acceptCall / joinCall 忙线校验 ========== + + @Test + public void testAcceptCall_joinedOtherRoom_throwSelfBusy() { + // 准备:当前通话仍在邀请中,但用户已加入另一个房间 + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc()); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.PRIVATE, null); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING)); + when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1"))) + .thenReturn(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED)); + + // 调用 + 断言:拒绝接听,不覆盖其它房间状态 + ServiceException exception = assertThrows(ServiceException.class, + () -> rtcCallService.acceptCall(100L, "r1")); + assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode()); + verify(rtcParticipantMapper, never()).updateByIdAndStatus(eq(10L), any(), any()); + } + + @Test + public void testJoinCall_joinedOtherRoom_throwSelfBusy() { + // 准备:群通话活跃,用户已加入另一个房间 + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc()); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + when(groupMemberService.validateMemberInGroup(999L, 100L)).thenReturn(new ImGroupMemberDO()); + when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1"))) + .thenReturn(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED)); + + // 调用 + 断言:拒绝加入,不写参与者状态 + ServiceException exception = assertThrows(ServiceException.class, + () -> rtcCallService.joinCall(100L, "r1")); + assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode()); + verify(rtcParticipantMapper, never()).insert(any(ImRtcParticipantDO.class)); + verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class)); + } + + @Test + public void testJoinCall_insertDuplicateKey_reuseExistingParticipant() { + // 准备:群通话活跃,首次查询无参与者,插入时命中唯一键 + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc()); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + when(groupMemberService.validateMemberInGroup(999L, 100L)).thenReturn(new ImGroupMemberDO()); + when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1"))) + .thenReturn(null); + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) + .thenReturn(null, buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.JOINED)); + when(rtcParticipantMapper.insert(any(ImRtcParticipantDO.class))).thenThrow(new DuplicateKeyException("dup")); + + // 调用 + ImRtcCallDO result = rtcCallService.joinCall(100L, "r1"); + + // 断言:不向上抛数据库异常 + assertSame(call, result); + verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class)); + } + + @Test + public void testInviteCall_overLimit_throws() throws Exception { + ImProperties.Rtc rtcConfig = new ImProperties.Rtc(); + rtcConfig.setGroupMaxParticipants(3); + when(imProperties.getRtc()).thenReturn(rtcConfig); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 200L)) + .thenReturn(buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED)); + when(rtcCallLockRedisDAO.lockGroup(eq(999L), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Callable callable = invocation.getArgument(1); + return callable.call(); + }); + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( + buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(11L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(12L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING) + )); + ImRtcCallInviteReqVO reqVO = new ImRtcCallInviteReqVO(); + reqVO.setRoom("r1"); + reqVO.setInviteeIds(Set.of(203L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> rtcCallService.inviteCall(200L, reqVO)); + + assertEquals(RTC_GROUP_INVITEE_OVER_LIMIT.getCode(), exception.getCode()); + verify(rtcParticipantMapper, never()).insertBatch(anyList()); + } + + // ========== 测试数据构造 ========== + + private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) { + return new ImRtcParticipantDO() + .setId(id) + .setRoom(room) + .setUserId(userId) + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) + .setStatus(status.getStatus()) + .setInviteTime(LocalDateTime.now()); + } + + private ImRtcCallDO buildCall(String room, Long inviterUserId, ImConversationTypeEnum conversationType, Long groupId) { + return new ImRtcCallDO() + .setRoom(room) + .setConversationType(conversationType.getType()) + .setMediaType(1) + .setInviterUserId(inviterUserId) + .setGroupId(groupId) + .setStatus(ImRtcCallStatusEnum.RUNNING.getStatus()) + .setStartTime(LocalDateTime.now()); + } + + private AdminUserRespDTO buildUser(Long id) { + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setId(id); + user.setNickname("user-" + id); + return user; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java new file mode 100644 index 000000000..44236264c --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java @@ -0,0 +1,277 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_DUPLICATED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * IM 敏感词 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { + + private static final Long TENANT_ID = 1L; + + @InjectMocks + private ImSensitiveWordServiceImpl sensitiveWordService; + + @Mock + private ImSensitiveWordMapper imSensitiveWordMapper; + + @BeforeEach + public void setUp() { + // 设置租户上下文;validateText 与 loadFresh 都依赖 TenantContextHolder + TenantContextHolder.setTenantId(TENANT_ID); + // mock 启用敏感词列表;LoadingCache 在首次 validateText 时懒加载,无需主动 init + // 用 lenient 避免 null / empty 等不触发 cache load 的用例报 UnnecessaryStubbing + lenient().when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(1L).word("badword") + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImSensitiveWordDO.builder().id(2L).word("违禁词") + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + } + + @AfterEach + public void tearDown() { + // 清理租户上下文,避免污染其它测试 + TenantContextHolder.clear(); + } + + @Test + public void testValidateText_null() { + // null 直接返回,不抛异常 + assertDoesNotThrow(() -> sensitiveWordService.validateText(null)); + } + + @Test + public void testValidateText_empty() { + assertDoesNotThrow(() -> sensitiveWordService.validateText("")); + } + + @Test + public void testValidateText_clean() { + // 正常文本不应命中 + assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world")); + } + + @Test + public void testValidateText_hitEnglish() { + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("this contains badword here")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + } + + @Test + public void testValidateText_hitChinese() { + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("这条消息里有违禁词哦")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + } + + @Test + public void testValidateText_lazyLoadsCacheOnFirstCall() { + // 调用:首次 validateText 应触发 cache load + sensitiveWordService.validateText("hello world"); + + // 断言:mapper 各调用 1 次(loadFresh 里先取 maxUpdateTime 再读词库) + verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID); + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + + @Test + public void testValidateText_reusesCachedBsAcrossCalls() { + // 调用:连续两次 validateText + sensitiveWordService.validateText("hello world"); + sensitiveWordService.validateText("another text"); + + // 断言:第二次复用 cache,mapper 仍只被调用 1 次 + verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID); + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + + @Test + public void testCreateSensitiveWord_invalidatesCacheAndReloadsOnNextValidate() { + // 准备:首次 validateText 触发 cache load(旧词库) + sensitiveWordService.validateText("hello world"); + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 准备:mapper 返回新词库(额外多一个 newbad),并让 createSensitiveWord 走通 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(1L).word("badword") + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImSensitiveWordDO.builder().id(2L).word("违禁词") + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImSensitiveWordDO.builder().id(3L).word("newbad") + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + when(imSensitiveWordMapper.selectByWord("newbad")).thenReturn(null); + + // 调用:新增敏感词,触发 invalidate + ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO(); + reqVO.setWord("newbad"); + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); + sensitiveWordService.createSensitiveWord(reqVO); + + // 调用:再次 validateText,应触发重新 load + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("contains newbad here")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + // 旧词依然命中 + assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("contains badword here")); + + // 断言:selectListByStatus 共被调用 2 次(首次 load + invalidate 后 reload) + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + + @Test + public void testCreateSensitiveWord_duplicateWord_throws() { + // 准备:mock 已存在同名敏感词 + when(imSensitiveWordMapper.selectByWord("dup")).thenReturn( + ImSensitiveWordDO.builder().id(99L).word("dup") + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + + // 调用 + 断言:重复敏感词抛 SENSITIVE_WORD_DUPLICATED + ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO(); + reqVO.setWord("dup"); + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.createSensitiveWord(reqVO)); + assertEquals(SENSITIVE_WORD_DUPLICATED.getCode(), exception.getCode()); + + // 断言:未走到 insert + verify(imSensitiveWordMapper, never()).insert(any(ImSensitiveWordDO.class)); + } + + @Test + public void testUpdateSensitiveWord_invalidatesCache() { + // 准备:首次 validateText 触发 cache load + sensitiveWordService.validateText("hello world"); + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 准备:让 update 校验通过 + when(imSensitiveWordMapper.selectById(1L)).thenReturn( + ImSensitiveWordDO.builder().id(1L).word("badword") + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(imSensitiveWordMapper.selectByWord("updatedbad")).thenReturn(null); + // 准备:reload 时返回新词库 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(1L).word("updatedbad") + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + // 调用:更新敏感词,触发 invalidate + ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO(); + reqVO.setId(1L); + reqVO.setWord("updatedbad"); + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); + sensitiveWordService.updateSensitiveWord(reqVO); + + // 调用 + 断言:再次 validateText 应使用新词库 + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("contains updatedbad here")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + + // 断言:selectListByStatus 共被调用 2 次 + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + + @Test + public void testDeleteSensitiveWord_invalidatesCache() { + // 准备:首次 validateText 触发 cache load + sensitiveWordService.validateText("hello world"); + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 准备:让 delete 校验通过 + when(imSensitiveWordMapper.selectById(1L)).thenReturn( + ImSensitiveWordDO.builder().id(1L).word("badword") + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + // 准备:reload 时返回剩余词库(只剩中文那条) + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(2L).word("违禁词") + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + // 调用:删除 badword,触发 invalidate + sensitiveWordService.deleteSensitiveWord(1L); + + // 调用 + 断言:badword 已被删,不再命中 + assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here")); + // 中文词依然在 + assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("这条消息里有违禁词哦")); + + // 断言:selectListByStatus 共被调用 2 次 + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + + @Test + public void testDeleteSensitiveWord_notExists_throws() { + // 准备:selectById 返回 null + when(imSensitiveWordMapper.selectById(999L)).thenReturn(null); + + // 调用 + 断言:抛 SENSITIVE_WORD_NOT_EXISTS + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.deleteSensitiveWord(999L)); + assertEquals(SENSITIVE_WORD_NOT_EXISTS.getCode(), exception.getCode()); + + // 断言:未走到 deleteById + verify(imSensitiveWordMapper, never()).deleteById(anyLong()); + } + + @Test + public void testDeleteSensitiveWordList_invalidatesCache() { + // 准备:首次 validateText 触发 cache load + sensitiveWordService.validateText("hello world"); + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 准备:reload 时返回空词库 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of()); + + // 调用:批量删除,触发 invalidate + sensitiveWordService.deleteSensitiveWordList(List.of(1L, 2L)); + + // 调用 + 断言:所有词都不再命中 + assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here")); + assertDoesNotThrow(() -> sensitiveWordService.validateText("这条消息里有违禁词哦")); + + // 断言:deleteByIds 被调用 1 次;selectListByStatus 共 2 次 + verify(imSensitiveWordMapper, times(1)).deleteByIds(List.of(1L, 2L)); + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + + @Test + public void testDeleteSensitiveWordList_emptyIds_skip() { + // 调用:空列表直接返回 + sensitiveWordService.deleteSensitiveWordList(List.of()); + + // 断言:mapper 不被调用 + verify(imSensitiveWordMapper, never()).deleteByIds(anyList()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java new file mode 100644 index 000000000..f5b57864f --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java @@ -0,0 +1,162 @@ +package cn.iocoder.yudao.module.im.service.statistics; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.dal.mysql.statistics.ImStatisticsManagerMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * IM 数据看板 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImStatisticsManagerServiceImplTest extends BaseMockitoUnitTest { + + private static final LocalDateTime BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0); + private static final LocalDateTime END = LocalDateTime.of(2026, 2, 1, 0, 0); + + @InjectMocks + private ImStatisticsManagerServiceImpl service; + + @Mock + private ImStatisticsManagerMapper statisticsMapper; + + // ========== 单值委托 ========== + + @Test + public void testGetTotalUserCount_delegate() { + when(statisticsMapper.selectTotalUserCount()).thenReturn(99L); + assertEquals(99L, service.getTotalUserCount()); + } + + @Test + public void testGetNewUserCount_delegate() { + when(statisticsMapper.selectNewUserCount(BEGIN, END)).thenReturn(10L); + assertEquals(10L, service.getNewUserCount(BEGIN, END)); + } + + @Test + public void testGetActiveUserCount_delegate() { + when(statisticsMapper.selectActiveUserCount(BEGIN, END)).thenReturn(5L); + assertEquals(5L, service.getActiveUserCount(BEGIN, END)); + } + + @Test + public void testGetTotalGroupCount_delegate() { + when(statisticsMapper.selectTotalGroupCount()).thenReturn(33L); + assertEquals(33L, service.getTotalGroupCount()); + } + + @Test + public void testGetPrivateMessageCount_delegate() { + when(statisticsMapper.selectPrivateMessageCount(BEGIN, END)).thenReturn(123L); + assertEquals(123L, service.getPrivateMessageCount(BEGIN, END)); + } + + @Test + public void testGetGroupMessageCount_delegate() { + when(statisticsMapper.selectGroupMessageCount(BEGIN, END)).thenReturn(456L); + assertEquals(456L, service.getGroupMessageCount(BEGIN, END)); + } + + // ========== 每日序列 toDailyCountMap ========== + + @Test + public void testGetNewUserDailyCountMap_dateConvert() { + // 准备:date 字段以 SQL Date / String 混入,count 混入多种 Number + when(statisticsMapper.selectNewUserDailyCount(eq(BEGIN), eq(END))).thenReturn(Arrays.asList( + row("date", java.sql.Date.valueOf("2026-01-10"), "count", BigInteger.valueOf(5)), + row("date", "2026-01-11", "count", 8))); + + // 调用 + Map result = service.getNewUserDailyCountMap(BEGIN, END); + + // 断言:date → LocalDateTime 起始零点 + assertEquals(2, result.size()); + assertEquals(5L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay())); + assertEquals(8L, result.get(LocalDate.of(2026, 1, 11).atStartOfDay())); + } + + @Test + public void testGetActiveUserDailyCountMap_dateConvert() { + when(statisticsMapper.selectActiveUserDailyCount(any(), any())).thenReturn(Arrays.asList( + row("date", "2026-01-10", "count", 3L))); + + Map result = service.getActiveUserDailyCountMap(BEGIN, END); + assertEquals(3L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay())); + } + + @Test + public void testGetPrivateMessageDailyCountMap_dateConvert() { + when(statisticsMapper.selectPrivateMessageDailyCount(any(), any())).thenReturn(Arrays.asList( + row("date", "2026-01-10", "count", 7L))); + assertEquals(7L, service.getPrivateMessageDailyCountMap(BEGIN, END) + .get(LocalDate.of(2026, 1, 10).atStartOfDay())); + } + + @Test + public void testGetGroupMessageDailyCountMap_dateConvert() { + when(statisticsMapper.selectGroupMessageDailyCount(any(), any())).thenReturn(Arrays.asList( + row("date", "2026-01-10", "count", 9L))); + assertEquals(9L, service.getGroupMessageDailyCountMap(BEGIN, END) + .get(LocalDate.of(2026, 1, 10).atStartOfDay())); + } + + // ========== 分桶 / 分布 ========== + + @Test + public void testGetGroupSizeCountMap() { + when(statisticsMapper.selectGroupSizeDistribution()).thenReturn(Arrays.asList( + row("range", "1-9 人", "count", BigInteger.valueOf(3)), + row("range", "10-49 人", "count", 2))); + + Map result = service.getGroupSizeCountMap(); + assertEquals(3L, result.get("1-9 人")); + assertEquals(2L, result.get("10-49 人")); + } + + @Test + public void testGetMessageTypeCountMap() { + when(statisticsMapper.selectMessageTypeDistribution(BEGIN, END)).thenReturn(Arrays.asList( + row("type", 101L, "count", BigInteger.valueOf(8)), + row("type", 102, "count", 1))); + + Map result = service.getMessageTypeCountMap(BEGIN, END); + assertEquals(8L, result.get(101)); + assertEquals(1L, result.get(102)); + } + + @Test + public void testGetTopSenderCountMap_passesLimit() { + when(statisticsMapper.selectTopSenders(BEGIN, END, 3)).thenReturn(Arrays.asList( + row("userId", 1, "messageCount", BigInteger.valueOf(10)), + row("userId", 2L, "messageCount", 5))); + + Map result = service.getTopSenderCountMap(BEGIN, END, 3); + assertEquals(10L, result.get(1L)); + assertEquals(5L, result.get(2L)); + } + + // ========== 工具 ========== + + private static Map row(String k1, Object v1, String k2, Object v2) { + Map map = new HashMap<>(); + map.put(k1, v1); + map.put(k2, v2); + return map; + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java new file mode 100644 index 000000000..f1c333916 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java @@ -0,0 +1,198 @@ +package cn.iocoder.yudao.module.im.service.websocket; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * IM WebSocket 推送 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImWebSocketServiceImpl imWebSocketService; + + @Mock + private WebSocketMessageSender webSocketMessageSender; + + @AfterEach + public void tearDown() { + // 清理事务同步上下文,避免串扰其它用例 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + // ========== sendPrivateMessageAsync ========== + + @Test + public void testSendPrivateMessageAsync_noTransactionSendsImmediately() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + + // 调用:无事务,应立即发送 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + // 断言 + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); + } + } + + @Test + public void testSendPrivateMessageAsync_inTransactionDeferredUntilCommit() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备:开启事务同步 + TransactionSynchronizationManager.initSynchronization(); + try { + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + + // 调用 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + // 断言:事务未提交,未推送 + verify(webSocketMessageSender, never()).sendObject(anyInt(), anyLong(), anyString(), any()); + + // 模拟事务提交 + List syncs = + TransactionSynchronizationManager.getSynchronizations(); + assertEquals(1, syncs.size()); + syncs.forEach(TransactionSynchronization::afterCommit); + + // 断言:提交后推送 + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); + } finally { + TransactionSynchronizationManager.clear(); + } + } + } + + // ========== sendGroupMessageAsync ========== + + @Test + public void testSendGroupMessageAsync_fanOutToAllUsers() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + dto.setSenderId(1L); + + imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); + + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(3L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + } + } + + @Test + public void testSendGroupMessageAsync_senderExceptionDoesNotBreakOthers() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + // 给 1 号用户推送时抛异常,不能影响 2/3 号 + doThrow(new RuntimeException("user offline")) + .when(webSocketMessageSender).sendObject(anyInt(), eq(1L), anyString(), any()); + + imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); + + // 2L 和 3L 也都被推送 + verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); + verify(webSocketMessageSender).sendObject(anyInt(), eq(3L), anyString(), any()); + } + } + + @Test + public void testDoSendGroupMessage_emptyUserIds_noSend() { + ImGroupMessageDTO dto = new ImGroupMessageDTO().setGroupId(10L); + + imWebSocketService.doSendGroupMessage(Collections.emptyList(), dto); + imWebSocketService.doSendGroupMessage(null, dto); + + verifyNoInteractions(webSocketMessageSender); + } + + @Test + public void testDoSendGroupMessage_distinctUserIds() { + ImGroupMessageDTO dto = new ImGroupMessageDTO().setGroupId(10L); + + imWebSocketService.doSendGroupMessage(Arrays.asList(1L, 2L, 1L, null), dto); + + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verifyNoMoreInteractions(webSocketMessageSender); + } + + @Test + public void testSendPrivateMessageAsync_exceptionSwallowed() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备:sender 抛异常 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + doThrow(new RuntimeException("user offline")) + .when(webSocketMessageSender).sendObject(anyInt(), anyLong(), anyString(), any()); + + // 调用:异常应被吞掉,不向上抛 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); + } + } + + @Test + public void testSendGroupMessageAsync_singleUserDefaultOverload() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + + imWebSocketService.sendGroupMessageAsync(42L, dto); + + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(42L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java new file mode 100644 index 000000000..03f6a1463 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java @@ -0,0 +1,184 @@ +package cn.iocoder.yudao.module.im.util; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * IM 消息内容工具类单元测试 + * + * @author 芋道源码 + */ +public class ImMessageUtilsTest { + + // ========== parseQuoteMessageId ========== + + @Test + public void testParseQuoteMessageId_valid() { + // 准备:content 含合法 quote 字段 + String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1001,\"senderId\":2,\"type\":1}}"; + + // 调用 + 断言 + assertEquals(1001L, ImMessageUtils.parseQuoteMessageId(content)); + } + + @Test + public void testParseQuoteMessageId_invalidJson() { + // 准备:content 非合法 JSON + // 调用 + 断言:解析失败返回 null + assertNull(ImMessageUtils.parseQuoteMessageId("not a json")); + } + + @Test + public void testParseQuoteMessageId_noQuoteField() { + // 准备:content 无 quote 字段 + String content = "{\"text\":\"你好\"}"; + + // 调用 + 断言 + assertNull(ImMessageUtils.parseQuoteMessageId(content)); + } + + @Test + public void testParseQuoteMessageId_quoteIsNotMap() { + // 准备:quote 字段不是对象 + String content = "{\"text\":\"你好\",\"quote\":\"oops\"}"; + + // 调用 + 断言 + assertNull(ImMessageUtils.parseQuoteMessageId(content)); + } + + @Test + public void testParseQuoteMessageId_messageIdMissing() { + // 准备:quote 对象内无 messageId + String content = "{\"quote\":{\"senderId\":2}}"; + + // 调用 + 断言 + assertNull(ImMessageUtils.parseQuoteMessageId(content)); + } + + // ========== appendQuote ========== + + @Test + public void testAppendQuote_existingContent() { + // 准备:已有 text 字段的 content + String content = "{\"text\":\"你好\"}"; + QuoteMessage quote = new QuoteMessage().setMessageId(1001L).setSenderId(2L).setType(1).setContent("{}"); + + // 调用 + String result = ImMessageUtils.appendQuote(content, quote); + + // 断言:保留原字段,注入 quote + Map map = JsonUtils.parseMap(result); + assertEquals("你好", map.get("text")); + assertTrue(map.get("quote") instanceof Map); + assertEquals(1001, ((Map) map.get("quote")).get("messageId")); + } + + @Test + public void testAppendQuote_blankContent() { + // 准备:content 为空字符串,无法解析为 map + QuoteMessage quote = new QuoteMessage().setMessageId(1001L); + + // 调用 + String result = ImMessageUtils.appendQuote("", quote); + + // 断言:内部新建 map,仅含 quote + Map map = JsonUtils.parseMap(result); + assertEquals(1, map.size()); + assertTrue(map.containsKey("quote")); + } + + @Test + public void testAppendQuote_overwriteExistingQuote() { + // 准备:content 已经有一个旧 quote,期望被新 quote 覆盖 + String content = "{\"text\":\"hi\",\"quote\":{\"messageId\":1}}"; + QuoteMessage quote = new QuoteMessage().setMessageId(9999L).setSenderId(2L).setType(1); + + // 调用 + String result = ImMessageUtils.appendQuote(content, quote); + + // 断言:quote.messageId 被覆盖为新值 + Map map = JsonUtils.parseMap(result); + assertEquals(9999, ((Map) map.get("quote")).get("messageId")); + } + + // ========== removeQuote ========== + + @Test + public void testRemoveQuote_blankContent() { + // 调用 + 断言:空内容原样返回 + assertEquals("", ImMessageUtils.removeQuote("")); + assertNull(ImMessageUtils.removeQuote(null)); + } + + @Test + public void testRemoveQuote_noQuoteField() { + // 准备:content 字符串里没有 quote 关键字,提前返回原值 + String content = "{\"text\":\"你好\"}"; + + // 调用 + 断言:同一引用直接返回 + assertEquals(content, ImMessageUtils.removeQuote(content)); + } + + @Test + public void testRemoveQuote_withQuote() { + // 准备:含 quote 的 content + String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1}}"; + + // 调用 + String result = ImMessageUtils.removeQuote(content); + + // 断言:quote 被移除,保留其它字段 + Map map = JsonUtils.parseMap(result); + assertFalse(map.containsKey("quote")); + assertEquals("你好", map.get("text")); + } + + @Test + public void testRemoveQuote_invalidJsonContainingQuoteToken() { + // 准备:字符串包含 quote 字面量但解析失败 + String content = "not-a-json-but-has-\"quote\"-token"; + + // 调用 + 断言:解析失败返回原值 + assertEquals(content, ImMessageUtils.removeQuote(content)); + } + + // ========== buildQuote ========== + + @Test + public void testBuildQuote_stripsNestedQuote() { + // 准备:被引用消息本身也含有 quote 字段;防止嵌套 + String originalContent = "{\"text\":\"被引用\",\"quote\":{\"messageId\":777}}"; + + // 调用 + QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent); + + // 断言:基础字段透传,content 已被剥离 quote + assertEquals(1001L, quote.getMessageId()); + assertEquals(2L, quote.getSenderId()); + assertEquals(1, quote.getType()); + Map contentMap = JsonUtils.parseMap(quote.getContent()); + assertFalse(contentMap.containsKey("quote")); + assertEquals("被引用", contentMap.get("text")); + } + + @Test + public void testBuildQuote_originalWithoutQuote() { + // 准备:被引用消息原本就没 quote 字段 + String originalContent = "{\"text\":\"hi\"}"; + + // 调用 + QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent); + + // 断言:content 原样保留 + assertEquals(originalContent, quote.getContent()); + } + +} diff --git a/yudao-module-im/yudao-module-im-server/src/test/resources/application-unit-test.yaml b/yudao-module-im/yudao-module-im-server/src/test/resources/application-unit-test.yaml new file mode 100644 index 000000000..44be16f85 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/resources/application-unit-test.yaml @@ -0,0 +1,49 @@ +spring: + main: + lazy-initialization: true # 开启懒加载,加快速度 + banner-mode: off # 单元测试,禁用 Banner + +--- #################### 数据库相关配置 #################### + +spring: + # 数据源配置项 + datasource: + name: ruoyi-vue-pro + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + driver-class-name: org.h2.Driver + username: sa + password: + druid: + async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 + initial-size: 1 # 单元测试,配置为 1,提升启动速度 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql + encoding: UTF-8 + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 127.0.0.1 # 地址 + port: 16379 # 端口(单元测试,使用 16379 端口) + database: 0 # 数据库索引 + +mybatis: + lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 + +--- #################### 定时任务相关配置 #################### + +--- #################### 配置中心相关配置 #################### + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项(单元测试,禁用 Lock4j) + +--- #################### 监控相关配置 #################### + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + info: + base-package: cn.iocoder.yudao.module diff --git a/yudao-module-im/yudao-module-im-server/src/test/resources/logback.xml b/yudao-module-im/yudao-module-im-server/src/test/resources/logback.xml new file mode 100644 index 000000000..daf756bff --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/resources/logback.xml @@ -0,0 +1,4 @@ + + + + diff --git a/yudao-module-im/yudao-module-im-server/src/test/resources/sql/clean.sql b/yudao-module-im/yudao-module-im-server/src/test/resources/sql/clean.sql new file mode 100644 index 000000000..64ff0dd1d --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/resources/sql/clean.sql @@ -0,0 +1,14 @@ +DELETE FROM "im_private_message"; +DELETE FROM "im_group_message"; +DELETE FROM "im_group"; +DELETE FROM "im_group_member"; +DELETE FROM "im_friend"; +DELETE FROM "im_friend_request"; +DELETE FROM "im_group_request"; +DELETE FROM "im_face_pack"; +DELETE FROM "im_face_pack_item"; +DELETE FROM "im_face_user_item"; +DELETE FROM "im_rtc_call"; +DELETE FROM "im_rtc_participant"; +DELETE FROM "im_sensitive_word"; +DELETE FROM "system_users"; diff --git a/yudao-module-im/yudao-module-im-server/src/test/resources/sql/create_tables.sql b/yudao-module-im/yudao-module-im-server/src/test/resources/sql/create_tables.sql new file mode 100644 index 000000000..2505fbb36 --- /dev/null +++ b/yudao-module-im/yudao-module-im-server/src/test/resources/sql/create_tables.sql @@ -0,0 +1,331 @@ +CREATE TABLE IF NOT EXISTS "im_private_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', + "sender_id" bigint NOT NULL COMMENT '发送人编号', + "receiver_id" bigint NOT NULL COMMENT '接收人编号', + "type" smallint NOT NULL COMMENT '消息类型', + "content" varchar(8192) DEFAULT NULL COMMENT '消息内容', + "status" tinyint NOT NULL COMMENT '消息状态', + "send_time" timestamp NOT NULL COMMENT '发送时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_private_message_sender_client" UNIQUE ("sender_id", "client_message_id", "tenant_id") +) COMMENT 'IM 私聊消息表'; + +CREATE TABLE IF NOT EXISTS "im_group_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', + "sender_id" bigint NOT NULL COMMENT '发送人编号', + "group_id" bigint NOT NULL COMMENT '群编号', + "type" smallint NOT NULL COMMENT '消息类型', + "content" varchar(8192) DEFAULT NULL COMMENT '消息内容', + "status" tinyint NOT NULL COMMENT '消息状态', + "send_time" timestamp NOT NULL COMMENT '发送时间', + "receiver_user_ids" varchar(1024) DEFAULT NULL COMMENT '定向接收用户编号列表', + "at_user_ids" varchar(1024) DEFAULT NULL COMMENT '@ 目标用户编号列表', + "receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_group_message_sender_client" UNIQUE ("sender_id", "client_message_id", "tenant_id") +) COMMENT 'IM 群聊消息表'; + +CREATE TABLE IF NOT EXISTS "im_group" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "name" varchar(64) NOT NULL COMMENT '群名称', + "owner_user_id" bigint NOT NULL COMMENT '群主用户编号', + "avatar" varchar(512) DEFAULT NULL COMMENT '群头像', + "notice" varchar(2048) DEFAULT NULL COMMENT '群公告', + "banned" bit DEFAULT FALSE COMMENT '是否封禁', + "banned_reason" varchar(512) DEFAULT NULL COMMENT '封禁原因', + "banned_time" timestamp DEFAULT NULL COMMENT '封禁时间', + "status" tinyint NOT NULL COMMENT '群状态', + "dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间', + "muted_all" bit DEFAULT FALSE COMMENT '是否全群禁言', + "join_approval" bit NOT NULL DEFAULT FALSE COMMENT '进群是否需群主 / 管理员审批;false 自由进群,true 需审批', + "pinned_message_ids" varchar(128) DEFAULT NULL COMMENT '群置顶消息编号列表,逗号分隔', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 群信息表'; + +CREATE TABLE IF NOT EXISTS "im_group_member" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "group_id" bigint NOT NULL COMMENT '群编号', + "user_id" bigint NOT NULL COMMENT '用户编号', + "display_user_name" varchar(64) DEFAULT NULL COMMENT '组内显示名', + "group_remark" varchar(64) DEFAULT NULL COMMENT '群备注', + "silent" bit DEFAULT FALSE COMMENT '是否免打扰', + "status" tinyint NOT NULL COMMENT '成员状态', + "role" tinyint NOT NULL DEFAULT 3 COMMENT '成员角色:1=群主 2=管理员 3=普通成员', + "join_time" timestamp DEFAULT NULL COMMENT '入群时间', + "add_source" tinyint DEFAULT NULL COMMENT '加入来源', + "inviter_user_id" bigint DEFAULT NULL COMMENT '邀请人用户编号;用户主动申请进群时为 NULL', + "quit_time" timestamp DEFAULT NULL COMMENT '退群时间', + "mute_end_time" timestamp DEFAULT NULL COMMENT '禁言到期时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_group_member" UNIQUE ("group_id", "user_id", "tenant_id") +) COMMENT 'IM 群成员表'; + +CREATE TABLE IF NOT EXISTS "im_friend" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "user_id" bigint NOT NULL COMMENT '用户编号', + "friend_user_id" bigint NOT NULL COMMENT '好友用户编号', + "silent" bit DEFAULT FALSE COMMENT '是否免打扰', + "display_name" varchar(64) NOT NULL DEFAULT '' COMMENT '好友展示备注(仅自己可见)', + "add_source" tinyint DEFAULT NULL COMMENT '添加来源', + "pinned" bit DEFAULT FALSE COMMENT '是否置顶联系人', + "blocked" bit DEFAULT FALSE COMMENT '是否拉黑', + "status" tinyint NOT NULL COMMENT '好友状态', + "add_time" timestamp DEFAULT NULL COMMENT '添加好友时间', + "delete_time" timestamp DEFAULT NULL COMMENT '删除好友时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_user_friend" UNIQUE ("user_id", "friend_user_id", "tenant_id") +) COMMENT 'IM 好友关系表'; + +CREATE TABLE IF NOT EXISTS "im_friend_request" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "from_user_id" bigint NOT NULL COMMENT '发起方用户编号', + "to_user_id" bigint NOT NULL COMMENT '接收方用户编号', + "handle_result" tinyint NOT NULL DEFAULT 0 COMMENT '处理结果;0未处理;1同意;2拒绝', + "apply_content" varchar(255) DEFAULT NULL COMMENT '申请理由', + "handle_content" varchar(255) DEFAULT NULL COMMENT '处理理由', + "display_name" varchar(64) DEFAULT NULL COMMENT '发起方对接收方的备注', + "add_source" tinyint DEFAULT NULL COMMENT '添加来源', + "handle_time" timestamp DEFAULT NULL COMMENT '处理时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_friend_request" UNIQUE ("from_user_id", "to_user_id", "tenant_id") +) COMMENT 'IM 好友申请记录表'; + +CREATE TABLE IF NOT EXISTS "im_group_request" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "group_id" bigint NOT NULL COMMENT '群编号', + "user_id" bigint NOT NULL COMMENT '申请人 / 被邀请人用户编号', + "inviter_user_id" bigint DEFAULT NULL COMMENT '邀请人用户编号;NULL=主动申请;非NULL=被邀请待审批', + "apply_content" varchar(255) DEFAULT NULL COMMENT '申请理由', + "add_source" tinyint DEFAULT NULL COMMENT '加入来源', + "handle_result" tinyint NOT NULL DEFAULT 0 COMMENT '处理结果;0未处理;1同意;2拒绝', + "handle_user_id" bigint DEFAULT NULL COMMENT '处理人用户编号', + "handle_content" varchar(255) DEFAULT NULL COMMENT '处理理由', + "handle_time" timestamp DEFAULT NULL COMMENT '处理时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_group_request" UNIQUE ("group_id", "user_id", "tenant_id") +) COMMENT 'IM 加群申请记录表'; + +CREATE TABLE IF NOT EXISTS "im_face_pack" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "name" varchar(64) NOT NULL COMMENT '表情包名称', + "icon" varchar(512) DEFAULT NULL COMMENT '表情包图标(面板底部 tab 显示)', + "sort" int NOT NULL DEFAULT 0 COMMENT '排序', + "status" tinyint NOT NULL COMMENT '状态', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 表情包表'; + +CREATE TABLE IF NOT EXISTS "im_face_pack_item" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "pack_id" bigint NOT NULL COMMENT '所属表情包编号', + "url" varchar(512) NOT NULL COMMENT '表情图 URL', + "name" varchar(64) DEFAULT NULL COMMENT '表情名(可选;如「狗头」「捂脸」)', + "width" int NOT NULL DEFAULT 0 COMMENT '渲染宽度(像素)', + "height" int NOT NULL DEFAULT 0 COMMENT '渲染高度(像素)', + "sort" int NOT NULL DEFAULT 0 COMMENT '排序', + "status" tinyint NOT NULL COMMENT '状态', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 表情包项表'; + +CREATE TABLE IF NOT EXISTS "im_rtc_call" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "room" varchar(64) NOT NULL COMMENT '业务通话编号', + "conversation_type" tinyint NOT NULL COMMENT '会话类型', + "media_type" tinyint NOT NULL COMMENT '媒体类型', + "inviter_user_id" bigint NOT NULL COMMENT '发起人用户编号', + "group_id" bigint DEFAULT NULL COMMENT '群编号', + "status" tinyint NOT NULL COMMENT '通话状态', + "end_reason" tinyint DEFAULT NULL COMMENT '结束原因', + "start_time" timestamp NOT NULL COMMENT '发起时间', + "accept_time" timestamp DEFAULT NULL COMMENT '接通时间', + "end_time" timestamp DEFAULT NULL COMMENT '结束时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 通话记录表'; + +CREATE TABLE IF NOT EXISTS "im_rtc_participant" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "call_id" bigint NOT NULL COMMENT '通话编号', + "room" varchar(64) NOT NULL COMMENT '业务通话编号', + "user_id" bigint NOT NULL COMMENT '参与者用户编号', + "role" tinyint NOT NULL COMMENT '参与角色', + "status" tinyint NOT NULL COMMENT '参与状态', + "invite_time" timestamp NOT NULL COMMENT '被邀请时间', + "accept_time" timestamp DEFAULT NULL COMMENT '接听时间', + "leave_time" timestamp DEFAULT NULL COMMENT '离开时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_rtc_participant_room_user" UNIQUE ("room", "user_id", "tenant_id") +) COMMENT 'IM 通话参与者表'; + +CREATE TABLE IF NOT EXISTS "im_face_user_item" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "user_id" bigint NOT NULL COMMENT '所属用户编号', + "url" varchar(512) NOT NULL COMMENT '表情图 URL', + "name" varchar(64) DEFAULT NULL COMMENT '表情名(可选)', + "width" int NOT NULL DEFAULT 0 COMMENT '渲染宽度(像素)', + "height" int NOT NULL DEFAULT 0 COMMENT '渲染高度(像素)', + "sort" int NOT NULL DEFAULT 0 COMMENT '排序', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_face_user_item_user_url_deleted" UNIQUE ("user_id", "url", "deleted") +) COMMENT 'IM 用户私有表情表'; + +CREATE TABLE IF NOT EXISTS "im_channel" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "code" varchar(64) NOT NULL COMMENT '频道业务码;唯一', + "name" varchar(64) NOT NULL COMMENT '频道名称', + "avatar" varchar(512) DEFAULT NULL COMMENT '频道头像', + "sort" int NOT NULL DEFAULT 0 COMMENT '排序', + "status" tinyint NOT NULL DEFAULT 0 COMMENT '状态;0 启用 1 停用', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 频道表'; + +CREATE TABLE IF NOT EXISTS "im_channel_material" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "channel_id" bigint NOT NULL COMMENT '频道编号', + "type" tinyint NOT NULL COMMENT '内容类型;1 站内富文本 2 外链', + "title" varchar(128) NOT NULL COMMENT '标题', + "cover_url" varchar(512) DEFAULT NULL COMMENT '封面图', + "summary" varchar(255) DEFAULT NULL COMMENT '摘要', + "content" clob DEFAULT NULL COMMENT '正文;富文本 HTML', + "url" varchar(512) DEFAULT NULL COMMENT '跳转链接;为空时点击在客户端内置详情页拉 content;非空则跳 url', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 频道素材表'; + +CREATE TABLE IF NOT EXISTS "im_channel_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "channel_id" bigint NOT NULL COMMENT '频道编号;冗余 im_channel_material.channel_id 便于检索', + "material_id" bigint NOT NULL COMMENT '关联素材编号', + "type" smallint NOT NULL COMMENT '消息类型', + "content" varchar(8192) DEFAULT NULL COMMENT '消息内容;推送时 payload JSON 快照;不含富文本正文', + "receiver_user_ids" varchar(1024) DEFAULT NULL COMMENT '接收人编号列表;逗号分隔;为空表示全员', + "send_time" timestamp NOT NULL COMMENT '发送时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 频道消息表'; + +CREATE TABLE IF NOT EXISTS "im_sensitive_word" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "word" varchar(128) NOT NULL COMMENT '敏感词', + "status" tinyint NOT NULL DEFAULT 0 COMMENT '状态;0 启用 1 停用', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + CONSTRAINT "uk_im_sensitive_word" UNIQUE ("word", "tenant_id") +) COMMENT 'IM 敏感词表'; + +CREATE TABLE IF NOT EXISTS "system_users" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "username" varchar(30) NOT NULL DEFAULT '', + "password" varchar(100) NOT NULL DEFAULT '', + "nickname" varchar(30) NOT NULL DEFAULT '', + "remark" varchar(500) DEFAULT NULL, + "dept_id" bigint DEFAULT NULL, + "post_ids" varchar(255) DEFAULT NULL, + "email" varchar(50) DEFAULT '', + "mobile" varchar(11) DEFAULT '', + "sex" tinyint DEFAULT 0, + "avatar" varchar(100) DEFAULT '', + "status" tinyint NOT NULL DEFAULT 0, + "login_ip" varchar(50) DEFAULT '', + "login_date" timestamp DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT '用户信息表'; diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/message/user/AdminUserProfileUpdateMessage.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/message/user/AdminUserProfileUpdateMessage.java new file mode 100644 index 000000000..13f3cc6b9 --- /dev/null +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/message/user/AdminUserProfileUpdateMessage.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.system.api.message.user; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 管理员用户资料(昵称 / 头像)变更消息 + *

+ * 仅当 nickname 或 avatar 真的发生变化时才发送;订阅方据此做下游分发, + * 例如 IM 模块向该用户的所有好友推送 FRIEND_INFO_UPDATED 通知 + * + * @author 芋道源码 + */ +@Data +public class AdminUserProfileUpdateMessage { + + /** + * 用户编号 + */ + @NotNull(message = "用户编号不能为空") + private Long userId; + /** + * 变更后的昵称(无变更时为 null) + */ + private String nickname; + /** + * 变更后的头像(无变更时为 null) + */ + private String avatar; + +} diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/mq/producer/user/AdminUserProducer.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/mq/producer/user/AdminUserProducer.java new file mode 100644 index 000000000..4af39318f --- /dev/null +++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/mq/producer/user/AdminUserProducer.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.system.mq.producer.user; + +import cn.iocoder.yudao.module.system.api.message.user.AdminUserProfileUpdateMessage; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +/** + * 管理员用户 Producer + * + * @author 芋道源码 + */ +@Slf4j +@Component +public class AdminUserProducer { + + @Resource + private ApplicationContext applicationContext; + + /** + * 发送 {@link AdminUserProfileUpdateMessage} 消息 + * + * @param userId 用户编号 + * @param nickname 变更后的昵称(无变更传 null) + * @param avatar 变更后的头像(无变更传 null) + */ + public void sendUserProfileUpdateMessage(Long userId, String nickname, String avatar) { + applicationContext.publishEvent(new AdminUserProfileUpdateMessage() + .setUserId(userId).setNickname(nickname).setAvatar(avatar)); + } + +} diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java index 5d332c4b7..abcb67a78 100644 --- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java +++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java @@ -25,6 +25,7 @@ import cn.iocoder.yudao.module.system.dal.dataobject.dept.UserPostDO; import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; import cn.iocoder.yudao.module.system.dal.mysql.dept.UserPostMapper; import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper; +import cn.iocoder.yudao.module.system.mq.producer.user.AdminUserProducer; import cn.iocoder.yudao.module.system.service.dept.DeptService; import cn.iocoder.yudao.module.system.service.dept.PostService; import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService; @@ -88,6 +89,9 @@ public class AdminUserServiceImpl implements AdminUserService { @Resource private ConfigApi configApi; + @Resource + private AdminUserProducer adminUserProducer; + @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_CREATE_SUB_TYPE, bizNo = "{{#user.id}}", @@ -158,6 +162,8 @@ public class AdminUserServiceImpl implements AdminUserService { userMapper.updateById(updateObj); // 2.2 更新岗位 updateUserPost(updateReqVO, updateObj); + // 2.3 昵称 / 头像变化时,发送消息供下游订阅(如 IM 模块推 FRIEND_INFO_UPDATED) + publishUserProfileUpdatedIfChanged(oldUser, updateReqVO.getNickname(), updateReqVO.getAvatar()); // 3. 记录操作日志上下文 LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldUser, UserSaveReqVO.class)); @@ -188,12 +194,30 @@ public class AdminUserServiceImpl implements AdminUserService { @Override public void updateUserProfile(Long id, UserProfileUpdateReqVO reqVO) { - // 校验正确性 - validateUserExists(id); + // 1. 校验正确性 + AdminUserDO oldUser = validateUserExists(id); validateEmailUnique(id, reqVO.getEmail()); validateMobileUnique(id, reqVO.getMobile()); - // 执行更新 + + // 2. 执行更新 userMapper.updateById(BeanUtils.toBean(reqVO, AdminUserDO.class).setId(id)); + + // 3. 昵称 / 头像变化时,发送消息供下游订阅(如 IM 模块推 FRIEND_INFO_UPDATED) + publishUserProfileUpdatedIfChanged(oldUser, reqVO.getNickname(), reqVO.getAvatar()); + } + + /** + * 仅当 nickname 或 avatar 跟旧值不一致时,发送 AdminUserProfileUpdateMessage + */ + private void publishUserProfileUpdatedIfChanged(AdminUserDO oldUser, String newNickname, String newAvatar) { + boolean nicknameChanged = newNickname != null && !ObjUtil.equal(oldUser.getNickname(), newNickname); + boolean avatarChanged = newAvatar != null && !ObjUtil.equal(oldUser.getAvatar(), newAvatar); + if (!nicknameChanged && !avatarChanged) { + return; + } + adminUserProducer.sendUserProfileUpdateMessage(oldUser.getId(), + nicknameChanged ? newNickname : null, + avatarChanged ? newAvatar : null); } @Override diff --git a/yudao-server/pom.xml b/yudao-server/pom.xml index f6834031e..5168d8b8d 100644 --- a/yudao-server/pom.xml +++ b/yudao-server/pom.xml @@ -127,6 +127,13 @@ + + + + + + +