From 530d3b3a5f2ca5857db38298c37706af2dcd0b79 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 12 May 2026 09:47:20 +0800 Subject: [PATCH] ✨ feat(im): rtc 增加数据库存储(从单体 =》集群) --- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java | 11 +++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java | 10 +--------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java | 4 ++-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java | 11 +++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java | 1 + yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java | 47 +++++++++++++++++++++++++++-------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java | 43 ------------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java | 31 ------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java | 46 ---------------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java | 42 ++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java | 46 ++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java | 38 ++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java | 11 ++++++++++- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java | 24 ++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java | 72 ------------------------------------------------------------------------ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java | 40 ++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java | 102 ------------------------------------------------------------------------------------------------------ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java | 104 -------------------------------------------------------------------------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java | 23 ----------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java | 25 ------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java | 31 ------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java | 35 ----------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java | 49 ------------------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java | 14 +++++++++++++- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java | 20 +++++++++++--------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java | 23 ++++++++++++++++++++++- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java | 2 +- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 38 files changed, 1202 insertions(+), 605 deletions(-) create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java 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 9858a1a..e0db0b1 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 @@ -145,6 +145,17 @@ public interface BaseMapperX extends MPJBaseMapper { return CollUtil.getFirst(list); } + /** + * 获取满足条件的最新一条记录 + *

+ * 目的:解决并发场景下,插入多条记录后,使用 selectOne 会报错的问题 + * + * @param queryWrapper 查询条件 + * @return 最新一条;不存在返回 null + */ + default T selectLast(LambdaQueryWrapper queryWrapper) { + return CollUtil.getLast(selectList(queryWrapper)); + } default Long selectCount() { return selectCount(new QueryWrapper<>()); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java index 8a61e50..b668d6e 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java @@ -14,8 +14,6 @@ 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.security.core.util.SecurityFrameworkUtils.getLoginUserId; @@ -79,17 +77,11 @@ public class ImRtcCallController { return success(rtcCallService.refreshToken(getLoginUserId(), roomName)); } - @GetMapping("/active-sessions") - @Operation(summary = "查询当前用户活跃通话;用于 App 冷启动 / 推送点开恢复") - public CommonResult> getActiveSessions() { - return success(rtcCallService.getActiveSessions(getLoginUserId())); - } - @GetMapping("/group-active-call") @Operation(summary = "查询群当前进行中的通话;用于群聊顶部「N 人正在通话」胶囊条") @Parameter(name = "groupId", description = "群编号", required = true, example = "2048") public CommonResult getGroupActiveCall(@RequestParam("groupId") Long groupId) { - return success(rtcCallService.getGroupActiveCall(groupId)); + return success(rtcCallService.getGroupActiveCall(getLoginUserId(), groupId)); } } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java index ad52c02..c286891 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java @@ -5,7 +5,7 @@ import lombok.Data; import java.util.Set; -@Schema(description = "管理后台 - 通话会话 Response VO;invite / accept / refreshToken / getActiveSessions 共用") +@Schema(description = "管理后台 - 通话会话 Response VO;invite / accept / refreshToken 共用") @Data public class ImRtcCallRespVO { @@ -18,7 +18,7 @@ public class ImRtcCallRespVO { @Schema(description = "LiveKit Server WebSocket 地址;前端 connect 用") private String livekitUrl; - @Schema(description = "LiveKit 接入 Token;getActiveSessions 列表里为 null,需要的话单独调 refreshToken") + @Schema(description = "LiveKit 接入 Token;需要时调 refreshToken 重新签发") private String token; @Schema(description = "会话场景") diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java new file mode 100644 index 0000000..ae56894 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java @@ -0,0 +1,99 @@ +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} 通过 callId 关联 + * + * @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 生成 + */ + private String callId; + /** + * LiveKit 房间名 + * + * 格式 {@code im_rtc_call_{callId}} + */ + private String roomName; + /** + * 会话类型 + * + * 枚举 {@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/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java new file mode 100644 index 0000000..ea93547 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java @@ -0,0 +1,74 @@ +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(用户级 / 明细表) + *

+ * 一通通话每个参与者一行;通过 callId 关联主表 {@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#getCallId()} + */ + private String callId; + /** + * 参与者用户编号 + *

+ * 关联 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/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java new file mode 100644 index 0000000..c583e94 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java @@ -0,0 +1,55 @@ +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.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 selectByCallId(String callId) { + return selectOne(ImRtcCallDO::getCallId, callId); + } + + default ImRtcCallDO selectByRoomName(String roomName) { + return selectOne(ImRtcCallDO::getRoomName, roomName); + } + + default ImRtcCallDO selectLastByGroupIdAndStatusIn(Long groupId, Collection statuses) { + return selectLast(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)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java new file mode 100644 index 0000000..4cee275 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java @@ -0,0 +1,56 @@ +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.util.Collection; +import java.util.List; + +/** + * IM 通话参与者 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImRtcParticipantMapper extends BaseMapperX { + + default ImRtcParticipantDO selectByCallIdAndUserId(String callId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getCallId, callId) + .eq(ImRtcParticipantDO::getUserId, userId)); + } + + default List selectListByCallId(String callId) { + return selectList(ImRtcParticipantDO::getCallId, callId); + } + + default List selectListByCallIdAndStatus(String callId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getCallId, callId) + .eq(ImRtcParticipantDO::getStatus, status)); + } + + default ImRtcParticipantDO selectLastByUserIdAndStatusIn(Long userId, Collection statuses) { + return selectLast(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getUserId, userId) + .in(ImRtcParticipantDO::getStatus, statuses)); + } + + @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 updateByCallIdAndStatus(String callId, Integer oldStatus, ImRtcParticipantDO updateObj) { + return update(updateObj, Wrappers.lambdaUpdate() + .eq(ImRtcParticipantDO::getCallId, callId) + .eq(ImRtcParticipantDO::getStatus, oldStatus)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java index a11a182..6272aca 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java @@ -41,4 +41,15 @@ public interface RedisKeyConstants { */ 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/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java new file mode 100644 index 0000000..0384bf4 --- /dev/null +++ b/yudao-module-im/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/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java index f5d686b..519c63e 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java @@ -89,5 +89,6 @@ public interface ErrorCodeConstants { 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, "通话发起繁忙,请稍后再试"); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java index 3257d0f..7bc5b6e 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java @@ -78,35 +78,42 @@ public enum ImMessageTypeEnum implements ArrayValuable { */ READ(2201, "已读", false, false), - // ========== 实时通话信令(2300-2302) ========== - // TODO @AI:openim 的信号,是怎么设计的?感觉对齐下,会好一点点,不然用户不好拓展; + // ========== 实时通话信令(1601-1605 段位与 OpenIM 对齐;1610+ 自有扩展) ========== /** - * 通话邀请:主叫发起后推给被叫弹「来电」界面;payload 为 RtcInviteNotification(roomName / mediaType / scene / inviter / token) + * 对应 OpenIM:SignalingNotification 1601(通话信令统一入口) + * 对应自己的类:ImRtcCallNotification + * 场景:通话信令;不入库,走 imWebSocketService 仅推参与方;signalingType 区分 INVITE / REJECT(预留 ACCEPT / CANCEL / HUNGUP) */ - RTC_INVITE(2300, "通话邀请", false, false), + RTC_CALL(1601, "通话信令", false, false), /** - * 通话已接通:被叫接听后推给主叫切换到「通话中」UI;payload 为 RtcAcceptNotification(roomName / acceptorId) + * 对应 OpenIM:RoomParticipantsConnectedNotification 1602 + * 对应自己的类:ImRtcParticipantConnectedNotification + * 场景:通话参与者加入;LiveKit webhook participant_joined 触发;私聊推 peer 多端 + inviter 多端,群聊全群广播;不入库 */ - RTC_ACCEPT(2301, "通话接通", false, false), + RTC_PARTICIPANT_CONNECTED(1602, "通话参与者加入", false, false), /** - * 通话结束:拒绝 / 取消 / 挂断 / 超时 / 异常 统一走这一条;payload 为 RtcEndNotification(roomName / reason / operatorId) + * 对应 OpenIM:RoomParticipantsDisconnectedNotification 1603 + * 对应自己的类:ImRtcParticipantDisconnectedNotification + * 场景:通话参与者离开;LiveKit webhook participant_left 触发;推送范围同 1602;不入库 */ - RTC_END(2302, "通话结束", false, false), - - // ========== 群通话广播信号(2310-2312):让所有群成员能感知 / 主动加入 ========== - /** - * 群通话开始:群通话发起时给所有群成员广播;payload 为 ImRtcGroupNotification(callId / roomName / mediaType / inviterId / joinedUserIds) - */ - RTC_GROUP_STARTED(2310, "群通话开始", false, false), + RTC_PARTICIPANT_DISCONNECTED(1603, "通话参与者离开", false, false), + // 1604-1609 OpenIM 已用 / 留作扩展,本系统暂不使用 /** - * 群通话结束:最后一人离开 / 异常关房时广播;payload 仅含 roomName / groupId + * 对应 OpenIM:无(自有扩展,OpenIM 通话事件不入消息流) + * 对应自己的类:ImRtcCallStartNotification + * 场景:群通话开始;入 im_group_message + 全群广播;前端渲染聊天 tip「{inviterNickname} 发起了{voice/video}通话」 + *

+ * 与 RTC_CALL_END(1611) 两段式配对:START 一定先于 END 入库(START 在 invite 接口事务里、END 在 cancel/leave 接口事务里,自然按请求顺序串行) */ - RTC_GROUP_ENDED(2311, "群通话结束", false, false), + RTC_CALL_START(1610, "通话开始", true, false), /** - * 群通话成员变更:有人加入 / 离开时广播;payload 为 ImRtcGroupNotification(含最新 joinedUserIds); - * 用于胶囊条上「N 人正在通话」实时刷新 + * 对应 OpenIM:无(自有扩展,OpenIM 通话事件不入消息流) + * 对应自己的类:ImRtcCallEndNotification + * 场景:通话结束;入 im_private_message / im_group_message;私聊渲染准气泡,群聊渲染 tip「{voice/video}通话已结束 [时长 X]」 + *

+ * 群通话两段式配对前导是 RTC_CALL_START(1610);私聊场景没有 START,单 END 准气泡承载完整生命周期 */ - RTC_GROUP_UPDATED(2312, "群通话成员变更", false, false), + RTC_CALL_END(1611, "通话结束", true, false), // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ========== // TODO @芋艿:FRIEND_REQUEST_* 与 GROUP_REQUEST_* 都是 persistent=false 的 SysMsg,离线 pull 拉不到, @@ -224,7 +231,7 @@ public enum ImMessageTypeEnum implements ArrayValuable { /** * 对应 OpenIM:sdkws.MemberInvitedTips(MemberInvitedNotification 1509) * 对应自己的类:GroupMemberInviteNotification - * 场景:成员邀请新人入群,全员广播(含被邀请者);被邀请人前端按 memberUserIds 含自己自判 fetchGroupInfo + fetchGroupMembers bootstrap + * 场景:成员邀请新人入群,全员广播(含被邀请者);被邀请人前端按 memberUserIds 含自己自判,初次拉取 fetchGroupInfo + fetchGroupMembers */ GROUP_MEMBER_INVITE(1509, "成员加入", true, false), /** diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java deleted file mode 100644 index 01edabd..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java +++ /dev/null @@ -1,43 +0,0 @@ -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 ImCallEndReasonEnum implements ArrayValuable { - - // TODO @AI:这个对齐的哪个东西; - // TODO @AI:尾注释; - /** 正常挂断:双方在通话中后由任一方主动挂断 */ - HANGUP(1, "通话结束"), - /** 拒绝:被叫接通前点拒接 */ - REJECT(2, "已拒绝"), - /** 取消:主叫接通前主动取消 */ - CANCEL(3, "已取消"), - /** 超时:振铃 30 秒未应答 */ - TIMEOUT(4, "未接通"), - /** 对方忙线 */ - BUSY(5, "对方正忙"), - /** 异常:网络中断、设备失败等 */ - ERROR(9, "通话异常"); - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallEndReasonEnum::getType).toArray(Integer[]::new); - - private final Integer type; - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java deleted file mode 100644 index 2468f7e..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java +++ /dev/null @@ -1,31 +0,0 @@ -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 ImCallMediaTypeEnum implements ArrayValuable { - - VOICE(1, "语音"), - VIDEO(2, "视频"); - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallMediaTypeEnum::getType).toArray(Integer[]::new); - - private final Integer type; - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java deleted file mode 100644 index 4088208..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java +++ /dev/null @@ -1,46 +0,0 @@ -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; - -// TODO @AI:是否一定要有 status 状态的记录????【可以在调研下】 -/** - * IM 通话状态枚举 - * - * @author 芋道源码 - */ -@RequiredArgsConstructor -@Getter -public enum ImCallStatusEnum implements ArrayValuable { - - INVITING(10, "邀请中"), // 邀请中:1v1 主叫等待被叫接听 - ONGOING(20, "通话中"), // 通话中:至少一名被叫已接听;群通话从发起人加入即进入此状态 - ENDED(30, "已结束"); // 已结束:会话即将销毁;写入消息流后从内存移除 - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallStatusEnum::getType).toArray(Integer[]::new); - - private final Integer type; - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - - public static boolean isInviting(Integer type) { - return Objects.equals(INVITING.type, type); - } - - public static boolean isOngoing(Integer type) { - return Objects.equals(ONGOING.type, type); - } - - public static boolean isEnded(Integer type) { - return Objects.equals(ENDED.type, type); - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java new file mode 100644 index 0000000..ca192fb --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java @@ -0,0 +1,42 @@ +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 文案 + *

+ * 不含 TIMEOUT;产品决策走微信式不超时,振铃由用户主动 cancel / reject + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImRtcCallEndReasonEnum implements ArrayValuable { + + HANGUP(1, "通话结束"), // 接通后任一方主动挂断 + REJECT(2, "已拒绝"), // 被叫接通前点拒接 + CANCEL(3, "已取消"), // 主叫接通前主动取消 + 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/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java new file mode 100644 index 0000000..9f767b9 --- /dev/null +++ b/yudao-module-im/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/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java new file mode 100644 index 0000000..7ae6387 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java @@ -0,0 +1,59 @@ +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.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 = List.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/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java new file mode 100644 index 0000000..78ad253 --- /dev/null +++ b/yudao-module-im/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, "被邀请者"), + ACTIVE_JOIN(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/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java new file mode 100644 index 0000000..770923e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java @@ -0,0 +1,62 @@ +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.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 = List.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/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java index e0ca205..2ce6136 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java @@ -1,10 +1,13 @@ 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; -// TODO @AI:参考别的模块;是不是也要分 im;config 和 core 这样;在瞅瞅; /** * IM 模块全局配置 *

@@ -15,6 +18,7 @@ import org.springframework.stereotype.Component; */ @Component @ConfigurationProperties(prefix = "yudao.im") +@Validated @Data public class ImProperties { @@ -24,6 +28,7 @@ public class ImProperties { private Message message = new Message(); + @Valid private Rtc rtc = new Rtc(); /** @@ -134,16 +139,20 @@ public class ImProperties { /** * 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"; /** diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java new file mode 100644 index 0000000..12bf2e1 --- /dev/null +++ b/yudao-module-im/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/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java new file mode 100644 index 0000000..40d44aa --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java @@ -0,0 +1,197 @@ +package cn.iocoder.yudao.module.im.framework.rtc.core; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +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.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 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; + + public LiveKitClient(ImProperties imProperties) { + this.imProperties = imProperties; + } + + /** + * 签发客户端进房 Token;有效期从 {@link ImProperties.Rtc#getTokenTtlHours()} 读取 + * + * @param identity 用户唯一标识;写入 sub claim;同 identity 重连会踢前一个连接 + * @param displayName 客户端展示名;可空 + * @param roomName 房间名 + * @return JWT 字符串 + */ + public String signJoinToken(String identity, String displayName, String roomName) { + Assert.notBlank(identity, "identity 不可为空"); + Assert.notBlank(roomName, "roomName 不可为空"); + ImProperties.Rtc cfg = imProperties.getRtc(); + + // video claim:限定客户端能在该房间内做什么 + Map video = new HashMap<>(); + video.put("roomJoin", true); // 允许加入房间 + video.put("room", roomName); // 限定只能加入这个房间 + 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())); + if (StrUtil.isNotEmpty(displayName)) { + jwt.setPayload("name", displayName); + } + return jwt.sign(); + } + + /** + * 调用 LiveKit Server API 删除房间:用于通话结束时,强制断开异常残留客户端 + * + * @param roomName 房间名 + */ + @SuppressWarnings("EmptyTryBlock") + public void deleteRoom(String roomName) { + try (HttpResponse ignored = postTwirp(TWIRP_DELETE_ROOM, roomName)) { + // 状态码不区分;调用方失败即兜底 + } + } + + /** + * 调用 LiveKit Server API 查询某房间内的参与者数量:用于定时扫描僵尸通话 + *

+ * 房间不存在 LiveKit 返回 404,视同 0 人 + * + * @param roomName 房间名 + * @return 参与者数量;HTTP 失败返回 -1 + */ + public int listParticipants(String roomName) { + try (HttpResponse response = postTwirp(TWIRP_LIST_PARTICIPANTS, roomName)) { + if (response.getStatus() == 404) { + return 0; + } + if (!response.isOk()) { + log.warn("[listParticipants][LiveKit 返回非 2xx status={} roomName={} body={}]", + response.getStatus(), roomName, 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; + } + } + + /** + * 签发管理 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", Map.of("roomAdmin", true)) + .setSigner(JWTSignerUtil.hs256(cfg.getApiSecret().getBytes())) + .sign(); + } + + /** + * Twirp 协议 POST 调用;统一处理 ws→http 协议切换、签 admin token、Bearer 头、JSON body、超时 + * + * @param path Twirp 端点路径,例如 {@code /twirp/livekit.RoomService/DeleteRoom} + * @param roomName 房间名;写入 JSON body 的 room 字段 + * @return HTTP 响应;调用方负责 close 与状态码判断 + */ + private HttpResponse postTwirp(String path, String roomName) { + Assert.notBlank(roomName, "roomName 不可为空"); + String token = signAdminToken(); + return HttpRequest.post(HttpUtils.wsUrlToHttp(imProperties.getRtc().getLivekitUrl()) + path) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .body(JSONUtil.toJsonStr(Map.of("room", roomName))) + .timeout(SERVER_API_TIMEOUT_MS) + .execute(); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java deleted file mode 100644 index 306654e..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java +++ /dev/null @@ -1,72 +0,0 @@ -package cn.iocoder.yudao.module.im.framework.rtc.core; - -import cn.hutool.core.lang.Assert; -import cn.hutool.jwt.JWT; -import cn.hutool.jwt.signers.JWTSignerUtil; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -import java.time.Duration; -import java.time.Instant; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; - -// TODO @AI:是不是搞成 LiveKitClient;然后 config 类去创建起来;通过 @Resource 注入进去? -/** - * LiveKit Access Token 签发工具 - *

- * LiveKit 协议把权限放在 video claim 里;这里只暴露通话场景需要的最小权限:roomJoin / canPublish / - * canSubscribe / canPublishData,避免 PoC 阶段误开 roomAdmin。生产想细分会议主持人时再扩字段。 - * - * @author 芋道源码 - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class LiveKitTokenUtils { - - /** - * 签发客户端进房 Token - * - * @param apiKey LiveKit API Key;写入 iss claim - * @param apiSecret LiveKit API Secret;HS256 签名密钥;长度必须 ≥ 32 - * @param identity 用户唯一标识;写入 sub claim;同 identity 重连会踢前一个连接 - * @param displayName 客户端展示名;可空 - * @param roomName 房间名 - * @param ttl 有效期 - * @return JWT 字符串 - */ - public static String signJoinToken(String apiKey, String apiSecret, String identity, - String displayName, String roomName, Duration ttl) { - Assert.notBlank(apiKey, "apiKey 不可为空"); - Assert.notBlank(apiSecret, "apiSecret 不可为空"); - Assert.isTrue(apiSecret.length() >= 32, "apiSecret 长度需 ≥ 32 位"); - Assert.notBlank(identity, "identity 不可为空"); - Assert.notBlank(roomName, "roomName 不可为空"); - Assert.notNull(ttl, "ttl 不可为空"); - - // TODO @AI:这里注释下; - Map video = new HashMap<>(); - video.put("roomJoin", true); // TODO @AI:这里帮忙注释下 - video.put("room", roomName); // TODO @AI:这里帮忙注释下 - video.put("canPublish", true); // TODO @AI:这里帮忙注释下 - video.put("canSubscribe", true); // TODO @AI:这里帮忙注释下 - video.put("canPublishData", true); // TODO @AI:这里帮忙注释下 - - // TODO @AI:这里注释下; - // TODO @AI:是不是 new Date;然后下面去处理,更合理?或者 System currentTime 这种? - long now = Instant.now().getEpochSecond(); - JWT jwt = JWT.create() - .setIssuer(apiKey) - .setSubject(identity) - .setNotBefore(new Date(now * 1000)) - .setExpiresAt(new Date((now + ttl.getSeconds()) * 1000)) - .setPayload("video", video) - .setSigner(JWTSignerUtil.hs256(apiSecret.getBytes())); - // TODO @AI:empty 处理下; - if (displayName != null && !displayName.isEmpty()) { - jwt.setPayload("name", displayName); - } - return jwt.sign(); - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java new file mode 100644 index 0000000..bb53501 --- /dev/null +++ b/yudao-module-im/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/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java new file mode 100644 index 0000000..670601e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java @@ -0,0 +1,40 @@ +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.quartz.core.handler.JobHandler; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 僵尸通话清理 Job:兜底 LiveKit Webhook 丢失 / 客户端异常关闭等未调 leave 等场景 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class ImRtcCallCleanupJob implements JobHandler { + + @Resource + private ImRtcCallService rtcCallService; + + /** + * 执行清理 + * + * @param param 阈值(分钟);为空走 Service 默认(5 分钟) + * @return 清理数量描述 + */ + @Override + @TenantJob + public String execute(String param) { + Integer thresholdMinutes = StrUtil.isNotBlank(param) && NumberUtil.isInteger(param) + ? Integer.parseInt(param) : null; + int cleaned = rtcCallService.cleanupZombieCalls(thresholdMinutes); + log.info("[execute][清理僵尸通话数量 ({}) 个]", cleaned); + return String.format("清理僵尸通话 %s 个", cleaned); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java deleted file mode 100644 index 570126d..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java +++ /dev/null @@ -1,102 +0,0 @@ -package cn.iocoder.yudao.module.im.service.rtc.bo; - -import lombok.Builder; -import lombok.Data; - -import java.util.LinkedHashSet; -import java.util.Set; - -// TODO @AI:后续讨论;抽成 DO;写到 mysql 里; -/** - * IM 通话会话 BO;存放在 {@link ImCallSessionStore} 内存里,通话结束即移除 - * - * @author 芋道源码 - */ -@Data -@Builder -public class ImCallSessionBO { - - /** - * 业务通话编号;雪花 ID 生成;用于消息流落库与 Webhook 关联 - */ - private String callId; - /** - * LiveKit 房间名;私聊由好友对派生 call_friend_{minId}_{maxId};群聊为 call_group_{groupId} - */ - private String roomName; - /** - * 会话场景;取自 {@link cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum} 的 type - */ - private Integer scene; - /** - * 媒体类型;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallMediaTypeEnum} 的 type - */ - private Integer mediaType; - /** - * 发起人编号 - */ - private Long inviterId; - /** - * 被邀请人集合;私聊为单元素;群聊为发起时所有候选成员(含发起人本人,方便统一遍历) - */ - private Set inviteeIds; - /** - * 群编号;群聊场景必填 - */ - private Long groupId; - /** - * 状态;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallStatusEnum} 的 type - */ - private Integer status; - /** - * 通话开始时间戳;millis - */ - private Long startTime; - /** - * 已加入房间的成员集合 - */ - @Builder.Default - private Set joinedUserIds = new LinkedHashSet<>(); - /** - * 已拒绝的成员集合(接通前点拒接) - */ - @Builder.Default - private Set rejectedUserIds = new LinkedHashSet<>(); - /** - * 已离开的成员集合(接通后挂断) - */ - @Builder.Default - private Set leftUserIds = new LinkedHashSet<>(); - - // TODO @AI:bo 里,不要有这种方法; - /** - * 当前会话中所有相关用户;用于 RTC_END 信号扇出与忙线清理 - */ - public Set getAllUserIds() { - Set result = new LinkedHashSet<>(); - result.add(inviterId); - if (inviteeIds != null) { - result.addAll(inviteeIds); - } - result.addAll(joinedUserIds); - return result; - } - - // TODO @AI:这个方法,是不是没在使用??? - /** - * 判断用户是否参与该通话;不在被邀请也不在已加入即非参与方 - */ - public boolean containsUser(Long userId) { - if (userId == null) { - return false; - } - if (userId.equals(inviterId)) { - return true; - } - if (inviteeIds != null && inviteeIds.contains(userId)) { - return true; - } - return joinedUserIds.contains(userId); - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java deleted file mode 100644 index 03bbf67..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java +++ /dev/null @@ -1,104 +0,0 @@ -package cn.iocoder.yudao.module.im.service.rtc.bo; - -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; - -/** - * IM 通话会话内存仓储;MVP 阶段不落库,全量存内存 - *

- * 双层索引: - * - sessions: roomName -> 通话会话;invite 时 computeIfAbsent 保证同一好友对 / 群只有一个进行中通话; - * - userActiveRoom: userId -> 当前所在 roomName;用于"对方正忙"提示与冷启动恢复。 - * 通话结束时 {@link #remove} 一次性清两个索引;同一用户的并发挂断走 ConcurrentHashMap 原子操作收敛。 - * - * @author 芋道源码 - */ -@Component -public class ImCallSessionStore { - - private final Map sessions = new ConcurrentHashMap<>(); - - private final Map userActiveRoom = new ConcurrentHashMap<>(); - - public ImCallSessionBO get(String roomName) { - if (roomName == null) { - return null; - } - return sessions.get(roomName); - } - - /** - * 原子获取或创建会话;在并发 invite 下保证同一 roomName 只 build 一次 - * - * @param roomName 房间名(已由 service 派生) - * @param builder 仅当不存在时调用;返回已组装好的 ImCallSessionBO - * @return 会话实例(新建或已有) - */ - public ImCallSessionBO getOrCreate(String roomName, Function builder) { - return sessions.computeIfAbsent(roomName, builder); - } - - /** - * 移除会话并清理参与者的 active 索引;调用方应在会话状态切到 ENDED 后调用 - */ - public void remove(String roomName) { - if (roomName == null) { - return; - } - ImCallSessionBO session = sessions.remove(roomName); - if (session == null) { - return; - } - for (Long userId : session.getAllUserIds()) { - // 仅当该用户的 active room 仍是当前 room 时才解绑;避免误清用户后续新建的通话 - userActiveRoom.remove(userId, roomName); - } - } - - /** - * 绑定用户到房间;invite / accept 时调用 - */ - public void bindUser(Long userId, String roomName) { - if (userId == null || roomName == null) { - return; - } - userActiveRoom.put(userId, roomName); - } - - /** - * 仅在用户当前 active room 等于给定 roomName 时解绑;用于 reject / leave 单方退出 - */ - public void unbindUserFromRoom(Long userId, String roomName) { - if (userId == null || roomName == null) { - return; - } - userActiveRoom.remove(userId, roomName); - } - - /** - * 用户是否处于通话中;用于 invite 时判定"对方正忙" - */ - public boolean isBusy(Long userId) { - return userId != null && userActiveRoom.containsKey(userId); - } - - /** - * 用户当前所在的 roomName;冷启动恢复用 - */ - public String getActiveRoom(Long userId) { - return userId == null ? null : userActiveRoom.get(userId); - } - - /** - * 列出当前所有进行中会话;用于运维 / 单元测试观察;线上调用频次需谨慎 - */ - public List listAll() { - return new ArrayList<>(sessions.values()); - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java deleted file mode 100644 index 45d2951..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java +++ /dev/null @@ -1,23 +0,0 @@ -package cn.iocoder.yudao.module.im.service.rtc.dto; - -import lombok.Data; - -// TODO @AI:一些 notification 应该挪到 message 那边,你在 -// TODO @AI:注释风格; -/** - * RTC 接通通知;推给主叫与已在房成员,提示某用户已加入;前端把"等待对方接受"切换成通话中 UI - * - * @author 芋道源码 - */ -@Data -public class ImRtcAcceptNotification { - - /** 业务通话编号 */ - // TODO @AI:是不是后续使用 id Long?(后续在改) - private String callId; - /** LiveKit 房间名 */ - private String roomName; - /** 接听者编号 */ - private Long acceptorId; - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java deleted file mode 100644 index 02f8f10..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java +++ /dev/null @@ -1,25 +0,0 @@ -package cn.iocoder.yudao.module.im.service.rtc.dto; - -import lombok.Data; - -// TODO @AI:注释风格; -/** - * RTC 结束通知;拒绝 / 取消 / 挂断 / 超时 / 异常 统一走这一条;前端关闭通话界面 - * - * @author 芋道源码 - */ -@Data -public class ImRtcEndNotification { - - /** 业务通话编号 */ - private String callId; - /** LiveKit 房间名 */ - private String roomName; - /** 操作者编号;TIMEOUT 等系统触发场景为 null */ - private Long operatorId; - /** 结束原因;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallEndReasonEnum} 的 type */ - private Integer reason; - /** 通话时长(秒);接通过为 endTime - acceptTime;未接通为 null */ - private Long durationSeconds; - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java deleted file mode 100644 index 1ae66a3..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java +++ /dev/null @@ -1,31 +0,0 @@ -package cn.iocoder.yudao.module.im.service.rtc.dto; - -import lombok.Data; - -import java.util.Set; - -// TODO @AI:注释风格; -/** - * RTC 群通话广播载荷;让群里所有成员(不仅是被邀请人)能感知通话存在,并支持主动加入 - * - * @author 芋道源码 - */ -@Data -public class ImRtcGroupNotification { - - /** 业务通话编号 */ - private String callId; - /** LiveKit 房间名 */ - private String roomName; - /** 群编号 */ - private Long groupId; - /** 媒体类型;1 语音;2 视频 */ - private Integer mediaType; - /** 发起人编号 */ - private Long inviterId; - /** 当前已加入房间的用户编号集合 */ - private Set joinedUserIds; - /** 当前被邀请池;展开胶囊条时用来展示「待加入」灰色头像 */ - private Set inviteeIds; - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java deleted file mode 100644 index 4c05505..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java +++ /dev/null @@ -1,35 +0,0 @@ -package cn.iocoder.yudao.module.im.service.rtc.dto; - -import lombok.Data; - -// TODO @AI:注释风格; -/** - * RTC 来电邀请通知;推给被叫弹「来电」界面 - * - * @author 芋道源码 - */ -@Data -public class ImRtcInviteNotification { - - /** 业务通话编号 */ - private String callId; - /** LiveKit 房间名 */ - private String roomName; - /** LiveKit Server WebSocket 地址 */ - private String livekitUrl; - /** 该被叫专属的 LiveKit 接听 Token;接通后直接 connect 用 */ - private String token; - /** 会话场景;1 私聊;2 群聊 */ - private Integer scene; - /** 媒体类型;1 语音;2 视频 */ - private Integer mediaType; - /** 发起人编号 */ - private Long inviterId; - /** 发起人昵称;前端来电界面展示 */ - private String inviterNickname; - /** 发起人头像 */ - private String inviterAvatar; - /** 群编号;群通话场景必填 */ - private Long groupId; - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java deleted file mode 100644 index 9d4f9e5..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java +++ /dev/null @@ -1,49 +0,0 @@ -package cn.iocoder.yudao.module.im.service.rtc.dto; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.Data; - -// TODO @AI:注释风格;(这个是不是 VO 哈。不放在 VO ) -/** - * LiveKit Webhook 事件载荷;只反序列化我们关心的字段,其余忽略 - *

- * 文档参考:https://docs.livekit.io/home/server/webhook/ - * - * @author 芋道源码 - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class LiveKitWebhookEventDTO { - - /** 事件类型;room_started / room_finished / participant_joined / participant_left / track_published 等 */ - private String event; - - /** 事件 id;用于幂等去重 */ - private String id; - - /** 房间元信息;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/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java index f7457fe..de84a3e 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java @@ -21,7 +21,19 @@ public interface ImWebSocketService { * @param userId 目标用户编号 * @param dto 私聊消息 DTO */ - void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto); + default void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) { + sendPrivateMessageAsync(Collections.singleton(userId), dto); + } + + /** + * 异步批量推送私聊消息给多个用户;用于一条 payload 扇出到多个收件人 + *

+ * 相比逐个 sendPrivateMessageAsync,仅注册一个 afterCommit 回调 + 一个 @Async 任务,大群参与者事件(1602 / 1603)下避免 N 次线程池调度 + * + * @param userIds 目标用户编号列表 + * @param dto 私聊消息 DTO + */ + void sendPrivateMessageAsync(Collection userIds, ImPrivateMessageDTO dto); /** * 异步推送群聊消息给指定用户 diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java index f73408f..ef06997 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java @@ -32,10 +32,10 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { private WebSocketMessageSender webSocketMessageSender; @Override - public void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) { + public void sendPrivateMessageAsync(Collection userIds, ImPrivateMessageDTO dto) { // 说明:通过 executeAfterCommitOrNow 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交 // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理) - executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userId, dto)); + executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userIds, dto)); } @Override @@ -44,15 +44,17 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { } /** - * 异步发送私聊 WebSocket 消息 + * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调 */ @Async - public void doSendPrivateMessage(Long userId, ImPrivateMessageDTO dto) { - try { - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, - ImPrivateMessageDTO.TYPE, dto); - } catch (Exception e) { - log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + public void doSendPrivateMessage(Collection userIds, ImPrivateMessageDTO dto) { + for (Long userId : userIds) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImPrivateMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + } } } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java index aaa4284..7f2c7e4 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java @@ -123,7 +123,7 @@ public class ImPrivateMessageDTO { // ==================== 群定向私聊通知 ==================== - // TODO @AI:能不能走群聊的通道????只是接收人不同。 + // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】 /** * 构建群通知推送 DTO(走私聊通道定向推送,不入群消息流) *

@@ -143,4 +143,25 @@ public class ImPrivateMessageDTO { .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); } + // ==================== 实时通话信令 ==================== + + // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】 + /** + * 构建通话信令推送 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) { + return new ImPrivateMessageDTO().setType(type) + .setSenderId(senderId).setReceiverId(receiverUserId) + .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); + } + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java index f0a9644..32ff290 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberEnterNotification.java @@ -7,7 +7,7 @@ import lombok.EqualsAndHashCode; * 自由进群事件通知 *

* 用户经搜索 / 二维码 / 分享链接自由进群(FREE 模式或审批通过后),全员广播; - * 进群者前端按 entrantUserId === self 自判 fetchGroupInfo + fetchGroupMembers bootstrap,其余成员局部插入新成员 + * 进群者前端按 entrantUserId === self 自判,初次拉取 fetchGroupInfo + fetchGroupMembers;其余成员局部插入新成员 */ @EqualsAndHashCode(callSuper = true) @Data diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java new file mode 100644 index 0000000..9399721 --- /dev/null +++ b/yudao-module-im/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 + *

+ * 文案分场景: + * 群聊:「{voice/video}通话已结束 [时长 X]」;发起人信息走配对的 {@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 callId; + /** + * 会话类型 + */ + 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.callId = call.getCallId(); + 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/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java new file mode 100644 index 0000000..d375d4c --- /dev/null +++ b/yudao-module-im/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} 发起了{voice/video}通话」 + *

+ * 与 {@link ImRtcCallEndNotification} 两段式配对: + * START 在 invite 接口事务里 INSERT,END 在 cancel / leave 接口事务里 INSERT, + * 两段位于不同请求 / 事务,自增 id 保证聊天流顺序;后续如果合并到同一事务里 push,需要额外保证 START 先于 END + *

+ * 私聊场景没有 START(单 END 准气泡足够),本通知不会用于 conversationType=PRIVATE + * + * @author 芋道源码 + */ +@Data +public class ImRtcCallStartNotification { + + /** + * 业务通话编号 + */ + private String callId; + /** + * 会话类型:当前固定 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.callId = call.getCallId(); + 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; + } + +} -- libgit2 0.21.4