From bcac553c691fc302bc3b63408e1673d113b92f38 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 9 May 2026 15:23:07 +0800 Subject: [PATCH] ✨ feat(im): 基于 livekit 构建 im 通话(语音聊天、视频聊天、共享桌面)v0.1:推进中 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcWebhookController.java | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteMoreReqVO.java | 23 +++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java | 32 ++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java | 33 +++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java | 10 ++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java | 30 ++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java | 43 +++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java | 31 +++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java | 46 ++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java | 44 ++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java | 724 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java | 23 +++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java | 25 +++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java | 31 +++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java | 35 +++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 22 files changed, 1792 insertions(+), 0 deletions(-) create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcWebhookController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteMoreReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcAcceptNotification.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcEndNotification.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcGroupNotification.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/ImRtcInviteNotification.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/dto/LiveKitWebhookEventDTO.java diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java new file mode 100644 index 0000000..8a61e50 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 实时通话") +@RestController +@RequestMapping("/im/rtc") +@Validated +public class ImRtcCallController { + + @Resource + private ImRtcCallService rtcCallService; + + @PostMapping("/invite") + @Operation(summary = "发起通话;私聊或群聊根据 scene 区分") + public CommonResult invite(@Valid @RequestBody ImRtcCallInviteReqVO reqVO) { + return success(rtcCallService.invite(getLoginUserId(), reqVO)); + } + + @PostMapping("/invite-more") + @Operation(summary = "通话中添加成员;仅群通话可用") + public CommonResult inviteMore(@Valid @RequestBody ImRtcCallInviteMoreReqVO reqVO) { + rtcCallService.inviteMore(getLoginUserId(), reqVO); + return success(true); + } + + @PostMapping("/accept") + @Operation(summary = "接听通话") + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2") + public CommonResult accept(@RequestParam("roomName") String roomName) { + return success(rtcCallService.accept(getLoginUserId(), roomName)); + } + + @PostMapping("/reject") + @Operation(summary = "拒绝通话") + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2") + public CommonResult reject(@RequestParam("roomName") String roomName) { + rtcCallService.reject(getLoginUserId(), roomName); + return success(true); + } + + @PostMapping("/cancel") + @Operation(summary = "取消邀请;主叫接通前调用") + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2") + public CommonResult cancel(@RequestParam("roomName") String roomName) { + rtcCallService.cancel(getLoginUserId(), roomName); + return success(true); + } + + @PostMapping("/leave") + @Operation(summary = "离开通话;接通后调用") + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2") + public CommonResult leave(@RequestParam("roomName") String roomName) { + rtcCallService.leave(getLoginUserId(), roomName); + return success(true); + } + + @GetMapping("/refresh-token") + @Operation(summary = "重新签发 Token;客户端重连或 Token 过期续期") + @Parameter(name = "roomName", description = "LiveKit 房间名", required = true, example = "call_friend_1_2") + public CommonResult refreshToken(@RequestParam("roomName") String roomName) { + return success(rtcCallService.refreshToken(getLoginUserId(), roomName)); + } + + @GetMapping("/active-sessions") + @Operation(summary = "查询当前用户活跃通话;用于 App 冷启动 / 推送点开恢复") + public CommonResult> getActiveSessions() { + return success(rtcCallService.getActiveSessions(getLoginUserId())); + } + + @GetMapping("/group-active-call") + @Operation(summary = "查询群当前进行中的通话;用于群聊顶部「N 人正在通话」胶囊条") + @Parameter(name = "groupId", description = "群编号", required = true, example = "2048") + public CommonResult getGroupActiveCall(@RequestParam("groupId") Long groupId) { + return success(rtcCallService.getGroupActiveCall(groupId)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcWebhookController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcWebhookController.java new file mode 100644 index 0000000..6de096d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcWebhookController.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.util.StrUtil; +import cn.hutool.jwt.JWT; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import cn.iocoder.yudao.module.im.service.rtc.dto.LiveKitWebhookEventDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +// TODO @AI:是不是改成 ImRtcLiveKitController 更合适??? +/** + * LiveKit Webhook 入口 + *

+ * 安全由请求签名(JWT + body sha256)保证,不走登录鉴权;伪造请求会被签名校验直接拒绝 + * + * @author 芋道源码 + */ +@Tag(name = "LiveKit Webhook 回调") +@RestController +@RequestMapping("/im/livekit") +@Slf4j +public class ImRtcWebhookController { + + @Resource + private ImProperties imProperties; + @Resource + private ImRtcCallService rtcCallService; + + @PostMapping(value = "/webhook", consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "LiveKit Webhook 回调;接收成员离开 / 房间结束等事件做业务态兜底清理") + @PermitAll + public CommonResult webhook(HttpServletRequest request, @RequestBody String rawBody) { + // TODO @AI:1.1 1.2 这种 + // 1. 校验签名;伪造请求直接 200 但忽略,避免给攻击者反馈 + if (!verifySignature(request, rawBody)) { + log.warn("[livekit-webhook][签名校验失败 ip={} body={}]", request.getRemoteAddr(), rawBody); + return success(false); + } + // 2. 解析事件并交给 service 处理;幂等由 service 自己保证 + LiveKitWebhookEventDTO event = JsonUtils.parseObject(rawBody, LiveKitWebhookEventDTO.class); + if (event == null || StrUtil.isBlank(event.getEvent())) { + return success(false); + } + + // TODO @AI:2. ;这里是不是要打个 logger info? + try { + rtcCallService.handleLiveKitEvent(event); + } catch (Exception e) { + log.error("[livekit-webhook][事件处理失败 event={} body={}]", event.getEvent(), rawBody, e); + } + return success(true); + } + + // TODO @AI:是不是给 LiveKitClient 校验? + /** + * 校验 LiveKit Webhook 签名;流程参见 + * https://docs.livekit.io/home/server/webhook/#receiving-webhooks + */ + private boolean verifySignature(HttpServletRequest request, String rawBody) { + String authHeader = request.getHeader("Authorization"); + if (StrUtil.isBlank(authHeader)) { + return false; + } + String token = StrUtil.removePrefix(authHeader, "Bearer ").trim(); + ImProperties.Rtc cfg = imProperties.getRtc(); + try { + JWT jwt = JWT.of(token); + // 1. JWT HS256 签名;密钥使用 LiveKit API Secret + if (!jwt.setKey(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8)).verify()) { + return false; + } + // 2. body sha256 一致性校验;防止抓到 token 后篡改 body + Object expectedSha = jwt.getPayload("sha256"); + if (expectedSha == null) { + return false; + } + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(rawBody.getBytes(StandardCharsets.UTF_8)); + String actualSha = Base64.encode(digest); + return Objects.equals(expectedSha.toString(), actualSha); + } catch (Exception e) { + // TODO @AI:是不是要加个什么 request 的参数,方便溯源? + log.warn("[livekit-webhook][签名解析异常]", e); + return false; + } + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteMoreReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteMoreReqVO.java new file mode 100644 index 0000000..901348d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteMoreReqVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 通话中添加成员 Request VO") +@Data +public class ImRtcCallInviteMoreReqVO { + + @Schema(description = "LiveKit 房间名", requiredMode = Schema.RequiredMode.REQUIRED, example = "call_group_2048") + @NotBlank(message = "房间名不能为空") + private String roomName; + + // TODO @AI:targetIds? + @Schema(description = "新邀请的用户编号集合", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "请至少选择一位成员") + private Set inviteeIds; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java new file mode 100644 index 0000000..0e8b029 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 发起通话 Request VO") +@Data +public class ImRtcCallInviteReqVO { + + // TODO @AI:换成 conversationType + @Schema(description = "会话场景", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "会话场景不能为空") + private Integer scene; // 参见 ImConversationTypeEnum 枚举类 + + @Schema(description = "媒体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "媒体类型不能为空") + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类 + + // TODO @AI:是不是融合到 inviteeIds 里? + @Schema(description = "对方用户编号;私聊场景必填", example = "1024") + private Long peerUserId; + + @Schema(description = "群编号;群聊场景必填", example = "2048") + private Long groupId; + + @Schema(description = "被邀请的用户编号集合;群聊场景由前端选成员后传入;为空时回退到拉群活跃成员") + private Set inviteeIds; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java new file mode 100644 index 0000000..ad52c02 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 通话会话 Response VO;invite / accept / refreshToken / getActiveSessions 共用") +@Data +public class ImRtcCallRespVO { + + @Schema(description = "业务通话编号") + private String callId; + + @Schema(description = "LiveKit 房间名") + private String roomName; + + @Schema(description = "LiveKit Server WebSocket 地址;前端 connect 用") + private String livekitUrl; + + @Schema(description = "LiveKit 接入 Token;getActiveSessions 列表里为 null,需要的话单独调 refreshToken") + private String token; + + @Schema(description = "会话场景") + private Integer scene; // 参见 ImConversationTypeEnum 枚举类 + + @Schema(description = "媒体类型") + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类 + + @Schema(description = "状态") + private Integer status; // 参见 ImCallStatusEnum 枚举类 + + @Schema(description = "发起人编号") + private Long inviterId; + + @Schema(description = "群编号;群通话才有") + private Long groupId; + + @Schema(description = "被邀请人编号集合") + private Set inviteeIds; + + @Schema(description = "已加入房间的成员编号集合") + private Set joinedUserIds; + + @Schema(description = "是否本次新建;true 主叫弹「等待对方接受」;false 直接进通话中(已有房间)") + private Boolean newCreated; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java new file mode 100644 index 0000000..9898317 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcGroupCallRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Set; + +@Schema(description = "管理后台 - 群活跃通话 Response VO;不含 token,胶囊条仅展示用") +@Data +public class ImRtcGroupCallRespVO { + + @Schema(description = "业务通话编号") + private String callId; + + @Schema(description = "LiveKit 房间名") + private String roomName; + + @Schema(description = "群编号") + private Long groupId; + + @Schema(description = "媒体类型") + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类 + + @Schema(description = "发起人编号") + private Long inviterId; + + @Schema(description = "已加入房间的用户编号集合") + private Set joinedUserIds; + + @Schema(description = "被邀请池;用于胶囊条展开时显示待加入头像") + private Set inviteeIds; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java index 213ff2a..f5d686b 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java @@ -80,4 +80,14 @@ public interface ErrorCodeConstants { ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情"); ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情"); + // ========== 实时通话 (1-040-900-000) ========== + ErrorCode RTC_NOT_ENABLED = new ErrorCode(1_040_900_000, "通话功能未开启"); + ErrorCode RTC_SESSION_NOT_EXISTS = new ErrorCode(1_040_900_001, "通话已结束"); + ErrorCode RTC_PEER_BUSY = new ErrorCode(1_040_900_002, "对方正在通话中"); + ErrorCode RTC_SELF_BUSY = new ErrorCode(1_040_900_003, "您正在通话中"); + ErrorCode RTC_NOT_PARTICIPANT = new ErrorCode(1_040_900_004, "您不在该通话中"); + ErrorCode RTC_INVITE_SELF = new ErrorCode(1_040_900_005, "不能呼叫自己"); + ErrorCode RTC_PRIVATE_INVITEE_REQUIRED = new ErrorCode(1_040_900_006, "私聊通话必须指定对方"); + ErrorCode RTC_GROUP_REQUIRED = new ErrorCode(1_040_900_007, "群聊通话必须指定群编号"); + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java index 7de9c6f..3257d0f 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java @@ -78,6 +78,36 @@ public enum ImMessageTypeEnum implements ArrayValuable { */ READ(2201, "已读", false, false), + // ========== 实时通话信令(2300-2302) ========== + // TODO @AI:openim 的信号,是怎么设计的?感觉对齐下,会好一点点,不然用户不好拓展; + /** + * 通话邀请:主叫发起后推给被叫弹「来电」界面;payload 为 RtcInviteNotification(roomName / mediaType / scene / inviter / token) + */ + RTC_INVITE(2300, "通话邀请", false, false), + /** + * 通话已接通:被叫接听后推给主叫切换到「通话中」UI;payload 为 RtcAcceptNotification(roomName / acceptorId) + */ + RTC_ACCEPT(2301, "通话接通", false, false), + /** + * 通话结束:拒绝 / 取消 / 挂断 / 超时 / 异常 统一走这一条;payload 为 RtcEndNotification(roomName / reason / operatorId) + */ + RTC_END(2302, "通话结束", false, false), + + // ========== 群通话广播信号(2310-2312):让所有群成员能感知 / 主动加入 ========== + /** + * 群通话开始:群通话发起时给所有群成员广播;payload 为 ImRtcGroupNotification(callId / roomName / mediaType / inviterId / joinedUserIds) + */ + RTC_GROUP_STARTED(2310, "群通话开始", false, false), + /** + * 群通话结束:最后一人离开 / 异常关房时广播;payload 仅含 roomName / groupId + */ + RTC_GROUP_ENDED(2311, "群通话结束", false, false), + /** + * 群通话成员变更:有人加入 / 离开时广播;payload 为 ImRtcGroupNotification(含最新 joinedUserIds); + * 用于胶囊条上「N 人正在通话」实时刷新 + */ + RTC_GROUP_UPDATED(2312, "群通话成员变更", false, false), + // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ========== // TODO @芋艿:FRIEND_REQUEST_* 与 GROUP_REQUEST_* 都是 persistent=false 的 SysMsg,离线 pull 拉不到, // 目前可能丢失「实时 toast 提醒」体验(业务状态不丢,前端上线 fetch{Friend,Group}RequestList 能补回来); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java new file mode 100644 index 0000000..01edabd --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallEndReasonEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 通话结束原因枚举;用于落历史消息时计算 content 文案 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImCallEndReasonEnum implements ArrayValuable { + + // TODO @AI:这个对齐的哪个东西; + // TODO @AI:尾注释; + /** 正常挂断:双方在通话中后由任一方主动挂断 */ + HANGUP(1, "通话结束"), + /** 拒绝:被叫接通前点拒接 */ + REJECT(2, "已拒绝"), + /** 取消:主叫接通前主动取消 */ + CANCEL(3, "已取消"), + /** 超时:振铃 30 秒未应答 */ + TIMEOUT(4, "未接通"), + /** 对方忙线 */ + BUSY(5, "对方正忙"), + /** 异常:网络中断、设备失败等 */ + ERROR(9, "通话异常"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallEndReasonEnum::getType).toArray(Integer[]::new); + + private final Integer type; + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java new file mode 100644 index 0000000..2468f7e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallMediaTypeEnum.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 通话媒体类型枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImCallMediaTypeEnum implements ArrayValuable { + + VOICE(1, "语音"), + VIDEO(2, "视频"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallMediaTypeEnum::getType).toArray(Integer[]::new); + + private final Integer type; + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java new file mode 100644 index 0000000..4088208 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImCallStatusEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.im.enums.rtc; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Objects; + +// TODO @AI:是否一定要有 status 状态的记录????【可以在调研下】 +/** + * IM 通话状态枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImCallStatusEnum implements ArrayValuable { + + INVITING(10, "邀请中"), // 邀请中:1v1 主叫等待被叫接听 + ONGOING(20, "通话中"), // 通话中:至少一名被叫已接听;群通话从发起人加入即进入此状态 + ENDED(30, "已结束"); // 已结束:会话即将销毁;写入消息流后从内存移除 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImCallStatusEnum::getType).toArray(Integer[]::new); + + private final Integer type; + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isInviting(Integer type) { + return Objects.equals(INVITING.type, type); + } + + public static boolean isOngoing(Integer type) { + return Objects.equals(ONGOING.type, type); + } + + public static boolean isEnded(Integer type) { + return Objects.equals(ENDED.type, type); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java index 0d65caf..e0ca205 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java @@ -4,6 +4,7 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +// TODO @AI:参考别的模块;是不是也要分 im;config 和 core 这样;在瞅瞅; /** * IM 模块全局配置 *

@@ -23,6 +24,8 @@ public class ImProperties { private Message message = new Message(); + private Rtc rtc = new Rtc(); + /** * 好友模块配置 */ @@ -114,4 +117,45 @@ public class ImProperties { } + /** + * 实时通话模块配置 + *

+ * 媒体走 LiveKit SFU;后端只签 Token + 通过 IM 长连接推送来电 / 接通 / 结束三种信令。 + * 关闭后所有 RTC 接口直接抛 RTC_NOT_ENABLED;前端可据此隐藏通话按钮。 + */ + @Data + public static class Rtc { + + /** + * 是否启用实时通话功能 + */ + private boolean enabled = true; + + /** + * LiveKit Server WebSocket 地址;客户端 connect 时使用,通常 ws://host:7880 或 wss://host + */ + private String livekitUrl = "ws://127.0.0.1:7880"; + + /** + * LiveKit API Key + */ + private String apiKey = "devkey"; + + /** + * LiveKit API Secret;生产必须改为强随机值 + */ + private String apiSecret = "secret-poc-key-min-32-chars-required-here"; + + /** + * 单次签发的 Token 有效期(小时) + */ + private int tokenTtlHours = 6; + + /** + * 群通话最大同时在房成员数;超过 invite 直接拒绝 + */ + private int groupMaxParticipants = 16; + + } + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java new file mode 100644 index 0000000..306654e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/rtc/core/LiveKitTokenUtils.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.im.framework.rtc.core; + +import cn.hutool.core.lang.Assert; +import cn.hutool.jwt.JWT; +import cn.hutool.jwt.signers.JWTSignerUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +// TODO @AI:是不是搞成 LiveKitClient;然后 config 类去创建起来;通过 @Resource 注入进去? +/** + * LiveKit Access Token 签发工具 + *

+ * LiveKit 协议把权限放在 video claim 里;这里只暴露通话场景需要的最小权限:roomJoin / canPublish / + * canSubscribe / canPublishData,避免 PoC 阶段误开 roomAdmin。生产想细分会议主持人时再扩字段。 + * + * @author 芋道源码 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LiveKitTokenUtils { + + /** + * 签发客户端进房 Token + * + * @param apiKey LiveKit API Key;写入 iss claim + * @param apiSecret LiveKit API Secret;HS256 签名密钥;长度必须 ≥ 32 + * @param identity 用户唯一标识;写入 sub claim;同 identity 重连会踢前一个连接 + * @param displayName 客户端展示名;可空 + * @param roomName 房间名 + * @param ttl 有效期 + * @return JWT 字符串 + */ + public static String signJoinToken(String apiKey, String apiSecret, String identity, + String displayName, String roomName, Duration ttl) { + Assert.notBlank(apiKey, "apiKey 不可为空"); + Assert.notBlank(apiSecret, "apiSecret 不可为空"); + Assert.isTrue(apiSecret.length() >= 32, "apiSecret 长度需 ≥ 32 位"); + Assert.notBlank(identity, "identity 不可为空"); + Assert.notBlank(roomName, "roomName 不可为空"); + Assert.notNull(ttl, "ttl 不可为空"); + + // TODO @AI:这里注释下; + Map video = new HashMap<>(); + video.put("roomJoin", true); // TODO @AI:这里帮忙注释下 + video.put("room", roomName); // TODO @AI:这里帮忙注释下 + video.put("canPublish", true); // TODO @AI:这里帮忙注释下 + video.put("canSubscribe", true); // TODO @AI:这里帮忙注释下 + video.put("canPublishData", true); // TODO @AI:这里帮忙注释下 + + // TODO @AI:这里注释下; + // TODO @AI:是不是 new Date;然后下面去处理,更合理?或者 System currentTime 这种? + long now = Instant.now().getEpochSecond(); + JWT jwt = JWT.create() + .setIssuer(apiKey) + .setSubject(identity) + .setNotBefore(new Date(now * 1000)) + .setExpiresAt(new Date((now + ttl.getSeconds()) * 1000)) + .setPayload("video", video) + .setSigner(JWTSignerUtil.hs256(apiSecret.getBytes())); + // TODO @AI:empty 处理下; + if (displayName != null && !displayName.isEmpty()) { + jwt.setPayload("name", displayName); + } + return jwt.sign(); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java new file mode 100644 index 0000000..61f2c7e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.im.service.rtc; + +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO; + +import java.util.List; + +// TODO @AI:一个好友,只能一个通话;一个群,只能一个通话;(同一时间); +/** + * IM 实时通话 Service + *

+ * 业务模型:一个好友对 / 一个群同时只能有一个进行中的通话;invite 兼具「创建」与「加入已有」两种语义。 + * 媒体协商完全交给 LiveKit;后端只做:会话状态机、Token 签发、来电信令推送、通话历史落消息流。 + * + * @author 芋道源码 + */ +public interface ImRtcCallService { + + // TODO @AI:是不是风格上,还是有 create 和 invite;这样两种逻辑上更好理解; + /** + * 发起通话;同一 roomName 已有进行中通话时返回该会话并标记 newCreated=false + * + * @param userId 操作人编号;通常是当前登录用户 + * @param reqVO 请求参数(scene / mediaType / peerUserId 或 groupId) + * @return 包含 token / roomName / livekitUrl 的会话信息 + */ + ImRtcCallRespVO invite(Long userId, ImRtcCallInviteReqVO reqVO); + + /** + * 通话中追加成员;仅群通话场景可用;给新邀请人推 RTC_INVITE + * + * @param userId 操作人编号;必须是当前会话参与者 + * @param reqVO 房间名 + 新邀请的用户编号集合 + */ + void inviteMore(Long userId, ImRtcCallInviteMoreReqVO reqVO); + + /** + * 接听通话;私聊状态切换 INVITING → ONGOING;群通话直接加入已有 ONGOING 房间 + */ + ImRtcCallRespVO accept(Long userId, String roomName); + + /** + * 拒绝通话;仅 INVITING 状态可拒;群通话拒绝等同于不参与,房间仍存在 + */ + void reject(Long userId, String roomName); + + /** + * 取消邀请;主叫在 INVITING 状态主动取消 + */ + void cancel(Long userId, String roomName); + + /** + * 离开通话;ONGOING 状态下离开;私聊任一方离开 = 通话结束;群通话最后一人离开才结束 + */ + void leave(Long userId, String roomName); + + /** + * 重新签发 Token;用于客户端重连或 Token 过期续期 + */ + ImRtcCallRespVO refreshToken(Long userId, String roomName); + + /** + * 列出用户当前的活跃通话;用于 App 冷启动 / 推送点开恢复 + */ + List getActiveSessions(Long userId); + + /** + * 查询群当前正在进行的通话;用于群聊顶部「N 人正在通话」胶囊条 + * + * @param groupId 群编号 + * @return 群活跃通话;不存在返回 null;不含 token,仅展示用 + */ + ImRtcGroupCallRespVO getGroupActiveCall(Long groupId); + + /** + * 处理 LiveKit Webhook 事件;用于关 tab / 强杀 / 网络断开等异常退出场景的兜底清理 + *

+ * 关键事件:participant_left(成员离开) / room_finished(房间结束)。前端正常 leave 时 + * 也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。 + */ + void handleLiveKitEvent(cn.iocoder.yudao.module.im.service.rtc.dto.LiveKitWebhookEventDTO event); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java new file mode 100644 index 0000000..83eb0fa --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java @@ -0,0 +1,724 @@ +package cn.iocoder.yudao.module.im.service.rtc; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO; +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImCallEndReasonEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImCallStatusEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitTokenUtils; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.rtc.bo.ImCallSessionBO; +import cn.iocoder.yudao.module.im.service.rtc.bo.ImCallSessionStore; +import cn.iocoder.yudao.module.im.service.rtc.dto.*; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; + +/** + * IM 实时通话 Service 实现 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImRtcCallServiceImpl implements ImRtcCallService { + + // TODO @AI:后续存储到 DB 里;(晚点改) + @Resource + private ImCallSessionStore sessionStore; + + @Resource + private ImGroupMemberService groupMemberService; + @Resource + private ImFriendService friendService; + @Resource + private ImWebSocketService webSocketService; + + @Resource + private AdminUserApi adminUserApi; + + @Resource + private ImProperties imProperties; + + @Override + public ImRtcCallRespVO invite(Long userId, ImRtcCallInviteReqVO reqVO) { + // TODO @AI:按照 1.1 1.2 1.3 这样搞; + ensureEnabled(); + Integer scene = reqVO.getScene(); + // 1. 校验入参与场景 + validateInviteScene(userId, reqVO); + // 2. 派生 roomName;同好友对 / 同群只对应一个 roomName + String roomName = deriveRoomName(scene, userId, reqVO.getPeerUserId(), reqVO.getGroupId()); + // 3. 用户级忙线检查;当前用户已在另一通话且不是同房间则拒绝 + String selfActive = sessionStore.getActiveRoom(userId); + // TODO @AI:notEquals 方案; + if (selfActive != null && !selfActive.equals(roomName)) { + throw exception(RTC_SELF_BUSY); + } + // 4. 私聊场景下检查被叫忙线;群通话候选成员各自决定接听,不在此拦截 + // TODO @AI:需要在看看,群聊会不会出现这个情况?感觉应该也会,可以调研下微信,以及其他 im + if (ImConversationTypeEnum.isPrivate(scene) + && sessionStore.isBusy(reqVO.getPeerUserId())) { + String peerActive = sessionStore.getActiveRoom(reqVO.getPeerUserId()); + // TODO @AI:notEquals 方案; + if (peerActive != null && !peerActive.equals(roomName)) { + throw exception(RTC_PEER_BUSY); + } + } + + // 5. 原子获取或创建会话;并发同时点呼叫只会建一份 + boolean[] newCreated = {false}; + ImCallSessionBO session = sessionStore.getOrCreate(roomName, key -> { + newCreated[0] = true; + return buildNewSession(key, userId, reqVO); + }); + // 6. 老 session:本次属于"加入已有通话";新 session:发邀请信令并把发起人加入房间 + sessionStore.bindUser(userId, roomName); + session.getJoinedUserIds().add(userId); + if (newCreated[0]) { + pushInviteNotifications(session, userId); + // 群通话首次发起:向全群广播 STARTED,胶囊条立刻在所有成员客户端出现 + if (ImConversationTypeEnum.isGroup(session.getScene())) { + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_STARTED.getType(), userId); + } + } else if (ImConversationTypeEnum.isGroup(session.getScene())) { + // 群通话主动加入已有通话:广播 UPDATED 让胶囊条人数刷新 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + } + // 7. 给当前调用方签 token + return buildResp(session, userId, newCreated[0]); + } + + @Override + public ImRtcCallRespVO accept(Long userId, String roomName) { + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) + ensureEnabled(); + ImCallSessionBO session = requireSession(roomName); + // TODO @AI:减少 ONGOING、INVITING 的混用;最好是中文(英文),大家更好理解。 + // 群通话处于 ONGOING;私聊必须 INVITING 才能接听 + if (ImConversationTypeEnum.isPrivate(session.getScene()) + && !ImCallStatusEnum.isInviting(session.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + if (!session.containsUser(userId)) { + throw exception(RTC_NOT_PARTICIPANT); + } + // 私聊接听 → 切到 ONGOING;群通话已经是 ONGOING;幂等:重复 accept 不报错 + if (ImConversationTypeEnum.isPrivate(session.getScene())) { + session.setStatus(ImCallStatusEnum.ONGOING.getType()); + // 振铃定时器在前端,后端不做 cancel 动作 + } + session.getJoinedUserIds().add(userId); + sessionStore.bindUser(userId, roomName); + // 推 RTC_ACCEPT 给除自己外的全部相关方 + pushAcceptNotification(session, userId); + // 群通话有人接听 → 全群广播 UPDATED 刷新胶囊条 + if (ImConversationTypeEnum.isGroup(session.getScene())) { + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + } + return buildResp(session, userId, false); + } + + @Override + public void reject(Long userId, String roomName) { + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) + ensureEnabled(); // TODO @AI:是不是只有发起校验,其它地方先不校验了; + ImCallSessionBO session = requireSession(roomName); + if (!session.containsUser(userId)) { + throw exception(RTC_NOT_PARTICIPANT); + } + // 群通话拒绝:不影响其他成员,房间继续存在 + if (ImConversationTypeEnum.isGroup(session.getScene())) { + session.getRejectedUserIds().add(userId); + sessionStore.unbindUserFromRoom(userId, roomName); + // 仅广播 UPDATED;之前向主叫推 RTC_END 会被前端错误识别为「整个通话结束」并 reset 主叫的通话窗 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + return; + } + // 私聊拒绝:仅 INVITING 状态可拒;推 RTC_END 给所有相关方并关房 + if (!ImCallStatusEnum.isInviting(session.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + endSession(session, userId, ImCallEndReasonEnum.REJECT); + } + + @Override + public void cancel(Long userId, String roomName) { + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) + ensureEnabled(); + ImCallSessionBO session = requireSession(roomName); + if (!Objects.equals(session.getInviterId(), userId)) { + throw exception(RTC_NOT_PARTICIPANT); + } + // 仅 INVITING 状态可取消;ONGOING 应走 leave + if (!ImCallStatusEnum.isInviting(session.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + endSession(session, userId, ImCallEndReasonEnum.CANCEL); + } + + @Override + public void leave(Long userId, String roomName) { + // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) + ensureEnabled(); + ImCallSessionBO session = requireSession(roomName); + if (!session.containsUser(userId)) { + throw exception(RTC_NOT_PARTICIPANT); + } + session.getLeftUserIds().add(userId); + session.getJoinedUserIds().remove(userId); + sessionStore.unbindUserFromRoom(userId, roomName); + // 关房条件:私聊任一方离开 = 关;群通话仅当所有被邀请人都已应答 + 在房无人时才关 + // 发起人离开后被邀请人仍可继续接听,房间不应当立即销毁 + // TODO @AI:单聊、群聊,是否可以复用一个逻辑;private 本质,只是一个特殊的方式而已;如果 private 后续支持邀请人,那 private 这个判断,就是 bug 了呀。其它地方,看看是不是也调整下; + if (ImConversationTypeEnum.isPrivate(session.getScene()) + || shouldCloseGroupRoom(session)) { + endSession(session, userId, ImCallEndReasonEnum.HANGUP); + return; + } + // 群通话单人离开:仅广播 UPDATED;不推 RTC_END 避免主叫误关通话窗 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + } + + /** + * 群通话是否应该关闭:在房成员空 + 没有还在响铃的被邀请人 + *

+ * 仅 INVITED 状态下未接受 / 拒绝 / 离开的成员视为「还在响铃」;这样发起人提前离开后,被邀请人仍能接听并独立组成通话,符合微信群通话语义 + */ + private boolean shouldCloseGroupRoom(ImCallSessionBO session) { + // TODO @AI:CollUtil + if (!session.getJoinedUserIds().isEmpty()) { + return false; + } + // TODO @AI:CollUtil? + if (session.getInviteeIds() == null) { + return true; + } + // TODO @AI:exists 或者哪种?这样更简洁一点。 + for (Long id : session.getInviteeIds()) { + boolean responded = session.getRejectedUserIds().contains(id) + || session.getLeftUserIds().contains(id); + if (!responded) { + return false; + } + } + return true; + } + + @Override + public ImRtcCallRespVO refreshToken(Long userId, String roomName) { + ensureEnabled(); // TODO @AI:简化下; + ImCallSessionBO session = requireSession(roomName); + if (!session.containsUser(userId)) { + throw exception(RTC_NOT_PARTICIPANT); + } + return buildResp(session, userId, false); + } + + @Override + public void inviteMore(Long userId, ImRtcCallInviteMoreReqVO reqVO) { + ensureEnabled(); + ImCallSessionBO session = requireSession(reqVO.getRoomName()); + // 仅群通话支持追加邀请 + // TODO @AI:如果放开私聊的邀请,代码改动量大么?(这里只讨论,不用改) + if (!ImConversationTypeEnum.isGroup(session.getScene())) { + throw exception(RTC_GROUP_REQUIRED); + } + // 操作者必须是已在房间的参与者 + if (!session.getJoinedUserIds().contains(userId)) { + throw exception(RTC_NOT_PARTICIPANT); + } + // 过滤已在被邀请池或已加入的人;仅给新成员发邀请 + Set existing = new LinkedHashSet<>(); + if (session.getInviteeIds() != null) { + existing.addAll(session.getInviteeIds()); + } + existing.addAll(session.getJoinedUserIds()); + // 校验被邀请人必须是该群活跃成员;防止操作者夹带任意 userId 进群房间 + // TODO @AI:如果放开私聊的邀请,代码改动量大么?(这里只讨论,不用改) + Set validMemberIds = new LinkedHashSet<>( + groupMemberService.getActiveGroupMemberUserIdsByGroupId(session.getGroupId())); + Set incoming = new LinkedHashSet<>(); + for (Long id : reqVO.getInviteeIds()) { + if (!existing.contains(id) && validMemberIds.contains(id)) { + incoming.add(id); + } + } + // TODO @AI:CollUtil isempty 判断; + if (incoming.isEmpty()) { + return; + } + if (session.getInviteeIds() == null) { + session.setInviteeIds(new LinkedHashSet<>()); + } + session.getInviteeIds().addAll(incoming); + // 给新人推 RTC_INVITE;签 token 复用 invite 的逻辑 + AdminUserRespDTO inviter = adminUserApi.getUser(userId); + for (Long inviteeId : incoming) { + ImRtcInviteNotification payload = buildInvitePayload(session, userId, inviter, inviteeId); + // TODO @AI:链式调用,相同的在一行; + ImPrivateMessageDTO dto = new ImPrivateMessageDTO() + .setType(ImMessageTypeEnum.RTC_INVITE.getType()) + .setSenderId(userId) + .setReceiverId(inviteeId) + .setContent(JsonUtils.toJsonString(payload)) + .setSendTime(LocalDateTime.now()); + webSocketService.sendPrivateMessageAsync(inviteeId, dto); + } + // 全群广播 UPDATED;让胶囊条上「等待加入」头像同步 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + } + + @Override + public List getActiveSessions(Long userId) { + // TODO @AI:报错,前端就不应该发起; + if (!imProperties.getRtc().isEnabled()) { + return Collections.emptyList(); + } + String roomName = sessionStore.getActiveRoom(userId); + if (StrUtil.isBlank(roomName)) { + return Collections.emptyList(); + } + ImCallSessionBO session = sessionStore.get(roomName); + if (session == null) { + return Collections.emptyList(); + } + + // TODO @AI:返回 ImCallSessionBO?controller 拼接?(后面在改,所有涉及到各种翻译的地方) + // 列表场景不签 token;前端要 token 时再调 refreshToken + ImRtcCallRespVO vo = toBaseVO(session); + return Collections.singletonList(vo); + } + + @Override + public void handleLiveKitEvent(LiveKitWebhookEventDTO event) { + // TODO @AI:需要 try catch 记录 hook + 成功 or 失败的日志么? + if (event == null || event.getEvent() == null) { + return; + } + // TODO @AI:需要检验 jdk8 语法; + // TODO @AI:livekit webhookevent 是不是搞个枚举类? + switch (event.getEvent()) { + case "participant_left" -> handleParticipantLeft(event); + case "room_finished" -> handleRoomFinished(event); + default -> { + // TODO @AI:仅仅 logger,后续按需搞; + /* 其它事件忽略;participant_joined / track_published 等业务态由 LiveKit 自身分发驱动 */ + } + } + } + + // TODO @AI:方法注释,里面的注释,按照 1. 2. 3. 阅读性更好 + // TODO @AI:处理必要的代码空行;阅读性更好(但是不要太多空行,层次感缺少) + /** + * 处理 LiveKit「成员离开」事件;正常 leave 接口已经清理过的话此处幂等忽略 + * 私聊任一方离开 → 结束通话;群通话仅一人离开 → 广播;最后一人离开 → 结束通话 + */ + private void handleParticipantLeft(LiveKitWebhookEventDTO event) { + if (event.getRoom() == null || event.getParticipant() == null) { + return; + } + String roomName = event.getRoom().getName(); + ImCallSessionBO session = sessionStore.get(roomName); + if (session == null) { + return; + } + Long userId = parseUserIdFromIdentity(event.getParticipant().getIdentity()); + if (userId == null) { + return; + } + // 业务态可能已被 leave 接口清掉;contains 判断保证幂等 + boolean wasInRoom = session.getJoinedUserIds().remove(userId); + sessionStore.unbindUserFromRoom(userId, roomName); + if (!wasInRoom) { + return; + } + session.getLeftUserIds().add(userId); + log.info("[handleParticipantLeft][roomName={} userId={} 由 LiveKit Webhook 兜底清理]", roomName, userId); + // 关房条件与 leave 一致;群通话仅在「无人在房 + 无人响铃」时关 + if (ImConversationTypeEnum.isPrivate(session.getScene()) + || shouldCloseGroupRoom(session)) { + endSession(session, userId, ImCallEndReasonEnum.HANGUP); + return; + } + // 群通话单人异常离开:仅广播 UPDATED;同 leave 路径,避免主叫误关 + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + } + + // TODO @AI:方法注释,里面的注释,按照 1. 2. 3. 阅读性更好 + // TODO @AI:处理必要的代码空行;阅读性更好(但是不要太多空行,层次感缺少) + /** 处理「房间结束」事件;兜底从 sessionStore 移除残留会话 */ + private void handleRoomFinished(LiveKitWebhookEventDTO event) { + if (event.getRoom() == null) { + return; + } + String roomName = event.getRoom().getName(); + ImCallSessionBO session = sessionStore.get(roomName); + if (session == null) { + return; + } + log.info("[handleRoomFinished][roomName={} 由 LiveKit Webhook 兜底清理]", roomName); + // 复用 endSession:状态切 ENDED + 推 RTC_END + 群通话广播 ENDED + 移除内存索引 + endSession(session, null, ImCallEndReasonEnum.HANGUP); + } + + // TODO @AI:这里多端是必须的么?如果非必须,就先不用了。。。 + /** + * 从 LiveKit identity 解析业务 userId + *

+ * 当前 identity 直接是 userId 字符串;预留 userId#terminal 多端格式的解析支持 + */ + private Long parseUserIdFromIdentity(String identity) { + if (identity == null || identity.isEmpty()) { + return null; + } + int sep = identity.indexOf('#'); + String idPart = sep >= 0 ? identity.substring(0, sep) : identity; + try { + return Long.parseLong(idPart); + } catch (NumberFormatException e) { + log.warn("[parseUserIdFromIdentity][非法 identity={}]", identity); + return null; + } + } + + @Override + public ImRtcGroupCallRespVO getGroupActiveCall(Long groupId) { + if (!imProperties.getRtc().isEnabled() || groupId == null) { + return null; + } + ImCallSessionBO session = sessionStore.get("call_group_" + groupId); + if (session == null) { + return null; + } + // TODO @AI:链式调用; + // TODO @AI:后续看看,能不能改成 controller 处理; + ImRtcGroupCallRespVO vo = new ImRtcGroupCallRespVO(); + vo.setCallId(session.getCallId()); + vo.setRoomName(session.getRoomName()); + vo.setGroupId(session.getGroupId()); + vo.setMediaType(session.getMediaType()); + vo.setInviterId(session.getInviterId()); + vo.setJoinedUserIds(new LinkedHashSet<>(session.getJoinedUserIds())); + if (session.getInviteeIds() != null) { + vo.setInviteeIds(new LinkedHashSet<>(session.getInviteeIds())); + } + return vo; + } + + // ========== 内部辅助 ========== + + /** 校验通话功能开关 */ + private void ensureEnabled() { + if (!imProperties.getRtc().isEnabled()) { + throw exception(RTC_NOT_ENABLED); + } + } + + /** 校验 invite 入参;按场景区分必填字段 + 群通话校验当前用户是群活跃成员 */ + private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) { + Integer scene = reqVO.getScene(); + if (ImConversationTypeEnum.isPrivate(scene)) { + // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue; + if (reqVO.getPeerUserId() == null) { + throw exception(RTC_PRIVATE_INVITEE_REQUIRED); + } + if (Objects.equals(userId, reqVO.getPeerUserId())) { + throw exception(RTC_INVITE_SELF); + } + // 好友 / 黑名单校验:和私聊消息发送同一套语义;NONE 已删 / 未加,BLOCKED 被对方拉黑 + // TODO @AI:是不是抽一个方法?validateFriendXXX(因为有多处调用); + ImFriendStateEnum state = friendService.getFriendState(userId, reqVO.getPeerUserId()); + if (state == ImFriendStateEnum.NONE) { + throw exception(FRIEND_NOT_FRIEND); + } + if (state == ImFriendStateEnum.BLOCKED) { + throw exception(FRIEND_BLOCKED_BY_PEER); + } + return; + } + if (ImConversationTypeEnum.isGroup(scene)) { + // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue; + if (reqVO.getGroupId() == null) { + throw exception(RTC_GROUP_REQUIRED); + } + // 当前用户必须是群活跃成员(包括主动加入已有通话的场景);非成员不允许进入群房间 + // TODO @AI:是不是校验下,被邀请人,是不是这个群里的? + // TODO @AI:member那搞个 ids 校验?避免太大的查询。 + List activeMemberIds = groupMemberService + .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); + if (!activeMemberIds.contains(userId)) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + return; + } + // TODO @AI:抛出异常的 scene 类型; + throw exception(RTC_PRIVATE_INVITEE_REQUIRED); + } + + // TODO @AI在:注释风格; + // TODO @AI:方法名,我们一般叫 buildXXX; + /** 派生 roomName;私聊用好友对小大 ID,双向同 key;群聊用 groupId */ + private String deriveRoomName(Integer scene, Long selfId, Long peerId, Long groupId) { + if (ImConversationTypeEnum.isPrivate(scene)) { + long min = Math.min(selfId, peerId); + long max = Math.max(selfId, peerId); + return "call_friend_" + min + "_" + max; + } + return "call_group_" + groupId; + } + + // TODO @AI:注释风格? + /** 构造新会话;私聊起始 INVITING;群聊起始 ONGOING */ + private ImCallSessionBO buildNewSession(String roomName, Long inviterId, ImRtcCallInviteReqVO reqVO) { + Set invitees = resolveInvitees(reqVO, inviterId); + long now = System.currentTimeMillis(); + return ImCallSessionBO.builder() + // TODO @AI:链式调用,相同的放在一行; + .callId(IdUtil.fastSimpleUUID()) + .roomName(roomName) + .scene(reqVO.getScene()) + .mediaType(reqVO.getMediaType()) + .inviterId(inviterId) + .inviteeIds(invitees) + .groupId(reqVO.getGroupId()) + // TODO @AI:是不是 + .status(ImConversationTypeEnum.isPrivate(reqVO.getScene()) + ? ImCallStatusEnum.INVITING.getType() + : ImCallStatusEnum.ONGOING.getType()) + .startTime(now) + .joinedUserIds(new LinkedHashSet<>()) + .rejectedUserIds(new LinkedHashSet<>()) + .leftUserIds(new LinkedHashSet<>()) + .build(); + } + + /** 解析被邀请池:私聊为 peerUserId 单元素;群聊优先用前端选中的子集,否则取群活跃成员 */ + private Set resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) { + // TODO @AI:私聊。 + if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) { + Set set = new LinkedHashSet<>(); + set.add(reqVO.getPeerUserId()); + return set; + } + + // 群通话:前端有传 inviteeIds 就用,否则回退拉群当前活跃成员 + Set initial = new LinkedHashSet<>(); + // TODO @AI:必须传递,否则报错; + if (CollUtil.isNotEmpty(reqVO.getInviteeIds())) { + initial.addAll(reqVO.getInviteeIds()); + } else { + groupMemberService.getActiveGroupMemberListByGroupId(reqVO.getGroupId()) + .forEach(member -> initial.add(member.getUserId())); + } + // 不论来源,发起人本人不进被邀请池 + initial.remove(inviterId); + // 超出最大同时在房成员数;截断为 max-1 个,避免邀请整个 500 人大群 + // TODO @AI:超出,直接报错,不要裁剪; + int max = imProperties.getRtc().getGroupMaxParticipants(); + if (initial.size() + 1 > max) { + return initial.stream().limit(max - 1L) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + return initial; + } + + /** 推送来电信令;给除发起人外的所有被邀请人独立签 token */ + private void pushInviteNotifications(ImCallSessionBO session, Long inviterId) { + AdminUserRespDTO inviter = adminUserApi.getUser(inviterId); + for (Long inviteeId : session.getInviteeIds()) { + ImRtcInviteNotification payload = buildInvitePayload(session, inviterId, inviter, inviteeId); + // TODO @AI:链式调用;在一行,相同的; + ImPrivateMessageDTO dto = new ImPrivateMessageDTO() + .setType(ImMessageTypeEnum.RTC_INVITE.getType()) + .setSenderId(inviterId) + .setReceiverId(inviteeId) + .setContent(JsonUtils.toJsonString(payload)) + .setSendTime(LocalDateTime.now()); + webSocketService.sendPrivateMessageAsync(inviteeId, dto); + } + } + + /** 组装单条来电载荷;invite / inviteMore 共用 */ + private ImRtcInviteNotification buildInvitePayload(ImCallSessionBO session, Long inviterId, + AdminUserRespDTO inviter, Long inviteeId) { + // TODO @AI:链式调用,相同的在一行; + ImRtcInviteNotification payload = new ImRtcInviteNotification(); + payload.setCallId(session.getCallId()); + payload.setRoomName(session.getRoomName()); + payload.setLivekitUrl(imProperties.getRtc().getLivekitUrl()); + payload.setToken(signToken(inviteeId, + inviter == null ? null : inviter.getNickname(), session.getRoomName())); + payload.setScene(session.getScene()); + payload.setMediaType(session.getMediaType()); + payload.setInviterId(inviterId); + payload.setInviterNickname(inviter == null ? null : inviter.getNickname()); + payload.setInviterAvatar(inviter == null ? null : inviter.getAvatar()); + payload.setGroupId(session.getGroupId()); + return payload; + } + + /** 推送接通信令;给除接听人外的所有相关方 */ + private void pushAcceptNotification(ImCallSessionBO session, Long acceptorId) { + ImRtcAcceptNotification payload = new ImRtcAcceptNotification(); + // TODO @AI:链式调用,相同的在一行; + payload.setCallId(session.getCallId()); + payload.setRoomName(session.getRoomName()); + payload.setAcceptorId(acceptorId); + for (Long target : session.getAllUserIds()) { + if (Objects.equals(target, acceptorId)) { + continue; + } + // TODO @AI:链式调用,相同的在一行; + ImPrivateMessageDTO dto = new ImPrivateMessageDTO() + .setType(ImMessageTypeEnum.RTC_ACCEPT.getType()) + .setSenderId(acceptorId) + .setReceiverId(target) + .setContent(JsonUtils.toJsonString(payload)) + .setSendTime(LocalDateTime.now()); + webSocketService.sendPrivateMessageAsync(target, dto); + } + } + + /** 推送结束信令 */ + private void pushEndNotification(ImCallSessionBO session, Long operatorId, + ImCallEndReasonEnum reason, boolean roomTerminated) { + // TODO @AI:链式调用,相同的在一行; + ImRtcEndNotification payload = new ImRtcEndNotification(); + payload.setCallId(session.getCallId()); + payload.setRoomName(session.getRoomName()); + payload.setOperatorId(operatorId); + payload.setReason(reason.getType()); + if (roomTerminated && session.getStartTime() != null) { + payload.setDurationSeconds((System.currentTimeMillis() - session.getStartTime()) / 1000L); + } + Set targets = roomTerminated + ? session.getAllUserIds() + : CollUtil.newLinkedHashSet(session.getInviterId()); + for (Long target : targets) { + // TODO @AI:链式调用,相同的在一行; + ImPrivateMessageDTO dto = new ImPrivateMessageDTO() + .setType(ImMessageTypeEnum.RTC_END.getType()) + .setSenderId(operatorId == null ? session.getInviterId() : operatorId) + .setReceiverId(target) + .setContent(JsonUtils.toJsonString(payload)) + .setSendTime(LocalDateTime.now()); + webSocketService.sendPrivateMessageAsync(target, dto); + } + } + + // TODO @AI:注释风格; + /** 关闭会话:状态切 ENDED → 推 RTC_END → 群通话再向全员广播 RTC_GROUP_ENDED → 从内存移除 */ + private void endSession(ImCallSessionBO session, Long operatorId, ImCallEndReasonEnum reason) { + session.setStatus(ImCallStatusEnum.ENDED.getType()); + pushEndNotification(session, operatorId, reason, true); + if (ImConversationTypeEnum.isGroup(session.getScene())) { + pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_ENDED.getType(), operatorId); + } + sessionStore.remove(session.getRoomName()); + log.info("[endSession][roomName={} operator={} reason={}]", + session.getRoomName(), operatorId, reason); + } + + /** + * 群通话广播:给该群当前所有活跃成员推一条信号;用于胶囊条「N 人正在通话 / 开始 / 结束」实时同步 + * + * @param session 会话;必须是群通话 + * @param messageType 信号类型;取自 RTC_GROUP_STARTED / RTC_GROUP_UPDATED / RTC_GROUP_ENDED + * @param operatorId 触发本次广播的操作人;STARTED 为发起人,ENDED 为关房操作者,UPDATED 为加入 / 离开者 + */ + private void pushGroupBroadcast(ImCallSessionBO session, Integer messageType, Long operatorId) { + if (!ImConversationTypeEnum.isGroup(session.getScene()) || session.getGroupId() == null) { + return; + } + // TODO @AI:链式调用,相同的在一行; + ImRtcGroupNotification payload = new ImRtcGroupNotification(); + payload.setCallId(session.getCallId()); + payload.setRoomName(session.getRoomName()); + payload.setGroupId(session.getGroupId()); + payload.setMediaType(session.getMediaType()); + payload.setInviterId(session.getInviterId()); + payload.setJoinedUserIds(new LinkedHashSet<>(session.getJoinedUserIds())); + if (session.getInviteeIds() != null) { + payload.setInviteeIds(new LinkedHashSet<>(session.getInviteeIds())); + } + Long senderId = operatorId != null ? operatorId : session.getInviterId(); + String content = JsonUtils.toJsonString(payload); + LocalDateTime now = LocalDateTime.now(); + List members = groupMemberService + .getActiveGroupMemberListByGroupId(session.getGroupId()); + for (ImGroupMemberDO member : members) { + ImPrivateMessageDTO dto = new ImPrivateMessageDTO() + .setType(messageType) + .setSenderId(senderId) + .setReceiverId(member.getUserId()) + .setContent(content) + .setSendTime(now); + webSocketService.sendPrivateMessageAsync(member.getUserId(), dto); + } + } + + // TODO @AI:注释风格; + /** 根据会话取出(不含 token 的)基础 VO */ + private ImRtcCallRespVO toBaseVO(ImCallSessionBO session) { + return BeanUtils.toBean(session, ImRtcCallRespVO.class) + .setLivekitUrl(imProperties.getRtc().getLivekitUrl()); + } + + /** 构造完整响应:含 token + livekitUrl + newCreated */ + // TODO @AI:controller 组装; + private ImRtcCallRespVO buildResp(ImCallSessionBO session, Long userId, boolean newCreated) { + AdminUserRespDTO user = adminUserApi.getUser(userId); + String displayName = user == null ? null : user.getNickname(); + ImRtcCallRespVO vo = toBaseVO(session); + vo.setToken(signToken(userId, displayName, session.getRoomName())); + vo.setNewCreated(newCreated); + return vo; + } + + /** 签 LiveKit Token;identity 用 userId 字符串保证多端唯一 */ + private String signToken(Long userId, String displayName, String roomName) { + ImProperties.Rtc cfg = imProperties.getRtc(); + return LiveKitTokenUtils.signJoinToken( + cfg.getApiKey(), cfg.getApiSecret(), + String.valueOf(userId), displayName, + roomName, Duration.ofHours(cfg.getTokenTtlHours())); + } + + // TODO @AI:方法名,改成项目里,符合预期的; + /** 拉取必存在的会话;不存在直接抛 */ + private ImCallSessionBO requireSession(String roomName) { + ImCallSessionBO session = sessionStore.get(roomName); + if (session == null) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + return session; + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java new file mode 100644 index 0000000..570126d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionBO.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.im.service.rtc.bo; + +import lombok.Builder; +import lombok.Data; + +import java.util.LinkedHashSet; +import java.util.Set; + +// TODO @AI:后续讨论;抽成 DO;写到 mysql 里; +/** + * IM 通话会话 BO;存放在 {@link ImCallSessionStore} 内存里,通话结束即移除 + * + * @author 芋道源码 + */ +@Data +@Builder +public class ImCallSessionBO { + + /** + * 业务通话编号;雪花 ID 生成;用于消息流落库与 Webhook 关联 + */ + private String callId; + /** + * LiveKit 房间名;私聊由好友对派生 call_friend_{minId}_{maxId};群聊为 call_group_{groupId} + */ + private String roomName; + /** + * 会话场景;取自 {@link cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum} 的 type + */ + private Integer scene; + /** + * 媒体类型;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallMediaTypeEnum} 的 type + */ + private Integer mediaType; + /** + * 发起人编号 + */ + private Long inviterId; + /** + * 被邀请人集合;私聊为单元素;群聊为发起时所有候选成员(含发起人本人,方便统一遍历) + */ + private Set inviteeIds; + /** + * 群编号;群聊场景必填 + */ + private Long groupId; + /** + * 状态;取自 {@link cn.iocoder.yudao.module.im.enums.rtc.ImCallStatusEnum} 的 type + */ + private Integer status; + /** + * 通话开始时间戳;millis + */ + private Long startTime; + /** + * 已加入房间的成员集合 + */ + @Builder.Default + private Set joinedUserIds = new LinkedHashSet<>(); + /** + * 已拒绝的成员集合(接通前点拒接) + */ + @Builder.Default + private Set rejectedUserIds = new LinkedHashSet<>(); + /** + * 已离开的成员集合(接通后挂断) + */ + @Builder.Default + private Set leftUserIds = new LinkedHashSet<>(); + + // TODO @AI:bo 里,不要有这种方法; + /** + * 当前会话中所有相关用户;用于 RTC_END 信号扇出与忙线清理 + */ + public Set getAllUserIds() { + Set result = new LinkedHashSet<>(); + result.add(inviterId); + if (inviteeIds != null) { + result.addAll(inviteeIds); + } + result.addAll(joinedUserIds); + return result; + } + + // TODO @AI:这个方法,是不是没在使用??? + /** + * 判断用户是否参与该通话;不在被邀请也不在已加入即非参与方 + */ + public boolean containsUser(Long userId) { + if (userId == null) { + return false; + } + if (userId.equals(inviterId)) { + return true; + } + if (inviteeIds != null && inviteeIds.contains(userId)) { + return true; + } + return joinedUserIds.contains(userId); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java new file mode 100644 index 0000000..03bbf67 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/bo/ImCallSessionStore.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.im.service.rtc.bo; + +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * IM 通话会话内存仓储;MVP 阶段不落库,全量存内存 + *

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

+ * 文档参考:https://docs.livekit.io/home/server/webhook/ + * + * @author 芋道源码 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class LiveKitWebhookEventDTO { + + /** 事件类型;room_started / room_finished / participant_joined / participant_left / track_published 等 */ + private String event; + + /** 事件 id;用于幂等去重 */ + private String id; + + /** 房间元信息;room_started / room_finished 必填 */ + private RoomInfo room; + + /** 参与者元信息;participant_* 事件必填 */ + private ParticipantInfo participant; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RoomInfo { + private String sid; + private String name; + private Long creationTime; + private Integer numParticipants; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ParticipantInfo { + private String sid; + /** 用户身份;我们签 token 时写入 userId 字符串,后续支持多端会拼成 userId#terminal */ + private String identity; + private String name; + private Long joinedAt; + } + +} -- libgit2 0.21.4