Commit bcac553c691fc302bc3b63408e1673d113b92f38

Authored by YunaiV
1 parent 348b9704

✨ feat(im): 基于 livekit 构建 im 通话(语音聊天、视频聊天、共享桌面)v0.1:推进中

Showing 22 changed files with 1792 additions and 0 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO;
  5 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
  6 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO;
  7 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO;
  8 +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService;
  9 +import io.swagger.v3.oas.annotations.Operation;
  10 +import io.swagger.v3.oas.annotations.Parameter;
  11 +import io.swagger.v3.oas.annotations.tags.Tag;
  12 +import jakarta.annotation.Resource;
  13 +import jakarta.validation.Valid;
  14 +import org.springframework.validation.annotation.Validated;
  15 +import org.springframework.web.bind.annotation.*;
  16 +
  17 +import java.util.List;
  18 +
  19 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  20 +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
  21 +
  22 +@Tag(name = "管理后台 - IM 实时通话")
  23 +@RestController
  24 +@RequestMapping("/im/rtc")
  25 +@Validated
  26 +public class ImRtcCallController {
  27 +
  28 + @Resource
  29 + private ImRtcCallService rtcCallService;
  30 +
  31 + @PostMapping("/invite")
  32 + @Operation(summary = "发起通话;私聊或群聊根据 scene 区分")
  33 + public CommonResult<ImRtcCallRespVO> invite(@Valid @RequestBody ImRtcCallInviteReqVO reqVO) {
  34 + return success(rtcCallService.invite(getLoginUserId(), reqVO));
  35 + }
  36 +
  37 + @PostMapping("/invite-more")
  38 + @Operation(summary = "通话中添加成员;仅群通话可用")
  39 + public CommonResult<Boolean> inviteMore(@Valid @RequestBody ImRtcCallInviteMoreReqVO reqVO) {
  40 + rtcCallService.inviteMore(getLoginUserId(), reqVO);
  41 + return success(true);
  42 + }
  43 +
  44 + @PostMapping("/accept")
  45 + @Operation(summary = "接听通话")
  46 + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2")
  47 + public CommonResult<ImRtcCallRespVO> accept(@RequestParam("roomName") String roomName) {
  48 + return success(rtcCallService.accept(getLoginUserId(), roomName));
  49 + }
  50 +
  51 + @PostMapping("/reject")
  52 + @Operation(summary = "拒绝通话")
  53 + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2")
  54 + public CommonResult<Boolean> reject(@RequestParam("roomName") String roomName) {
  55 + rtcCallService.reject(getLoginUserId(), roomName);
  56 + return success(true);
  57 + }
  58 +
  59 + @PostMapping("/cancel")
  60 + @Operation(summary = "取消邀请;主叫接通前调用")
  61 + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2")
  62 + public CommonResult<Boolean> cancel(@RequestParam("roomName") String roomName) {
  63 + rtcCallService.cancel(getLoginUserId(), roomName);
  64 + return success(true);
  65 + }
  66 +
  67 + @PostMapping("/leave")
  68 + @Operation(summary = "离开通话;接通后调用")
  69 + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2")
  70 + public CommonResult<Boolean> leave(@RequestParam("roomName") String roomName) {
  71 + rtcCallService.leave(getLoginUserId(), roomName);
  72 + return success(true);
  73 + }
  74 +
  75 + @GetMapping("/refresh-token")
  76 + @Operation(summary = "重新签发 Token;客户端重连或 Token 过期续期")
  77 + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2")
  78 + public CommonResult<ImRtcCallRespVO> refreshToken(@RequestParam("roomName") String roomName) {
  79 + return success(rtcCallService.refreshToken(getLoginUserId(), roomName));
  80 + }
  81 +
  82 + @GetMapping("/active-sessions")
  83 + @Operation(summary = "查询当前用户活跃通话;用于 App 冷启动 / 推送点开恢复")
  84 + public CommonResult<List<ImRtcCallRespVO>> getActiveSessions() {
  85 + return success(rtcCallService.getActiveSessions(getLoginUserId()));
  86 + }
  87 +
  88 + @GetMapping("/group-active-call")
  89 + @Operation(summary = "查询群当前进行中的通话;用于群聊顶部「N 人正在通话」胶囊条")
  90 + @Parameter(name = "groupId", description = "群编号", required = true, example = "2048")
  91 + public CommonResult<ImRtcGroupCallRespVO> getGroupActiveCall(@RequestParam("groupId") Long groupId) {
  92 + return success(rtcCallService.getGroupActiveCall(groupId));
  93 + }
  94 +
  95 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcWebhookController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.rtc;
  2 +
  3 +import cn.hutool.core.codec.Base64;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.hutool.jwt.JWT;
  6 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  7 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  8 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  9 +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService;
  10 +import cn.iocoder.yudao.module.im.service.rtc.dto.LiveKitWebhookEventDTO;
  11 +import io.swagger.v3.oas.annotations.Operation;
  12 +import io.swagger.v3.oas.annotations.tags.Tag;
  13 +import jakarta.annotation.Resource;
  14 +import jakarta.annotation.security.PermitAll;
  15 +import jakarta.servlet.http.HttpServletRequest;
  16 +import lombok.extern.slf4j.Slf4j;
  17 +import org.springframework.http.MediaType;
  18 +import org.springframework.web.bind.annotation.PostMapping;
  19 +import org.springframework.web.bind.annotation.RequestBody;
  20 +import org.springframework.web.bind.annotation.RequestMapping;
  21 +import org.springframework.web.bind.annotation.RestController;
  22 +
  23 +import java.nio.charset.StandardCharsets;
  24 +import java.security.MessageDigest;
  25 +import java.util.Objects;
  26 +
  27 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  28 +
  29 +// TODO @AI:是不是改成 ImRtcLiveKitController 更合适???
  30 +/**
  31 + * LiveKit Webhook 入口
  32 + * <p>
  33 + * 安全由请求签名(JWT + body sha256)保证,不走登录鉴权;伪造请求会被签名校验直接拒绝
  34 + *
  35 + * @author 芋道源码
  36 + */
  37 +@Tag(name = "LiveKit Webhook 回调")
  38 +@RestController
  39 +@RequestMapping("/im/livekit")
  40 +@Slf4j
  41 +public class ImRtcWebhookController {
  42 +
  43 + @Resource
  44 + private ImProperties imProperties;
  45 + @Resource
  46 + private ImRtcCallService rtcCallService;
  47 +
  48 + @PostMapping(value = "/webhook", consumes = MediaType.APPLICATION_JSON_VALUE)
  49 + @Operation(summary = "LiveKit Webhook 回调;接收成员离开 / 房间结束等事件做业务态兜底清理")
  50 + @PermitAll
  51 + public CommonResult<Boolean> webhook(HttpServletRequest request, @RequestBody String rawBody) {
  52 + // TODO @AI:1.1 1.2 这种
  53 + // 1. 校验签名;伪造请求直接 200 但忽略,避免给攻击者反馈
  54 + if (!verifySignature(request, rawBody)) {
  55 + log.warn("[livekit-webhook][签名校验失败 ip={} body={}]", request.getRemoteAddr(), rawBody);
  56 + return success(false);
  57 + }
  58 + // 2. 解析事件并交给 service 处理;幂等由 service 自己保证
  59 + LiveKitWebhookEventDTO event = JsonUtils.parseObject(rawBody, LiveKitWebhookEventDTO.class);
  60 + if (event == null || StrUtil.isBlank(event.getEvent())) {
  61 + return success(false);
  62 + }
  63 +
  64 + // TODO @AI:2. ;这里是不是要打个 logger info?
  65 + try {
  66 + rtcCallService.handleLiveKitEvent(event);
  67 + } catch (Exception e) {
  68 + log.error("[livekit-webhook][事件处理失败 event={} body={}]", event.getEvent(), rawBody, e);
  69 + }
  70 + return success(true);
  71 + }
  72 +
  73 + // TODO @AI:是不是给 LiveKitClient 校验?
  74 + /**
  75 + * 校验 LiveKit Webhook 签名;流程参见
  76 + * https://docs.livekit.io/home/server/webhook/#receiving-webhooks
  77 + */
  78 + private boolean verifySignature(HttpServletRequest request, String rawBody) {
  79 + String authHeader = request.getHeader("Authorization");
  80 + if (StrUtil.isBlank(authHeader)) {
  81 + return false;
  82 + }
  83 + String token = StrUtil.removePrefix(authHeader, "Bearer ").trim();
  84 + ImProperties.Rtc cfg = imProperties.getRtc();
  85 + try {
  86 + JWT jwt = JWT.of(token);
  87 + // 1. JWT HS256 签名;密钥使用 LiveKit API Secret
  88 + if (!jwt.setKey(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8)).verify()) {
  89 + return false;
  90 + }
  91 + // 2. body sha256 一致性校验;防止抓到 token 后篡改 body
  92 + Object expectedSha = jwt.getPayload("sha256");
  93 + if (expectedSha == null) {
  94 + return false;
  95 + }
  96 + byte[] digest = MessageDigest.getInstance("SHA-256")
  97 + .digest(rawBody.getBytes(StandardCharsets.UTF_8));
  98 + String actualSha = Base64.encode(digest);
  99 + return Objects.equals(expectedSha.toString(), actualSha);
  100 + } catch (Exception e) {
  101 + // TODO @AI:是不是要加个什么 request 的参数,方便溯源?
  102 + log.warn("[livekit-webhook][签名解析异常]", e);
  103 + return false;
  104 + }
  105 + }
  106 +
  107 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteMoreReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.NotEmpty;
  6 +import lombok.Data;
  7 +
  8 +import java.util.Set;
  9 +
  10 +@Schema(description = "管理后台 - 通话中添加成员 Request VO")
  11 +@Data
  12 +public class ImRtcCallInviteMoreReqVO {
  13 +
  14 + @Schema(description = "LiveKit 房间名", requiredMode = Schema.RequiredMode.REQUIRED, example = "call_group_2048")
  15 + @NotBlank(message = "房间名不能为空")
  16 + private String roomName;
  17 +
  18 + // TODO @AI:targetIds?
  19 + @Schema(description = "新邀请的用户编号集合", requiredMode = Schema.RequiredMode.REQUIRED)
  20 + @NotEmpty(message = "请至少选择一位成员")
  21 + private Set<Long> inviteeIds;
  22 +
  23 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import lombok.Data;
  6 +
  7 +import java.util.Set;
  8 +
  9 +@Schema(description = "管理后台 - 发起通话 Request VO")
  10 +@Data
  11 +public class ImRtcCallInviteReqVO {
  12 +
  13 + // TODO @AI:换成 conversationType
  14 + @Schema(description = "会话场景", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  15 + @NotNull(message = "会话场景不能为空")
  16 + private Integer scene; // 参见 ImConversationTypeEnum 枚举类
  17 +
  18 + @Schema(description = "媒体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
  19 + @NotNull(message = "媒体类型不能为空")
  20 + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类
  21 +
  22 + // TODO @AI:是不是融合到 inviteeIds 里?
  23 + @Schema(description = "对方用户编号;私聊场景必填", example = "1024")
  24 + private Long peerUserId;
  25 +
  26 + @Schema(description = "群编号;群聊场景必填", example = "2048")
  27 + private Long groupId;
  28 +
  29 + @Schema(description = "被邀请的用户编号集合;群聊场景由前端选成员后传入;为空时回退到拉群活跃成员")
  30 + private Set<Long> inviteeIds;
  31 +
  32 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.util.Set;
  7 +
  8 +@Schema(description = "管理后台 - 通话会话 Response VO;invite / accept / refreshToken / getActiveSessions 共用")
  9 +@Data
  10 +public class ImRtcCallRespVO {
  11 +
  12 + @Schema(description = "业务通话编号")
  13 + private String callId;
  14 +
  15 + @Schema(description = "LiveKit 房间名")
  16 + private String roomName;
  17 +
  18 + @Schema(description = "LiveKit Server WebSocket 地址;前端 connect 用")
  19 + private String livekitUrl;
  20 +
  21 + @Schema(description = "LiveKit 接入 Token;getActiveSessions 列表里为 null,需要的话单独调 refreshToken")
  22 + private String token;
  23 +
  24 + @Schema(description = "会话场景")
  25 + private Integer scene; // 参见 ImConversationTypeEnum 枚举类
  26 +
  27 + @Schema(description = "媒体类型")
  28 + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类
  29 +
  30 + @Schema(description = "状态")
  31 + private Integer status; // 参见 ImCallStatusEnum 枚举类
  32 +
  33 + @Schema(description = "发起人编号")
  34 + private Long inviterId;
  35 +
  36 + @Schema(description = "群编号;群通话才有")
  37 + private Long groupId;
  38 +
  39 + @Schema(description = "被邀请人编号集合")
  40 + private Set<Long> inviteeIds;
  41 +
  42 + @Schema(description = "已加入房间的成员编号集合")
  43 + private Set<Long> joinedUserIds;
  44 +
  45 + @Schema(description = "是否本次新建;true 主叫弹「等待对方接受」;false 直接进通话中(已有房间)")
  46 + private Boolean newCreated;
  47 +
  48 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.util.Set;
  7 +
  8 +@Schema(description = "管理后台 - 群活跃通话 Response VO;不含 token,胶囊条仅展示用")
  9 +@Data
  10 +public class ImRtcGroupCallRespVO {
  11 +
  12 + @Schema(description = "业务通话编号")
  13 + private String callId;
  14 +
  15 + @Schema(description = "LiveKit 房间名")
  16 + private String roomName;
  17 +
  18 + @Schema(description = "群编号")
  19 + private Long groupId;
  20 +
  21 + @Schema(description = "媒体类型")
  22 + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类
  23 +
  24 + @Schema(description = "发起人编号")
  25 + private Long inviterId;
  26 +
  27 + @Schema(description = "已加入房间的用户编号集合")
  28 + private Set<Long> joinedUserIds;
  29 +
  30 + @Schema(description = "被邀请池;用于胶囊条展开时显示待加入头像")
  31 + private Set<Long> inviteeIds;
  32 +
  33 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
@@ -80,4 +80,14 @@ public interface ErrorCodeConstants { @@ -80,4 +80,14 @@ public interface ErrorCodeConstants {
80 ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情"); 80 ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情");
81 ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情"); 81 ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情");
82 82
  83 + // ========== 实时通话 (1-040-900-000) ==========
  84 + ErrorCode RTC_NOT_ENABLED = new ErrorCode(1_040_900_000, "通话功能未开启");
  85 + ErrorCode RTC_SESSION_NOT_EXISTS = new ErrorCode(1_040_900_001, "通话已结束");
  86 + ErrorCode RTC_PEER_BUSY = new ErrorCode(1_040_900_002, "对方正在通话中");
  87 + ErrorCode RTC_SELF_BUSY = new ErrorCode(1_040_900_003, "您正在通话中");
  88 + ErrorCode RTC_NOT_PARTICIPANT = new ErrorCode(1_040_900_004, "您不在该通话中");
  89 + ErrorCode RTC_INVITE_SELF = new ErrorCode(1_040_900_005, "不能呼叫自己");
  90 + ErrorCode RTC_PRIVATE_INVITEE_REQUIRED = new ErrorCode(1_040_900_006, "私聊通话必须指定对方");
  91 + ErrorCode RTC_GROUP_REQUIRED = new ErrorCode(1_040_900_007, "群聊通话必须指定群编号");
  92 +
83 } 93 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
@@ -78,6 +78,36 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; { @@ -78,6 +78,36 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
78 */ 78 */
79 READ(2201, "已读", false, false), 79 READ(2201, "已读", false, false),
80 80
  81 + // ========== 实时通话信令(2300-2302) ==========
  82 + // TODO @AI:openim 的信号,是怎么设计的?感觉对齐下,会好一点点,不然用户不好拓展;
  83 + /**
  84 + * 通话邀请:主叫发起后推给被叫弹「来电」界面;payload 为 RtcInviteNotification(roomName / mediaType / scene / inviter / token)
  85 + */
  86 + RTC_INVITE(2300, "通话邀请", false, false),
  87 + /**
  88 + * 通话已接通:被叫接听后推给主叫切换到「通话中」UI;payload 为 RtcAcceptNotification(roomName / acceptorId)
  89 + */
  90 + RTC_ACCEPT(2301, "通话接通", false, false),
  91 + /**
  92 + * 通话结束:拒绝 / 取消 / 挂断 / 超时 / 异常 统一走这一条;payload 为 RtcEndNotification(roomName / reason / operatorId)
  93 + */
  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),
  101 + /**
  102 + * 群通话结束:最后一人离开 / 异常关房时广播;payload 仅含 roomName / groupId
  103 + */
  104 + RTC_GROUP_ENDED(2311, "群通话结束", false, false),
  105 + /**
  106 + * 群通话成员变更:有人加入 / 离开时广播;payload 为 ImRtcGroupNotification(含最新 joinedUserIds);
  107 + * 用于胶囊条上「N 人正在通话」实时刷新
  108 + */
  109 + RTC_GROUP_UPDATED(2312, "群通话成员变更", false, false),
  110 +
81 // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ========== 111 // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ==========
82 // TODO @芋艿:FRIEND_REQUEST_* 与 GROUP_REQUEST_* 都是 persistent=false 的 SysMsg,离线 pull 拉不到, 112 // TODO @芋艿:FRIEND_REQUEST_* 与 GROUP_REQUEST_* 都是 persistent=false 的 SysMsg,离线 pull 拉不到,
83 // 目前可能丢失「实时 toast 提醒」体验(业务状态不丢,前端上线 fetch{Friend,Group}RequestList 能补回来); 113 // 目前可能丢失「实时 toast 提醒」体验(业务状态不丢,前端上线 fetch{Friend,Group}RequestList 能补回来);
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.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 + *
  12 + * @author 芋道源码
  13 + */
  14 +@RequiredArgsConstructor
  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;
  36 + private final String name;
  37 +
  38 + @Override
  39 + public Integer[] array() {
  40 + return ARRAYS;
  41 + }
  42 +
  43 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.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 通话媒体类型枚举
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@RequiredArgsConstructor
  15 +@Getter
  16 +public enum ImCallMediaTypeEnum implements ArrayValuable<Integer> {
  17 +
  18 + VOICE(1, "语音"),
  19 + VIDEO(2, "视频");
  20 +
  21 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallMediaTypeEnum::getType).toArray(Integer[]::new);
  22 +
  23 + private final Integer type;
  24 + private final String name;
  25 +
  26 + @Override
  27 + public Integer[] array() {
  28 + return ARRAYS;
  29 + }
  30 +
  31 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.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.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/framework/config/ImProperties.java
@@ -4,6 +4,7 @@ import lombok.Data; @@ -4,6 +4,7 @@ import lombok.Data;
4 import org.springframework.boot.context.properties.ConfigurationProperties; 4 import org.springframework.boot.context.properties.ConfigurationProperties;
5 import org.springframework.stereotype.Component; 5 import org.springframework.stereotype.Component;
6 6
  7 +// TODO @AI:参考别的模块;是不是也要分 im;config 和 core 这样;在瞅瞅;
7 /** 8 /**
8 * IM 模块全局配置 9 * IM 模块全局配置
9 * <p> 10 * <p>
@@ -23,6 +24,8 @@ public class ImProperties { @@ -23,6 +24,8 @@ public class ImProperties {
23 24
24 private Message message = new Message(); 25 private Message message = new Message();
25 26
  27 + private Rtc rtc = new Rtc();
  28 +
26 /** 29 /**
27 * 好友模块配置 30 * 好友模块配置
28 */ 31 */
@@ -114,4 +117,45 @@ public class ImProperties { @@ -114,4 +117,45 @@ public class ImProperties {
114 117
115 } 118 }
116 119
  120 + /**
  121 + * 实时通话模块配置
  122 + * <p>
  123 + * 媒体走 LiveKit SFU;后端只签 Token + 通过 IM 长连接推送来电 / 接通 / 结束三种信令。
  124 + * 关闭后所有 RTC 接口直接抛 RTC_NOT_ENABLED;前端可据此隐藏通话按钮。
  125 + */
  126 + @Data
  127 + public static class Rtc {
  128 +
  129 + /**
  130 + * 是否启用实时通话功能
  131 + */
  132 + private boolean enabled = true;
  133 +
  134 + /**
  135 + * LiveKit Server WebSocket 地址;客户端 connect 时使用,通常 ws://host:7880 或 wss://host
  136 + */
  137 + private String livekitUrl = "ws://127.0.0.1:7880";
  138 +
  139 + /**
  140 + * LiveKit API Key
  141 + */
  142 + private String apiKey = "devkey";
  143 +
  144 + /**
  145 + * LiveKit API Secret;生产必须改为强随机值
  146 + */
  147 + private String apiSecret = "secret-poc-key-min-32-chars-required-here";
  148 +
  149 + /**
  150 + * 单次签发的 Token 有效期(小时)
  151 + */
  152 + private int tokenTtlHours = 6;
  153 +
  154 + /**
  155 + * 群通话最大同时在房成员数;超过 invite 直接拒绝
  156 + */
  157 + private int groupMaxParticipants = 16;
  158 +
  159 + }
  160 +
117 } 161 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java 0 → 100644
  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/ImRtcCallService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.rtc;
  2 +
  3 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO;
  4 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
  5 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO;
  6 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO;
  7 +
  8 +import java.util.List;
  9 +
  10 +// TODO @AI:一个好友,只能一个通话;一个群,只能一个通话;(同一时间);
  11 +/**
  12 + * IM 实时通话 Service
  13 + * <p>
  14 + * 业务模型:一个好友对 / 一个群同时只能有一个进行中的通话;invite 兼具「创建」与「加入已有」两种语义。
  15 + * 媒体协商完全交给 LiveKit;后端只做:会话状态机、Token 签发、来电信令推送、通话历史落消息流。
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +public interface ImRtcCallService {
  20 +
  21 + // TODO @AI:是不是风格上,还是有 create 和 invite;这样两种逻辑上更好理解;
  22 + /**
  23 + * 发起通话;同一 roomName 已有进行中通话时返回该会话并标记 newCreated=false
  24 + *
  25 + * @param userId 操作人编号;通常是当前登录用户
  26 + * @param reqVO 请求参数(scene / mediaType / peerUserId 或 groupId)
  27 + * @return 包含 token / roomName / livekitUrl 的会话信息
  28 + */
  29 + ImRtcCallRespVO invite(Long userId, ImRtcCallInviteReqVO reqVO);
  30 +
  31 + /**
  32 + * 通话中追加成员;仅群通话场景可用;给新邀请人推 RTC_INVITE
  33 + *
  34 + * @param userId 操作人编号;必须是当前会话参与者
  35 + * @param reqVO 房间名 + 新邀请的用户编号集合
  36 + */
  37 + void inviteMore(Long userId, ImRtcCallInviteMoreReqVO reqVO);
  38 +
  39 + /**
  40 + * 接听通话;私聊状态切换 INVITING → ONGOING;群通话直接加入已有 ONGOING 房间
  41 + */
  42 + ImRtcCallRespVO accept(Long userId, String roomName);
  43 +
  44 + /**
  45 + * 拒绝通话;仅 INVITING 状态可拒;群通话拒绝等同于不参与,房间仍存在
  46 + */
  47 + void reject(Long userId, String roomName);
  48 +
  49 + /**
  50 + * 取消邀请;主叫在 INVITING 状态主动取消
  51 + */
  52 + void cancel(Long userId, String roomName);
  53 +
  54 + /**
  55 + * 离开通话;ONGOING 状态下离开;私聊任一方离开 = 通话结束;群通话最后一人离开才结束
  56 + */
  57 + void leave(Long userId, String roomName);
  58 +
  59 + /**
  60 + * 重新签发 Token;用于客户端重连或 Token 过期续期
  61 + */
  62 + ImRtcCallRespVO refreshToken(Long userId, String roomName);
  63 +
  64 + /**
  65 + * 列出用户当前的活跃通话;用于 App 冷启动 / 推送点开恢复
  66 + */
  67 + List<ImRtcCallRespVO> getActiveSessions(Long userId);
  68 +
  69 + /**
  70 + * 查询群当前正在进行的通话;用于群聊顶部「N 人正在通话」胶囊条
  71 + *
  72 + * @param groupId 群编号
  73 + * @return 群活跃通话;不存在返回 null;不含 token,仅展示用
  74 + */
  75 + ImRtcGroupCallRespVO getGroupActiveCall(Long groupId);
  76 +
  77 + /**
  78 + * 处理 LiveKit Webhook 事件;用于关 tab / 强杀 / 网络断开等异常退出场景的兜底清理
  79 + * <p>
  80 + * 关键事件:participant_left(成员离开) / room_finished(房间结束)。前端正常 leave 时
  81 + * 也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。
  82 + */
  83 + void handleLiveKitEvent(cn.iocoder.yudao.module.im.service.rtc.dto.LiveKitWebhookEventDTO event);
  84 +
  85 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.rtc;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.IdUtil;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  7 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  8 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO;
  9 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
  10 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO;
  11 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO;
  12 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
  13 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
  14 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
  15 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  16 +import cn.iocoder.yudao.module.im.enums.rtc.ImCallEndReasonEnum;
  17 +import cn.iocoder.yudao.module.im.enums.rtc.ImCallStatusEnum;
  18 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  19 +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitTokenUtils;
  20 +import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
  21 +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
  22 +import cn.iocoder.yudao.module.im.service.rtc.bo.ImCallSessionBO;
  23 +import cn.iocoder.yudao.module.im.service.rtc.bo.ImCallSessionStore;
  24 +import cn.iocoder.yudao.module.im.service.rtc.dto.*;
  25 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  26 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  27 +import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
  28 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  29 +import jakarta.annotation.Resource;
  30 +import lombok.extern.slf4j.Slf4j;
  31 +import org.springframework.stereotype.Service;
  32 +import org.springframework.validation.annotation.Validated;
  33 +
  34 +import java.time.Duration;
  35 +import java.time.LocalDateTime;
  36 +import java.util.*;
  37 +import java.util.stream.Collectors;
  38 +
  39 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  40 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  41 +
  42 +/**
  43 + * IM 实时通话 Service 实现
  44 + *
  45 + * @author 芋道源码
  46 + */
  47 +@Service
  48 +@Validated
  49 +@Slf4j
  50 +public class ImRtcCallServiceImpl implements ImRtcCallService {
  51 +
  52 + // TODO @AI:后续存储到 DB 里;(晚点改)
  53 + @Resource
  54 + private ImCallSessionStore sessionStore;
  55 +
  56 + @Resource
  57 + private ImGroupMemberService groupMemberService;
  58 + @Resource
  59 + private ImFriendService friendService;
  60 + @Resource
  61 + private ImWebSocketService webSocketService;
  62 +
  63 + @Resource
  64 + private AdminUserApi adminUserApi;
  65 +
  66 + @Resource
  67 + private ImProperties imProperties;
  68 +
  69 + @Override
  70 + public ImRtcCallRespVO invite(Long userId, ImRtcCallInviteReqVO reqVO) {
  71 + // TODO @AI:按照 1.1 1.2 1.3 这样搞;
  72 + ensureEnabled();
  73 + Integer scene = reqVO.getScene();
  74 + // 1. 校验入参与场景
  75 + validateInviteScene(userId, reqVO);
  76 + // 2. 派生 roomName;同好友对 / 同群只对应一个 roomName
  77 + String roomName = deriveRoomName(scene, userId, reqVO.getPeerUserId(), reqVO.getGroupId());
  78 + // 3. 用户级忙线检查;当前用户已在另一通话且不是同房间则拒绝
  79 + String selfActive = sessionStore.getActiveRoom(userId);
  80 + // TODO @AI:notEquals 方案;
  81 + if (selfActive != null && !selfActive.equals(roomName)) {
  82 + throw exception(RTC_SELF_BUSY);
  83 + }
  84 + // 4. 私聊场景下检查被叫忙线;群通话候选成员各自决定接听,不在此拦截
  85 + // TODO @AI:需要在看看,群聊会不会出现这个情况?感觉应该也会,可以调研下微信,以及其他 im
  86 + if (ImConversationTypeEnum.isPrivate(scene)
  87 + && sessionStore.isBusy(reqVO.getPeerUserId())) {
  88 + String peerActive = sessionStore.getActiveRoom(reqVO.getPeerUserId());
  89 + // TODO @AI:notEquals 方案;
  90 + if (peerActive != null && !peerActive.equals(roomName)) {
  91 + throw exception(RTC_PEER_BUSY);
  92 + }
  93 + }
  94 +
  95 + // 5. 原子获取或创建会话;并发同时点呼叫只会建一份
  96 + boolean[] newCreated = {false};
  97 + ImCallSessionBO session = sessionStore.getOrCreate(roomName, key -> {
  98 + newCreated[0] = true;
  99 + return buildNewSession(key, userId, reqVO);
  100 + });
  101 + // 6. 老 session:本次属于"加入已有通话";新 session:发邀请信令并把发起人加入房间
  102 + sessionStore.bindUser(userId, roomName);
  103 + session.getJoinedUserIds().add(userId);
  104 + if (newCreated[0]) {
  105 + pushInviteNotifications(session, userId);
  106 + // 群通话首次发起:向全群广播 STARTED,胶囊条立刻在所有成员客户端出现
  107 + if (ImConversationTypeEnum.isGroup(session.getScene())) {
  108 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_STARTED.getType(), userId);
  109 + }
  110 + } else if (ImConversationTypeEnum.isGroup(session.getScene())) {
  111 + // 群通话主动加入已有通话:广播 UPDATED 让胶囊条人数刷新
  112 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId);
  113 + }
  114 + // 7. 给当前调用方签 token
  115 + return buildResp(session, userId, newCreated[0]);
  116 + }
  117 +
  118 + @Override
  119 + public ImRtcCallRespVO accept(Long userId, String roomName) {
  120 + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点)
  121 + ensureEnabled();
  122 + ImCallSessionBO session = requireSession(roomName);
  123 + // TODO @AI:减少 ONGOING、INVITING 的混用;最好是中文(英文),大家更好理解。
  124 + // 群通话处于 ONGOING;私聊必须 INVITING 才能接听
  125 + if (ImConversationTypeEnum.isPrivate(session.getScene())
  126 + && !ImCallStatusEnum.isInviting(session.getStatus())) {
  127 + throw exception(RTC_SESSION_NOT_EXISTS);
  128 + }
  129 + if (!session.containsUser(userId)) {
  130 + throw exception(RTC_NOT_PARTICIPANT);
  131 + }
  132 + // 私聊接听 → 切到 ONGOING;群通话已经是 ONGOING;幂等:重复 accept 不报错
  133 + if (ImConversationTypeEnum.isPrivate(session.getScene())) {
  134 + session.setStatus(ImCallStatusEnum.ONGOING.getType());
  135 + // 振铃定时器在前端,后端不做 cancel 动作
  136 + }
  137 + session.getJoinedUserIds().add(userId);
  138 + sessionStore.bindUser(userId, roomName);
  139 + // 推 RTC_ACCEPT 给除自己外的全部相关方
  140 + pushAcceptNotification(session, userId);
  141 + // 群通话有人接听 → 全群广播 UPDATED 刷新胶囊条
  142 + if (ImConversationTypeEnum.isGroup(session.getScene())) {
  143 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId);
  144 + }
  145 + return buildResp(session, userId, false);
  146 + }
  147 +
  148 + @Override
  149 + public void reject(Long userId, String roomName) {
  150 + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点)
  151 + ensureEnabled(); // TODO @AI:是不是只有发起校验,其它地方先不校验了;
  152 + ImCallSessionBO session = requireSession(roomName);
  153 + if (!session.containsUser(userId)) {
  154 + throw exception(RTC_NOT_PARTICIPANT);
  155 + }
  156 + // 群通话拒绝:不影响其他成员,房间继续存在
  157 + if (ImConversationTypeEnum.isGroup(session.getScene())) {
  158 + session.getRejectedUserIds().add(userId);
  159 + sessionStore.unbindUserFromRoom(userId, roomName);
  160 + // 仅广播 UPDATED;之前向主叫推 RTC_END 会被前端错误识别为「整个通话结束」并 reset 主叫的通话窗
  161 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId);
  162 + return;
  163 + }
  164 + // 私聊拒绝:仅 INVITING 状态可拒;推 RTC_END 给所有相关方并关房
  165 + if (!ImCallStatusEnum.isInviting(session.getStatus())) {
  166 + throw exception(RTC_SESSION_NOT_EXISTS);
  167 + }
  168 + endSession(session, userId, ImCallEndReasonEnum.REJECT);
  169 + }
  170 +
  171 + @Override
  172 + public void cancel(Long userId, String roomName) {
  173 + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点)
  174 + ensureEnabled();
  175 + ImCallSessionBO session = requireSession(roomName);
  176 + if (!Objects.equals(session.getInviterId(), userId)) {
  177 + throw exception(RTC_NOT_PARTICIPANT);
  178 + }
  179 + // 仅 INVITING 状态可取消;ONGOING 应走 leave
  180 + if (!ImCallStatusEnum.isInviting(session.getStatus())) {
  181 + throw exception(RTC_SESSION_NOT_EXISTS);
  182 + }
  183 + endSession(session, userId, ImCallEndReasonEnum.CANCEL);
  184 + }
  185 +
  186 + @Override
  187 + public void leave(Long userId, String roomName) {
  188 + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点)
  189 + ensureEnabled();
  190 + ImCallSessionBO session = requireSession(roomName);
  191 + if (!session.containsUser(userId)) {
  192 + throw exception(RTC_NOT_PARTICIPANT);
  193 + }
  194 + session.getLeftUserIds().add(userId);
  195 + session.getJoinedUserIds().remove(userId);
  196 + sessionStore.unbindUserFromRoom(userId, roomName);
  197 + // 关房条件:私聊任一方离开 = 关;群通话仅当所有被邀请人都已应答 + 在房无人时才关
  198 + // 发起人离开后被邀请人仍可继续接听,房间不应当立即销毁
  199 + // TODO @AI:单聊、群聊,是否可以复用一个逻辑;private 本质,只是一个特殊的方式而已;如果 private 后续支持邀请人,那 private 这个判断,就是 bug 了呀。其它地方,看看是不是也调整下;
  200 + if (ImConversationTypeEnum.isPrivate(session.getScene())
  201 + || shouldCloseGroupRoom(session)) {
  202 + endSession(session, userId, ImCallEndReasonEnum.HANGUP);
  203 + return;
  204 + }
  205 + // 群通话单人离开:仅广播 UPDATED;不推 RTC_END 避免主叫误关通话窗
  206 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId);
  207 + }
  208 +
  209 + /**
  210 + * 群通话是否应该关闭:在房成员空 + 没有还在响铃的被邀请人
  211 + * <p>
  212 + * 仅 INVITED 状态下未接受 / 拒绝 / 离开的成员视为「还在响铃」;这样发起人提前离开后,被邀请人仍能接听并独立组成通话,符合微信群通话语义
  213 + */
  214 + private boolean shouldCloseGroupRoom(ImCallSessionBO session) {
  215 + // TODO @AI:CollUtil
  216 + if (!session.getJoinedUserIds().isEmpty()) {
  217 + return false;
  218 + }
  219 + // TODO @AI:CollUtil?
  220 + if (session.getInviteeIds() == null) {
  221 + return true;
  222 + }
  223 + // TODO @AI:exists 或者哪种?这样更简洁一点。
  224 + for (Long id : session.getInviteeIds()) {
  225 + boolean responded = session.getRejectedUserIds().contains(id)
  226 + || session.getLeftUserIds().contains(id);
  227 + if (!responded) {
  228 + return false;
  229 + }
  230 + }
  231 + return true;
  232 + }
  233 +
  234 + @Override
  235 + public ImRtcCallRespVO refreshToken(Long userId, String roomName) {
  236 + ensureEnabled(); // TODO @AI:简化下;
  237 + ImCallSessionBO session = requireSession(roomName);
  238 + if (!session.containsUser(userId)) {
  239 + throw exception(RTC_NOT_PARTICIPANT);
  240 + }
  241 + return buildResp(session, userId, false);
  242 + }
  243 +
  244 + @Override
  245 + public void inviteMore(Long userId, ImRtcCallInviteMoreReqVO reqVO) {
  246 + ensureEnabled();
  247 + ImCallSessionBO session = requireSession(reqVO.getRoomName());
  248 + // 仅群通话支持追加邀请
  249 + // TODO @AI:如果放开私聊的邀请,代码改动量大么?(这里只讨论,不用改)
  250 + if (!ImConversationTypeEnum.isGroup(session.getScene())) {
  251 + throw exception(RTC_GROUP_REQUIRED);
  252 + }
  253 + // 操作者必须是已在房间的参与者
  254 + if (!session.getJoinedUserIds().contains(userId)) {
  255 + throw exception(RTC_NOT_PARTICIPANT);
  256 + }
  257 + // 过滤已在被邀请池或已加入的人;仅给新成员发邀请
  258 + Set<Long> existing = new LinkedHashSet<>();
  259 + if (session.getInviteeIds() != null) {
  260 + existing.addAll(session.getInviteeIds());
  261 + }
  262 + existing.addAll(session.getJoinedUserIds());
  263 + // 校验被邀请人必须是该群活跃成员;防止操作者夹带任意 userId 进群房间
  264 + // TODO @AI:如果放开私聊的邀请,代码改动量大么?(这里只讨论,不用改)
  265 + Set<Long> validMemberIds = new LinkedHashSet<>(
  266 + groupMemberService.getActiveGroupMemberUserIdsByGroupId(session.getGroupId()));
  267 + Set<Long> incoming = new LinkedHashSet<>();
  268 + for (Long id : reqVO.getInviteeIds()) {
  269 + if (!existing.contains(id) && validMemberIds.contains(id)) {
  270 + incoming.add(id);
  271 + }
  272 + }
  273 + // TODO @AI:CollUtil isempty 判断;
  274 + if (incoming.isEmpty()) {
  275 + return;
  276 + }
  277 + if (session.getInviteeIds() == null) {
  278 + session.setInviteeIds(new LinkedHashSet<>());
  279 + }
  280 + session.getInviteeIds().addAll(incoming);
  281 + // 给新人推 RTC_INVITE;签 token 复用 invite 的逻辑
  282 + AdminUserRespDTO inviter = adminUserApi.getUser(userId);
  283 + for (Long inviteeId : incoming) {
  284 + ImRtcInviteNotification payload = buildInvitePayload(session, userId, inviter, inviteeId);
  285 + // TODO @AI:链式调用,相同的在一行;
  286 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO()
  287 + .setType(ImMessageTypeEnum.RTC_INVITE.getType())
  288 + .setSenderId(userId)
  289 + .setReceiverId(inviteeId)
  290 + .setContent(JsonUtils.toJsonString(payload))
  291 + .setSendTime(LocalDateTime.now());
  292 + webSocketService.sendPrivateMessageAsync(inviteeId, dto);
  293 + }
  294 + // 全群广播 UPDATED;让胶囊条上「等待加入」头像同步
  295 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId);
  296 + }
  297 +
  298 + @Override
  299 + public List<ImRtcCallRespVO> getActiveSessions(Long userId) {
  300 + // TODO @AI:报错,前端就不应该发起;
  301 + if (!imProperties.getRtc().isEnabled()) {
  302 + return Collections.emptyList();
  303 + }
  304 + String roomName = sessionStore.getActiveRoom(userId);
  305 + if (StrUtil.isBlank(roomName)) {
  306 + return Collections.emptyList();
  307 + }
  308 + ImCallSessionBO session = sessionStore.get(roomName);
  309 + if (session == null) {
  310 + return Collections.emptyList();
  311 + }
  312 +
  313 + // TODO @AI:返回 ImCallSessionBO?controller 拼接?(后面在改,所有涉及到各种翻译的地方)
  314 + // 列表场景不签 token;前端要 token 时再调 refreshToken
  315 + ImRtcCallRespVO vo = toBaseVO(session);
  316 + return Collections.singletonList(vo);
  317 + }
  318 +
  319 + @Override
  320 + public void handleLiveKitEvent(LiveKitWebhookEventDTO event) {
  321 + // TODO @AI:需要 try catch 记录 hook + 成功 or 失败的日志么?
  322 + if (event == null || event.getEvent() == null) {
  323 + return;
  324 + }
  325 + // TODO @AI:需要检验 jdk8 语法;
  326 + // TODO @AI:livekit webhookevent 是不是搞个枚举类?
  327 + switch (event.getEvent()) {
  328 + case "participant_left" -> handleParticipantLeft(event);
  329 + case "room_finished" -> handleRoomFinished(event);
  330 + default -> {
  331 + // TODO @AI:仅仅 logger,后续按需搞;
  332 + /* 其它事件忽略;participant_joined / track_published 等业务态由 LiveKit 自身分发驱动 */
  333 + }
  334 + }
  335 + }
  336 +
  337 + // TODO @AI:方法注释,里面的注释,按照 1. 2. 3. 阅读性更好
  338 + // TODO @AI:处理必要的代码空行;阅读性更好(但是不要太多空行,层次感缺少)
  339 + /**
  340 + * 处理 LiveKit「成员离开」事件;正常 leave 接口已经清理过的话此处幂等忽略
  341 + * 私聊任一方离开 → 结束通话;群通话仅一人离开 → 广播;最后一人离开 → 结束通话
  342 + */
  343 + private void handleParticipantLeft(LiveKitWebhookEventDTO event) {
  344 + if (event.getRoom() == null || event.getParticipant() == null) {
  345 + return;
  346 + }
  347 + String roomName = event.getRoom().getName();
  348 + ImCallSessionBO session = sessionStore.get(roomName);
  349 + if (session == null) {
  350 + return;
  351 + }
  352 + Long userId = parseUserIdFromIdentity(event.getParticipant().getIdentity());
  353 + if (userId == null) {
  354 + return;
  355 + }
  356 + // 业务态可能已被 leave 接口清掉;contains 判断保证幂等
  357 + boolean wasInRoom = session.getJoinedUserIds().remove(userId);
  358 + sessionStore.unbindUserFromRoom(userId, roomName);
  359 + if (!wasInRoom) {
  360 + return;
  361 + }
  362 + session.getLeftUserIds().add(userId);
  363 + log.info("[handleParticipantLeft][roomName={} userId={} 由 LiveKit Webhook 兜底清理]", roomName, userId);
  364 + // 关房条件与 leave 一致;群通话仅在「无人在房 + 无人响铃」时关
  365 + if (ImConversationTypeEnum.isPrivate(session.getScene())
  366 + || shouldCloseGroupRoom(session)) {
  367 + endSession(session, userId, ImCallEndReasonEnum.HANGUP);
  368 + return;
  369 + }
  370 + // 群通话单人异常离开:仅广播 UPDATED;同 leave 路径,避免主叫误关
  371 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId);
  372 + }
  373 +
  374 + // TODO @AI:方法注释,里面的注释,按照 1. 2. 3. 阅读性更好
  375 + // TODO @AI:处理必要的代码空行;阅读性更好(但是不要太多空行,层次感缺少)
  376 + /** 处理「房间结束」事件;兜底从 sessionStore 移除残留会话 */
  377 + private void handleRoomFinished(LiveKitWebhookEventDTO event) {
  378 + if (event.getRoom() == null) {
  379 + return;
  380 + }
  381 + String roomName = event.getRoom().getName();
  382 + ImCallSessionBO session = sessionStore.get(roomName);
  383 + if (session == null) {
  384 + return;
  385 + }
  386 + log.info("[handleRoomFinished][roomName={} 由 LiveKit Webhook 兜底清理]", roomName);
  387 + // 复用 endSession:状态切 ENDED + 推 RTC_END + 群通话广播 ENDED + 移除内存索引
  388 + endSession(session, null, ImCallEndReasonEnum.HANGUP);
  389 + }
  390 +
  391 + // TODO @AI:这里多端是必须的么?如果非必须,就先不用了。。。
  392 + /**
  393 + * 从 LiveKit identity 解析业务 userId
  394 + * <p>
  395 + * 当前 identity 直接是 userId 字符串;预留 userId#terminal 多端格式的解析支持
  396 + */
  397 + private Long parseUserIdFromIdentity(String identity) {
  398 + if (identity == null || identity.isEmpty()) {
  399 + return null;
  400 + }
  401 + int sep = identity.indexOf('#');
  402 + String idPart = sep >= 0 ? identity.substring(0, sep) : identity;
  403 + try {
  404 + return Long.parseLong(idPart);
  405 + } catch (NumberFormatException e) {
  406 + log.warn("[parseUserIdFromIdentity][非法 identity={}]", identity);
  407 + return null;
  408 + }
  409 + }
  410 +
  411 + @Override
  412 + public ImRtcGroupCallRespVO getGroupActiveCall(Long groupId) {
  413 + if (!imProperties.getRtc().isEnabled() || groupId == null) {
  414 + return null;
  415 + }
  416 + ImCallSessionBO session = sessionStore.get("call_group_" + groupId);
  417 + if (session == null) {
  418 + return null;
  419 + }
  420 + // TODO @AI:链式调用;
  421 + // TODO @AI:后续看看,能不能改成 controller 处理;
  422 + ImRtcGroupCallRespVO vo = new ImRtcGroupCallRespVO();
  423 + vo.setCallId(session.getCallId());
  424 + vo.setRoomName(session.getRoomName());
  425 + vo.setGroupId(session.getGroupId());
  426 + vo.setMediaType(session.getMediaType());
  427 + vo.setInviterId(session.getInviterId());
  428 + vo.setJoinedUserIds(new LinkedHashSet<>(session.getJoinedUserIds()));
  429 + if (session.getInviteeIds() != null) {
  430 + vo.setInviteeIds(new LinkedHashSet<>(session.getInviteeIds()));
  431 + }
  432 + return vo;
  433 + }
  434 +
  435 + // ========== 内部辅助 ==========
  436 +
  437 + /** 校验通话功能开关 */
  438 + private void ensureEnabled() {
  439 + if (!imProperties.getRtc().isEnabled()) {
  440 + throw exception(RTC_NOT_ENABLED);
  441 + }
  442 + }
  443 +
  444 + /** 校验 invite 入参;按场景区分必填字段 + 群通话校验当前用户是群活跃成员 */
  445 + private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) {
  446 + Integer scene = reqVO.getScene();
  447 + if (ImConversationTypeEnum.isPrivate(scene)) {
  448 + // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue;
  449 + if (reqVO.getPeerUserId() == null) {
  450 + throw exception(RTC_PRIVATE_INVITEE_REQUIRED);
  451 + }
  452 + if (Objects.equals(userId, reqVO.getPeerUserId())) {
  453 + throw exception(RTC_INVITE_SELF);
  454 + }
  455 + // 好友 / 黑名单校验:和私聊消息发送同一套语义;NONE 已删 / 未加,BLOCKED 被对方拉黑
  456 + // TODO @AI:是不是抽一个方法?validateFriendXXX(因为有多处调用);
  457 + ImFriendStateEnum state = friendService.getFriendState(userId, reqVO.getPeerUserId());
  458 + if (state == ImFriendStateEnum.NONE) {
  459 + throw exception(FRIEND_NOT_FRIEND);
  460 + }
  461 + if (state == ImFriendStateEnum.BLOCKED) {
  462 + throw exception(FRIEND_BLOCKED_BY_PEER);
  463 + }
  464 + return;
  465 + }
  466 + if (ImConversationTypeEnum.isGroup(scene)) {
  467 + // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue;
  468 + if (reqVO.getGroupId() == null) {
  469 + throw exception(RTC_GROUP_REQUIRED);
  470 + }
  471 + // 当前用户必须是群活跃成员(包括主动加入已有通话的场景);非成员不允许进入群房间
  472 + // TODO @AI:是不是校验下,被邀请人,是不是这个群里的?
  473 + // TODO @AI:member那搞个 ids 校验?避免太大的查询。
  474 + List<Long> activeMemberIds = groupMemberService
  475 + .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
  476 + if (!activeMemberIds.contains(userId)) {
  477 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  478 + }
  479 + return;
  480 + }
  481 + // TODO @AI:抛出异常的 scene 类型;
  482 + throw exception(RTC_PRIVATE_INVITEE_REQUIRED);
  483 + }
  484 +
  485 + // TODO @AI在:注释风格;
  486 + // TODO @AI:方法名,我们一般叫 buildXXX;
  487 + /** 派生 roomName;私聊用好友对小大 ID,双向同 key;群聊用 groupId */
  488 + private String deriveRoomName(Integer scene, Long selfId, Long peerId, Long groupId) {
  489 + if (ImConversationTypeEnum.isPrivate(scene)) {
  490 + long min = Math.min(selfId, peerId);
  491 + long max = Math.max(selfId, peerId);
  492 + return "call_friend_" + min + "_" + max;
  493 + }
  494 + return "call_group_" + groupId;
  495 + }
  496 +
  497 + // TODO @AI:注释风格?
  498 + /** 构造新会话;私聊起始 INVITING;群聊起始 ONGOING */
  499 + private ImCallSessionBO buildNewSession(String roomName, Long inviterId, ImRtcCallInviteReqVO reqVO) {
  500 + Set<Long> invitees = resolveInvitees(reqVO, inviterId);
  501 + long now = System.currentTimeMillis();
  502 + return ImCallSessionBO.builder()
  503 + // TODO @AI:链式调用,相同的放在一行;
  504 + .callId(IdUtil.fastSimpleUUID())
  505 + .roomName(roomName)
  506 + .scene(reqVO.getScene())
  507 + .mediaType(reqVO.getMediaType())
  508 + .inviterId(inviterId)
  509 + .inviteeIds(invitees)
  510 + .groupId(reqVO.getGroupId())
  511 + // TODO @AI:是不是
  512 + .status(ImConversationTypeEnum.isPrivate(reqVO.getScene())
  513 + ? ImCallStatusEnum.INVITING.getType()
  514 + : ImCallStatusEnum.ONGOING.getType())
  515 + .startTime(now)
  516 + .joinedUserIds(new LinkedHashSet<>())
  517 + .rejectedUserIds(new LinkedHashSet<>())
  518 + .leftUserIds(new LinkedHashSet<>())
  519 + .build();
  520 + }
  521 +
  522 + /** 解析被邀请池:私聊为 peerUserId 单元素;群聊优先用前端选中的子集,否则取群活跃成员 */
  523 + private Set<Long> resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) {
  524 + // TODO @AI:私聊。
  525 + if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) {
  526 + Set<Long> set = new LinkedHashSet<>();
  527 + set.add(reqVO.getPeerUserId());
  528 + return set;
  529 + }
  530 +
  531 + // 群通话:前端有传 inviteeIds 就用,否则回退拉群当前活跃成员
  532 + Set<Long> initial = new LinkedHashSet<>();
  533 + // TODO @AI:必须传递,否则报错;
  534 + if (CollUtil.isNotEmpty(reqVO.getInviteeIds())) {
  535 + initial.addAll(reqVO.getInviteeIds());
  536 + } else {
  537 + groupMemberService.getActiveGroupMemberListByGroupId(reqVO.getGroupId())
  538 + .forEach(member -> initial.add(member.getUserId()));
  539 + }
  540 + // 不论来源,发起人本人不进被邀请池
  541 + initial.remove(inviterId);
  542 + // 超出最大同时在房成员数;截断为 max-1 个,避免邀请整个 500 人大群
  543 + // TODO @AI:超出,直接报错,不要裁剪;
  544 + int max = imProperties.getRtc().getGroupMaxParticipants();
  545 + if (initial.size() + 1 > max) {
  546 + return initial.stream().limit(max - 1L)
  547 + .collect(Collectors.toCollection(LinkedHashSet::new));
  548 + }
  549 + return initial;
  550 + }
  551 +
  552 + /** 推送来电信令;给除发起人外的所有被邀请人独立签 token */
  553 + private void pushInviteNotifications(ImCallSessionBO session, Long inviterId) {
  554 + AdminUserRespDTO inviter = adminUserApi.getUser(inviterId);
  555 + for (Long inviteeId : session.getInviteeIds()) {
  556 + ImRtcInviteNotification payload = buildInvitePayload(session, inviterId, inviter, inviteeId);
  557 + // TODO @AI:链式调用;在一行,相同的;
  558 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO()
  559 + .setType(ImMessageTypeEnum.RTC_INVITE.getType())
  560 + .setSenderId(inviterId)
  561 + .setReceiverId(inviteeId)
  562 + .setContent(JsonUtils.toJsonString(payload))
  563 + .setSendTime(LocalDateTime.now());
  564 + webSocketService.sendPrivateMessageAsync(inviteeId, dto);
  565 + }
  566 + }
  567 +
  568 + /** 组装单条来电载荷;invite / inviteMore 共用 */
  569 + private ImRtcInviteNotification buildInvitePayload(ImCallSessionBO session, Long inviterId,
  570 + AdminUserRespDTO inviter, Long inviteeId) {
  571 + // TODO @AI:链式调用,相同的在一行;
  572 + ImRtcInviteNotification payload = new ImRtcInviteNotification();
  573 + payload.setCallId(session.getCallId());
  574 + payload.setRoomName(session.getRoomName());
  575 + payload.setLivekitUrl(imProperties.getRtc().getLivekitUrl());
  576 + payload.setToken(signToken(inviteeId,
  577 + inviter == null ? null : inviter.getNickname(), session.getRoomName()));
  578 + payload.setScene(session.getScene());
  579 + payload.setMediaType(session.getMediaType());
  580 + payload.setInviterId(inviterId);
  581 + payload.setInviterNickname(inviter == null ? null : inviter.getNickname());
  582 + payload.setInviterAvatar(inviter == null ? null : inviter.getAvatar());
  583 + payload.setGroupId(session.getGroupId());
  584 + return payload;
  585 + }
  586 +
  587 + /** 推送接通信令;给除接听人外的所有相关方 */
  588 + private void pushAcceptNotification(ImCallSessionBO session, Long acceptorId) {
  589 + ImRtcAcceptNotification payload = new ImRtcAcceptNotification();
  590 + // TODO @AI:链式调用,相同的在一行;
  591 + payload.setCallId(session.getCallId());
  592 + payload.setRoomName(session.getRoomName());
  593 + payload.setAcceptorId(acceptorId);
  594 + for (Long target : session.getAllUserIds()) {
  595 + if (Objects.equals(target, acceptorId)) {
  596 + continue;
  597 + }
  598 + // TODO @AI:链式调用,相同的在一行;
  599 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO()
  600 + .setType(ImMessageTypeEnum.RTC_ACCEPT.getType())
  601 + .setSenderId(acceptorId)
  602 + .setReceiverId(target)
  603 + .setContent(JsonUtils.toJsonString(payload))
  604 + .setSendTime(LocalDateTime.now());
  605 + webSocketService.sendPrivateMessageAsync(target, dto);
  606 + }
  607 + }
  608 +
  609 + /** 推送结束信令 */
  610 + private void pushEndNotification(ImCallSessionBO session, Long operatorId,
  611 + ImCallEndReasonEnum reason, boolean roomTerminated) {
  612 + // TODO @AI:链式调用,相同的在一行;
  613 + ImRtcEndNotification payload = new ImRtcEndNotification();
  614 + payload.setCallId(session.getCallId());
  615 + payload.setRoomName(session.getRoomName());
  616 + payload.setOperatorId(operatorId);
  617 + payload.setReason(reason.getType());
  618 + if (roomTerminated && session.getStartTime() != null) {
  619 + payload.setDurationSeconds((System.currentTimeMillis() - session.getStartTime()) / 1000L);
  620 + }
  621 + Set<Long> targets = roomTerminated
  622 + ? session.getAllUserIds()
  623 + : CollUtil.newLinkedHashSet(session.getInviterId());
  624 + for (Long target : targets) {
  625 + // TODO @AI:链式调用,相同的在一行;
  626 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO()
  627 + .setType(ImMessageTypeEnum.RTC_END.getType())
  628 + .setSenderId(operatorId == null ? session.getInviterId() : operatorId)
  629 + .setReceiverId(target)
  630 + .setContent(JsonUtils.toJsonString(payload))
  631 + .setSendTime(LocalDateTime.now());
  632 + webSocketService.sendPrivateMessageAsync(target, dto);
  633 + }
  634 + }
  635 +
  636 + // TODO @AI:注释风格;
  637 + /** 关闭会话:状态切 ENDED → 推 RTC_END → 群通话再向全员广播 RTC_GROUP_ENDED → 从内存移除 */
  638 + private void endSession(ImCallSessionBO session, Long operatorId, ImCallEndReasonEnum reason) {
  639 + session.setStatus(ImCallStatusEnum.ENDED.getType());
  640 + pushEndNotification(session, operatorId, reason, true);
  641 + if (ImConversationTypeEnum.isGroup(session.getScene())) {
  642 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_ENDED.getType(), operatorId);
  643 + }
  644 + sessionStore.remove(session.getRoomName());
  645 + log.info("[endSession][roomName={} operator={} reason={}]",
  646 + session.getRoomName(), operatorId, reason);
  647 + }
  648 +
  649 + /**
  650 + * 群通话广播:给该群当前所有活跃成员推一条信号;用于胶囊条「N 人正在通话 / 开始 / 结束」实时同步
  651 + *
  652 + * @param session 会话;必须是群通话
  653 + * @param messageType 信号类型;取自 RTC_GROUP_STARTED / RTC_GROUP_UPDATED / RTC_GROUP_ENDED
  654 + * @param operatorId 触发本次广播的操作人;STARTED 为发起人,ENDED 为关房操作者,UPDATED 为加入 / 离开者
  655 + */
  656 + private void pushGroupBroadcast(ImCallSessionBO session, Integer messageType, Long operatorId) {
  657 + if (!ImConversationTypeEnum.isGroup(session.getScene()) || session.getGroupId() == null) {
  658 + return;
  659 + }
  660 + // TODO @AI:链式调用,相同的在一行;
  661 + ImRtcGroupNotification payload = new ImRtcGroupNotification();
  662 + payload.setCallId(session.getCallId());
  663 + payload.setRoomName(session.getRoomName());
  664 + payload.setGroupId(session.getGroupId());
  665 + payload.setMediaType(session.getMediaType());
  666 + payload.setInviterId(session.getInviterId());
  667 + payload.setJoinedUserIds(new LinkedHashSet<>(session.getJoinedUserIds()));
  668 + if (session.getInviteeIds() != null) {
  669 + payload.setInviteeIds(new LinkedHashSet<>(session.getInviteeIds()));
  670 + }
  671 + Long senderId = operatorId != null ? operatorId : session.getInviterId();
  672 + String content = JsonUtils.toJsonString(payload);
  673 + LocalDateTime now = LocalDateTime.now();
  674 + List<ImGroupMemberDO> members = groupMemberService
  675 + .getActiveGroupMemberListByGroupId(session.getGroupId());
  676 + for (ImGroupMemberDO member : members) {
  677 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO()
  678 + .setType(messageType)
  679 + .setSenderId(senderId)
  680 + .setReceiverId(member.getUserId())
  681 + .setContent(content)
  682 + .setSendTime(now);
  683 + webSocketService.sendPrivateMessageAsync(member.getUserId(), dto);
  684 + }
  685 + }
  686 +
  687 + // TODO @AI:注释风格;
  688 + /** 根据会话取出(不含 token 的)基础 VO */
  689 + private ImRtcCallRespVO toBaseVO(ImCallSessionBO session) {
  690 + return BeanUtils.toBean(session, ImRtcCallRespVO.class)
  691 + .setLivekitUrl(imProperties.getRtc().getLivekitUrl());
  692 + }
  693 +
  694 + /** 构造完整响应:含 token + livekitUrl + newCreated */
  695 + // TODO @AI:controller 组装;
  696 + private ImRtcCallRespVO buildResp(ImCallSessionBO session, Long userId, boolean newCreated) {
  697 + AdminUserRespDTO user = adminUserApi.getUser(userId);
  698 + String displayName = user == null ? null : user.getNickname();
  699 + ImRtcCallRespVO vo = toBaseVO(session);
  700 + vo.setToken(signToken(userId, displayName, session.getRoomName()));
  701 + vo.setNewCreated(newCreated);
  702 + return vo;
  703 + }
  704 +
  705 + /** 签 LiveKit Token;identity 用 userId 字符串保证多端唯一 */
  706 + private String signToken(Long userId, String displayName, String roomName) {
  707 + ImProperties.Rtc cfg = imProperties.getRtc();
  708 + return LiveKitTokenUtils.signJoinToken(
  709 + cfg.getApiKey(), cfg.getApiSecret(),
  710 + String.valueOf(userId), displayName,
  711 + roomName, Duration.ofHours(cfg.getTokenTtlHours()));
  712 + }
  713 +
  714 + // TODO @AI:方法名,改成项目里,符合预期的;
  715 + /** 拉取必存在的会话;不存在直接抛 */
  716 + private ImCallSessionBO requireSession(String roomName) {
  717 + ImCallSessionBO session = sessionStore.get(roomName);
  718 + if (session == null) {
  719 + throw exception(RTC_SESSION_NOT_EXISTS);
  720 + }
  721 + return session;
  722 + }
  723 +
  724 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java 0 → 100644
  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 0 → 100644
  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 0 → 100644
  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 0 → 100644
  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 0 → 100644
  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 0 → 100644
  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/rtc/dto/LiveKitWebhookEventDTO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.rtc.dto;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
  4 +import lombok.Data;
  5 +
  6 +// TODO @AI:注释风格;(这个是不是 VO 哈。不放在 VO )
  7 +/**
  8 + * LiveKit Webhook 事件载荷;只反序列化我们关心的字段,其余忽略
  9 + * <p>
  10 + * 文档参考:https://docs.livekit.io/home/server/webhook/
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Data
  15 +@JsonIgnoreProperties(ignoreUnknown = true)
  16 +public class LiveKitWebhookEventDTO {
  17 +
  18 + /** 事件类型;room_started / room_finished / participant_joined / participant_left / track_published 等 */
  19 + private String event;
  20 +
  21 + /** 事件 id;用于幂等去重 */
  22 + private String id;
  23 +
  24 + /** 房间元信息;room_started / room_finished 必填 */
  25 + private RoomInfo room;
  26 +
  27 + /** 参与者元信息;participant_* 事件必填 */
  28 + private ParticipantInfo participant;
  29 +
  30 + @Data
  31 + @JsonIgnoreProperties(ignoreUnknown = true)
  32 + public static class RoomInfo {
  33 + private String sid;
  34 + private String name;
  35 + private Long creationTime;
  36 + private Integer numParticipants;
  37 + }
  38 +
  39 + @Data
  40 + @JsonIgnoreProperties(ignoreUnknown = true)
  41 + public static class ParticipantInfo {
  42 + private String sid;
  43 + /** 用户身份;我们签 token 时写入 userId 字符串,后续支持多端会拼成 userId#terminal */
  44 + private String identity;
  45 + private String name;
  46 + private Long joinedAt;
  47 + }
  48 +
  49 +}