Commit b68303152808a56636fbf59e3e69a0ab44252c4f

Authored by YunaiV
1 parent 8b497fa6

✨ feat(im): rtc 交流通信的部分

yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
@@ -2,31 +2,36 @@ package cn.iocoder.yudao.module.im.service.rtc; @@ -2,31 +2,36 @@ package cn.iocoder.yudao.module.im.service.rtc;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
4 import cn.hutool.core.util.IdUtil; 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; 5 +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
8 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO; 6 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO;
9 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; 7 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; 8 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
  9 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
  10 +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
  11 +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper;
  12 +import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO;
13 import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; 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; 14 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; 15 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum;
  16 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
  17 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;
  18 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
18 import cn.iocoder.yudao.module.im.framework.config.ImProperties; 19 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.framework.rtc.core.LiveKitClient;
20 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 21 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.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.*; 23 +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
  24 +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
  25 +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
  26 +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
  27 +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitWebhookEventDTO;
  28 +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc.*;
25 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 29 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
26 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 30 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
27 import cn.iocoder.yudao.module.system.api.user.AdminUserApi; 31 import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
28 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; 32 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
29 import jakarta.annotation.Resource; 33 import jakarta.annotation.Resource;
  34 +import lombok.SneakyThrows;
30 import lombok.extern.slf4j.Slf4j; 35 import lombok.extern.slf4j.Slf4j;
31 import org.springframework.stereotype.Service; 36 import org.springframework.stereotype.Service;
32 import org.springframework.validation.annotation.Validated; 37 import org.springframework.validation.annotation.Validated;
@@ -41,6 +46,18 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; @@ -41,6 +46,18 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
41 46
42 /** 47 /**
43 * IM 实时通话 Service 实现 48 * IM 实时通话 Service 实现
  49 + * <p>
  50 + * 存储模型:DB 单一存储(im_rtc_call 主表 + im_rtc_participant 明细表)
  51 + * <p>
  52 + * 并发幂等:同好友对 / 同群活跃唯一性走 {@link ImRtcCallLockRedisDAO} 分布式锁 + 锁内 SELECT 兜底;webhook 兜底走条件 UPDATE;
  53 + * <p>
  54 + * 推送通道分流:
  55 + * 1601 RTC_CALL(INVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendPrivateMessageAsync} 仅推参与方;
  56 + * 1602 / 1603 PARTICIPANT_CONNECTED / DISCONNECTED → {@link ImWebSocketService} 推参与方 + 群通话场景广播全群;
  57 + * 1610 RTC_CALL_START + 1611 RTC_CALL_END → {@link ImPrivateMessageService} / {@link ImGroupMessageService} 入消息流当聊天 tip
  58 + * (START 仅群通话;两者分别在 invite / cancel(leave) 事务里 INSERT,自增 id 自然保证顺序)
  59 + * <p>
  60 + * 职责边界:媒体协商完全交给 LiveKit;后端只做会话状态机、Token 签发、来电信令推送、通话历史落消息流;房内媒体流变化交给 LiveKit 客户端事件(TrackSubscribed 等),后端不重复推
44 * 61 *
45 * @author 芋道源码 62 * @author 芋道源码
46 */ 63 */
@@ -49,9 +66,13 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; @@ -49,9 +66,13 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
49 @Slf4j 66 @Slf4j
50 public class ImRtcCallServiceImpl implements ImRtcCallService { 67 public class ImRtcCallServiceImpl implements ImRtcCallService {
51 68
52 - // TODO @AI:后续存储到 DB 里;(晚点改)  
53 @Resource 69 @Resource
54 - private ImCallSessionStore sessionStore; 70 + private ImRtcCallMapper rtcCallMapper;
  71 + // TODO @AI:可以搞个 ImRtcParticipantService,从而简化这个类的大小么?
  72 + @Resource
  73 + private ImRtcParticipantMapper rtcParticipantMapper;
  74 + @Resource
  75 + private ImRtcCallLockRedisDAO rtcCallLockRedisDAO;
55 76
56 @Resource 77 @Resource
57 private ImGroupMemberService groupMemberService; 78 private ImGroupMemberService groupMemberService;
@@ -59,6 +80,13 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -59,6 +80,13 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
59 private ImFriendService friendService; 80 private ImFriendService friendService;
60 @Resource 81 @Resource
61 private ImWebSocketService webSocketService; 82 private ImWebSocketService webSocketService;
  83 + @Resource
  84 + private ImPrivateMessageService privateMessageService;
  85 + @Resource
  86 + private ImGroupMessageService groupMessageService;
  87 +
  88 + @Resource
  89 + private LiveKitClient liveKitClient;
62 90
63 @Resource 91 @Resource
64 private AdminUserApi adminUserApi; 92 private AdminUserApi adminUserApi;
@@ -66,411 +94,507 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -66,411 +94,507 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
66 @Resource 94 @Resource
67 private ImProperties imProperties; 95 private ImProperties imProperties;
68 96
  97 + // ========== 业务接口 ==========
  98 +
69 @Override 99 @Override
70 - public ImRtcCallRespVO invite(Long userId, ImRtcCallInviteReqVO reqVO) {  
71 - // TODO @AI:按照 1.1 1.2 1.3 这样搞; 100 + @SneakyThrows
  101 + public ImRtcCallDO inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) {
72 ensureEnabled(); 102 ensureEnabled();
73 - Integer scene = reqVO.getScene();  
74 // 1. 校验入参与场景 103 // 1. 校验入参与场景
75 validateInviteScene(userId, reqVO); 104 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)) { 105 + // TODO @AI:2 这个注释,有点复杂。略微重复了。应该就是加锁 + 所内逻辑。为什么加锁:xxxx
  106 + // 2. 包锁:同好友 / 同群串行;锁内 SELECT 命中已有活跃通话改走加入分支,否则 INSERT 新通话
  107 + if (ImConversationTypeEnum.isGroup(reqVO.getScene())) {
  108 + return rtcCallLockRedisDAO.lockGroup(reqVO.getGroupId(), () -> inviteCall0(userId, reqVO));
  109 + }
  110 + return rtcCallLockRedisDAO.lockPrivate(userId, reqVO.getPeerUserId(), () -> inviteCall0(userId, reqVO));
  111 + }
  112 +
  113 + /**
  114 + * invite 锁内主体;SELECT 已有活跃通话 → 直接抛错(群走 joinCall,私聊用户已是参与者);否则 INSERT 新通话
  115 + */
  116 + private ImRtcCallDO inviteCall0(Long userId, ImRtcCallInviteReqVO reqVO) {
  117 + // TODO @AI:是不是可以改成:私聊的检测、群聊的检测;通用的检测;现在 if else 有点混乱。
  118 + // 1.1 已有活跃通话:群通话提示走加入按钮;私聊一定是当前用户已在通话中(自身忙线)
  119 + ImRtcCallDO active = ImConversationTypeEnum.isGroup(reqVO.getScene())
  120 + // TODO @AI:selectLastOneByXXX;然后 basemapperx 也改成这样的 selectLastOne;这样整体风格对齐;
  121 + ? rtcCallMapper.selectLastByGroupIdAndStatusIn(reqVO.getGroupId(), ImRtcCallStatusEnum.ACTIVE_STATUSES)
  122 + : getActivePrivateCallByPair(userId, reqVO.getPeerUserId());
  123 + if (active != null) {
  124 + throw exception(ImConversationTypeEnum.isGroup(reqVO.getScene())
  125 + ? RTC_GROUP_CALL_ACTIVE : RTC_SELF_BUSY);
  126 + }
  127 + // 1.2 自身忙线:当前用户已在另一通话;私聊场景再额外检查对端 TODO @AI:是不是要拆成 2 段注释。就是“私聊场景再额外检查对端”下面的“ImConversationTypeEnum.isPrivate(reqVO.getScene())”
  128 + ImRtcParticipantDO selfActive = rtcParticipantMapper.selectLastByUserIdAndStatus(userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
  129 + if (selfActive != null) {
82 throw exception(RTC_SELF_BUSY); 130 throw exception(RTC_SELF_BUSY);
83 } 131 }
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)) { 132 + if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) {
  133 + ImRtcParticipantDO peerActive = rtcParticipantMapper.selectLastByUserIdAndStatus(reqVO.getPeerUserId(), ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
  134 + if (peerActive != null) {
91 throw exception(RTC_PEER_BUSY); 135 throw exception(RTC_PEER_BUSY);
92 } 136 }
93 } 137 }
94 138
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); 139 + // 2. 新建通话
  140 + return doInsertNewCall(userId, reqVO);
  141 + }
  142 +
  143 + // TODO @AI:改成 createCall 方法;
  144 + // TODO @AI:希望逻辑是:1 构造参数,2.1 插入主表、2.2 插入参与表;3. 推送通知;
  145 + private ImRtcCallDO doInsertNewCall(Long inviterId, ImRtcCallInviteReqVO reqVO) {
  146 + // 1. 派生 callId + roomName
  147 + String callId = IdUtil.fastSimpleUUID();
  148 + String roomName = ImRtcCallDO.ROOM_NAME_PREFIX + callId;
  149 + LocalDateTime now = LocalDateTime.now();
  150 + // 2. 构建被邀请池(私聊为 peerUserId 单元素;群聊取 invitee 子集 / 全员)
  151 + Set<Long> invitees = resolveInvitees(reqVO, inviterId);
  152 +
  153 + // 3. INSERT 主表;群聊发起人即时 JOINED 但通话仍处 CREATED,等首个非发起人接通才切 RUNNING
  154 + ImRtcCallDO call = new ImRtcCallDO()
  155 + .setCallId(callId).setRoomName(roomName)
  156 + .setConversationType(reqVO.getScene()).setMediaType(reqVO.getMediaType())
  157 + .setInviterUserId(inviterId).setGroupId(reqVO.getGroupId())
  158 + .setStatus(ImRtcCallStatusEnum.CREATED.getStatus())
  159 + .setStartTime(now);
  160 + rtcCallMapper.insert(call);
  161 + // TODO @AI:是不是 4 和 5,改成 批量插入;
  162 + // 4. INSERT 发起人 participant
  163 + rtcParticipantMapper.insert(new ImRtcParticipantDO()
  164 + .setCallId(callId).setUserId(inviterId)
  165 + .setRole(ImRtcParticipantRoleEnum.INVITER.getRole())
  166 + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus())
  167 + .setInviteTime(now).setAcceptTime(now));
  168 + // 5. INSERT 被邀请人 participants
  169 + for (Long inviteeId : invitees) {
  170 + rtcParticipantMapper.insert(new ImRtcParticipantDO()
  171 + .setCallId(callId).setUserId(inviteeId)
  172 + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole())
  173 + .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus())
  174 + .setInviteTime(now));
  175 + }
  176 + // 6. 推送 RTC_CALL(INVITE) 给被邀请人;群通话再向全群广播 RTC_CALL_START 入聊天流
  177 + AdminUserRespDTO inviterUser = adminUserApi.getUser(inviterId);
  178 + for (Long inviteeId : invitees) {
  179 + pushCallInviteNotification(call, inviterUser, inviteeId);
  180 + }
  181 + if (ImConversationTypeEnum.isGroup(reqVO.getScene())) {
  182 + pushCallStartNotification(call, inviterUser);
  183 + }
  184 + return call;
  185 + }
  186 +
  187 + @Override
  188 + public ImRtcCallDO joinCall(Long userId, String roomName) {
  189 + ensureEnabled();
  190 + // 1.1 校验通话存在且活跃
  191 + ImRtcCallDO call = requireActiveCall(roomName);
  192 + // 1.2 仅群通话支持「旁观者加入」
  193 + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) {
  194 + throw exception(RTC_GROUP_REQUIRED);
113 } 195 }
114 - // 7. 给当前调用方签 token  
115 - return buildResp(session, userId, newCreated[0]); 196 +
  197 + // 2. TODO 把 joinExistingCall 拿进来?避免方法太多太碎。
  198 + return joinExistingCall(call, userId);
  199 + }
  200 +
  201 + /**
  202 + * 命中已有活跃通话;当前用户加入或重新进入;不再触发 INVITE / START 广播
  203 + */
  204 + private ImRtcCallDO joinExistingCall(ImRtcCallDO active, Long userId) {
  205 + ImRtcParticipantDO existing = rtcParticipantMapper.selectByCallIdAndUserId(active.getCallId(), userId);
  206 + LocalDateTime now = LocalDateTime.now();
  207 + if (existing != null) {
  208 + // 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连 / 旁观点胶囊条都走这条
  209 + if (!ImRtcParticipantStatusEnum.isJoined(existing.getStatus())) {
  210 + rtcParticipantMapper.updateById(new ImRtcParticipantDO()
  211 + .setId(existing.getId())
  212 + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus())
  213 + .setAcceptTime(now));
  214 + }
  215 + } else {
  216 + // 旁观者主动加入:INSERT role=ACTIVE_JOIN
  217 + rtcParticipantMapper.insert(new ImRtcParticipantDO()
  218 + .setCallId(active.getCallId()).setUserId(userId)
  219 + .setRole(ImRtcParticipantRoleEnum.ACTIVE_JOIN.getRole())
  220 + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus())
  221 + .setInviteTime(now).setAcceptTime(now));
  222 + }
  223 + // 主表 CREATED → RUNNING(首次有非发起人加入)
  224 + maybeMarkOngoing(active, userId, now);
  225 + return active;
116 } 226 }
117 227
118 @Override 228 @Override
119 - public ImRtcCallRespVO accept(Long userId, String roomName) {  
120 - // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) 229 + public void inviteMoreCall(Long userId, ImRtcCallInviteMoreReqVO reqVO) {
121 ensureEnabled(); 230 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); 231 + ImRtcCallDO call = requireActiveCall(reqVO.getRoomName());
  232 + // 仅群通话支持追加邀请
  233 + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) {
  234 + throw exception(RTC_GROUP_REQUIRED);
128 } 235 }
129 - if (!session.containsUser(userId)) { 236 + // 操作者必须是已在房间的参与者
  237 + ImRtcParticipantDO operator = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId);
  238 + if (operator == null || !ImRtcParticipantStatusEnum.isJoined(operator.getStatus())) {
130 throw exception(RTC_NOT_PARTICIPANT); 239 throw exception(RTC_NOT_PARTICIPANT);
131 } 240 }
132 - // 私聊接听 → 切到 ONGOING;群通话已经是 ONGOING;幂等:重复 accept 不报错  
133 - if (ImConversationTypeEnum.isPrivate(session.getScene())) {  
134 - session.setStatus(ImCallStatusEnum.ONGOING.getType());  
135 - // 振铃定时器在前端,后端不做 cancel 动作 241 + // 校验被邀请人必须是该群活跃成员;且未在通话池
  242 + Set<Long> validMemberIds = new LinkedHashSet<>(
  243 + groupMemberService.getActiveGroupMemberUserIdsByGroupId(call.getGroupId()));
  244 + Set<Long> existingUserIds = rtcParticipantMapper.selectListByCallId(call.getCallId()).stream()
  245 + .map(ImRtcParticipantDO::getUserId)
  246 + .collect(Collectors.toSet());
  247 + List<Long> incoming = reqVO.getInviteeIds().stream()
  248 + .filter(id -> validMemberIds.contains(id) && !existingUserIds.contains(id))
  249 + .toList();
  250 + if (CollUtil.isEmpty(incoming)) {
  251 + return;
  252 + }
  253 + // INSERT 新邀请人 + 推 INVITE
  254 + LocalDateTime now = LocalDateTime.now();
  255 + for (Long inviteeId : incoming) {
  256 + rtcParticipantMapper.insert(new ImRtcParticipantDO()
  257 + .setCallId(call.getCallId()).setUserId(inviteeId)
  258 + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole())
  259 + .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus())
  260 + .setInviteTime(now));
136 } 261 }
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); 262 + AdminUserRespDTO inviter = adminUserApi.getUser(userId);
  263 + for (Long inviteeId : incoming) {
  264 + pushCallInviteNotification(call, inviter, inviteeId);
144 } 265 }
145 - return buildResp(session, userId, false);  
146 } 266 }
147 267
148 @Override 268 @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)) { 269 + public ImRtcCallDO acceptCall(Long userId, String roomName) {
  270 + ensureEnabled();
  271 + ImRtcCallDO call = requireActiveCall(roomName);
  272 + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId);
  273 + if (participant == null) {
154 throw exception(RTC_NOT_PARTICIPANT); 274 throw exception(RTC_NOT_PARTICIPANT);
155 } 275 }
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; 276 + // 已 JOINED 视为重复 accept,幂等返回
  277 + LocalDateTime now = LocalDateTime.now();
  278 + if (!ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
  279 + // 仅 INVITING → JOINED;其它状态拒
  280 + if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) {
  281 + throw exception(RTC_SESSION_NOT_EXISTS);
  282 + }
  283 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(),
  284 + ImRtcParticipantStatusEnum.INVITING.getStatus(),
  285 + new ImRtcParticipantDO()
  286 + .setId(participant.getId())
  287 + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus())
  288 + .setAcceptTime(now));
  289 + if (updated == 0) {
  290 + throw exception(RTC_SESSION_NOT_EXISTS);
  291 + }
163 } 292 }
164 - // 私聊拒绝:仅 INVITING 状态可拒;推 RTC_END 给所有相关方并关房  
165 - if (!ImCallStatusEnum.isInviting(session.getStatus())) { 293 + // 主表 CREATED → RUNNING(首次有非发起人接通)
  294 + maybeMarkOngoing(call, userId, now);
  295 + return call;
  296 + }
  297 +
  298 + @Override
  299 + public void rejectCall(Long userId, String roomName) {
  300 + ensureEnabled();
  301 + ImRtcCallDO call = requireActiveCall(roomName);
  302 + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId);
  303 + if (participant == null) {
  304 + throw exception(RTC_NOT_PARTICIPANT);
  305 + }
  306 + // 仅 INVITING 状态可拒
  307 + if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) {
166 throw exception(RTC_SESSION_NOT_EXISTS); 308 throw exception(RTC_SESSION_NOT_EXISTS);
167 } 309 }
168 - endSession(session, userId, ImCallEndReasonEnum.REJECT); 310 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(),
  311 + ImRtcParticipantStatusEnum.INVITING.getStatus(),
  312 + new ImRtcParticipantDO()
  313 + .setId(participant.getId())
  314 + .setStatus(ImRtcParticipantStatusEnum.REJECTED.getStatus())
  315 + .setLeaveTime(LocalDateTime.now()));
  316 + if (updated == 0) {
  317 + return;
  318 + }
  319 + // 群通话拒绝:仅推 RTC_CALL(REJECT) 给主叫;不影响通话整体
  320 + if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
  321 + pushCallRejectNotification(call, userId);
  322 + return;
  323 + }
  324 + // 私聊拒绝:走 endSession(推 RTC_CALL_END(reason=REJECT))
  325 + endSession(call, userId, ImRtcCallEndReasonEnum.REJECT);
169 } 326 }
170 327
171 @Override 328 @Override
172 - public void cancel(Long userId, String roomName) {  
173 - // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) 329 + public void cancelCall(Long userId, String roomName) {
174 ensureEnabled(); 330 ensureEnabled();
175 - ImCallSessionBO session = requireSession(roomName);  
176 - if (!Objects.equals(session.getInviterId(), userId)) { 331 + ImRtcCallDO call = requireActiveCall(roomName);
  332 + if (!Objects.equals(call.getInviterUserId(), userId)) {
177 throw exception(RTC_NOT_PARTICIPANT); 333 throw exception(RTC_NOT_PARTICIPANT);
178 } 334 }
179 - // 仅 INVITING 状态可取消;ONGOING 应走 leave  
180 - if (!ImCallStatusEnum.isInviting(session.getStatus())) { 335 + // 仅 CREATED 状态可取消(RUNNING 应走 leave)
  336 + if (!ImRtcCallStatusEnum.isCreated(call.getStatus())) {
181 throw exception(RTC_SESSION_NOT_EXISTS); 337 throw exception(RTC_SESSION_NOT_EXISTS);
182 } 338 }
183 - endSession(session, userId, ImCallEndReasonEnum.CANCEL); 339 + endSession(call, userId, ImRtcCallEndReasonEnum.CANCEL);
184 } 340 }
185 341
186 @Override 342 @Override
187 - public void leave(Long userId, String roomName) {  
188 - // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) 343 + public void leaveCall(Long userId, String roomName) {
189 ensureEnabled(); 344 ensureEnabled();
190 - ImCallSessionBO session = requireSession(roomName);  
191 - if (!session.containsUser(userId)) { 345 + ImRtcCallDO call = requireActiveCall(roomName);
  346 + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId);
  347 + if (participant == null) {
192 throw exception(RTC_NOT_PARTICIPANT); 348 throw exception(RTC_NOT_PARTICIPANT);
193 } 349 }
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); 350 + LocalDateTime now = LocalDateTime.now();
  351 + rtcParticipantMapper.updateByIdAndStatus(participant.getId(), participant.getStatus(),
  352 + new ImRtcParticipantDO()
  353 + .setId(participant.getId())
  354 + .setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus())
  355 + .setLeaveTime(now));
  356 + // 关房条件:私聊任一方离开 = 关;群通话仅在「无人在房 + 无人响铃」时关
  357 + if (ImConversationTypeEnum.isPrivate(call.getConversationType())
  358 + || shouldCloseGroupRoom(call.getCallId())) {
  359 + endSession(call, userId, ImRtcCallEndReasonEnum.HANGUP);
  360 + }
  361 + // 群通话单人离开:LiveKit `ParticipantDisconnected` 自动通知房内成员;业务后端不另外推
  362 + }
  363 +
  364 + /**
  365 + * 群通话是否应该关闭:房内没有 JOINED 就关
  366 + * <p>
  367 + * 群通话至少需要 1 个 JOINED 在房才有意义;如果只剩 INVITING(响铃中)的,接通后也只是 1 人独房,无价值
  368 + * <p>
  369 + * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛
  370 + */
  371 + private boolean shouldCloseGroupRoom(String callId) {
  372 + List<ImRtcParticipantDO> joined = rtcParticipantMapper.selectListByCallIdAndStatus(callId,
  373 + ImRtcParticipantStatusEnum.JOINED.getStatus());
  374 + return CollUtil.isEmpty(joined);
207 } 375 }
208 376
209 /** 377 /**
210 - * 群通话是否应该关闭:在房成员空 + 没有还在响铃的被邀请人 378 + * 查询两人共同所在的活跃私聊通话;由应用层 Redisson 锁串行化,最多一个
211 * <p> 379 * <p>
212 - * 仅 INVITED 状态下未接受 / 拒绝 / 离开的成员视为「还在响铃」;这样发起人提前离开后,被邀请人仍能接听并独立组成通话,符合微信群通话语义 380 + * 拆成 3 段简单查询:拿 A 的活跃 participant → 拿主表判私聊未结束 → 看 B 是否在同 call 活跃
213 */ 381 */
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 - } 382 + private ImRtcCallDO getActivePrivateCallByPair(Long userIdA, Long userIdB) {
  383 + ImRtcParticipantDO aPart = rtcParticipantMapper.selectLastByUserIdAndStatus(userIdA, ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
  384 + if (aPart == null) {
  385 + return null;
230 } 386 }
231 - return true; 387 + ImRtcCallDO call = rtcCallMapper.selectByCallId(aPart.getCallId());
  388 + if (call == null
  389 + || !ImConversationTypeEnum.isPrivate(call.getConversationType())
  390 + || ImRtcCallStatusEnum.isEnded(call.getStatus())) {
  391 + return null;
  392 + }
  393 + ImRtcParticipantDO bPart = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userIdB);
  394 + if (bPart == null
  395 + || !ImRtcParticipantStatusEnum.ACTIVE_STATUSES.contains(bPart.getStatus())) {
  396 + return null;
  397 + }
  398 + return call;
232 } 399 }
233 400
234 @Override 401 @Override
235 - public ImRtcCallRespVO refreshToken(Long userId, String roomName) {  
236 - ensureEnabled(); // TODO @AI:简化下;  
237 - ImCallSessionBO session = requireSession(roomName);  
238 - if (!session.containsUser(userId)) { 402 + public ImRtcCallDO refreshCallToken(Long userId, String roomName) {
  403 + ensureEnabled();
  404 + ImRtcCallDO call = requireActiveCall(roomName);
  405 + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId);
  406 + if (participant == null) {
239 throw exception(RTC_NOT_PARTICIPANT); 407 throw exception(RTC_NOT_PARTICIPANT);
240 } 408 }
241 - return buildResp(session, userId, false); 409 + return call;
242 } 410 }
243 411
244 @Override 412 @Override
245 - public void inviteMore(Long userId, ImRtcCallInviteMoreReqVO reqVO) { 413 + public ImRtcCallDO getActiveCall(Long userId, Long groupId) {
246 ensureEnabled(); 414 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); 415 + // 1. 鉴权:仅群活跃成员能查(走单行 SQL,不依赖成员列表缓存)
  416 + groupMemberService.validateMemberInGroup(groupId, userId);
  417 + // 2. 查询活跃通话
  418 + return rtcCallMapper.selectLastByGroupIdAndStatusIn(groupId, ImRtcCallStatusEnum.ACTIVE_STATUSES);
296 } 419 }
297 420
298 @Override 421 @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 - } 422 + public List<ImRtcParticipantDO> getCallParticipants(String callId) {
  423 + return rtcParticipantMapper.selectListByCallId(callId);
  424 + }
312 425
313 - // TODO @AI:返回 ImCallSessionBO?controller 拼接?(后面在改,所有涉及到各种翻译的地方)  
314 - // 列表场景不签 token;前端要 token 时再调 refreshToken  
315 - ImRtcCallRespVO vo = toBaseVO(session);  
316 - return Collections.singletonList(vo); 426 + @Override
  427 + public String signCallToken(Long userId, String roomName) {
  428 + AdminUserRespDTO user = adminUserApi.getUser(userId);
  429 + String displayName = user == null ? null : user.getNickname();
  430 + return signToken(userId, displayName, roomName);
317 } 431 }
318 432
319 @Override 433 @Override
  434 + // TODO @AI:这个方法的编写,需要兼容 JDK8
320 public void handleLiveKitEvent(LiveKitWebhookEventDTO event) { 435 public void handleLiveKitEvent(LiveKitWebhookEventDTO event) {
321 - // TODO @AI:需要 try catch 记录 hook + 成功 or 失败的日志么?  
322 if (event == null || event.getEvent() == null) { 436 if (event == null || event.getEvent() == null) {
323 return; 437 return;
324 } 438 }
325 - // TODO @AI:需要检验 jdk8 语法;  
326 - // TODO @AI:livekit webhookevent 是不是搞个枚举类?  
327 switch (event.getEvent()) { 439 switch (event.getEvent()) {
328 - case "participant_left" -> handleParticipantLeft(event);  
329 - case "room_finished" -> handleRoomFinished(event); 440 + case LiveKitWebhookEventDTO.EVENT_PARTICIPANT_JOINED -> handleParticipantJoined(event);
  441 + case LiveKitWebhookEventDTO.EVENT_PARTICIPANT_LEFT -> handleParticipantLeft(event);
  442 + case LiveKitWebhookEventDTO.EVENT_ROOM_FINISHED -> handleRoomFinished(event);
330 default -> { 443 default -> {
331 - // TODO @AI:仅仅 logger,后续按需搞;  
332 - /* 其它事件忽略;participant_joined / track_published 等业务态由 LiveKit 自身分发驱动 */ 444 + /* 其它事件忽略;track_published 等业务态由 LiveKit 自身分发驱动 */
333 } 445 }
334 } 446 }
335 } 447 }
336 448
337 - // TODO @AI:方法注释,里面的注释,按照 1. 2. 3. 阅读性更好  
338 - // TODO @AI:处理必要的代码空行;阅读性更好(但是不要太多空行,层次感缺少)  
339 /** 449 /**
340 - * 处理 LiveKit「成员离开」事件;正常 leave 接口已经清理过的话此处幂等忽略  
341 - * 私聊任一方离开 → 结束通话;群通话仅一人离开 → 广播;最后一人离开 → 结束通话 450 + * 处理 LiveKit 「成员加入」事件;DB 接通态由 accept 接口写入,此处仅做 1602 转推
342 */ 451 */
343 - private void handleParticipantLeft(LiveKitWebhookEventDTO event) { 452 + private void handleParticipantJoined(LiveKitWebhookEventDTO event) {
344 if (event.getRoom() == null || event.getParticipant() == null) { 453 if (event.getRoom() == null || event.getParticipant() == null) {
345 return; 454 return;
346 } 455 }
347 String roomName = event.getRoom().getName(); 456 String roomName = event.getRoom().getName();
348 - ImCallSessionBO session = sessionStore.get(roomName);  
349 - if (session == null) { 457 + ImRtcCallDO call = rtcCallMapper.selectByRoomName(roomName);
  458 + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) {
350 return; 459 return;
351 } 460 }
352 - Long userId = parseUserIdFromIdentity(event.getParticipant().getIdentity()); 461 + Long userId = liveKitClient.parseUserId(event.getParticipant().getIdentity());
353 if (userId == null) { 462 if (userId == null) {
354 return; 463 return;
355 } 464 }
356 - // 业务态可能已被 leave 接口清掉;contains 判断保证幂等  
357 - boolean wasInRoom = session.getJoinedUserIds().remove(userId);  
358 - sessionStore.unbindUserFromRoom(userId, roomName);  
359 - if (!wasInRoom) { 465 + pushParticipantConnectedNotification(call, userId);
  466 + }
  467 +
  468 + /**
  469 + * 处理 LiveKit 「成员离开」事件;正常 leave 接口已经清理过的话条件 UPDATE 自动幂等
  470 + */
  471 + private void handleParticipantLeft(LiveKitWebhookEventDTO event) {
  472 + if (event.getRoom() == null || event.getParticipant() == null) {
360 return; 473 return;
361 } 474 }
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); 475 + String roomName = event.getRoom().getName();
  476 + ImRtcCallDO call = rtcCallMapper.selectByRoomName(roomName);
  477 + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) {
368 return; 478 return;
369 } 479 }
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) { 480 + Long userId = liveKitClient.parseUserId(event.getParticipant().getIdentity());
  481 + if (userId == null) {
379 return; 482 return;
380 } 483 }
381 - String roomName = event.getRoom().getName();  
382 - ImCallSessionBO session = sessionStore.get(roomName);  
383 - if (session == null) { 484 + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId);
  485 + if (participant == null || !ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
  486 + return;
  487 + }
  488 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(),
  489 + ImRtcParticipantStatusEnum.JOINED.getStatus(),
  490 + new ImRtcParticipantDO()
  491 + .setId(participant.getId())
  492 + .setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus())
  493 + .setLeaveTime(LocalDateTime.now()));
  494 + if (updated == 0) {
384 return; 495 return;
385 } 496 }
386 - log.info("[handleRoomFinished][roomName={} 由 LiveKit Webhook 兜底清理]", roomName);  
387 - // 复用 endSession:状态切 ENDED + 推 RTC_END + 群通话广播 ENDED + 移除内存索引  
388 - endSession(session, null, ImCallEndReasonEnum.HANGUP); 497 + log.info("[handleParticipantLeft][roomName={} userId={} 由 LiveKit Webhook 兜底]", roomName, userId);
  498 + // 推 1603 通知参与方 / 全群参与方
  499 + pushParticipantDisconnectedNotification(call, userId);
  500 + // 关房条件与 leave 一致;群通话仅在「无人在房 + 无人响铃」时关
  501 + if (ImConversationTypeEnum.isPrivate(call.getConversationType())
  502 + || shouldCloseGroupRoom(call.getCallId())) {
  503 + endSession(call, userId, ImRtcCallEndReasonEnum.HANGUP);
  504 + }
389 } 505 }
390 506
391 - // TODO @AI:这里多端是必须的么?如果非必须,就先不用了。。。  
392 /** 507 /**
393 - * 从 LiveKit identity 解析业务 userId  
394 - * <p>  
395 - * 当前 identity 直接是 userId 字符串;预留 userId#terminal 多端格式的解析支持 508 + * 处理 LiveKit 「房间结束」事件;兜底把 call 推到 ENDED
396 */ 509 */
397 - private Long parseUserIdFromIdentity(String identity) {  
398 - if (identity == null || identity.isEmpty()) {  
399 - return null; 510 + private void handleRoomFinished(LiveKitWebhookEventDTO event) {
  511 + if (event.getRoom() == null) {
  512 + return;
400 } 513 }
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; 514 + String roomName = event.getRoom().getName();
  515 + ImRtcCallDO call = rtcCallMapper.selectByRoomName(roomName);
  516 + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) {
  517 + return;
408 } 518 }
  519 + log.info("[handleRoomFinished][roomName={} 由 LiveKit Webhook 兜底]", roomName);
  520 + endSession(call, null, ImRtcCallEndReasonEnum.HANGUP);
409 } 521 }
410 522
411 @Override 523 @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())); 524 + public int cleanupZombieCalls(int thresholdMinutes) {
  525 + // 阈值由调用方(Job)保证 > 0;低于 1 分钟会误杀刚发起的合理零人态
  526 + LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes);
  527 + List<ImRtcCallDO> candidates = rtcCallMapper.selectListByStatusInAndStartTimeBefore(
  528 + ImRtcCallStatusEnum.ACTIVE_STATUSES, threshold);
  529 + if (CollUtil.isEmpty(candidates)) {
  530 + return 0;
  531 + }
  532 +
  533 + // 2. 逐个查 LiveKit 房间真实 participant 数
  534 + int cleaned = 0;
  535 + for (ImRtcCallDO call : candidates) {
  536 + int count;
  537 + try {
  538 + count = liveKitClient.listParticipants(call.getRoomName());
  539 + } catch (Exception e) {
  540 + log.warn("[cleanupZombieCalls][查询 LiveKit 失败 callId={} roomName={}]",
  541 + call.getCallId(), call.getRoomName(), e);
  542 + continue;
  543 + }
  544 + if (count != 0) {
  545 + continue;
  546 + }
  547 + log.info("[cleanupZombieCalls][清理僵尸通话 callId={} roomName={}]",
  548 + call.getCallId(), call.getRoomName());
  549 + endSession(call, null, ImRtcCallEndReasonEnum.HANGUP);
  550 + cleaned++;
431 } 551 }
432 - return vo; 552 + return cleaned;
433 } 553 }
434 554
435 // ========== 内部辅助 ========== 555 // ========== 内部辅助 ==========
436 556
437 - /** 校验通话功能开关 */ 557 + /**
  558 + * 校验通话功能开关
  559 + */
438 private void ensureEnabled() { 560 private void ensureEnabled() {
439 if (!imProperties.getRtc().isEnabled()) { 561 if (!imProperties.getRtc().isEnabled()) {
440 throw exception(RTC_NOT_ENABLED); 562 throw exception(RTC_NOT_ENABLED);
441 } 563 }
442 } 564 }
443 565
444 - /** 校验 invite 入参;按场景区分必填字段 + 群通话校验当前用户是群活跃成员 */ 566 + // TODO @AI:validateXXX
  567 + /**
  568 + * 取活跃通话;不存在 / 已 ENDED 直接抛
  569 + */
  570 + private ImRtcCallDO requireActiveCall(String roomName) {
  571 + ImRtcCallDO call = rtcCallMapper.selectByRoomName(roomName);
  572 + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) {
  573 + throw exception(RTC_SESSION_NOT_EXISTS);
  574 + }
  575 + return call;
  576 + }
  577 +
  578 + /**
  579 + * 校验 invite 入参;按场景区分必填字段;私聊补好友 / 黑名单校验,群聊补群成员校验
  580 + */
445 private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) { 581 private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) {
446 Integer scene = reqVO.getScene(); 582 Integer scene = reqVO.getScene();
447 if (ImConversationTypeEnum.isPrivate(scene)) { 583 if (ImConversationTypeEnum.isPrivate(scene)) {
448 - // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue;  
449 if (reqVO.getPeerUserId() == null) { 584 if (reqVO.getPeerUserId() == null) {
450 throw exception(RTC_PRIVATE_INVITEE_REQUIRED); 585 throw exception(RTC_PRIVATE_INVITEE_REQUIRED);
451 } 586 }
452 if (Objects.equals(userId, reqVO.getPeerUserId())) { 587 if (Objects.equals(userId, reqVO.getPeerUserId())) {
453 throw exception(RTC_INVITE_SELF); 588 throw exception(RTC_INVITE_SELF);
454 } 589 }
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 - } 590 + // TODO DONE @AI:抽到 ImFriendService#validateFriend;
  591 + friendService.validateFriend(userId, reqVO.getPeerUserId());
464 return; 592 return;
465 } 593 }
466 if (ImConversationTypeEnum.isGroup(scene)) { 594 if (ImConversationTypeEnum.isGroup(scene)) {
467 - // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue;  
468 if (reqVO.getGroupId() == null) { 595 if (reqVO.getGroupId() == null) {
469 throw exception(RTC_GROUP_REQUIRED); 596 throw exception(RTC_GROUP_REQUIRED);
470 } 597 }
471 - // 当前用户必须是群活跃成员(包括主动加入已有通话的场景);非成员不允许进入群房间  
472 - // TODO @AI:是不是校验下,被邀请人,是不是这个群里的?  
473 - // TODO @AI:member那搞个 ids 校验?避免太大的查询。  
474 List<Long> activeMemberIds = groupMemberService 598 List<Long> activeMemberIds = groupMemberService
475 .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); 599 .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
476 if (!activeMemberIds.contains(userId)) { 600 if (!activeMemberIds.contains(userId)) {
@@ -478,69 +602,35 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -478,69 +602,35 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
478 } 602 }
479 return; 603 return;
480 } 604 }
481 - // TODO @AI:抛出异常的 scene 类型;  
482 throw exception(RTC_PRIVATE_INVITEE_REQUIRED); 605 throw exception(RTC_PRIVATE_INVITEE_REQUIRED);
483 } 606 }
484 607
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 单元素;群聊优先用前端选中的子集,否则取群活跃成员 */ 608 + /**
  609 + * 解析被邀请池:私聊为 peerUserId 单元素;群聊优先用前端选中的子集,否则取群活跃成员;超量截断
  610 + * <p>
  611 + * 群聊场景:被邀请人必须是该群活跃成员;防止恶意客户端塞任意 userId 进群房间
  612 + */
523 private Set<Long> resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) { 613 private Set<Long> resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) {
524 - // TODO @AI:私聊。  
525 if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) { 614 if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) {
526 - Set<Long> set = new LinkedHashSet<>();  
527 - set.add(reqVO.getPeerUserId());  
528 - return set; 615 + return CollUtil.newLinkedHashSet(reqVO.getPeerUserId());
529 } 616 }
530 -  
531 - // 群通话:前端有传 inviteeIds 就用,否则回退拉群当前活跃成员 617 + List<Long> activeMemberIds = groupMemberService
  618 + .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
532 Set<Long> initial = new LinkedHashSet<>(); 619 Set<Long> initial = new LinkedHashSet<>();
533 - // TODO @AI:必须传递,否则报错;  
534 if (CollUtil.isNotEmpty(reqVO.getInviteeIds())) { 620 if (CollUtil.isNotEmpty(reqVO.getInviteeIds())) {
535 - initial.addAll(reqVO.getInviteeIds()); 621 + Set<Long> activeMemberSet = new HashSet<>(activeMemberIds);
  622 + for (Long inviteeId : reqVO.getInviteeIds()) {
  623 + if (!activeMemberSet.contains(inviteeId)) {
  624 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  625 + }
  626 + initial.add(inviteeId);
  627 + }
536 } else { 628 } else {
537 - groupMemberService.getActiveGroupMemberListByGroupId(reqVO.getGroupId())  
538 - .forEach(member -> initial.add(member.getUserId())); 629 + initial.addAll(activeMemberIds);
539 } 630 }
540 // 不论来源,发起人本人不进被邀请池 631 // 不论来源,发起人本人不进被邀请池
541 initial.remove(inviterId); 632 initial.remove(inviterId);
542 // 超出最大同时在房成员数;截断为 max-1 个,避免邀请整个 500 人大群 633 // 超出最大同时在房成员数;截断为 max-1 个,避免邀请整个 500 人大群
543 - // TODO @AI:超出,直接报错,不要裁剪;  
544 int max = imProperties.getRtc().getGroupMaxParticipants(); 634 int max = imProperties.getRtc().getGroupMaxParticipants();
545 if (initial.size() + 1 > max) { 635 if (initial.size() + 1 > max) {
546 return initial.stream().limit(max - 1L) 636 return initial.stream().limit(max - 1L)
@@ -549,176 +639,198 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -549,176 +639,198 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
549 return initial; 639 return initial;
550 } 640 }
551 641
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); 642 + /**
  643 + * 主表 CREATED → RUNNING;仅当首个非发起人加入时推进;条件 UPDATE 保幂等
  644 + * <p>
  645 + * 与 cancel / endSession 并发时 update 可能影响 0 行;reload 看真实状态:
  646 + * ENDED → 通话已被另一路径终结,抛 RTC_SESSION_NOT_EXISTS 让 accept 失败而不是返回假成功 token
  647 + * RUNNING → 已被另一接听者抢先推进,幂等同步内存
  648 + */
  649 + private void maybeMarkOngoing(ImRtcCallDO call, Long acceptorId, LocalDateTime now) {
  650 + if (!ImRtcCallStatusEnum.isCreated(call.getStatus())) {
  651 + return;
  652 + }
  653 + if (Objects.equals(call.getInviterUserId(), acceptorId)) {
  654 + return;
  655 + }
  656 + int updated = rtcCallMapper.updateByIdAndStatus(call.getId(),
  657 + ImRtcCallStatusEnum.CREATED.getStatus(),
  658 + new ImRtcCallDO()
  659 + .setStatus(ImRtcCallStatusEnum.RUNNING.getStatus())
  660 + .setAcceptTime(now));
  661 + if (updated > 0) {
  662 + // 内存值同步给后续判断
  663 + call.setStatus(ImRtcCallStatusEnum.RUNNING.getStatus());
  664 + call.setAcceptTime(now);
  665 + return;
  666 + }
  667 + // updated == 0:与并发路径竞争失败;reload 看真实终态
  668 + ImRtcCallDO latest = rtcCallMapper.selectById(call.getId());
  669 + if (latest == null || ImRtcCallStatusEnum.isEnded(latest.getStatus())) {
  670 + throw exception(RTC_SESSION_NOT_EXISTS);
  671 + }
  672 + call.setStatus(latest.getStatus());
  673 + call.setAcceptTime(latest.getAcceptTime());
647 } 674 }
648 675
649 /** 676 /**
650 - * 群通话广播:给该群当前所有活跃成员推一条信号;用于胶囊条「N 人正在通话 / 开始 / 结束」实时同步  
651 - *  
652 - * @param session 会话;必须是群通话  
653 - * @param messageType 信号类型;取自 RTC_GROUP_STARTED / RTC_GROUP_UPDATED / RTC_GROUP_ENDED  
654 - * @param operatorId 触发本次广播的操作人;STARTED 为发起人,ENDED 为关房操作者,UPDATED 为加入 / 离开者 677 + * 关闭会话:主表条件 UPDATE 推到 ENDED → 残留 INVITING 批量改 NO_ANSWER → 推 RTC_CALL_END
655 */ 678 */
656 - private void pushGroupBroadcast(ImCallSessionBO session, Integer messageType, Long operatorId) {  
657 - if (!ImConversationTypeEnum.isGroup(session.getScene()) || session.getGroupId() == null) { 679 + private void endSession(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) {
  680 + // 1.1 更新通话主表为已结束;条件 UPDATE 仅在 status 还活跃时生效
  681 + LocalDateTime now = LocalDateTime.now();
  682 + int updated = rtcCallMapper.updateByIdAndStatusIn(call.getId(), ImRtcCallStatusEnum.ACTIVE_STATUSES,
  683 + new ImRtcCallDO().setStatus(ImRtcCallStatusEnum.ENDED.getStatus())
  684 + .setEndReason(reason.getReason()).setEndTime(now));
  685 + if (updated == 0) {
  686 + log.info("[endSession][已被另一路径终结,跳过 callId={} roomName={} operator={} reason={}]",
  687 + call.getCallId(), call.getRoomName(), operatorId, reason);
658 return; 688 return;
659 } 689 }
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); 690 + // 1.2 更新参与表为已结束:残留 INVITING 改 NO_ANSWER;残留 JOINED 改 LEFT 并写 leaveTime
  691 + rtcParticipantMapper.updateByCallIdAndStatus(call.getCallId(), ImRtcParticipantStatusEnum.INVITING.getStatus(),
  692 + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()));
  693 + rtcParticipantMapper.updateByCallIdAndStatus(call.getCallId(), ImRtcParticipantStatusEnum.JOINED.getStatus(),
  694 + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now));
  695 +
  696 + // 2. 兜底删除 LiveKit 房间,强制断开异常残留客户端;失败仅记日志,不阻断业务
  697 + try {
  698 + liveKitClient.deleteRoom(call.getRoomName());
  699 + } catch (Exception e) {
  700 + log.warn("[endSession][删除 LiveKit 房间失败 callId={} roomName={} operator={} reason={}]",
  701 + call.getCallId(), call.getRoomName(), operatorId, reason, e);
684 } 702 }
  703 +
  704 + // 3. 推 RTC_CALL_END
  705 + Long durationSeconds = call.getAcceptTime() != null ?
  706 + Duration.between(call.getAcceptTime(), now).getSeconds() : null;
  707 + pushCallEndNotification(call, operatorId, reason, durationSeconds);
  708 + log.info("[endSession][roomName={} operator={} reason={}]", call.getRoomName(), operatorId, reason);
685 } 709 }
686 710
687 - // TODO @AI:注释风格;  
688 - /** 根据会话取出(不含 token 的)基础 VO */  
689 - private ImRtcCallRespVO toBaseVO(ImCallSessionBO session) {  
690 - return BeanUtils.toBean(session, ImRtcCallRespVO.class)  
691 - .setLivekitUrl(imProperties.getRtc().getLivekitUrl()); 711 +
  712 + /**
  713 + * 通话事件的收件人池:私聊为双方参与者,群聊为群活跃成员
  714 + */
  715 + private Collection<Long> getCallAudience(ImRtcCallDO call) {
  716 + if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
  717 + return groupMemberService.getActiveGroupMemberUserIdsByGroupId(call.getGroupId());
  718 + }
  719 + return CollectionUtils.convertSet(
  720 + rtcParticipantMapper.selectListByCallId(call.getCallId()), ImRtcParticipantDO::getUserId);
692 } 721 }
693 722
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; 723 + // ========== 推送 ==========
  724 +
  725 + /**
  726 + * RTC_CALL(INVITE):走 webSocketService 直推到被邀请人
  727 + * <p>
  728 + * 不能走 imPrivateMessageService.send:persistent=false 时 send 仅推 sender 多端,receiver 不感知
  729 + */
  730 + private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter, Long inviteeId) {
  731 + String token = signToken(inviteeId, inviter == null ? null : inviter.getNickname(), call.getRoomName());
  732 + ImRtcCallNotification payload = ImRtcCallNotification.ofInvite(
  733 + call, inviter, imProperties.getRtc().getLivekitUrl(), token);
  734 + webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification(
  735 + ImMessageTypeEnum.RTC_CALL.getType(), call.getInviterUserId(), inviteeId, payload));
703 } 736 }
704 737
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); 738 + /**
  739 + * RTC_CALL(REJECT):仅群通话场景;走 webSocketService 直推主叫
  740 + */
  741 + private void pushCallRejectNotification(ImRtcCallDO call, Long operatorUserId) {
  742 + AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId) : null;
  743 + ImRtcCallNotification payload = ImRtcCallNotification.ofReject(call, operatorUserId, operator);
  744 + webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification(
  745 + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload));
  746 + }
  747 +
  748 + /**
  749 + * 通话参与者加入:LiveKit webhook participant_joined 触发;私聊推双方多端、群聊推全群成员(胶囊条 + 1)
  750 + *
  751 + * @param call 通话主表
  752 + * @param userId 加入的参与者用户编号
  753 + */
  754 + private void pushParticipantConnectedNotification(ImRtcCallDO call, Long userId) {
  755 + pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_CONNECTED.getType(), userId,
  756 + ImRtcParticipantConnectedNotification.of(call, userId));
  757 + }
  758 +
  759 + /**
  760 + * 通话参与者离开:LiveKit webhook participant_left 触发;推送范围同 {@link #pushParticipantConnectedNotification}
  761 + *
  762 + * @param call 通话主表
  763 + * @param userId 离开的参与者用户编号
  764 + */
  765 + private void pushParticipantDisconnectedNotification(ImRtcCallDO call, Long userId) {
  766 + pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_DISCONNECTED.getType(), userId,
  767 + ImRtcParticipantDisconnectedNotification.of(call, userId));
  768 + }
  769 +
  770 + /**
  771 + * 推送参与者事件的公共骨架;按会话类型决定收件人,单次 batch 推送扇出
  772 + */
  773 + private void pushParticipantNotification(ImRtcCallDO call, Integer type, Long actorUserId, Object payload) {
  774 + Collection<Long> receivers = getCallAudience(call);
  775 + if (CollUtil.isEmpty(receivers)) {
  776 + return;
720 } 777 }
721 - return session; 778 + ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofRtcNotification(type, actorUserId, null, payload);
  779 + webSocketService.sendPrivateMessageAsync(receivers, dto);
  780 + }
  781 +
  782 + /**
  783 + * RTC_CALL_START:仅群通话场景;走 imGroupMessageService.send,入 im_group_message 全群广播
  784 + * <p>
  785 + * 与 RTC_CALL_END 两段式配对:START 在本接口(invite)事务里 INSERT,END 在 cancel / leave 接口事务里 INSERT,
  786 + * 自增 id 自然保证聊天流顺序
  787 + */
  788 + private void pushCallStartNotification(ImRtcCallDO call, AdminUserRespDTO inviter) {
  789 + ImRtcCallStartNotification payload = ImRtcCallStartNotification.of(call, inviter);
  790 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  791 + .setGroupId(call.getGroupId())
  792 + .setType(ImMessageTypeEnum.RTC_CALL_START.getType())
  793 + .setContent(payload);
  794 + groupMessageService.sendGroupMessage(call.getInviterUserId(), dto);
  795 + }
  796 +
  797 + /**
  798 + * RTC_CALL_END:私聊走 imPrivateMessageService.send;群通话走 imGroupMessageService.send
  799 + * <p>
  800 + * senderId 始终用通话发起人,让前端按「谁发起通话」决定气泡左右;操作者从 payload.operatorUserId 拿
  801 + */
  802 + private void pushCallEndNotification(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason,
  803 + Long durationSeconds) {
  804 + AdminUserRespDTO operator = operatorId != null ? adminUserApi.getUser(operatorId) : null;
  805 + ImRtcCallEndNotification payload = ImRtcCallEndNotification.of(call, reason, durationSeconds, operatorId, operator);
  806 + Long senderId = call.getInviterUserId();
  807 + if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
  808 + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
  809 + .setGroupId(call.getGroupId())
  810 + .setType(ImMessageTypeEnum.RTC_CALL_END.getType())
  811 + .setContent(payload);
  812 + groupMessageService.sendGroupMessage(senderId, dto);
  813 + return;
  814 + }
  815 + // 私聊:发起人总是 sender;receiver = 通话另一方
  816 + ImRtcParticipantDO peer = CollUtil.findOne(
  817 + rtcParticipantMapper.selectListByCallId(call.getCallId()),
  818 + p -> !Objects.equals(p.getUserId(), senderId));
  819 + Long receiverId = peer != null ? peer.getUserId() : senderId;
  820 + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO()
  821 + .setReceiverId(receiverId)
  822 + .setType(ImMessageTypeEnum.RTC_CALL_END.getType())
  823 + .setContent(payload);
  824 + privateMessageService.sendPrivateMessage(senderId, dto);
  825 + }
  826 +
  827 + // ========== Token / VO ==========
  828 +
  829 + /**
  830 + * 签 LiveKit Token
  831 + */
  832 + private String signToken(Long userId, String displayName, String roomName) {
  833 + return liveKitClient.signJoinToken(liveKitClient.buildIdentity(userId), displayName, roomName);
722 } 834 }
723 835
724 } 836 }