Commit b68303152808a56636fbf59e3e69a0ab44252c4f
1 parent
8b497fa6
✨ feat(im): rtc 交流通信的部分
Showing
1 changed file
with
610 additions
and
498 deletions
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 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | 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 | 6 | import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO; |
| 9 | 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 | 13 | import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; |
| 14 | -import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; | |
| 15 | 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 | 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 | 21 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; |
| 21 | 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 | 29 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; |
| 26 | 30 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; |
| 27 | 31 | import cn.iocoder.yudao.module.system.api.user.AdminUserApi; |
| 28 | 32 | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| 29 | 33 | import jakarta.annotation.Resource; |
| 34 | +import lombok.SneakyThrows; | |
| 30 | 35 | import lombok.extern.slf4j.Slf4j; |
| 31 | 36 | import org.springframework.stereotype.Service; |
| 32 | 37 | import org.springframework.validation.annotation.Validated; |
| ... | ... | @@ -41,6 +46,18 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 41 | 46 | |
| 42 | 47 | /** |
| 43 | 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 | 62 | * @author 芋道源码 |
| 46 | 63 | */ |
| ... | ... | @@ -49,9 +66,13 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 49 | 66 | @Slf4j |
| 50 | 67 | public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 51 | 68 | |
| 52 | - // TODO @AI:后续存储到 DB 里;(晚点改) | |
| 53 | 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 | 77 | @Resource |
| 57 | 78 | private ImGroupMemberService groupMemberService; |
| ... | ... | @@ -59,6 +80,13 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 59 | 80 | private ImFriendService friendService; |
| 60 | 81 | @Resource |
| 61 | 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 | 91 | @Resource |
| 64 | 92 | private AdminUserApi adminUserApi; |
| ... | ... | @@ -66,411 +94,507 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 66 | 94 | @Resource |
| 67 | 95 | private ImProperties imProperties; |
| 68 | 96 | |
| 97 | + // ========== 业务接口 ========== | |
| 98 | + | |
| 69 | 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 | 102 | ensureEnabled(); |
| 73 | - Integer scene = reqVO.getScene(); | |
| 74 | 103 | // 1. 校验入参与场景 |
| 75 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 407 | throw exception(RTC_NOT_PARTICIPANT); |
| 240 | 408 | } |
| 241 | - return buildResp(session, userId, false); | |
| 409 | + return call; | |
| 242 | 410 | } |
| 243 | 411 | |
| 244 | 412 | @Override |
| 245 | - public void inviteMore(Long userId, ImRtcCallInviteMoreReqVO reqVO) { | |
| 413 | + public ImRtcCallDO getActiveCall(Long userId, Long groupId) { | |
| 246 | 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 | 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 | 433 | @Override |
| 434 | + // TODO @AI:这个方法的编写,需要兼容 JDK8 | |
| 320 | 435 | public void handleLiveKitEvent(LiveKitWebhookEventDTO event) { |
| 321 | - // TODO @AI:需要 try catch 记录 hook + 成功 or 失败的日志么? | |
| 322 | 436 | if (event == null || event.getEvent() == null) { |
| 323 | 437 | return; |
| 324 | 438 | } |
| 325 | - // TODO @AI:需要检验 jdk8 语法; | |
| 326 | - // TODO @AI:livekit webhookevent 是不是搞个枚举类? | |
| 327 | 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 | 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 | 453 | if (event.getRoom() == null || event.getParticipant() == null) { |
| 345 | 454 | return; |
| 346 | 455 | } |
| 347 | 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 | 459 | return; |
| 351 | 460 | } |
| 352 | - Long userId = parseUserIdFromIdentity(event.getParticipant().getIdentity()); | |
| 461 | + Long userId = liveKitClient.parseUserId(event.getParticipant().getIdentity()); | |
| 353 | 462 | if (userId == null) { |
| 354 | 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 | 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 | 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 | 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 | 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 | 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 | 560 | private void ensureEnabled() { |
| 439 | 561 | if (!imProperties.getRtc().isEnabled()) { |
| 440 | 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 | 581 | private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) { |
| 446 | 582 | Integer scene = reqVO.getScene(); |
| 447 | 583 | if (ImConversationTypeEnum.isPrivate(scene)) { |
| 448 | - // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue; | |
| 449 | 584 | if (reqVO.getPeerUserId() == null) { |
| 450 | 585 | throw exception(RTC_PRIVATE_INVITEE_REQUIRED); |
| 451 | 586 | } |
| 452 | 587 | if (Objects.equals(userId, reqVO.getPeerUserId())) { |
| 453 | 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 | 592 | return; |
| 465 | 593 | } |
| 466 | 594 | if (ImConversationTypeEnum.isGroup(scene)) { |
| 467 | - // TODO @AI:这个在 validator,参数那校验;vo 里写 asserttrue; | |
| 468 | 595 | if (reqVO.getGroupId() == null) { |
| 469 | 596 | throw exception(RTC_GROUP_REQUIRED); |
| 470 | 597 | } |
| 471 | - // 当前用户必须是群活跃成员(包括主动加入已有通话的场景);非成员不允许进入群房间 | |
| 472 | - // TODO @AI:是不是校验下,被邀请人,是不是这个群里的? | |
| 473 | - // TODO @AI:member那搞个 ids 校验?避免太大的查询。 | |
| 474 | 598 | List<Long> activeMemberIds = groupMemberService |
| 475 | 599 | .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); |
| 476 | 600 | if (!activeMemberIds.contains(userId)) { |
| ... | ... | @@ -478,69 +602,35 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 478 | 602 | } |
| 479 | 603 | return; |
| 480 | 604 | } |
| 481 | - // TODO @AI:抛出异常的 scene 类型; | |
| 482 | 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 | 613 | private Set<Long> resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) { |
| 524 | - // TODO @AI:私聊。 | |
| 525 | 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 | 619 | Set<Long> initial = new LinkedHashSet<>(); |
| 533 | - // TODO @AI:必须传递,否则报错; | |
| 534 | 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 | 628 | } else { |
| 537 | - groupMemberService.getActiveGroupMemberListByGroupId(reqVO.getGroupId()) | |
| 538 | - .forEach(member -> initial.add(member.getUserId())); | |
| 629 | + initial.addAll(activeMemberIds); | |
| 539 | 630 | } |
| 540 | 631 | // 不论来源,发起人本人不进被邀请池 |
| 541 | 632 | initial.remove(inviterId); |
| 542 | 633 | // 超出最大同时在房成员数;截断为 max-1 个,避免邀请整个 500 人大群 |
| 543 | - // TODO @AI:超出,直接报错,不要裁剪; | |
| 544 | 634 | int max = imProperties.getRtc().getGroupMaxParticipants(); |
| 545 | 635 | if (initial.size() + 1 > max) { |
| 546 | 636 | return initial.stream().limit(max - 1L) |
| ... | ... | @@ -549,176 +639,198 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 549 | 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 | 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 | } | ... | ... |