diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java index 83eb0fa..d952fdf 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java @@ -2,31 +2,36 @@ package cn.iocoder.yudao.module.im.service.rtc; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.framework.common.util.json.JsonUtils; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO; import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; -import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO; -import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper; +import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO; import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; -import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import cn.iocoder.yudao.module.im.enums.rtc.ImCallEndReasonEnum; -import cn.iocoder.yudao.module.im.enums.rtc.ImCallStatusEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; -import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitTokenUtils; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitClient; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; -import cn.iocoder.yudao.module.im.service.rtc.bo.ImCallSessionBO; -import cn.iocoder.yudao.module.im.service.rtc.bo.ImCallSessionStore; -import cn.iocoder.yudao.module.im.service.rtc.dto.*; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; +import cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitWebhookEventDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc.*; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -41,6 +46,18 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; /** * IM 实时通话 Service 实现 + *

+ * 存储模型:DB 单一存储(im_rtc_call 主表 + im_rtc_participant 明细表) + *

+ * 并发幂等:同好友对 / 同群活跃唯一性走 {@link ImRtcCallLockRedisDAO} 分布式锁 + 锁内 SELECT 兜底;webhook 兜底走条件 UPDATE; + *

+ * 推送通道分流: + * 1601 RTC_CALL(INVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendPrivateMessageAsync} 仅推参与方; + * 1602 / 1603 PARTICIPANT_CONNECTED / DISCONNECTED → {@link ImWebSocketService} 推参与方 + 群通话场景广播全群; + * 1610 RTC_CALL_START + 1611 RTC_CALL_END → {@link ImPrivateMessageService} / {@link ImGroupMessageService} 入消息流当聊天 tip + * (START 仅群通话;两者分别在 invite / cancel(leave) 事务里 INSERT,自增 id 自然保证顺序) + *

+ * 职责边界:媒体协商完全交给 LiveKit;后端只做会话状态机、Token 签发、来电信令推送、通话历史落消息流;房内媒体流变化交给 LiveKit 客户端事件(TrackSubscribed 等),后端不重复推 * * @author 芋道源码 */ @@ -49,9 +66,13 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; @Slf4j public class ImRtcCallServiceImpl implements ImRtcCallService { - // TODO @AI:后续存储到 DB 里;(晚点改) @Resource - private ImCallSessionStore sessionStore; + private ImRtcCallMapper rtcCallMapper; + // TODO @AI:可以搞个 ImRtcParticipantService,从而简化这个类的大小么? + @Resource + private ImRtcParticipantMapper rtcParticipantMapper; + @Resource + private ImRtcCallLockRedisDAO rtcCallLockRedisDAO; @Resource private ImGroupMemberService groupMemberService; @@ -59,6 +80,13 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { private ImFriendService friendService; @Resource private ImWebSocketService webSocketService; + @Resource + private ImPrivateMessageService privateMessageService; + @Resource + private ImGroupMessageService groupMessageService; + + @Resource + private LiveKitClient liveKitClient; @Resource private AdminUserApi adminUserApi; @@ -66,411 +94,507 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @Resource private ImProperties imProperties; + // ========== 业务接口 ========== + @Override - public ImRtcCallRespVO invite(Long userId, ImRtcCallInviteReqVO reqVO) { - // TODO @AI:按照 1.1 1.2 1.3 这样搞; + @SneakyThrows + public ImRtcCallDO inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) { ensureEnabled(); - Integer scene = reqVO.getScene(); // 1. 校验入参与场景 validateInviteScene(userId, reqVO); - // 2. 派生 roomName;同好友对 / 同群只对应一个 roomName - String roomName = deriveRoomName(scene, userId, reqVO.getPeerUserId(), reqVO.getGroupId()); - // 3. 用户级忙线检查;当前用户已在另一通话且不是同房间则拒绝 - String selfActive = sessionStore.getActiveRoom(userId); - // TODO @AI:notEquals 方案; - if (selfActive != null && !selfActive.equals(roomName)) { + // TODO @AI:2 这个注释,有点复杂。略微重复了。应该就是加锁 + 所内逻辑。为什么加锁:xxxx + // 2. 包锁:同好友 / 同群串行;锁内 SELECT 命中已有活跃通话改走加入分支,否则 INSERT 新通话 + if (ImConversationTypeEnum.isGroup(reqVO.getScene())) { + return rtcCallLockRedisDAO.lockGroup(reqVO.getGroupId(), () -> inviteCall0(userId, reqVO)); + } + return rtcCallLockRedisDAO.lockPrivate(userId, reqVO.getPeerUserId(), () -> inviteCall0(userId, reqVO)); + } + + /** + * invite 锁内主体;SELECT 已有活跃通话 → 直接抛错(群走 joinCall,私聊用户已是参与者);否则 INSERT 新通话 + */ + private ImRtcCallDO inviteCall0(Long userId, ImRtcCallInviteReqVO reqVO) { + // TODO @AI:是不是可以改成:私聊的检测、群聊的检测;通用的检测;现在 if else 有点混乱。 + // 1.1 已有活跃通话:群通话提示走加入按钮;私聊一定是当前用户已在通话中(自身忙线) + ImRtcCallDO active = ImConversationTypeEnum.isGroup(reqVO.getScene()) + // TODO @AI:selectLastOneByXXX;然后 basemapperx 也改成这样的 selectLastOne;这样整体风格对齐; + ? rtcCallMapper.selectLastByGroupIdAndStatusIn(reqVO.getGroupId(), ImRtcCallStatusEnum.ACTIVE_STATUSES) + : getActivePrivateCallByPair(userId, reqVO.getPeerUserId()); + if (active != null) { + throw exception(ImConversationTypeEnum.isGroup(reqVO.getScene()) + ? RTC_GROUP_CALL_ACTIVE : RTC_SELF_BUSY); + } + // 1.2 自身忙线:当前用户已在另一通话;私聊场景再额外检查对端 TODO @AI:是不是要拆成 2 段注释。就是“私聊场景再额外检查对端”下面的“ImConversationTypeEnum.isPrivate(reqVO.getScene())” + ImRtcParticipantDO selfActive = rtcParticipantMapper.selectLastByUserIdAndStatus(userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES); + if (selfActive != null) { throw exception(RTC_SELF_BUSY); } - // 4. 私聊场景下检查被叫忙线;群通话候选成员各自决定接听,不在此拦截 - // TODO @AI:需要在看看,群聊会不会出现这个情况?感觉应该也会,可以调研下微信,以及其他 im - if (ImConversationTypeEnum.isPrivate(scene) - && sessionStore.isBusy(reqVO.getPeerUserId())) { - String peerActive = sessionStore.getActiveRoom(reqVO.getPeerUserId()); - // TODO @AI:notEquals 方案; - if (peerActive != null && !peerActive.equals(roomName)) { + if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) { + ImRtcParticipantDO peerActive = rtcParticipantMapper.selectLastByUserIdAndStatus(reqVO.getPeerUserId(), ImRtcParticipantStatusEnum.ACTIVE_STATUSES); + if (peerActive != null) { throw exception(RTC_PEER_BUSY); } } - // 5. 原子获取或创建会话;并发同时点呼叫只会建一份 - boolean[] newCreated = {false}; - ImCallSessionBO session = sessionStore.getOrCreate(roomName, key -> { - newCreated[0] = true; - return buildNewSession(key, userId, reqVO); - }); - // 6. 老 session:本次属于"加入已有通话";新 session:发邀请信令并把发起人加入房间 - sessionStore.bindUser(userId, roomName); - session.getJoinedUserIds().add(userId); - if (newCreated[0]) { - pushInviteNotifications(session, userId); - // 群通话首次发起:向全群广播 STARTED,胶囊条立刻在所有成员客户端出现 - if (ImConversationTypeEnum.isGroup(session.getScene())) { - pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_STARTED.getType(), userId); - } - } else if (ImConversationTypeEnum.isGroup(session.getScene())) { - // 群通话主动加入已有通话:广播 UPDATED 让胶囊条人数刷新 - pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + // 2. 新建通话 + return doInsertNewCall(userId, reqVO); + } + + // TODO @AI:改成 createCall 方法; + // TODO @AI:希望逻辑是:1 构造参数,2.1 插入主表、2.2 插入参与表;3. 推送通知; + private ImRtcCallDO doInsertNewCall(Long inviterId, ImRtcCallInviteReqVO reqVO) { + // 1. 派生 callId + roomName + String callId = IdUtil.fastSimpleUUID(); + String roomName = ImRtcCallDO.ROOM_NAME_PREFIX + callId; + LocalDateTime now = LocalDateTime.now(); + // 2. 构建被邀请池(私聊为 peerUserId 单元素;群聊取 invitee 子集 / 全员) + Set invitees = resolveInvitees(reqVO, inviterId); + + // 3. INSERT 主表;群聊发起人即时 JOINED 但通话仍处 CREATED,等首个非发起人接通才切 RUNNING + ImRtcCallDO call = new ImRtcCallDO() + .setCallId(callId).setRoomName(roomName) + .setConversationType(reqVO.getScene()).setMediaType(reqVO.getMediaType()) + .setInviterUserId(inviterId).setGroupId(reqVO.getGroupId()) + .setStatus(ImRtcCallStatusEnum.CREATED.getStatus()) + .setStartTime(now); + rtcCallMapper.insert(call); + // TODO @AI:是不是 4 和 5,改成 批量插入; + // 4. INSERT 发起人 participant + rtcParticipantMapper.insert(new ImRtcParticipantDO() + .setCallId(callId).setUserId(inviterId) + .setRole(ImRtcParticipantRoleEnum.INVITER.getRole()) + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()) + .setInviteTime(now).setAcceptTime(now)); + // 5. INSERT 被邀请人 participants + for (Long inviteeId : invitees) { + rtcParticipantMapper.insert(new ImRtcParticipantDO() + .setCallId(callId).setUserId(inviteeId) + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) + .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus()) + .setInviteTime(now)); + } + // 6. 推送 RTC_CALL(INVITE) 给被邀请人;群通话再向全群广播 RTC_CALL_START 入聊天流 + AdminUserRespDTO inviterUser = adminUserApi.getUser(inviterId); + for (Long inviteeId : invitees) { + pushCallInviteNotification(call, inviterUser, inviteeId); + } + if (ImConversationTypeEnum.isGroup(reqVO.getScene())) { + pushCallStartNotification(call, inviterUser); + } + return call; + } + + @Override + public ImRtcCallDO joinCall(Long userId, String roomName) { + ensureEnabled(); + // 1.1 校验通话存在且活跃 + ImRtcCallDO call = requireActiveCall(roomName); + // 1.2 仅群通话支持「旁观者加入」 + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { + throw exception(RTC_GROUP_REQUIRED); } - // 7. 给当前调用方签 token - return buildResp(session, userId, newCreated[0]); + + // 2. TODO 把 joinExistingCall 拿进来?避免方法太多太碎。 + return joinExistingCall(call, userId); + } + + /** + * 命中已有活跃通话;当前用户加入或重新进入;不再触发 INVITE / START 广播 + */ + private ImRtcCallDO joinExistingCall(ImRtcCallDO active, Long userId) { + ImRtcParticipantDO existing = rtcParticipantMapper.selectByCallIdAndUserId(active.getCallId(), userId); + LocalDateTime now = LocalDateTime.now(); + if (existing != null) { + // 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连 / 旁观点胶囊条都走这条 + if (!ImRtcParticipantStatusEnum.isJoined(existing.getStatus())) { + rtcParticipantMapper.updateById(new ImRtcParticipantDO() + .setId(existing.getId()) + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()) + .setAcceptTime(now)); + } + } else { + // 旁观者主动加入:INSERT role=ACTIVE_JOIN + rtcParticipantMapper.insert(new ImRtcParticipantDO() + .setCallId(active.getCallId()).setUserId(userId) + .setRole(ImRtcParticipantRoleEnum.ACTIVE_JOIN.getRole()) + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()) + .setInviteTime(now).setAcceptTime(now)); + } + // 主表 CREATED → RUNNING(首次有非发起人加入) + maybeMarkOngoing(active, userId, now); + return active; } @Override - public ImRtcCallRespVO accept(Long userId, String roomName) { - // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) + public void inviteMoreCall(Long userId, ImRtcCallInviteMoreReqVO reqVO) { ensureEnabled(); - ImCallSessionBO session = requireSession(roomName); - // TODO @AI:减少 ONGOING、INVITING 的混用;最好是中文(英文),大家更好理解。 - // 群通话处于 ONGOING;私聊必须 INVITING 才能接听 - if (ImConversationTypeEnum.isPrivate(session.getScene()) - && !ImCallStatusEnum.isInviting(session.getStatus())) { - throw exception(RTC_SESSION_NOT_EXISTS); + ImRtcCallDO call = requireActiveCall(reqVO.getRoomName()); + // 仅群通话支持追加邀请 + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { + throw exception(RTC_GROUP_REQUIRED); } - if (!session.containsUser(userId)) { + // 操作者必须是已在房间的参与者 + ImRtcParticipantDO operator = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId); + if (operator == null || !ImRtcParticipantStatusEnum.isJoined(operator.getStatus())) { throw exception(RTC_NOT_PARTICIPANT); } - // 私聊接听 → 切到 ONGOING;群通话已经是 ONGOING;幂等:重复 accept 不报错 - if (ImConversationTypeEnum.isPrivate(session.getScene())) { - session.setStatus(ImCallStatusEnum.ONGOING.getType()); - // 振铃定时器在前端,后端不做 cancel 动作 + // 校验被邀请人必须是该群活跃成员;且未在通话池 + Set validMemberIds = new LinkedHashSet<>( + groupMemberService.getActiveGroupMemberUserIdsByGroupId(call.getGroupId())); + Set existingUserIds = rtcParticipantMapper.selectListByCallId(call.getCallId()).stream() + .map(ImRtcParticipantDO::getUserId) + .collect(Collectors.toSet()); + List incoming = reqVO.getInviteeIds().stream() + .filter(id -> validMemberIds.contains(id) && !existingUserIds.contains(id)) + .toList(); + if (CollUtil.isEmpty(incoming)) { + return; + } + // INSERT 新邀请人 + 推 INVITE + LocalDateTime now = LocalDateTime.now(); + for (Long inviteeId : incoming) { + rtcParticipantMapper.insert(new ImRtcParticipantDO() + .setCallId(call.getCallId()).setUserId(inviteeId) + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) + .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus()) + .setInviteTime(now)); } - session.getJoinedUserIds().add(userId); - sessionStore.bindUser(userId, roomName); - // 推 RTC_ACCEPT 给除自己外的全部相关方 - pushAcceptNotification(session, userId); - // 群通话有人接听 → 全群广播 UPDATED 刷新胶囊条 - if (ImConversationTypeEnum.isGroup(session.getScene())) { - pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + AdminUserRespDTO inviter = adminUserApi.getUser(userId); + for (Long inviteeId : incoming) { + pushCallInviteNotification(call, inviter, inviteeId); } - return buildResp(session, userId, false); } @Override - public void reject(Long userId, String roomName) { - // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) - ensureEnabled(); // TODO @AI:是不是只有发起校验,其它地方先不校验了; - ImCallSessionBO session = requireSession(roomName); - if (!session.containsUser(userId)) { + public ImRtcCallDO acceptCall(Long userId, String roomName) { + ensureEnabled(); + ImRtcCallDO call = requireActiveCall(roomName); + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId); + if (participant == null) { throw exception(RTC_NOT_PARTICIPANT); } - // 群通话拒绝:不影响其他成员,房间继续存在 - if (ImConversationTypeEnum.isGroup(session.getScene())) { - session.getRejectedUserIds().add(userId); - sessionStore.unbindUserFromRoom(userId, roomName); - // 仅广播 UPDATED;之前向主叫推 RTC_END 会被前端错误识别为「整个通话结束」并 reset 主叫的通话窗 - pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); - return; + // 已 JOINED 视为重复 accept,幂等返回 + LocalDateTime now = LocalDateTime.now(); + if (!ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { + // 仅 INVITING → JOINED;其它状态拒 + if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO() + .setId(participant.getId()) + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()) + .setAcceptTime(now)); + if (updated == 0) { + throw exception(RTC_SESSION_NOT_EXISTS); + } } - // 私聊拒绝:仅 INVITING 状态可拒;推 RTC_END 给所有相关方并关房 - if (!ImCallStatusEnum.isInviting(session.getStatus())) { + // 主表 CREATED → RUNNING(首次有非发起人接通) + maybeMarkOngoing(call, userId, now); + return call; + } + + @Override + public void rejectCall(Long userId, String roomName) { + ensureEnabled(); + ImRtcCallDO call = requireActiveCall(roomName); + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId); + if (participant == null) { + throw exception(RTC_NOT_PARTICIPANT); + } + // 仅 INVITING 状态可拒 + if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) { throw exception(RTC_SESSION_NOT_EXISTS); } - endSession(session, userId, ImCallEndReasonEnum.REJECT); + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO() + .setId(participant.getId()) + .setStatus(ImRtcParticipantStatusEnum.REJECTED.getStatus()) + .setLeaveTime(LocalDateTime.now())); + if (updated == 0) { + return; + } + // 群通话拒绝:仅推 RTC_CALL(REJECT) 给主叫;不影响通话整体 + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + pushCallRejectNotification(call, userId); + return; + } + // 私聊拒绝:走 endSession(推 RTC_CALL_END(reason=REJECT)) + endSession(call, userId, ImRtcCallEndReasonEnum.REJECT); } @Override - public void cancel(Long userId, String roomName) { - // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) + public void cancelCall(Long userId, String roomName) { ensureEnabled(); - ImCallSessionBO session = requireSession(roomName); - if (!Objects.equals(session.getInviterId(), userId)) { + ImRtcCallDO call = requireActiveCall(roomName); + if (!Objects.equals(call.getInviterUserId(), userId)) { throw exception(RTC_NOT_PARTICIPANT); } - // 仅 INVITING 状态可取消;ONGOING 应走 leave - if (!ImCallStatusEnum.isInviting(session.getStatus())) { + // 仅 CREATED 状态可取消(RUNNING 应走 leave) + if (!ImRtcCallStatusEnum.isCreated(call.getStatus())) { throw exception(RTC_SESSION_NOT_EXISTS); } - endSession(session, userId, ImCallEndReasonEnum.CANCEL); + endSession(call, userId, ImRtcCallEndReasonEnum.CANCEL); } @Override - public void leave(Long userId, String roomName) { - // TODO @AI:按照 1.1 1.2 1.3 这样搞;(注释分层一点) + public void leaveCall(Long userId, String roomName) { ensureEnabled(); - ImCallSessionBO session = requireSession(roomName); - if (!session.containsUser(userId)) { + ImRtcCallDO call = requireActiveCall(roomName); + ImRtcParticipantDO participant = rtcParticipantMapper.selectByCallIdAndUserId(call.getCallId(), userId); + if (participant == null) { throw exception(RTC_NOT_PARTICIPANT); } - session.getLeftUserIds().add(userId); - session.getJoinedUserIds().remove(userId); - sessionStore.unbindUserFromRoom(userId, roomName); - // 关房条件:私聊任一方离开 = 关;群通话仅当所有被邀请人都已应答 + 在房无人时才关 - // 发起人离开后被邀请人仍可继续接听,房间不应当立即销毁 - // TODO @AI:单聊、群聊,是否可以复用一个逻辑;private 本质,只是一个特殊的方式而已;如果 private 后续支持邀请人,那 private 这个判断,就是 bug 了呀。其它地方,看看是不是也调整下; - if (ImConversationTypeEnum.isPrivate(session.getScene()) - || shouldCloseGroupRoom(session)) { - endSession(session, userId, ImCallEndReasonEnum.HANGUP); - return; - } - // 群通话单人离开:仅广播 UPDATED;不推 RTC_END 避免主叫误关通话窗 - pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_UPDATED.getType(), userId); + LocalDateTime now = LocalDateTime.now(); + rtcParticipantMapper.updateByIdAndStatus(participant.getId(), participant.getStatus(), + new ImRtcParticipantDO() + .setId(participant.getId()) + .setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()) + .setLeaveTime(now)); + // 关房条件:私聊任一方离开 = 关;群通话仅在「无人在房 + 无人响铃」时关 + if (ImConversationTypeEnum.isPrivate(call.getConversationType()) + || shouldCloseGroupRoom(call.getCallId())) { + endSession(call, userId, ImRtcCallEndReasonEnum.HANGUP); + } + // 群通话单人离开:LiveKit `ParticipantDisconnected` 自动通知房内成员;业务后端不另外推 + } + + /** + * 群通话是否应该关闭:房内没有 JOINED 就关 + *

+ * 群通话至少需要 1 个 JOINED 在房才有意义;如果只剩 INVITING(响铃中)的,接通后也只是 1 人独房,无价值 + *

+ * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛 + */ + private boolean shouldCloseGroupRoom(String callId) { + List joined = rtcParticipantMapper.selectListByCallIdAndStatus(callId, + ImRtcParticipantStatusEnum.JOINED.getStatus()); + return CollUtil.isEmpty(joined); } /** - * 群通话是否应该关闭:在房成员空 + 没有还在响铃的被邀请人 + * 查询两人共同所在的活跃私聊通话;由应用层 Redisson 锁串行化,最多一个 *

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

- * 当前 identity 直接是 userId 字符串;预留 userId#terminal 多端格式的解析支持 + * 处理 LiveKit 「房间结束」事件;兜底把 call 推到 ENDED */ - private Long parseUserIdFromIdentity(String identity) { - if (identity == null || identity.isEmpty()) { - return null; + private void handleRoomFinished(LiveKitWebhookEventDTO event) { + if (event.getRoom() == null) { + return; } - int sep = identity.indexOf('#'); - String idPart = sep >= 0 ? identity.substring(0, sep) : identity; - try { - return Long.parseLong(idPart); - } catch (NumberFormatException e) { - log.warn("[parseUserIdFromIdentity][非法 identity={}]", identity); - return null; + String roomName = event.getRoom().getName(); + ImRtcCallDO call = rtcCallMapper.selectByRoomName(roomName); + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) { + return; } + log.info("[handleRoomFinished][roomName={} 由 LiveKit Webhook 兜底]", roomName); + endSession(call, null, ImRtcCallEndReasonEnum.HANGUP); } @Override - public ImRtcGroupCallRespVO getGroupActiveCall(Long groupId) { - if (!imProperties.getRtc().isEnabled() || groupId == null) { - return null; - } - ImCallSessionBO session = sessionStore.get("call_group_" + groupId); - if (session == null) { - return null; - } - // TODO @AI:链式调用; - // TODO @AI:后续看看,能不能改成 controller 处理; - ImRtcGroupCallRespVO vo = new ImRtcGroupCallRespVO(); - vo.setCallId(session.getCallId()); - vo.setRoomName(session.getRoomName()); - vo.setGroupId(session.getGroupId()); - vo.setMediaType(session.getMediaType()); - vo.setInviterId(session.getInviterId()); - vo.setJoinedUserIds(new LinkedHashSet<>(session.getJoinedUserIds())); - if (session.getInviteeIds() != null) { - vo.setInviteeIds(new LinkedHashSet<>(session.getInviteeIds())); + public int cleanupZombieCalls(int thresholdMinutes) { + // 阈值由调用方(Job)保证 > 0;低于 1 分钟会误杀刚发起的合理零人态 + LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes); + List candidates = rtcCallMapper.selectListByStatusInAndStartTimeBefore( + ImRtcCallStatusEnum.ACTIVE_STATUSES, threshold); + if (CollUtil.isEmpty(candidates)) { + return 0; + } + + // 2. 逐个查 LiveKit 房间真实 participant 数 + int cleaned = 0; + for (ImRtcCallDO call : candidates) { + int count; + try { + count = liveKitClient.listParticipants(call.getRoomName()); + } catch (Exception e) { + log.warn("[cleanupZombieCalls][查询 LiveKit 失败 callId={} roomName={}]", + call.getCallId(), call.getRoomName(), e); + continue; + } + if (count != 0) { + continue; + } + log.info("[cleanupZombieCalls][清理僵尸通话 callId={} roomName={}]", + call.getCallId(), call.getRoomName()); + endSession(call, null, ImRtcCallEndReasonEnum.HANGUP); + cleaned++; } - return vo; + return cleaned; } // ========== 内部辅助 ========== - /** 校验通话功能开关 */ + /** + * 校验通话功能开关 + */ private void ensureEnabled() { if (!imProperties.getRtc().isEnabled()) { throw exception(RTC_NOT_ENABLED); } } - /** 校验 invite 入参;按场景区分必填字段 + 群通话校验当前用户是群活跃成员 */ + // TODO @AI:validateXXX + /** + * 取活跃通话;不存在 / 已 ENDED 直接抛 + */ + private ImRtcCallDO requireActiveCall(String roomName) { + ImRtcCallDO call = rtcCallMapper.selectByRoomName(roomName); + if (call == null || ImRtcCallStatusEnum.isEnded(call.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + return call; + } + + /** + * 校验 invite 入参;按场景区分必填字段;私聊补好友 / 黑名单校验,群聊补群成员校验 + */ private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) { Integer scene = reqVO.getScene(); if (ImConversationTypeEnum.isPrivate(scene)) { - // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue; if (reqVO.getPeerUserId() == null) { throw exception(RTC_PRIVATE_INVITEE_REQUIRED); } if (Objects.equals(userId, reqVO.getPeerUserId())) { throw exception(RTC_INVITE_SELF); } - // 好友 / 黑名单校验:和私聊消息发送同一套语义;NONE 已删 / 未加,BLOCKED 被对方拉黑 - // TODO @AI:是不是抽一个方法?validateFriendXXX(因为有多处调用); - ImFriendStateEnum state = friendService.getFriendState(userId, reqVO.getPeerUserId()); - if (state == ImFriendStateEnum.NONE) { - throw exception(FRIEND_NOT_FRIEND); - } - if (state == ImFriendStateEnum.BLOCKED) { - throw exception(FRIEND_BLOCKED_BY_PEER); - } + // TODO DONE @AI:抽到 ImFriendService#validateFriend; + friendService.validateFriend(userId, reqVO.getPeerUserId()); return; } if (ImConversationTypeEnum.isGroup(scene)) { - // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue; if (reqVO.getGroupId() == null) { throw exception(RTC_GROUP_REQUIRED); } - // 当前用户必须是群活跃成员(包括主动加入已有通话的场景);非成员不允许进入群房间 - // TODO @AI:是不是校验下,被邀请人,是不是这个群里的? - // TODO @AI:member那搞个 ids 校验?避免太大的查询。 List activeMemberIds = groupMemberService .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); if (!activeMemberIds.contains(userId)) { @@ -478,69 +602,35 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { } return; } - // TODO @AI:抛出异常的 scene 类型; throw exception(RTC_PRIVATE_INVITEE_REQUIRED); } - // TODO @AI在:注释风格; - // TODO @AI:方法名,我们一般叫 buildXXX; - /** 派生 roomName;私聊用好友对小大 ID,双向同 key;群聊用 groupId */ - private String deriveRoomName(Integer scene, Long selfId, Long peerId, Long groupId) { - if (ImConversationTypeEnum.isPrivate(scene)) { - long min = Math.min(selfId, peerId); - long max = Math.max(selfId, peerId); - return "call_friend_" + min + "_" + max; - } - return "call_group_" + groupId; - } - - // TODO @AI:注释风格? - /** 构造新会话;私聊起始 INVITING;群聊起始 ONGOING */ - private ImCallSessionBO buildNewSession(String roomName, Long inviterId, ImRtcCallInviteReqVO reqVO) { - Set invitees = resolveInvitees(reqVO, inviterId); - long now = System.currentTimeMillis(); - return ImCallSessionBO.builder() - // TODO @AI:链式调用,相同的放在一行; - .callId(IdUtil.fastSimpleUUID()) - .roomName(roomName) - .scene(reqVO.getScene()) - .mediaType(reqVO.getMediaType()) - .inviterId(inviterId) - .inviteeIds(invitees) - .groupId(reqVO.getGroupId()) - // TODO @AI:是不是 - .status(ImConversationTypeEnum.isPrivate(reqVO.getScene()) - ? ImCallStatusEnum.INVITING.getType() - : ImCallStatusEnum.ONGOING.getType()) - .startTime(now) - .joinedUserIds(new LinkedHashSet<>()) - .rejectedUserIds(new LinkedHashSet<>()) - .leftUserIds(new LinkedHashSet<>()) - .build(); - } - - /** 解析被邀请池:私聊为 peerUserId 单元素;群聊优先用前端选中的子集,否则取群活跃成员 */ + /** + * 解析被邀请池:私聊为 peerUserId 单元素;群聊优先用前端选中的子集,否则取群活跃成员;超量截断 + *

+ * 群聊场景:被邀请人必须是该群活跃成员;防止恶意客户端塞任意 userId 进群房间 + */ private Set resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) { - // TODO @AI:私聊。 if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) { - Set set = new LinkedHashSet<>(); - set.add(reqVO.getPeerUserId()); - return set; + return CollUtil.newLinkedHashSet(reqVO.getPeerUserId()); } - - // 群通话:前端有传 inviteeIds 就用,否则回退拉群当前活跃成员 + List activeMemberIds = groupMemberService + .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); Set initial = new LinkedHashSet<>(); - // TODO @AI:必须传递,否则报错; if (CollUtil.isNotEmpty(reqVO.getInviteeIds())) { - initial.addAll(reqVO.getInviteeIds()); + Set activeMemberSet = new HashSet<>(activeMemberIds); + for (Long inviteeId : reqVO.getInviteeIds()) { + if (!activeMemberSet.contains(inviteeId)) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + initial.add(inviteeId); + } } else { - groupMemberService.getActiveGroupMemberListByGroupId(reqVO.getGroupId()) - .forEach(member -> initial.add(member.getUserId())); + initial.addAll(activeMemberIds); } // 不论来源,发起人本人不进被邀请池 initial.remove(inviterId); // 超出最大同时在房成员数;截断为 max-1 个,避免邀请整个 500 人大群 - // TODO @AI:超出,直接报错,不要裁剪; int max = imProperties.getRtc().getGroupMaxParticipants(); if (initial.size() + 1 > max) { return initial.stream().limit(max - 1L) @@ -549,176 +639,198 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { return initial; } - /** 推送来电信令;给除发起人外的所有被邀请人独立签 token */ - private void pushInviteNotifications(ImCallSessionBO session, Long inviterId) { - AdminUserRespDTO inviter = adminUserApi.getUser(inviterId); - for (Long inviteeId : session.getInviteeIds()) { - ImRtcInviteNotification payload = buildInvitePayload(session, inviterId, inviter, inviteeId); - // TODO @AI:链式调用;在一行,相同的; - ImPrivateMessageDTO dto = new ImPrivateMessageDTO() - .setType(ImMessageTypeEnum.RTC_INVITE.getType()) - .setSenderId(inviterId) - .setReceiverId(inviteeId) - .setContent(JsonUtils.toJsonString(payload)) - .setSendTime(LocalDateTime.now()); - webSocketService.sendPrivateMessageAsync(inviteeId, dto); - } - } - - /** 组装单条来电载荷;invite / inviteMore 共用 */ - private ImRtcInviteNotification buildInvitePayload(ImCallSessionBO session, Long inviterId, - AdminUserRespDTO inviter, Long inviteeId) { - // TODO @AI:链式调用,相同的在一行; - ImRtcInviteNotification payload = new ImRtcInviteNotification(); - payload.setCallId(session.getCallId()); - payload.setRoomName(session.getRoomName()); - payload.setLivekitUrl(imProperties.getRtc().getLivekitUrl()); - payload.setToken(signToken(inviteeId, - inviter == null ? null : inviter.getNickname(), session.getRoomName())); - payload.setScene(session.getScene()); - payload.setMediaType(session.getMediaType()); - payload.setInviterId(inviterId); - payload.setInviterNickname(inviter == null ? null : inviter.getNickname()); - payload.setInviterAvatar(inviter == null ? null : inviter.getAvatar()); - payload.setGroupId(session.getGroupId()); - return payload; - } - - /** 推送接通信令;给除接听人外的所有相关方 */ - private void pushAcceptNotification(ImCallSessionBO session, Long acceptorId) { - ImRtcAcceptNotification payload = new ImRtcAcceptNotification(); - // TODO @AI:链式调用,相同的在一行; - payload.setCallId(session.getCallId()); - payload.setRoomName(session.getRoomName()); - payload.setAcceptorId(acceptorId); - for (Long target : session.getAllUserIds()) { - if (Objects.equals(target, acceptorId)) { - continue; - } - // TODO @AI:链式调用,相同的在一行; - ImPrivateMessageDTO dto = new ImPrivateMessageDTO() - .setType(ImMessageTypeEnum.RTC_ACCEPT.getType()) - .setSenderId(acceptorId) - .setReceiverId(target) - .setContent(JsonUtils.toJsonString(payload)) - .setSendTime(LocalDateTime.now()); - webSocketService.sendPrivateMessageAsync(target, dto); - } - } - - /** 推送结束信令 */ - private void pushEndNotification(ImCallSessionBO session, Long operatorId, - ImCallEndReasonEnum reason, boolean roomTerminated) { - // TODO @AI:链式调用,相同的在一行; - ImRtcEndNotification payload = new ImRtcEndNotification(); - payload.setCallId(session.getCallId()); - payload.setRoomName(session.getRoomName()); - payload.setOperatorId(operatorId); - payload.setReason(reason.getType()); - if (roomTerminated && session.getStartTime() != null) { - payload.setDurationSeconds((System.currentTimeMillis() - session.getStartTime()) / 1000L); - } - Set targets = roomTerminated - ? session.getAllUserIds() - : CollUtil.newLinkedHashSet(session.getInviterId()); - for (Long target : targets) { - // TODO @AI:链式调用,相同的在一行; - ImPrivateMessageDTO dto = new ImPrivateMessageDTO() - .setType(ImMessageTypeEnum.RTC_END.getType()) - .setSenderId(operatorId == null ? session.getInviterId() : operatorId) - .setReceiverId(target) - .setContent(JsonUtils.toJsonString(payload)) - .setSendTime(LocalDateTime.now()); - webSocketService.sendPrivateMessageAsync(target, dto); - } - } - - // TODO @AI:注释风格; - /** 关闭会话:状态切 ENDED → 推 RTC_END → 群通话再向全员广播 RTC_GROUP_ENDED → 从内存移除 */ - private void endSession(ImCallSessionBO session, Long operatorId, ImCallEndReasonEnum reason) { - session.setStatus(ImCallStatusEnum.ENDED.getType()); - pushEndNotification(session, operatorId, reason, true); - if (ImConversationTypeEnum.isGroup(session.getScene())) { - pushGroupBroadcast(session, ImMessageTypeEnum.RTC_GROUP_ENDED.getType(), operatorId); - } - sessionStore.remove(session.getRoomName()); - log.info("[endSession][roomName={} operator={} reason={}]", - session.getRoomName(), operatorId, reason); + /** + * 主表 CREATED → RUNNING;仅当首个非发起人加入时推进;条件 UPDATE 保幂等 + *

+ * 与 cancel / endSession 并发时 update 可能影响 0 行;reload 看真实状态: + * ENDED → 通话已被另一路径终结,抛 RTC_SESSION_NOT_EXISTS 让 accept 失败而不是返回假成功 token + * RUNNING → 已被另一接听者抢先推进,幂等同步内存 + */ + private void maybeMarkOngoing(ImRtcCallDO call, Long acceptorId, LocalDateTime now) { + if (!ImRtcCallStatusEnum.isCreated(call.getStatus())) { + return; + } + if (Objects.equals(call.getInviterUserId(), acceptorId)) { + return; + } + int updated = rtcCallMapper.updateByIdAndStatus(call.getId(), + ImRtcCallStatusEnum.CREATED.getStatus(), + new ImRtcCallDO() + .setStatus(ImRtcCallStatusEnum.RUNNING.getStatus()) + .setAcceptTime(now)); + if (updated > 0) { + // 内存值同步给后续判断 + call.setStatus(ImRtcCallStatusEnum.RUNNING.getStatus()); + call.setAcceptTime(now); + return; + } + // updated == 0:与并发路径竞争失败;reload 看真实终态 + ImRtcCallDO latest = rtcCallMapper.selectById(call.getId()); + if (latest == null || ImRtcCallStatusEnum.isEnded(latest.getStatus())) { + throw exception(RTC_SESSION_NOT_EXISTS); + } + call.setStatus(latest.getStatus()); + call.setAcceptTime(latest.getAcceptTime()); } /** - * 群通话广播:给该群当前所有活跃成员推一条信号;用于胶囊条「N 人正在通话 / 开始 / 结束」实时同步 - * - * @param session 会话;必须是群通话 - * @param messageType 信号类型;取自 RTC_GROUP_STARTED / RTC_GROUP_UPDATED / RTC_GROUP_ENDED - * @param operatorId 触发本次广播的操作人;STARTED 为发起人,ENDED 为关房操作者,UPDATED 为加入 / 离开者 + * 关闭会话:主表条件 UPDATE 推到 ENDED → 残留 INVITING 批量改 NO_ANSWER → 推 RTC_CALL_END */ - private void pushGroupBroadcast(ImCallSessionBO session, Integer messageType, Long operatorId) { - if (!ImConversationTypeEnum.isGroup(session.getScene()) || session.getGroupId() == null) { + private void endSession(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) { + // 1.1 更新通话主表为已结束;条件 UPDATE 仅在 status 还活跃时生效 + LocalDateTime now = LocalDateTime.now(); + int updated = rtcCallMapper.updateByIdAndStatusIn(call.getId(), ImRtcCallStatusEnum.ACTIVE_STATUSES, + new ImRtcCallDO().setStatus(ImRtcCallStatusEnum.ENDED.getStatus()) + .setEndReason(reason.getReason()).setEndTime(now)); + if (updated == 0) { + log.info("[endSession][已被另一路径终结,跳过 callId={} roomName={} operator={} reason={}]", + call.getCallId(), call.getRoomName(), operatorId, reason); return; } - // TODO @AI:链式调用,相同的在一行; - ImRtcGroupNotification payload = new ImRtcGroupNotification(); - payload.setCallId(session.getCallId()); - payload.setRoomName(session.getRoomName()); - payload.setGroupId(session.getGroupId()); - payload.setMediaType(session.getMediaType()); - payload.setInviterId(session.getInviterId()); - payload.setJoinedUserIds(new LinkedHashSet<>(session.getJoinedUserIds())); - if (session.getInviteeIds() != null) { - payload.setInviteeIds(new LinkedHashSet<>(session.getInviteeIds())); - } - Long senderId = operatorId != null ? operatorId : session.getInviterId(); - String content = JsonUtils.toJsonString(payload); - LocalDateTime now = LocalDateTime.now(); - List members = groupMemberService - .getActiveGroupMemberListByGroupId(session.getGroupId()); - for (ImGroupMemberDO member : members) { - ImPrivateMessageDTO dto = new ImPrivateMessageDTO() - .setType(messageType) - .setSenderId(senderId) - .setReceiverId(member.getUserId()) - .setContent(content) - .setSendTime(now); - webSocketService.sendPrivateMessageAsync(member.getUserId(), dto); + // 1.2 更新参与表为已结束:残留 INVITING 改 NO_ANSWER;残留 JOINED 改 LEFT 并写 leaveTime + rtcParticipantMapper.updateByCallIdAndStatus(call.getCallId(), ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())); + rtcParticipantMapper.updateByCallIdAndStatus(call.getCallId(), ImRtcParticipantStatusEnum.JOINED.getStatus(), + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now)); + + // 2. 兜底删除 LiveKit 房间,强制断开异常残留客户端;失败仅记日志,不阻断业务 + try { + liveKitClient.deleteRoom(call.getRoomName()); + } catch (Exception e) { + log.warn("[endSession][删除 LiveKit 房间失败 callId={} roomName={} operator={} reason={}]", + call.getCallId(), call.getRoomName(), operatorId, reason, e); } + + // 3. 推 RTC_CALL_END + Long durationSeconds = call.getAcceptTime() != null ? + Duration.between(call.getAcceptTime(), now).getSeconds() : null; + pushCallEndNotification(call, operatorId, reason, durationSeconds); + log.info("[endSession][roomName={} operator={} reason={}]", call.getRoomName(), operatorId, reason); } - // TODO @AI:注释风格; - /** 根据会话取出(不含 token 的)基础 VO */ - private ImRtcCallRespVO toBaseVO(ImCallSessionBO session) { - return BeanUtils.toBean(session, ImRtcCallRespVO.class) - .setLivekitUrl(imProperties.getRtc().getLivekitUrl()); + + /** + * 通话事件的收件人池:私聊为双方参与者,群聊为群活跃成员 + */ + private Collection getCallAudience(ImRtcCallDO call) { + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + return groupMemberService.getActiveGroupMemberUserIdsByGroupId(call.getGroupId()); + } + return CollectionUtils.convertSet( + rtcParticipantMapper.selectListByCallId(call.getCallId()), ImRtcParticipantDO::getUserId); } - /** 构造完整响应:含 token + livekitUrl + newCreated */ - // TODO @AI:controller 组装; - private ImRtcCallRespVO buildResp(ImCallSessionBO session, Long userId, boolean newCreated) { - AdminUserRespDTO user = adminUserApi.getUser(userId); - String displayName = user == null ? null : user.getNickname(); - ImRtcCallRespVO vo = toBaseVO(session); - vo.setToken(signToken(userId, displayName, session.getRoomName())); - vo.setNewCreated(newCreated); - return vo; + // ========== 推送 ========== + + /** + * RTC_CALL(INVITE):走 webSocketService 直推到被邀请人 + *

+ * 不能走 imPrivateMessageService.send:persistent=false 时 send 仅推 sender 多端,receiver 不感知 + */ + private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter, Long inviteeId) { + String token = signToken(inviteeId, inviter == null ? null : inviter.getNickname(), call.getRoomName()); + ImRtcCallNotification payload = ImRtcCallNotification.ofInvite( + call, inviter, imProperties.getRtc().getLivekitUrl(), token); + webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification( + ImMessageTypeEnum.RTC_CALL.getType(), call.getInviterUserId(), inviteeId, payload)); } - /** 签 LiveKit Token;identity 用 userId 字符串保证多端唯一 */ - private String signToken(Long userId, String displayName, String roomName) { - ImProperties.Rtc cfg = imProperties.getRtc(); - return LiveKitTokenUtils.signJoinToken( - cfg.getApiKey(), cfg.getApiSecret(), - String.valueOf(userId), displayName, - roomName, Duration.ofHours(cfg.getTokenTtlHours())); - } - - // TODO @AI:方法名,改成项目里,符合预期的; - /** 拉取必存在的会话;不存在直接抛 */ - private ImCallSessionBO requireSession(String roomName) { - ImCallSessionBO session = sessionStore.get(roomName); - if (session == null) { - throw exception(RTC_SESSION_NOT_EXISTS); + /** + * RTC_CALL(REJECT):仅群通话场景;走 webSocketService 直推主叫 + */ + private void pushCallRejectNotification(ImRtcCallDO call, Long operatorUserId) { + AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId) : null; + ImRtcCallNotification payload = ImRtcCallNotification.ofReject(call, operatorUserId, operator); + webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification( + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload)); + } + + /** + * 通话参与者加入:LiveKit webhook participant_joined 触发;私聊推双方多端、群聊推全群成员(胶囊条 + 1) + * + * @param call 通话主表 + * @param userId 加入的参与者用户编号 + */ + private void pushParticipantConnectedNotification(ImRtcCallDO call, Long userId) { + pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_CONNECTED.getType(), userId, + ImRtcParticipantConnectedNotification.of(call, userId)); + } + + /** + * 通话参与者离开:LiveKit webhook participant_left 触发;推送范围同 {@link #pushParticipantConnectedNotification} + * + * @param call 通话主表 + * @param userId 离开的参与者用户编号 + */ + private void pushParticipantDisconnectedNotification(ImRtcCallDO call, Long userId) { + pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_DISCONNECTED.getType(), userId, + ImRtcParticipantDisconnectedNotification.of(call, userId)); + } + + /** + * 推送参与者事件的公共骨架;按会话类型决定收件人,单次 batch 推送扇出 + */ + private void pushParticipantNotification(ImRtcCallDO call, Integer type, Long actorUserId, Object payload) { + Collection receivers = getCallAudience(call); + if (CollUtil.isEmpty(receivers)) { + return; } - return session; + ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofRtcNotification(type, actorUserId, null, payload); + webSocketService.sendPrivateMessageAsync(receivers, dto); + } + + /** + * RTC_CALL_START:仅群通话场景;走 imGroupMessageService.send,入 im_group_message 全群广播 + *

+ * 与 RTC_CALL_END 两段式配对:START 在本接口(invite)事务里 INSERT,END 在 cancel / leave 接口事务里 INSERT, + * 自增 id 自然保证聊天流顺序 + */ + private void pushCallStartNotification(ImRtcCallDO call, AdminUserRespDTO inviter) { + ImRtcCallStartNotification payload = ImRtcCallStartNotification.of(call, inviter); + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(call.getGroupId()) + .setType(ImMessageTypeEnum.RTC_CALL_START.getType()) + .setContent(payload); + groupMessageService.sendGroupMessage(call.getInviterUserId(), dto); + } + + /** + * RTC_CALL_END:私聊走 imPrivateMessageService.send;群通话走 imGroupMessageService.send + *

+ * senderId 始终用通话发起人,让前端按「谁发起通话」决定气泡左右;操作者从 payload.operatorUserId 拿 + */ + private void pushCallEndNotification(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason, + Long durationSeconds) { + AdminUserRespDTO operator = operatorId != null ? adminUserApi.getUser(operatorId) : null; + ImRtcCallEndNotification payload = ImRtcCallEndNotification.of(call, reason, durationSeconds, operatorId, operator); + Long senderId = call.getInviterUserId(); + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() + .setGroupId(call.getGroupId()) + .setType(ImMessageTypeEnum.RTC_CALL_END.getType()) + .setContent(payload); + groupMessageService.sendGroupMessage(senderId, dto); + return; + } + // 私聊:发起人总是 sender;receiver = 通话另一方 + ImRtcParticipantDO peer = CollUtil.findOne( + rtcParticipantMapper.selectListByCallId(call.getCallId()), + p -> !Objects.equals(p.getUserId(), senderId)); + Long receiverId = peer != null ? peer.getUserId() : senderId; + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() + .setReceiverId(receiverId) + .setType(ImMessageTypeEnum.RTC_CALL_END.getType()) + .setContent(payload); + privateMessageService.sendPrivateMessage(senderId, dto); + } + + // ========== Token / VO ========== + + /** + * 签 LiveKit Token + */ + private String signToken(Long userId, String displayName, String roomName) { + return liveKitClient.signJoinToken(liveKitClient.buildIdentity(userId), displayName, roomName); } }