Commit 530d3b3a5f2ca5857db38298c37706af2dcd0b79

Authored by YunaiV
1 parent fe77884c

✨ feat(im): rtc 增加数据库存储(从单体 =》集群)

Showing 35 changed files with 1113 additions and 516 deletions
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<T> extends MPJBaseMapper<T> {
145 145 return CollUtil.getFirst(list);
146 146 }
147 147  
  148 + /**
  149 + * 获取满足条件的最新一条记录
  150 + * <p>
  151 + * 目的:解决并发场景下,插入多条记录后,使用 selectOne 会报错的问题
  152 + *
  153 + * @param queryWrapper 查询条件
  154 + * @return 最新一条;不存在返回 null
  155 + */
  156 + default T selectLast(LambdaQueryWrapper<T> queryWrapper) {
  157 + return CollUtil.getLast(selectList(queryWrapper));
  158 + }
148 159  
149 160 default Long selectCount() {
150 161 return selectCount(new QueryWrapper<>());
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java
... ... @@ -14,8 +14,6 @@ import jakarta.validation.Valid;
14 14 import org.springframework.validation.annotation.Validated;
15 15 import org.springframework.web.bind.annotation.*;
16 16  
17   -import java.util.List;
18   -
19 17 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
20 18 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
21 19  
... ... @@ -79,17 +77,11 @@ public class ImRtcCallController {
79 77 return success(rtcCallService.refreshToken(getLoginUserId(), roomName));
80 78 }
81 79  
82   - @GetMapping("/active-sessions")
83   - @Operation(summary = "查询当前用户活跃通话;用于 App 冷启动 / 推送点开恢复")
84   - public CommonResult<List<ImRtcCallRespVO>> getActiveSessions() {
85   - return success(rtcCallService.getActiveSessions(getLoginUserId()));
86   - }
87   -
88 80 @GetMapping("/group-active-call")
89 81 @Operation(summary = "查询群当前进行中的通话;用于群聊顶部「N 人正在通话」胶囊条")
90 82 @Parameter(name = "groupId", description = "群编号", required = true, example = "2048")
91 83 public CommonResult<ImRtcGroupCallRespVO> getGroupActiveCall(@RequestParam("groupId") Long groupId) {
92   - return success(rtcCallService.getGroupActiveCall(groupId));
  84 + return success(rtcCallService.getGroupActiveCall(getLoginUserId(), groupId));
93 85 }
94 86  
95 87 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java
... ... @@ -5,7 +5,7 @@ import lombok.Data;
5 5  
6 6 import java.util.Set;
7 7  
8   -@Schema(description = "管理后台 - 通话会话 Response VO;invite / accept / refreshToken / getActiveSessions 共用")
  8 +@Schema(description = "管理后台 - 通话会话 Response VO;invite / accept / refreshToken 共用")
9 9 @Data
10 10 public class ImRtcCallRespVO {
11 11  
... ... @@ -18,7 +18,7 @@ public class ImRtcCallRespVO {
18 18 @Schema(description = "LiveKit Server WebSocket 地址;前端 connect 用")
19 19 private String livekitUrl;
20 20  
21   - @Schema(description = "LiveKit 接入 Token;getActiveSessions 列表里为 null,需要的话单独调 refreshToken")
  21 + @Schema(description = "LiveKit 接入 Token;需要时调 refreshToken 重新签发")
22 22 private String token;
23 23  
24 24 @Schema(description = "会话场景")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcCallDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
  5 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
  6 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum;
  7 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallMediaTypeEnum;
  8 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
  9 +import com.baomidou.mybatisplus.annotation.KeySequence;
  10 +import com.baomidou.mybatisplus.annotation.TableId;
  11 +import com.baomidou.mybatisplus.annotation.TableName;
  12 +import lombok.*;
  13 +
  14 +import java.time.LocalDateTime;
  15 +
  16 +/**
  17 + * IM 通话记录 DO(房间级 / 主表)
  18 + * <p>
  19 + * 一通通话一行;状态机 CREATED → RUNNING → ENDED;和明细表 {@link ImRtcParticipantDO} 通过 callId 关联
  20 + *
  21 + * @author 芋道源码
  22 + */
  23 +@TableName("im_rtc_call")
  24 +@KeySequence("im_rtc_call_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增;MySQL 等数据库可不写
  25 +@Data
  26 +@EqualsAndHashCode(callSuper = true)
  27 +@ToString(callSuper = true)
  28 +@Builder
  29 +@NoArgsConstructor
  30 +@AllArgsConstructor
  31 +public class ImRtcCallDO extends BaseDO {
  32 +
  33 + /**
  34 + * 编号
  35 + */
  36 + @TableId
  37 + private Long id;
  38 + /**
  39 + * 业务通话编号;唯一;UUID 生成
  40 + */
  41 + private String callId;
  42 + /**
  43 + * LiveKit 房间名
  44 + *
  45 + * 格式 {@code im_rtc_call_{callId}}
  46 + */
  47 + private String roomName;
  48 + /**
  49 + * 会话类型
  50 + *
  51 + * 枚举 {@link ImConversationTypeEnum}
  52 + */
  53 + private Integer conversationType;
  54 + /**
  55 + * 媒体类型
  56 + *
  57 + * 枚举 {@link ImRtcCallMediaTypeEnum}
  58 + */
  59 + private Integer mediaType;
  60 + /**
  61 + * 发起人用户编号
  62 + * <p>
  63 + * 关联 AdminUserDO 的 id 字段
  64 + */
  65 + private Long inviterUserId;
  66 + /**
  67 + * 群编号;私聊为 NULL
  68 + * <p>
  69 + * 关联 {@link ImGroupDO#getId()}
  70 + */
  71 + private Long groupId;
  72 + /**
  73 + * 通话状态
  74 + *
  75 + * 枚举 {@link ImRtcCallStatusEnum}
  76 + */
  77 + private Integer status;
  78 + /**
  79 + * 结束原因;通话未结束时为 NULL
  80 + *
  81 + * 枚举 {@link ImRtcCallEndReasonEnum}
  82 + */
  83 + private Integer endReason;
  84 + /**
  85 + * 发起时间
  86 + */
  87 + private LocalDateTime startTime;
  88 + /**
  89 + * 接通时间
  90 + *
  91 + * 首个非发起人加入时写入;未接通保持 NULL
  92 + */
  93 + private LocalDateTime acceptTime;
  94 + /**
  95 + * 通话结束时间
  96 + */
  97 + private LocalDateTime endTime;
  98 +
  99 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/rtc/ImRtcParticipantDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
  5 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;
  6 +import com.baomidou.mybatisplus.annotation.KeySequence;
  7 +import com.baomidou.mybatisplus.annotation.TableId;
  8 +import com.baomidou.mybatisplus.annotation.TableName;
  9 +import lombok.*;
  10 +
  11 +import java.time.LocalDateTime;
  12 +
  13 +/**
  14 + * IM 通话参与者 DO(用户级 / 明细表)
  15 + * <p>
  16 + * 一通通话每个参与者一行;通过 callId 关联主表 {@link ImRtcCallDO}
  17 + * <p>
  18 + * 终态闭合:通话 ENDED 时所有明细 status 必属 {LEFT / REJECTED / NO_ANSWER} 之一
  19 + *
  20 + * @author 芋道源码
  21 + */
  22 +@TableName("im_rtc_participant")
  23 +@KeySequence("im_rtc_participant_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增;MySQL 等数据库可不写
  24 +@Data
  25 +@EqualsAndHashCode(callSuper = true)
  26 +@ToString(callSuper = true)
  27 +@Builder
  28 +@NoArgsConstructor
  29 +@AllArgsConstructor
  30 +public class ImRtcParticipantDO extends BaseDO {
  31 +
  32 + /**
  33 + * 编号
  34 + */
  35 + @TableId
  36 + private Long id;
  37 + /**
  38 + * 业务通话编号
  39 + * <p>
  40 + * 关联 {@link ImRtcCallDO#getCallId()}
  41 + */
  42 + private String callId;
  43 + /**
  44 + * 参与者用户编号
  45 + * <p>
  46 + * 关联 AdminUserDO 的 id 字段
  47 + */
  48 + private Long userId;
  49 + /**
  50 + * 参与角色
  51 + *
  52 + * 枚举 {@link ImRtcParticipantRoleEnum}
  53 + */
  54 + private Integer role;
  55 + /**
  56 + * 参与状态
  57 + *
  58 + * 枚举 {@link ImRtcParticipantStatusEnum}
  59 + */
  60 + private Integer status;
  61 + /**
  62 + * 被邀请时间;发起人取通话 startTime
  63 + */
  64 + private LocalDateTime inviteTime;
  65 + /**
  66 + * 接听时间;未接听 NULL
  67 + */
  68 + private LocalDateTime acceptTime;
  69 + /**
  70 + * 离开时间;未加入 NULL
  71 + */
  72 + private LocalDateTime leaveTime;
  73 +
  74 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcCallMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
  6 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  7 +import org.apache.ibatis.annotations.Mapper;
  8 +
  9 +import java.time.LocalDateTime;
  10 +import java.util.Collection;
  11 +import java.util.List;
  12 +
  13 +/**
  14 + * IM 通话记录 Mapper
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Mapper
  19 +public interface ImRtcCallMapper extends BaseMapperX<ImRtcCallDO> {
  20 +
  21 + default ImRtcCallDO selectByCallId(String callId) {
  22 + return selectOne(ImRtcCallDO::getCallId, callId);
  23 + }
  24 +
  25 + default ImRtcCallDO selectByRoomName(String roomName) {
  26 + return selectOne(ImRtcCallDO::getRoomName, roomName);
  27 + }
  28 +
  29 + default ImRtcCallDO selectLastByGroupIdAndStatusIn(Long groupId, Collection<Integer> statuses) {
  30 + return selectLast(new LambdaQueryWrapperX<ImRtcCallDO>()
  31 + .eq(ImRtcCallDO::getGroupId, groupId)
  32 + .in(ImRtcCallDO::getStatus, statuses));
  33 + }
  34 +
  35 + default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcCallDO updateObj) {
  36 + return update(updateObj, Wrappers.<ImRtcCallDO>lambdaUpdate()
  37 + .eq(ImRtcCallDO::getId, id)
  38 + .eq(ImRtcCallDO::getStatus, oldStatus));
  39 + }
  40 +
  41 + @SuppressWarnings("UnusedReturnValue")
  42 + default int updateByIdAndStatusIn(Long id, Collection<Integer> statuses, ImRtcCallDO updateObj) {
  43 + return update(updateObj, Wrappers.<ImRtcCallDO>lambdaUpdate()
  44 + .eq(ImRtcCallDO::getId, id)
  45 + .in(ImRtcCallDO::getStatus, statuses));
  46 + }
  47 +
  48 + default List<ImRtcCallDO> selectListByStatusInAndStartTimeBefore(Collection<Integer> statuses,
  49 + LocalDateTime startTimeBefore) {
  50 + return selectList(new LambdaQueryWrapperX<ImRtcCallDO>()
  51 + .in(ImRtcCallDO::getStatus, statuses)
  52 + .lt(ImRtcCallDO::getStartTime, startTimeBefore));
  53 + }
  54 +
  55 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
  6 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  7 +import org.apache.ibatis.annotations.Mapper;
  8 +
  9 +import java.util.Collection;
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * IM 通话参与者 Mapper
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +@Mapper
  18 +public interface ImRtcParticipantMapper extends BaseMapperX<ImRtcParticipantDO> {
  19 +
  20 + default ImRtcParticipantDO selectByCallIdAndUserId(String callId, Long userId) {
  21 + return selectOne(new LambdaQueryWrapperX<ImRtcParticipantDO>()
  22 + .eq(ImRtcParticipantDO::getCallId, callId)
  23 + .eq(ImRtcParticipantDO::getUserId, userId));
  24 + }
  25 +
  26 + default List<ImRtcParticipantDO> selectListByCallId(String callId) {
  27 + return selectList(ImRtcParticipantDO::getCallId, callId);
  28 + }
  29 +
  30 + default List<ImRtcParticipantDO> selectListByCallIdAndStatus(String callId, Integer status) {
  31 + return selectList(new LambdaQueryWrapperX<ImRtcParticipantDO>()
  32 + .eq(ImRtcParticipantDO::getCallId, callId)
  33 + .eq(ImRtcParticipantDO::getStatus, status));
  34 + }
  35 +
  36 + default ImRtcParticipantDO selectLastByUserIdAndStatusIn(Long userId, Collection<Integer> statuses) {
  37 + return selectLast(new LambdaQueryWrapperX<ImRtcParticipantDO>()
  38 + .eq(ImRtcParticipantDO::getUserId, userId)
  39 + .in(ImRtcParticipantDO::getStatus, statuses));
  40 + }
  41 +
  42 + @SuppressWarnings("UnusedReturnValue")
  43 + default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcParticipantDO updateObj) {
  44 + return update(updateObj, Wrappers.<ImRtcParticipantDO>lambdaUpdate()
  45 + .eq(ImRtcParticipantDO::getId, id)
  46 + .eq(ImRtcParticipantDO::getStatus, oldStatus));
  47 + }
  48 +
  49 + @SuppressWarnings("UnusedReturnValue")
  50 + default int updateByCallIdAndStatus(String callId, Integer oldStatus, ImRtcParticipantDO updateObj) {
  51 + return update(updateObj, Wrappers.<ImRtcParticipantDO>lambdaUpdate()
  52 + .eq(ImRtcParticipantDO::getCallId, callId)
  53 + .eq(ImRtcParticipantDO::getStatus, oldStatus));
  54 + }
  55 +
  56 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
... ... @@ -41,4 +41,15 @@ public interface RedisKeyConstants {
41 41 */
42 42 String GROUP_MEMBER_IDS = "group_member_ids";
43 43  
  44 + /**
  45 + * 通话同对 / 同群活跃唯一性的分布式锁
  46 + * <p>
  47 + * KEY 格式:im_rtc_call:{conversationType}:{suffix}
  48 + * 私聊(conversationType=1):suffix = {小 userId}_{大 userId}
  49 + * 群聊(conversationType=2):suffix = {groupId}
  50 + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构
  51 + * 过期时间:不固定(lock 时显式传 timeoutMillis)
  52 + */
  53 + String IM_RTC_CALL_LOCK = "im_rtc_call:%d:%s";
  54 +
44 55 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/rtc/ImRtcCallLockRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.redis.rtc;
  2 +
  3 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
  4 +import jakarta.annotation.Resource;
  5 +import lombok.extern.slf4j.Slf4j;
  6 +import org.redisson.api.RLock;
  7 +import org.redisson.api.RedissonClient;
  8 +import org.springframework.stereotype.Repository;
  9 +
  10 +import java.util.concurrent.Callable;
  11 +import java.util.concurrent.TimeUnit;
  12 +
  13 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  14 +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.IM_RTC_CALL_LOCK;
  15 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_INVITE_BUSY;
  16 +
  17 +/**
  18 + * IM 通话同对 / 同群活跃唯一性的锁 Redis DAO
  19 + * <p>
  20 + * invite 入口包一层;锁内做「SELECT 已有活跃通话 → 命中即加入分支;否则 INSERT 新通话」
  21 + *
  22 + * @author 芋道源码
  23 + */
  24 +@Repository
  25 +@Slf4j
  26 +public class ImRtcCallLockRedisDAO {
  27 +
  28 + /**
  29 + * 等待获取锁的最长时间;超时抛 RTC_INVITE_BUSY
  30 + */
  31 + private static final long LOCK_WAIT_MS = 5_000L;
  32 + /**
  33 + * 持有锁的最长时间;自动释放兜底;给 LiveKit / DB / 推送偶发慢留余地
  34 + */
  35 + private static final long LOCK_LEASE_MS = 30_000L;
  36 +
  37 + @Resource
  38 + private RedissonClient redissonClient;
  39 +
  40 + /**
  41 + * 私聊通话锁;按 userId 排序拼 key 保对称(同对呼叫和反向呼叫共用一把锁)
  42 + */
  43 + public <V> V lockPrivate(Long userIdA, Long userIdB, Callable<V> callable) throws Exception {
  44 + String key = String.format(IM_RTC_CALL_LOCK, ImConversationTypeEnum.PRIVATE.getType(),
  45 + Math.min(userIdA, userIdB)+ "_" + Math.max(userIdA, userIdB));
  46 + return doLock(key, callable);
  47 + }
  48 +
  49 + /**
  50 + * 群通话锁;同群所有 invite 串行
  51 + */
  52 + public <V> V lockGroup(Long groupId, Callable<V> callable) throws Exception {
  53 + String key = String.format(IM_RTC_CALL_LOCK, ImConversationTypeEnum.GROUP.getType(), groupId);
  54 + return doLock(key, callable);
  55 + }
  56 +
  57 + /**
  58 + * tryLock(waitTime, leaseTime, unit):waitTime 内拿不到锁直接抛繁忙;拿到后 leaseTime 自动释放
  59 + * <p>
  60 + * unlock 前用 isHeldByCurrentThread 兜底;业务超过 leaseTime 时锁已自动释放,不再抛 IllegalMonitorStateException
  61 + */
  62 + private <V> V doLock(String lockKey, Callable<V> callable) throws Exception {
  63 + RLock lock = redissonClient.getLock(lockKey);
  64 + boolean acquired = lock.tryLock(LOCK_WAIT_MS, LOCK_LEASE_MS, TimeUnit.MILLISECONDS);
  65 + if (!acquired) {
  66 + log.error("[doLock][lockKey={} 等待 {}ms 仍未获取到锁]", lockKey, LOCK_WAIT_MS);
  67 + throw exception(RTC_INVITE_BUSY);
  68 + }
  69 + try {
  70 + return callable.call();
  71 + } finally {
  72 + if (lock.isHeldByCurrentThread()) {
  73 + lock.unlock();
  74 + } else {
  75 + log.error("[doLock][lockKey={} 业务超过 leaseTime={}ms,锁已被 Redisson 自动释放]", lockKey, LOCK_LEASE_MS);
  76 + }
  77 + }
  78 + }
  79 +
  80 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
... ... @@ -89,5 +89,6 @@ public interface ErrorCodeConstants {
89 89 ErrorCode RTC_INVITE_SELF = new ErrorCode(1_040_900_005, "不能呼叫自己");
90 90 ErrorCode RTC_PRIVATE_INVITEE_REQUIRED = new ErrorCode(1_040_900_006, "私聊通话必须指定对方");
91 91 ErrorCode RTC_GROUP_REQUIRED = new ErrorCode(1_040_900_007, "群聊通话必须指定群编号");
  92 + ErrorCode RTC_INVITE_BUSY = new ErrorCode(1_040_900_008, "通话发起繁忙,请稍后再试");
92 93  
93 94 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
... ... @@ -78,35 +78,42 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
78 78 */
79 79 READ(2201, "已读", false, false),
80 80  
81   - // ========== 实时通话信令(2300-2302) ==========
82   - // TODO @AI:openim 的信号,是怎么设计的?感觉对齐下,会好一点点,不然用户不好拓展;
  81 + // ========== 实时通话信令(1601-1605 段位与 OpenIM 对齐;1610+ 自有扩展) ==========
83 82 /**
84   - * 通话邀请:主叫发起后推给被叫弹「来电」界面;payload 为 RtcInviteNotification(roomName / mediaType / scene / inviter / token)
  83 + * 对应 OpenIM:SignalingNotification 1601(通话信令统一入口)
  84 + * 对应自己的类:ImRtcCallNotification
  85 + * 场景:通话信令;不入库,走 imWebSocketService 仅推参与方;signalingType 区分 INVITE / REJECT(预留 ACCEPT / CANCEL / HUNGUP)
85 86 */
86   - RTC_INVITE(2300, "通话邀请", false, false),
  87 + RTC_CALL(1601, "通话信令", false, false),
87 88 /**
88   - * 通话已接通:被叫接听后推给主叫切换到「通话中」UI;payload 为 RtcAcceptNotification(roomName / acceptorId)
  89 + * 对应 OpenIM:RoomParticipantsConnectedNotification 1602
  90 + * 对应自己的类:ImRtcParticipantConnectedNotification
  91 + * 场景:通话参与者加入;LiveKit webhook participant_joined 触发;私聊推 peer 多端 + inviter 多端,群聊全群广播;不入库
89 92 */
90   - RTC_ACCEPT(2301, "通话接通", false, false),
  93 + RTC_PARTICIPANT_CONNECTED(1602, "通话参与者加入", false, false),
91 94 /**
92   - * 通话结束:拒绝 / 取消 / 挂断 / 超时 / 异常 统一走这一条;payload 为 RtcEndNotification(roomName / reason / operatorId)
  95 + * 对应 OpenIM:RoomParticipantsDisconnectedNotification 1603
  96 + * 对应自己的类:ImRtcParticipantDisconnectedNotification
  97 + * 场景:通话参与者离开;LiveKit webhook participant_left 触发;推送范围同 1602;不入库
93 98 */
94   - RTC_END(2302, "通话结束", false, false),
95   -
96   - // ========== 群通话广播信号(2310-2312):让所有群成员能感知 / 主动加入 ==========
97   - /**
98   - * 群通话开始:群通话发起时给所有群成员广播;payload 为 ImRtcGroupNotification(callId / roomName / mediaType / inviterId / joinedUserIds)
99   - */
100   - RTC_GROUP_STARTED(2310, "群通话开始", false, false),
  99 + RTC_PARTICIPANT_DISCONNECTED(1603, "通话参与者离开", false, false),
  100 + // 1604-1609 OpenIM 已用 / 留作扩展,本系统暂不使用
101 101 /**
102   - * 群通话结束:最后一人离开 / 异常关房时广播;payload 仅含 roomName / groupId
  102 + * 对应 OpenIM:无(自有扩展,OpenIM 通话事件不入消息流)
  103 + * 对应自己的类:ImRtcCallStartNotification
  104 + * 场景:群通话开始;入 im_group_message + 全群广播;前端渲染聊天 tip「{inviterNickname} 发起了{voice/video}通话」
  105 + * <p>
  106 + * 与 RTC_CALL_END(1611) 两段式配对:START 一定先于 END 入库(START 在 invite 接口事务里、END 在 cancel/leave 接口事务里,自然按请求顺序串行)
103 107 */
104   - RTC_GROUP_ENDED(2311, "群通话结束", false, false),
  108 + RTC_CALL_START(1610, "通话开始", true, false),
105 109 /**
106   - * 群通话成员变更:有人加入 / 离开时广播;payload 为 ImRtcGroupNotification(含最新 joinedUserIds);
107   - * 用于胶囊条上「N 人正在通话」实时刷新
  110 + * 对应 OpenIM:无(自有扩展,OpenIM 通话事件不入消息流)
  111 + * 对应自己的类:ImRtcCallEndNotification
  112 + * 场景:通话结束;入 im_private_message / im_group_message;私聊渲染准气泡,群聊渲染 tip「{voice/video}通话已结束 [时长 X]」
  113 + * <p>
  114 + * 群通话两段式配对前导是 RTC_CALL_START(1610);私聊场景没有 START,单 END 准气泡承载完整生命周期
108 115 */
109   - RTC_GROUP_UPDATED(2312, "群通话成员变更", false, false),
  116 + RTC_CALL_END(1611, "通话结束", true, false),
110 117  
111 118 // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ==========
112 119 // TODO @芋艿:FRIEND_REQUEST_* 与 GROUP_REQUEST_* 都是 persistent=false 的 SysMsg,离线 pull 拉不到,
... ... @@ -224,7 +231,7 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
224 231 /**
225 232 * 对应 OpenIM:sdkws.MemberInvitedTips(MemberInvitedNotification 1509)
226 233 * 对应自己的类:GroupMemberInviteNotification
227   - * 场景:成员邀请新人入群,全员广播(含被邀请者);被邀请人前端按 memberUserIds 含自己自判 fetchGroupInfo + fetchGroupMembers bootstrap
  234 + * 场景:成员邀请新人入群,全员广播(含被邀请者);被邀请人前端按 memberUserIds 含自己自判,初次拉取 fetchGroupInfo + fetchGroupMembers
228 235 */
229 236 GROUP_MEMBER_INVITE(1509, "成员加入", true, false),
230 237 /**
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java deleted
1   -package cn.iocoder.yudao.module.im.enums.rtc;
2   -
3   -import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4   -import lombok.Getter;
5   -import lombok.RequiredArgsConstructor;
6   -
7   -import java.util.Arrays;
8   -import java.util.Objects;
9   -
10   -// TODO @AI:是否一定要有 status 状态的记录????【可以在调研下】
11   -/**
12   - * IM 通话状态枚举
13   - *
14   - * @author 芋道源码
15   - */
16   -@RequiredArgsConstructor
17   -@Getter
18   -public enum ImCallStatusEnum implements ArrayValuable<Integer> {
19   -
20   - INVITING(10, "邀请中"), // 邀请中:1v1 主叫等待被叫接听
21   - ONGOING(20, "通话中"), // 通话中:至少一名被叫已接听;群通话从发起人加入即进入此状态
22   - ENDED(30, "已结束"); // 已结束:会话即将销毁;写入消息流后从内存移除
23   -
24   - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallStatusEnum::getType).toArray(Integer[]::new);
25   -
26   - private final Integer type;
27   - private final String name;
28   -
29   - @Override
30   - public Integer[] array() {
31   - return ARRAYS;
32   - }
33   -
34   - public static boolean isInviting(Integer type) {
35   - return Objects.equals(INVITING.type, type);
36   - }
37   -
38   - public static boolean isOngoing(Integer type) {
39   - return Objects.equals(ONGOING.type, type);
40   - }
41   -
42   - public static boolean isEnded(Integer type) {
43   - return Objects.equals(ENDED.type, type);
44   - }
45   -
46   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.Getter;
  5 +import lombok.RequiredArgsConstructor;
  6 +
  7 +import java.util.Arrays;
  8 +
  9 +/**
  10 + * IM 通话结束原因枚举;落历史消息时计算 content 文案
  11 + * <p>
  12 + * 不含 TIMEOUT;产品决策走微信式不超时,振铃由用户主动 cancel / reject
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +@RequiredArgsConstructor
  17 +@Getter
  18 +public enum ImRtcCallEndReasonEnum implements ArrayValuable<Integer> {
  19 +
  20 + HANGUP(1, "通话结束"), // 接通后任一方主动挂断
  21 + REJECT(2, "已拒绝"), // 被叫接通前点拒接
  22 + CANCEL(3, "已取消"), // 主叫接通前主动取消
  23 + BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话
  24 + ERROR(9, "通话异常"); // 网络中断、设备失败等
  25 +
  26 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcCallEndReasonEnum::getReason).toArray(Integer[]::new);
  27 +
  28 + /**
  29 + * 原因值
  30 + */
  31 + private final Integer reason;
  32 + /**
  33 + * 名字
  34 + */
  35 + private final String name;
  36 +
  37 + @Override
  38 + public Integer[] array() {
  39 + return ARRAYS;
  40 + }
  41 +
  42 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallMediaTypeEnum.java
... ... @@ -5,6 +5,7 @@ import lombok.Getter;
5 5 import lombok.RequiredArgsConstructor;
6 6  
7 7 import java.util.Arrays;
  8 +import java.util.Objects;
8 9  
9 10 /**
10 11 * IM 通话媒体类型枚举
... ... @@ -13,14 +14,20 @@ import java.util.Arrays;
13 14 */
14 15 @RequiredArgsConstructor
15 16 @Getter
16   -public enum ImCallMediaTypeEnum implements ArrayValuable<Integer> {
  17 +public enum ImRtcCallMediaTypeEnum implements ArrayValuable<Integer> {
17 18  
18   - VOICE(1, "语音"),
19   - VIDEO(2, "视频");
  19 + VOICE(1, "语音"), // 仅音频
  20 + VIDEO(2, "视频"); // 音频 + 视频
20 21  
21   - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallMediaTypeEnum::getType).toArray(Integer[]::new);
  22 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcCallMediaTypeEnum::getType).toArray(Integer[]::new);
22 23  
  24 + /**
  25 + * 类型
  26 + */
23 27 private final Integer type;
  28 + /**
  29 + * 名字
  30 + */
24 31 private final String name;
25 32  
26 33 @Override
... ... @@ -28,4 +35,12 @@ public enum ImCallMediaTypeEnum implements ArrayValuable&lt;Integer&gt; {
28 35 return ARRAYS;
29 36 }
30 37  
  38 + public static boolean isVoice(Integer type) {
  39 + return Objects.equals(VOICE.type, type);
  40 + }
  41 +
  42 + public static boolean isVideo(Integer type) {
  43 + return Objects.equals(VIDEO.type, type);
  44 + }
  45 +
31 46 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallStatusEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.Getter;
  5 +import lombok.RequiredArgsConstructor;
  6 +
  7 +import java.util.Arrays;
  8 +import java.util.List;
  9 +import java.util.Objects;
  10 +
  11 +/**
  12 + * IM 通话状态枚举(主表 status)
  13 + * <p>
  14 + * 状态机:CREATED → RUNNING → ENDED;CREATED 直接到 ENDED 表示无人接听 / 主叫取消
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@RequiredArgsConstructor
  19 +@Getter
  20 +public enum ImRtcCallStatusEnum implements ArrayValuable<Integer> {
  21 +
  22 + CREATED(10, "创建"), // 通话已创建;私聊:等被叫接听;群聊:发起人已进房,等其他人加入
  23 + RUNNING(20, "进行中"), // 第一个非发起人接通后进入
  24 + ENDED(30, "已结束"); // 任一方挂断 / 主叫 cancel / Webhook 兜底
  25 +
  26 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcCallStatusEnum::getStatus).toArray(Integer[]::new);
  27 +
  28 + /**
  29 + * 活跃状态集合;通话未结束(CREATED / RUNNING)
  30 + */
  31 + public static final List<Integer> ACTIVE_STATUSES = List.of(CREATED.getStatus(), RUNNING.getStatus());
  32 +
  33 + /**
  34 + * 状态值
  35 + */
  36 + private final Integer status;
  37 + /**
  38 + * 状态名
  39 + */
  40 + private final String name;
  41 +
  42 + @Override
  43 + public Integer[] array() {
  44 + return ARRAYS;
  45 + }
  46 +
  47 + public static boolean isCreated(Integer status) {
  48 + return Objects.equals(CREATED.status, status);
  49 + }
  50 +
  51 + public static boolean isRunning(Integer status) {
  52 + return Objects.equals(RUNNING.status, status);
  53 + }
  54 +
  55 + public static boolean isEnded(Integer status) {
  56 + return Objects.equals(ENDED.status, status);
  57 + }
  58 +
  59 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java
... ... @@ -7,32 +7,27 @@ import lombok.RequiredArgsConstructor;
7 7 import java.util.Arrays;
8 8  
9 9 /**
10   - * IM 通话结束原因枚举;用于落历史消息时计算 content 文案
  10 + * IM 通话参与者角色枚举
11 11 *
12 12 * @author 芋道源码
13 13 */
14 14 @RequiredArgsConstructor
15 15 @Getter
16   -public enum ImCallEndReasonEnum implements ArrayValuable<Integer> {
17   -
18   - // TODO @AI:这个对齐的哪个东西;
19   - // TODO @AI:尾注释;
20   - /** 正常挂断:双方在通话中后由任一方主动挂断 */
21   - HANGUP(1, "通话结束"),
22   - /** 拒绝:被叫接通前点拒接 */
23   - REJECT(2, "已拒绝"),
24   - /** 取消:主叫接通前主动取消 */
25   - CANCEL(3, "已取消"),
26   - /** 超时:振铃 30 秒未应答 */
27   - TIMEOUT(4, "未接通"),
28   - /** 对方忙线 */
29   - BUSY(5, "对方正忙"),
30   - /** 异常:网络中断、设备失败等 */
31   - ERROR(9, "通话异常");
32   -
33   - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallEndReasonEnum::getType).toArray(Integer[]::new);
34   -
35   - private final Integer type;
  16 +public enum ImRtcParticipantRoleEnum implements ArrayValuable<Integer> {
  17 +
  18 + INVITER(1, "发起人"),
  19 + INVITEE(2, "被邀请者"),
  20 + ACTIVE_JOIN(3, "主动加入者"); // 仅群通话场景:旁观者点胶囊条加入已有通话
  21 +
  22 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcParticipantRoleEnum::getRole).toArray(Integer[]::new);
  23 +
  24 + /**
  25 + * 角色
  26 + */
  27 + private final Integer role;
  28 + /**
  29 + * 名字
  30 + */
36 31 private final String name;
37 32  
38 33 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantStatusEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.Getter;
  5 +import lombok.RequiredArgsConstructor;
  6 +
  7 +import java.util.Arrays;
  8 +import java.util.List;
  9 +import java.util.Objects;
  10 +
  11 +/**
  12 + * IM 通话参与者状态枚举(明细表 status)
  13 + * <p>
  14 + * 终态闭合:通话 ENDED 时所有明细 status 必属 {LEFT / REJECTED / NO_ANSWER}
  15 + * <p>
  16 + * 1、INVITING → JOINED → LEFT(接通后挂断 / 离开);
  17 + * 2、INVITING → REJECTED(接通前点拒接);
  18 + * 3、INVITING → NO_ANSWER(通话结束仍未应答)
  19 + *
  20 + * @author 芋道源码
  21 + */
  22 +@RequiredArgsConstructor
  23 +@Getter
  24 +public enum ImRtcParticipantStatusEnum implements ArrayValuable<Integer> {
  25 +
  26 + // TODO @芋艿:hand up 要不要也搞下。
  27 + INVITING(10, "邀请中"), // 已发出 invite,等被叫响应
  28 + JOINED(20, "已加入"), // 已 connect 进 LiveKit 房间
  29 + REJECTED(30, "已拒绝"), // 接通前点拒接
  30 + NO_ANSWER(40, "未应答"), // 通话已结束仍未应答;endSession 批量改
  31 + LEFT(50, "已离开"); // 接通后挂断 / Webhook 兜底
  32 +
  33 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcParticipantStatusEnum::getStatus).toArray(Integer[]::new);
  34 +
  35 + /**
  36 + * 活跃状态集合;尚未离开通话(INVITING / JOINED)
  37 + */
  38 + public static final List<Integer> ACTIVE_STATUSES = List.of(INVITING.getStatus(), JOINED.getStatus());
  39 +
  40 + /**
  41 + * 状态值
  42 + */
  43 + private final Integer status;
  44 + /**
  45 + * 状态名
  46 + */
  47 + private final String name;
  48 +
  49 + @Override
  50 + public Integer[] array() {
  51 + return ARRAYS;
  52 + }
  53 +
  54 + public static boolean isInviting(Integer status) {
  55 + return Objects.equals(INVITING.status, status);
  56 + }
  57 +
  58 + public static boolean isJoined(Integer status) {
  59 + return Objects.equals(JOINED.status, status);
  60 + }
  61 +
  62 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java
1 1 package cn.iocoder.yudao.module.im.framework.config;
2 2  
  3 +import jakarta.validation.Valid;
  4 +import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.Size;
3 6 import lombok.Data;
4 7 import org.springframework.boot.context.properties.ConfigurationProperties;
5 8 import org.springframework.stereotype.Component;
  9 +import org.springframework.validation.annotation.Validated;
6 10  
7   -// TODO @AI:参考别的模块;是不是也要分 im;config 和 core 这样;在瞅瞅;
8 11 /**
9 12 * IM 模块全局配置
10 13 * <p>
... ... @@ -15,6 +18,7 @@ import org.springframework.stereotype.Component;
15 18 */
16 19 @Component
17 20 @ConfigurationProperties(prefix = "yudao.im")
  21 +@Validated
18 22 @Data
19 23 public class ImProperties {
20 24  
... ... @@ -24,6 +28,7 @@ public class ImProperties {
24 28  
25 29 private Message message = new Message();
26 30  
  31 + @Valid
27 32 private Rtc rtc = new Rtc();
28 33  
29 34 /**
... ... @@ -134,16 +139,20 @@ public class ImProperties {
134 139 /**
135 140 * LiveKit Server WebSocket 地址;客户端 connect 时使用,通常 ws://host:7880 或 wss://host
136 141 */
  142 + @NotBlank(message = "LiveKit URL 不能为空")
137 143 private String livekitUrl = "ws://127.0.0.1:7880";
138 144  
139 145 /**
140 146 * LiveKit API Key
141 147 */
  148 + @NotBlank(message = "LiveKit API Key 不能为空")
142 149 private String apiKey = "devkey";
143 150  
144 151 /**
145 152 * LiveKit API Secret;生产必须改为强随机值
146 153 */
  154 + @NotBlank(message = "LiveKit API Secret 不能为空")
  155 + @Size(min = 32, message = "LiveKit API Secret 长度需 ≥ 32 位")
147 156 private String apiSecret = "secret-poc-key-min-32-chars-required-here";
148 157  
149 158 /**
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/config/ImRtcConfiguration.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.framework.rtc.config;
  2 +
  3 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  4 +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitClient;
  5 +import org.springframework.context.annotation.Bean;
  6 +import org.springframework.context.annotation.Configuration;
  7 +
  8 +/**
  9 + * im 模块的 RTC 组件的 Configuration
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Configuration(proxyBeanMethods = false)
  14 +public class ImRtcConfiguration {
  15 +
  16 + /**
  17 + * LiveKit 客户端
  18 + */
  19 + @Bean
  20 + public LiveKitClient liveKitClient(ImProperties imProperties) {
  21 + return new LiveKitClient(imProperties);
  22 + }
  23 +
  24 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitClient.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.framework.rtc.core;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Assert;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.hutool.http.HttpRequest;
  7 +import cn.hutool.http.HttpResponse;
  8 +import cn.hutool.json.JSONArray;
  9 +import cn.hutool.json.JSONUtil;
  10 +import cn.hutool.jwt.JWT;
  11 +import cn.hutool.jwt.signers.JWTSignerUtil;
  12 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
  13 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  14 +import lombok.extern.slf4j.Slf4j;
  15 +
  16 +import java.time.Duration;
  17 +import java.time.Instant;
  18 +import java.util.Date;
  19 +import java.util.HashMap;
  20 +import java.util.Map;
  21 +
  22 +/**
  23 + * LiveKit 客户端
  24 + * <p>
  25 + * 厂商绑定层封装:Token 签发(join / admin)、Server API(DeleteRoom / ListParticipants)、identity 拼接 / 解析
  26 + * <p>
  27 + * 由 {@link cn.iocoder.yudao.module.im.framework.rtc.config.ImRtcConfiguration} 注册为 Bean,本类不带 {@code @Component}
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +@Slf4j
  32 +public class LiveKitClient {
  33 +
  34 + /**
  35 + * Twirp 端点路径
  36 + */
  37 + private static final String TWIRP_DELETE_ROOM = "/twirp/livekit.RoomService/DeleteRoom";
  38 + private static final String TWIRP_LIST_PARTICIPANTS = "/twirp/livekit.RoomService/ListParticipants";
  39 +
  40 + /**
  41 + * 管理 Token 有效期;Server API 一次调用即弃,10 秒足够
  42 + */
  43 + private static final Duration ADMIN_TOKEN_TTL = Duration.ofSeconds(10);
  44 +
  45 + /**
  46 + * Server API HTTP 调用的超时上限;超时后直接报错,避免 LiveKit 异常 / 网络抖动时业务长时间阻塞
  47 + */
  48 + private static final int SERVER_API_TIMEOUT_MS = 10_000;
  49 +
  50 + private final ImProperties imProperties;
  51 +
  52 + public LiveKitClient(ImProperties imProperties) {
  53 + this.imProperties = imProperties;
  54 + }
  55 +
  56 + /**
  57 + * 签发客户端进房 Token;有效期从 {@link ImProperties.Rtc#getTokenTtlHours()} 读取
  58 + *
  59 + * @param identity 用户唯一标识;写入 sub claim;同 identity 重连会踢前一个连接
  60 + * @param displayName 客户端展示名;可空
  61 + * @param roomName 房间名
  62 + * @return JWT 字符串
  63 + */
  64 + public String signJoinToken(String identity, String displayName, String roomName) {
  65 + Assert.notBlank(identity, "identity 不可为空");
  66 + Assert.notBlank(roomName, "roomName 不可为空");
  67 + ImProperties.Rtc cfg = imProperties.getRtc();
  68 +
  69 + // video claim:限定客户端能在该房间内做什么
  70 + Map<String, Object> video = new HashMap<>();
  71 + video.put("roomJoin", true); // 允许加入房间
  72 + video.put("room", roomName); // 限定只能加入这个房间
  73 + video.put("canPublish", true); // 允许发布媒体(推流)
  74 + video.put("canSubscribe", true); // 允许订阅媒体(拉流)
  75 + video.put("canPublishData", true); // 允许发送 data channel 消息
  76 +
  77 + long nowSec = Instant.now().getEpochSecond();
  78 + long ttlSec = Duration.ofHours(cfg.getTokenTtlHours()).getSeconds();
  79 + JWT jwt = JWT.create()
  80 + .setIssuer(cfg.getApiKey())
  81 + .setSubject(identity)
  82 + .setNotBefore(new Date(nowSec * 1000))
  83 + .setExpiresAt(new Date((nowSec + ttlSec) * 1000))
  84 + .setPayload("video", video)
  85 + .setSigner(JWTSignerUtil.hs256(cfg.getApiSecret().getBytes()));
  86 + if (StrUtil.isNotEmpty(displayName)) {
  87 + jwt.setPayload("name", displayName);
  88 + }
  89 + return jwt.sign();
  90 + }
  91 +
  92 + /**
  93 + * 调用 LiveKit Server API 删除房间:用于通话结束时,强制断开异常残留客户端
  94 + *
  95 + * @param roomName 房间名
  96 + */
  97 + @SuppressWarnings("EmptyTryBlock")
  98 + public void deleteRoom(String roomName) {
  99 + try (HttpResponse ignored = postTwirp(TWIRP_DELETE_ROOM, roomName)) {
  100 + // 状态码不区分;调用方失败即兜底
  101 + }
  102 + }
  103 +
  104 + /**
  105 + * 调用 LiveKit Server API 查询某房间内的参与者数量:用于定时扫描僵尸通话
  106 + * <p>
  107 + * 房间不存在 LiveKit 返回 404,视同 0 人
  108 + *
  109 + * @param roomName 房间名
  110 + * @return 参与者数量;HTTP 失败返回 -1
  111 + */
  112 + public int listParticipants(String roomName) {
  113 + try (HttpResponse response = postTwirp(TWIRP_LIST_PARTICIPANTS, roomName)) {
  114 + if (response.getStatus() == 404) {
  115 + return 0;
  116 + }
  117 + if (!response.isOk()) {
  118 + log.warn("[listParticipants][LiveKit 返回非 2xx status={} roomName={} body={}]",
  119 + response.getStatus(), roomName, response.body());
  120 + return -1;
  121 + }
  122 + JSONArray participants = JSONUtil.parseObj(response.body()).getJSONArray("participants");
  123 + return CollUtil.size(participants);
  124 + }
  125 + }
  126 +
  127 + /**
  128 + * 拼接 LiveKit identity;当前单端 = userId 字符串
  129 + * <p>
  130 + * 多端扩展时改 {@code userId + "#" + terminal} 格式,调用方无需改
  131 + *
  132 + * @param userId 用户编号
  133 + * @return identity 字符串
  134 + */
  135 + public String buildIdentity(Long userId) {
  136 + Assert.notNull(userId, "userId 不可为空");
  137 + return String.valueOf(userId);
  138 + }
  139 +
  140 + /**
  141 + * 从 LiveKit identity 解析业务 userId
  142 + * <p>
  143 + * 当前 identity 直接是 userId 字符串;预留 {@code userId#terminal} 多端格式
  144 + *
  145 + * @param identity LiveKit identity
  146 + * @return 用户编号;解析失败返回 null
  147 + */
  148 + public Long parseUserId(String identity) {
  149 + if (StrUtil.isBlank(identity)) {
  150 + return null;
  151 + }
  152 + int sep = identity.indexOf('#');
  153 + String idPart = sep >= 0 ? identity.substring(0, sep) : identity;
  154 + try {
  155 + return Long.parseLong(idPart);
  156 + } catch (NumberFormatException e) {
  157 + return null;
  158 + }
  159 + }
  160 +
  161 + /**
  162 + * 签发管理 Token;用于调 Server API(DeleteRoom / ListParticipants / RemoveParticipant 等)
  163 + *
  164 + * @return JWT 字符串
  165 + */
  166 + private String signAdminToken() {
  167 + ImProperties.Rtc cfg = imProperties.getRtc();
  168 + // roomAdmin claim 给管理类 API 必备
  169 + long nowSec = Instant.now().getEpochSecond();
  170 + return JWT.create()
  171 + .setIssuer(cfg.getApiKey())
  172 + .setNotBefore(new Date(nowSec * 1000))
  173 + .setExpiresAt(new Date((nowSec + ADMIN_TOKEN_TTL.getSeconds()) * 1000))
  174 + .setPayload("video", Map.of("roomAdmin", true))
  175 + .setSigner(JWTSignerUtil.hs256(cfg.getApiSecret().getBytes()))
  176 + .sign();
  177 + }
  178 +
  179 + /**
  180 + * Twirp 协议 POST 调用;统一处理 ws→http 协议切换、签 admin token、Bearer 头、JSON body、超时
  181 + *
  182 + * @param path Twirp 端点路径,例如 {@code /twirp/livekit.RoomService/DeleteRoom}
  183 + * @param roomName 房间名;写入 JSON body 的 room 字段
  184 + * @return HTTP 响应;调用方负责 close 与状态码判断
  185 + */
  186 + private HttpResponse postTwirp(String path, String roomName) {
  187 + Assert.notBlank(roomName, "roomName 不可为空");
  188 + String token = signAdminToken();
  189 + return HttpRequest.post(HttpUtils.wsUrlToHttp(imProperties.getRtc().getLivekitUrl()) + path)
  190 + .header("Authorization", "Bearer " + token)
  191 + .header("Content-Type", "application/json")
  192 + .body(JSONUtil.toJsonStr(Map.of("room", roomName)))
  193 + .timeout(SERVER_API_TIMEOUT_MS)
  194 + .execute();
  195 + }
  196 +
  197 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java deleted
1   -package cn.iocoder.yudao.module.im.framework.rtc.core;
2   -
3   -import cn.hutool.core.lang.Assert;
4   -import cn.hutool.jwt.JWT;
5   -import cn.hutool.jwt.signers.JWTSignerUtil;
6   -import lombok.AccessLevel;
7   -import lombok.NoArgsConstructor;
8   -
9   -import java.time.Duration;
10   -import java.time.Instant;
11   -import java.util.Date;
12   -import java.util.HashMap;
13   -import java.util.Map;
14   -
15   -// TODO @AI:是不是搞成 LiveKitClient;然后 config 类去创建起来;通过 @Resource 注入进去?
16   -/**
17   - * LiveKit Access Token 签发工具
18   - * <p>
19   - * LiveKit 协议把权限放在 video claim 里;这里只暴露通话场景需要的最小权限:roomJoin / canPublish /
20   - * canSubscribe / canPublishData,避免 PoC 阶段误开 roomAdmin。生产想细分会议主持人时再扩字段。
21   - *
22   - * @author 芋道源码
23   - */
24   -@NoArgsConstructor(access = AccessLevel.PRIVATE)
25   -public class LiveKitTokenUtils {
26   -
27   - /**
28   - * 签发客户端进房 Token
29   - *
30   - * @param apiKey LiveKit API Key;写入 iss claim
31   - * @param apiSecret LiveKit API Secret;HS256 签名密钥;长度必须 ≥ 32
32   - * @param identity 用户唯一标识;写入 sub claim;同 identity 重连会踢前一个连接
33   - * @param displayName 客户端展示名;可空
34   - * @param roomName 房间名
35   - * @param ttl 有效期
36   - * @return JWT 字符串
37   - */
38   - public static String signJoinToken(String apiKey, String apiSecret, String identity,
39   - String displayName, String roomName, Duration ttl) {
40   - Assert.notBlank(apiKey, "apiKey 不可为空");
41   - Assert.notBlank(apiSecret, "apiSecret 不可为空");
42   - Assert.isTrue(apiSecret.length() >= 32, "apiSecret 长度需 ≥ 32 位");
43   - Assert.notBlank(identity, "identity 不可为空");
44   - Assert.notBlank(roomName, "roomName 不可为空");
45   - Assert.notNull(ttl, "ttl 不可为空");
46   -
47   - // TODO @AI:这里注释下;
48   - Map<String, Object> video = new HashMap<>();
49   - video.put("roomJoin", true); // TODO @AI:这里帮忙注释下
50   - video.put("room", roomName); // TODO @AI:这里帮忙注释下
51   - video.put("canPublish", true); // TODO @AI:这里帮忙注释下
52   - video.put("canSubscribe", true); // TODO @AI:这里帮忙注释下
53   - video.put("canPublishData", true); // TODO @AI:这里帮忙注释下
54   -
55   - // TODO @AI:这里注释下;
56   - // TODO @AI:是不是 new Date;然后下面去处理,更合理?或者 System currentTime 这种?
57   - long now = Instant.now().getEpochSecond();
58   - JWT jwt = JWT.create()
59   - .setIssuer(apiKey)
60   - .setSubject(identity)
61   - .setNotBefore(new Date(now * 1000))
62   - .setExpiresAt(new Date((now + ttl.getSeconds()) * 1000))
63   - .setPayload("video", video)
64   - .setSigner(JWTSignerUtil.hs256(apiSecret.getBytes()));
65   - // TODO @AI:empty 处理下;
66   - if (displayName != null && !displayName.isEmpty()) {
67   - jwt.setPayload("name", displayName);
68   - }
69   - return jwt.sign();
70   - }
71   -
72   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitWebhookEventDTO.java
1   -package cn.iocoder.yudao.module.im.service.rtc.dto;
  1 +package cn.iocoder.yudao.module.im.framework.rtc.core;
2 2  
3 3 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4 4 import lombok.Data;
5 5  
6   -// TODO @AI:注释风格;(这个是不是 VO 哈。不放在 VO )
7 6 /**
8 7 * LiveKit Webhook 事件载荷;只反序列化我们关心的字段,其余忽略
9 8 * <p>
10   - * 文档参考:https://docs.livekit.io/home/server/webhook/
  9 + * 文档参考:<a href="https://docs.livekit.io/home/server/webhook/">webhook 文档</a>
11 10 *
12 11 * @author 芋道源码
13 12 */
... ... @@ -15,35 +14,70 @@ import lombok.Data;
15 14 @JsonIgnoreProperties(ignoreUnknown = true)
16 15 public class LiveKitWebhookEventDTO {
17 16  
18   - /** 事件类型;room_started / room_finished / participant_joined / participant_left / track_published 等 */
19   - private String event;
  17 + /**
  18 + * 房间创建:首个 participant 加入时触发
  19 + */
  20 + public static final String EVENT_ROOM_STARTED = "room_started";
  21 + /**
  22 + * 房间销毁:最后一个 participant 离开 / 显式 DeleteRoom 后触发;用于业务侧兜底关房
  23 + */
  24 + public static final String EVENT_ROOM_FINISHED = "room_finished";
  25 + /**
  26 + * 参与者加入房间
  27 + */
  28 + public static final String EVENT_PARTICIPANT_JOINED = "participant_joined";
  29 + /**
  30 + * 参与者离开房间:关 tab / 网络断 / 显式 disconnect 都会触发;用于业务侧兜底清理
  31 + */
  32 + public static final String EVENT_PARTICIPANT_LEFT = "participant_left";
  33 + /**
  34 + * 参与者发布媒体轨道(摄像头 / 麦克风 / 屏幕共享)
  35 + */
  36 + public static final String EVENT_TRACK_PUBLISHED = "track_published";
20 37  
21   - /** 事件 id;用于幂等去重 */
  38 + /**
  39 + * 事件 id:用于幂等去重
  40 + */
22 41 private String id;
23 42  
24   - /** 房间元信息;room_started / room_finished 必填 */
  43 + /**
  44 + * 事件类型;取值参见本类 EVENT_* 常量
  45 + */
  46 + private String event;
  47 +
  48 + /**
  49 + * 房间元信息:room_started / room_finished 必填
  50 + */
25 51 private RoomInfo room;
26 52  
27   - /** 参与者元信息;participant_* 事件必填 */
  53 + /**
  54 + * 参与者元信息:participant_* 事件必填
  55 + */
28 56 private ParticipantInfo participant;
29 57  
30 58 @Data
31 59 @JsonIgnoreProperties(ignoreUnknown = true)
32 60 public static class RoomInfo {
  61 +
33 62 private String sid;
34 63 private String name;
35 64 private Long creationTime;
36 65 private Integer numParticipants;
  66 +
37 67 }
38 68  
39 69 @Data
40 70 @JsonIgnoreProperties(ignoreUnknown = true)
41 71 public static class ParticipantInfo {
  72 +
42 73 private String sid;
43   - /** 用户身份;我们签 token 时写入 userId 字符串,后续支持多端会拼成 userId#terminal */
  74 + /**
  75 + * 用户身份:签 token 时写入 userId 字符串,后续支持多端会拼成 userId#terminal
  76 + */
44 77 private String identity;
45 78 private String name;
46 79 private Long joinedAt;
  80 +
47 81 }
48 82  
49 83 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.job.rtc;
  2 +
  3 +import cn.hutool.core.util.NumberUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
  6 +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
  7 +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService;
  8 +import jakarta.annotation.Resource;
  9 +import lombok.extern.slf4j.Slf4j;
  10 +import org.springframework.stereotype.Component;
  11 +
  12 +/**
  13 + * 僵尸通话清理 Job:兜底 LiveKit Webhook 丢失 / 客户端异常关闭等未调 leave 等场景
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +@Component
  18 +@Slf4j
  19 +public class ImRtcCallCleanupJob implements JobHandler {
  20 +
  21 + @Resource
  22 + private ImRtcCallService rtcCallService;
  23 +
  24 + /**
  25 + * 执行清理
  26 + *
  27 + * @param param 阈值(分钟);为空走 Service 默认(5 分钟)
  28 + * @return 清理数量描述
  29 + */
  30 + @Override
  31 + @TenantJob
  32 + public String execute(String param) {
  33 + Integer thresholdMinutes = StrUtil.isNotBlank(param) && NumberUtil.isInteger(param)
  34 + ? Integer.parseInt(param) : null;
  35 + int cleaned = rtcCallService.cleanupZombieCalls(thresholdMinutes);
  36 + log.info("[execute][清理僵尸通话数量 ({}) 个]", cleaned);
  37 + return String.format("清理僵尸通话 %s 个", cleaned);
  38 + }
  39 +
  40 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java deleted
1   -package cn.iocoder.yudao.module.im.service.rtc.bo;
2   -
3   -import lombok.Builder;
4   -import lombok.Data;
5   -
6   -import java.util.LinkedHashSet;
7   -import java.util.Set;
8   -
9   -// TODO @AI:后续讨论;抽成 DO;写到 mysql 里;
10   -/**
11   - * IM 通话会话 BO;存放在 {@link ImCallSessionStore} 内存里,通话结束即移除
12   - *
13   - * @author 芋道源码
14   - */
15   -@Data
16   -@Builder
17   -public class ImCallSessionBO {
18   -
19   - /**
20   - * 业务通话编号;雪花 ID 生成;用于消息流落库与 Webhook 关联
21   - */
22   - private String callId;
23   - /**
24   - * LiveKit 房间名;私聊由好友对派生 call_friend_{minId}_{maxId};群聊为 call_group_{groupId}
25   - */
26   - private String roomName;
27   - /**
28   - * 会话场景;取自 {@link cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum} 的 type
29   - */
30   - private Integer scene;
31   - /**
32   - * 媒体类型;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallMediaTypeEnum} 的 type
33   - */
34   - private Integer mediaType;
35   - /**
36   - * 发起人编号
37   - */
38   - private Long inviterId;
39   - /**
40   - * 被邀请人集合;私聊为单元素;群聊为发起时所有候选成员(含发起人本人,方便统一遍历)
41   - */
42   - private Set<Long> inviteeIds;
43   - /**
44   - * 群编号;群聊场景必填
45   - */
46   - private Long groupId;
47   - /**
48   - * 状态;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallStatusEnum} 的 type
49   - */
50   - private Integer status;
51   - /**
52   - * 通话开始时间戳;millis
53   - */
54   - private Long startTime;
55   - /**
56   - * 已加入房间的成员集合
57   - */
58   - @Builder.Default
59   - private Set<Long> joinedUserIds = new LinkedHashSet<>();
60   - /**
61   - * 已拒绝的成员集合(接通前点拒接)
62   - */
63   - @Builder.Default
64   - private Set<Long> rejectedUserIds = new LinkedHashSet<>();
65   - /**
66   - * 已离开的成员集合(接通后挂断)
67   - */
68   - @Builder.Default
69   - private Set<Long> leftUserIds = new LinkedHashSet<>();
70   -
71   - // TODO @AI:bo 里,不要有这种方法;
72   - /**
73   - * 当前会话中所有相关用户;用于 RTC_END 信号扇出与忙线清理
74   - */
75   - public Set<Long> getAllUserIds() {
76   - Set<Long> result = new LinkedHashSet<>();
77   - result.add(inviterId);
78   - if (inviteeIds != null) {
79   - result.addAll(inviteeIds);
80   - }
81   - result.addAll(joinedUserIds);
82   - return result;
83   - }
84   -
85   - // TODO @AI:这个方法,是不是没在使用???
86   - /**
87   - * 判断用户是否参与该通话;不在被邀请也不在已加入即非参与方
88   - */
89   - public boolean containsUser(Long userId) {
90   - if (userId == null) {
91   - return false;
92   - }
93   - if (userId.equals(inviterId)) {
94   - return true;
95   - }
96   - if (inviteeIds != null && inviteeIds.contains(userId)) {
97   - return true;
98   - }
99   - return joinedUserIds.contains(userId);
100   - }
101   -
102   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java deleted
1   -package cn.iocoder.yudao.module.im.service.rtc.bo;
2   -
3   -import org.springframework.stereotype.Component;
4   -
5   -import java.util.ArrayList;
6   -import java.util.List;
7   -import java.util.Map;
8   -import java.util.concurrent.ConcurrentHashMap;
9   -import java.util.function.Function;
10   -
11   -/**
12   - * IM 通话会话内存仓储;MVP 阶段不落库,全量存内存
13   - * <p>
14   - * 双层索引:
15   - * - sessions: roomName -> 通话会话;invite 时 computeIfAbsent 保证同一好友对 / 群只有一个进行中通话;
16   - * - userActiveRoom: userId -> 当前所在 roomName;用于"对方正忙"提示与冷启动恢复。
17   - * 通话结束时 {@link #remove} 一次性清两个索引;同一用户的并发挂断走 ConcurrentHashMap 原子操作收敛。
18   - *
19   - * @author 芋道源码
20   - */
21   -@Component
22   -public class ImCallSessionStore {
23   -
24   - private final Map<String, ImCallSessionBO> sessions = new ConcurrentHashMap<>();
25   -
26   - private final Map<Long, String> userActiveRoom = new ConcurrentHashMap<>();
27   -
28   - public ImCallSessionBO get(String roomName) {
29   - if (roomName == null) {
30   - return null;
31   - }
32   - return sessions.get(roomName);
33   - }
34   -
35   - /**
36   - * 原子获取或创建会话;在并发 invite 下保证同一 roomName 只 build 一次
37   - *
38   - * @param roomName 房间名(已由 service 派生)
39   - * @param builder 仅当不存在时调用;返回已组装好的 ImCallSessionBO
40   - * @return 会话实例(新建或已有)
41   - */
42   - public ImCallSessionBO getOrCreate(String roomName, Function<String, ImCallSessionBO> builder) {
43   - return sessions.computeIfAbsent(roomName, builder);
44   - }
45   -
46   - /**
47   - * 移除会话并清理参与者的 active 索引;调用方应在会话状态切到 ENDED 后调用
48   - */
49   - public void remove(String roomName) {
50   - if (roomName == null) {
51   - return;
52   - }
53   - ImCallSessionBO session = sessions.remove(roomName);
54   - if (session == null) {
55   - return;
56   - }
57   - for (Long userId : session.getAllUserIds()) {
58   - // 仅当该用户的 active room 仍是当前 room 时才解绑;避免误清用户后续新建的通话
59   - userActiveRoom.remove(userId, roomName);
60   - }
61   - }
62   -
63   - /**
64   - * 绑定用户到房间;invite / accept 时调用
65   - */
66   - public void bindUser(Long userId, String roomName) {
67   - if (userId == null || roomName == null) {
68   - return;
69   - }
70   - userActiveRoom.put(userId, roomName);
71   - }
72   -
73   - /**
74   - * 仅在用户当前 active room 等于给定 roomName 时解绑;用于 reject / leave 单方退出
75   - */
76   - public void unbindUserFromRoom(Long userId, String roomName) {
77   - if (userId == null || roomName == null) {
78   - return;
79   - }
80   - userActiveRoom.remove(userId, roomName);
81   - }
82   -
83   - /**
84   - * 用户是否处于通话中;用于 invite 时判定"对方正忙"
85   - */
86   - public boolean isBusy(Long userId) {
87   - return userId != null && userActiveRoom.containsKey(userId);
88   - }
89   -
90   - /**
91   - * 用户当前所在的 roomName;冷启动恢复用
92   - */
93   - public String getActiveRoom(Long userId) {
94   - return userId == null ? null : userActiveRoom.get(userId);
95   - }
96   -
97   - /**
98   - * 列出当前所有进行中会话;用于运维 / 单元测试观察;线上调用频次需谨慎
99   - */
100   - public List<ImCallSessionBO> listAll() {
101   - return new ArrayList<>(sessions.values());
102   - }
103   -
104   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java deleted
1   -package cn.iocoder.yudao.module.im.service.rtc.dto;
2   -
3   -import lombok.Data;
4   -
5   -// TODO @AI:一些 notification 应该挪到 message 那边,你在
6   -// TODO @AI:注释风格;
7   -/**
8   - * RTC 接通通知;推给主叫与已在房成员,提示某用户已加入;前端把"等待对方接受"切换成通话中 UI
9   - *
10   - * @author 芋道源码
11   - */
12   -@Data
13   -public class ImRtcAcceptNotification {
14   -
15   - /** 业务通话编号 */
16   - // TODO @AI:是不是后续使用 id Long?(后续在改)
17   - private String callId;
18   - /** LiveKit 房间名 */
19   - private String roomName;
20   - /** 接听者编号 */
21   - private Long acceptorId;
22   -
23   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java deleted
1   -package cn.iocoder.yudao.module.im.service.rtc.dto;
2   -
3   -import lombok.Data;
4   -
5   -// TODO @AI:注释风格;
6   -/**
7   - * RTC 结束通知;拒绝 / 取消 / 挂断 / 超时 / 异常 统一走这一条;前端关闭通话界面
8   - *
9   - * @author 芋道源码
10   - */
11   -@Data
12   -public class ImRtcEndNotification {
13   -
14   - /** 业务通话编号 */
15   - private String callId;
16   - /** LiveKit 房间名 */
17   - private String roomName;
18   - /** 操作者编号;TIMEOUT 等系统触发场景为 null */
19   - private Long operatorId;
20   - /** 结束原因;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallEndReasonEnum} 的 type */
21   - private Integer reason;
22   - /** 通话时长(秒);接通过为 endTime - acceptTime;未接通为 null */
23   - private Long durationSeconds;
24   -
25   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java deleted
1   -package cn.iocoder.yudao.module.im.service.rtc.dto;
2   -
3   -import lombok.Data;
4   -
5   -import java.util.Set;
6   -
7   -// TODO @AI:注释风格;
8   -/**
9   - * RTC 群通话广播载荷;让群里所有成员(不仅是被邀请人)能感知通话存在,并支持主动加入
10   - *
11   - * @author 芋道源码
12   - */
13   -@Data
14   -public class ImRtcGroupNotification {
15   -
16   - /** 业务通话编号 */
17   - private String callId;
18   - /** LiveKit 房间名 */
19   - private String roomName;
20   - /** 群编号 */
21   - private Long groupId;
22   - /** 媒体类型;1 语音;2 视频 */
23   - private Integer mediaType;
24   - /** 发起人编号 */
25   - private Long inviterId;
26   - /** 当前已加入房间的用户编号集合 */
27   - private Set<Long> joinedUserIds;
28   - /** 当前被邀请池;展开胶囊条时用来展示「待加入」灰色头像 */
29   - private Set<Long> inviteeIds;
30   -
31   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java deleted
1   -package cn.iocoder.yudao.module.im.service.rtc.dto;
2   -
3   -import lombok.Data;
4   -
5   -// TODO @AI:注释风格;
6   -/**
7   - * RTC 来电邀请通知;推给被叫弹「来电」界面
8   - *
9   - * @author 芋道源码
10   - */
11   -@Data
12   -public class ImRtcInviteNotification {
13   -
14   - /** 业务通话编号 */
15   - private String callId;
16   - /** LiveKit 房间名 */
17   - private String roomName;
18   - /** LiveKit Server WebSocket 地址 */
19   - private String livekitUrl;
20   - /** 该被叫专属的 LiveKit 接听 Token;接通后直接 connect 用 */
21   - private String token;
22   - /** 会话场景;1 私聊;2 群聊 */
23   - private Integer scene;
24   - /** 媒体类型;1 语音;2 视频 */
25   - private Integer mediaType;
26   - /** 发起人编号 */
27   - private Long inviterId;
28   - /** 发起人昵称;前端来电界面展示 */
29   - private String inviterNickname;
30   - /** 发起人头像 */
31   - private String inviterAvatar;
32   - /** 群编号;群通话场景必填 */
33   - private Long groupId;
34   -
35   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java
... ... @@ -21,7 +21,19 @@ public interface ImWebSocketService {
21 21 * @param userId 目标用户编号
22 22 * @param dto 私聊消息 DTO
23 23 */
24   - void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto);
  24 + default void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) {
  25 + sendPrivateMessageAsync(Collections.singleton(userId), dto);
  26 + }
  27 +
  28 + /**
  29 + * 异步批量推送私聊消息给多个用户;用于一条 payload 扇出到多个收件人
  30 + * <p>
  31 + * 相比逐个 sendPrivateMessageAsync,仅注册一个 afterCommit 回调 + 一个 @Async 任务,大群参与者事件(1602 / 1603)下避免 N 次线程池调度
  32 + *
  33 + * @param userIds 目标用户编号列表
  34 + * @param dto 私聊消息 DTO
  35 + */
  36 + void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto);
25 37  
26 38 /**
27 39 * 异步推送群聊消息给指定用户
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java
... ... @@ -32,10 +32,10 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
32 32 private WebSocketMessageSender webSocketMessageSender;
33 33  
34 34 @Override
35   - public void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) {
  35 + public void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto) {
36 36 // 说明:通过 executeAfterCommitOrNow 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交
37 37 // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理)
38   - executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userId, dto));
  38 + executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userIds, dto));
39 39 }
40 40  
41 41 @Override
... ... @@ -44,15 +44,17 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
44 44 }
45 45  
46 46 /**
47   - * 异步发送私聊 WebSocket 消息
  47 + * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调
48 48 */
49 49 @Async
50   - public void doSendPrivateMessage(Long userId, ImPrivateMessageDTO dto) {
51   - try {
52   - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
53   - ImPrivateMessageDTO.TYPE, dto);
54   - } catch (Exception e) {
55   - log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
  50 + public void doSendPrivateMessage(Collection<Long> userIds, ImPrivateMessageDTO dto) {
  51 + for (Long userId : userIds) {
  52 + try {
  53 + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  54 + ImPrivateMessageDTO.TYPE, dto);
  55 + } catch (Exception e) {
  56 + log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
  57 + }
56 58 }
57 59 }
58 60  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java
... ... @@ -123,7 +123,7 @@ public class ImPrivateMessageDTO {
123 123  
124 124 // ==================== 群定向私聊通知 ====================
125 125  
126   - // TODO @AI:能不能走群聊的通道????只是接收人不同。
  126 + // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】
127 127 /**
128 128 * 构建群通知推送 DTO(走私聊通道定向推送,不入群消息流)
129 129 * <p>
... ... @@ -143,4 +143,25 @@ public class ImPrivateMessageDTO {
143 143 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
144 144 }
145 145  
  146 + // ==================== 实时通话信令 ====================
  147 +
  148 + // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】
  149 + /**
  150 + * 构建通话信令推送 DTO(走私聊通道仅推参与方,不入消息流)
  151 + * <p>
  152 + * 用于 RTC_CALL(INVITE / REJECT 等)/ RTC_PARTICIPANT_CONNECTED / RTC_PARTICIPANT_DISCONNECTED
  153 + *
  154 + * @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 RTC_* 段
  155 + * @param senderId 发送人编号;INVITE 时为发起人,REJECT 时为拒绝者,参与者事件时为加入 / 离开者
  156 + * @param receiverUserId 推送目标用户编号
  157 + * @param payload 通话事件 payload(任意 RTC 通知 DTO)
  158 + * @return 私聊 DTO
  159 + */
  160 + public static ImPrivateMessageDTO ofRtcNotification(Integer type, Long senderId,
  161 + Long receiverUserId, Object payload) {
  162 + return new ImPrivateMessageDTO().setType(type)
  163 + .setSenderId(senderId).setReceiverId(receiverUserId)
  164 + .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
  165 + }
  166 +
146 167 }
... ...
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;
7 7 * 自由进群事件通知
8 8 * <p>
9 9 * 用户经搜索 / 二维码 / 分享链接自由进群(FREE 模式或审批通过后),全员广播;
10   - * 进群者前端按 entrantUserId === self 自判 fetchGroupInfo + fetchGroupMembers bootstrap,其余成员局部插入新成员
  10 + * 进群者前端按 entrantUserId === self 自判,初次拉取 fetchGroupInfo + fetchGroupMembers;其余成员局部插入新成员
11 11 */
12 12 @EqualsAndHashCode(callSuper = true)
13 13 @Data
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallEndNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc;
  2 +
  3 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
  4 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum;
  5 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  6 +import lombok.Data;
  7 +
  8 +/**
  9 + * RTC_CALL_END 通话结束通知
  10 + * <p>
  11 + * 入 im_private_message / im_group_message;接收方关闭通话窗 + 渲染聊天 tip
  12 + * <p>
  13 + * 文案分场景:
  14 + * 群聊:「{voice/video}通话已结束 [时长 X]」;发起人信息走配对的 {@link ImRtcCallStartNotification}
  15 + * 私聊:仿微信准气泡,按 endReason × selfSend 视角转换文案(HANGUP / CANCEL / REJECT / BUSY / ERROR)
  16 + * <p>
  17 + * 与 {@link ImRtcCallStartNotification} 两段式配对(仅群通话):START 在 invite 事务里 INSERT,END 在 cancel / leave 事务里 INSERT;
  18 + * 两段位于不同请求 / 事务,自增 id 保证聊天流顺序
  19 + *
  20 + * @author 芋道源码
  21 + */
  22 +@Data
  23 +public class ImRtcCallEndNotification {
  24 +
  25 + /**
  26 + * 业务通话编号
  27 + */
  28 + private String callId;
  29 + /**
  30 + * 会话类型
  31 + */
  32 + private Integer conversationType;
  33 + /**
  34 + * 媒体类型
  35 + */
  36 + private Integer mediaType;
  37 + /**
  38 + * 结束原因
  39 + */
  40 + private Integer endReason;
  41 + /**
  42 + * 通话时长(秒):接通过为 endTime - acceptTime;未接通为 null
  43 + */
  44 + private Long durationSeconds;
  45 + /**
  46 + * 操作者用户编号:HANGUP / CANCEL / REJECT 是触发结束的人;HANGUP webhook 兜底为 null
  47 + * <p>
  48 + * 用于前端「被某某挂断」类文案;普通文案不依赖此字段
  49 + */
  50 + private Long operatorUserId;
  51 + /**
  52 + * 操作者昵称:前端按需展示(被某某挂断 / 头像 tip);操作者为空时随之为空
  53 + */
  54 + private String operatorNickname;
  55 + /**
  56 + * 操作者头像:前端按需展示;操作者为空时随之为空
  57 + */
  58 + private String operatorAvatar;
  59 +
  60 + public static ImRtcCallEndNotification of(ImRtcCallDO call, ImRtcCallEndReasonEnum reason, Long durationSeconds,
  61 + Long operatorId, AdminUserRespDTO operator) {
  62 + ImRtcCallEndNotification notification = new ImRtcCallEndNotification();
  63 + notification.callId = call.getCallId();
  64 + notification.conversationType = call.getConversationType();
  65 + notification.mediaType = call.getMediaType();
  66 + notification.endReason = reason.getReason();
  67 + notification.durationSeconds = durationSeconds;
  68 + notification.operatorUserId = operatorId;
  69 + if (operator != null) {
  70 + notification.operatorNickname = operator.getNickname();
  71 + notification.operatorAvatar = operator.getAvatar();
  72 + }
  73 + return notification;
  74 + }
  75 +
  76 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallStartNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc;
  2 +
  3 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
  4 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  5 +import lombok.Data;
  6 +
  7 +/**
  8 + * RTC_CALL_START 通话开始通知
  9 + * <p>
  10 + * 仅群通话场景;入 im_group_message + 全群广播;前端渲染聊天 tip「{inviterNickname} 发起了{voice/video}通话」
  11 + * <p>
  12 + * 与 {@link ImRtcCallEndNotification} 两段式配对:
  13 + * START 在 invite 接口事务里 INSERT,END 在 cancel / leave 接口事务里 INSERT,
  14 + * 两段位于不同请求 / 事务,自增 id 保证聊天流顺序;后续如果合并到同一事务里 push,需要额外保证 START 先于 END
  15 + * <p>
  16 + * 私聊场景没有 START(单 END 准气泡足够),本通知不会用于 conversationType=PRIVATE
  17 + *
  18 + * @author 芋道源码
  19 + */
  20 +@Data
  21 +public class ImRtcCallStartNotification {
  22 +
  23 + /**
  24 + * 业务通话编号
  25 + */
  26 + private String callId;
  27 + /**
  28 + * 会话类型:当前固定 GROUP(私聊无 START)
  29 + */
  30 + private Integer conversationType;
  31 + /**
  32 + * 媒体类型
  33 + */
  34 + private Integer mediaType;
  35 + /**
  36 + * 发起人用户编号
  37 + */
  38 + private Long inviterUserId;
  39 + /**
  40 + * 发起人昵称:用于聊天 tip 文案,可空走前端 fallback
  41 + */
  42 + private String inviterNickname;
  43 + /**
  44 + * 发起人头像:可空,预留给点击 tip 展示发起人卡片
  45 + */
  46 + private String inviterAvatar;
  47 +
  48 + public static ImRtcCallStartNotification of(ImRtcCallDO call, AdminUserRespDTO inviter) {
  49 + ImRtcCallStartNotification notification = new ImRtcCallStartNotification();
  50 + notification.callId = call.getCallId();
  51 + notification.conversationType = call.getConversationType();
  52 + notification.mediaType = call.getMediaType();
  53 + notification.inviterUserId = call.getInviterUserId();
  54 + if (inviter != null) {
  55 + notification.inviterNickname = inviter.getNickname();
  56 + notification.inviterAvatar = inviter.getAvatar();
  57 + }
  58 + return notification;
  59 + }
  60 +
  61 +}
... ...