Commit bcac553c691fc302bc3b63408e1673d113b92f38
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<Integer> { | @@ -78,6 +78,36 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> { | ||
| 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 | +} |