Commit 27685b99c8c7ff467382f80119f6d479caff6d3a
1 parent
bb5ac45b
```
✨ feat(im): RTC 通话逻辑收口(displayName / inviteeIds 校验 / 配置化阈值) - fix: pushCallInviteNotification 给被邀请人签 token 时 displayName 改用 invitee 自己的昵称(原来错用 inviter) - feat: 群通话发起 / 追加邀请均强制 inviteeIds 非空,对齐微信交互;超量改抛 RTC_GROUP_INVITEE_OVER_LIMIT,不再静默截断 - refactor: ImGroupMemberService 抽 validateMembersInGroup(groupId, ids) 批量校验,inviteMoreCall / resolveInvitees 复用 - refactor: 僵尸通话清理阈值从 ImRtcCallCleanupJob 静态常量挪到 ImProperties.rtc.cleanupZombieThresholdMinutes - perf: createCall / inviteMoreCall 推 INVITE 前一次 getUserMap 批量拉用户,省 N 次 RPC - refactor: inviteMoreCall 参与者构造改 CollectionUtils.convertList;trim 多余注释 - docs: 补齐 RTC service / controller / job / LiveKitClient 私有方法的 @param / @return - chore: 错误码新增 RTC_GROUP_INVITEE_REQUIRED / RTC_GROUP_INVITEE_OVER_LIMIT ```
Showing
7 changed files
with
145 additions
and
48 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
| @@ -92,5 +92,6 @@ public interface ErrorCodeConstants { | @@ -92,5 +92,6 @@ public interface ErrorCodeConstants { | ||
| 92 | ErrorCode RTC_INVITE_BUSY = new ErrorCode(1_040_900_008, "通话发起繁忙,请稍后再试"); | 92 | ErrorCode RTC_INVITE_BUSY = new ErrorCode(1_040_900_008, "通话发起繁忙,请稍后再试"); |
| 93 | ErrorCode RTC_GROUP_CALL_ACTIVE = new ErrorCode(1_040_900_009, "该群已有进行中通话,请通过胶囊条加入"); | 93 | ErrorCode RTC_GROUP_CALL_ACTIVE = new ErrorCode(1_040_900_009, "该群已有进行中通话,请通过胶囊条加入"); |
| 94 | ErrorCode RTC_GROUP_INVITEE_OVER_LIMIT = new ErrorCode(1_040_900_010, "群通话邀请人数超过最大值"); | 94 | ErrorCode RTC_GROUP_INVITEE_OVER_LIMIT = new ErrorCode(1_040_900_010, "群通话邀请人数超过最大值"); |
| 95 | + ErrorCode RTC_GROUP_INVITEE_REQUIRED = new ErrorCode(1_040_900_011, "群通话必须选择被邀请人"); | ||
| 95 | 96 | ||
| 96 | } | 97 | } |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java
| @@ -165,6 +165,11 @@ public class ImProperties { | @@ -165,6 +165,11 @@ public class ImProperties { | ||
| 165 | */ | 165 | */ |
| 166 | private int groupMaxParticipants = 16; | 166 | private int groupMaxParticipants = 16; |
| 167 | 167 | ||
| 168 | + /** | ||
| 169 | + * 僵尸通话清理阈值(分钟);通话创建超过此值仍未结束才纳入扫描,避开「刚发起还在响铃」的合理零人态 | ||
| 170 | + */ | ||
| 171 | + private int cleanupZombieThresholdMinutes = 5; | ||
| 172 | + | ||
| 168 | } | 173 | } |
| 169 | 174 | ||
| 170 | } | 175 | } |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcCallCleanupJob.java
| @@ -4,6 +4,7 @@ import cn.hutool.core.util.NumberUtil; | @@ -4,6 +4,7 @@ import cn.hutool.core.util.NumberUtil; | ||
| 4 | import cn.hutool.core.util.StrUtil; | 4 | import cn.hutool.core.util.StrUtil; |
| 5 | import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; | 5 | import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; |
| 6 | import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; | 6 | import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; |
| 7 | +import cn.iocoder.yudao.module.im.framework.config.ImProperties; | ||
| 7 | import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; | 8 | import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; |
| 8 | import jakarta.annotation.Resource; | 9 | import jakarta.annotation.Resource; |
| 9 | import lombok.extern.slf4j.Slf4j; | 10 | import lombok.extern.slf4j.Slf4j; |
| @@ -18,18 +19,16 @@ import org.springframework.stereotype.Component; | @@ -18,18 +19,16 @@ import org.springframework.stereotype.Component; | ||
| 18 | @Slf4j | 19 | @Slf4j |
| 19 | public class ImRtcCallCleanupJob implements JobHandler { | 20 | public class ImRtcCallCleanupJob implements JobHandler { |
| 20 | 21 | ||
| 21 | - /** | ||
| 22 | - * 默认扫描阈值(分钟);通话创建超过此值仍未结束才纳入扫描,避开「刚发起还在响铃」的合理零人态 | ||
| 23 | - */ | ||
| 24 | - private static final int DEFAULT_THRESHOLD_MINUTES = 5; | ||
| 25 | - | ||
| 26 | @Resource | 22 | @Resource |
| 27 | private ImRtcCallService rtcCallService; | 23 | private ImRtcCallService rtcCallService; |
| 28 | 24 | ||
| 25 | + @Resource | ||
| 26 | + private ImProperties imProperties; | ||
| 27 | + | ||
| 29 | /** | 28 | /** |
| 30 | * 执行清理 | 29 | * 执行清理 |
| 31 | * | 30 | * |
| 32 | - * @param param 阈值(分钟);为空 / 非法走默认 {@link #DEFAULT_THRESHOLD_MINUTES} | 31 | + * @param param 阈值(分钟);为空 / 非法走 {@link ImProperties.Rtc#getCleanupZombieThresholdMinutes()} 默认值 |
| 33 | * @return 清理数量描述 | 32 | * @return 清理数量描述 |
| 34 | */ | 33 | */ |
| 35 | @Override | 34 | @Override |
| @@ -42,14 +41,18 @@ public class ImRtcCallCleanupJob implements JobHandler { | @@ -42,14 +41,18 @@ public class ImRtcCallCleanupJob implements JobHandler { | ||
| 42 | } | 41 | } |
| 43 | 42 | ||
| 44 | /** | 43 | /** |
| 45 | - * 解析 quartz param 为分钟阈值;非法 / 非正数走默认值 | 44 | + * 解析 quartz param 为分钟阈值;非法 / 非正数走配置默认值 |
| 45 | + * | ||
| 46 | + * @param param quartz 调度入参字符串 | ||
| 47 | + * @return 分钟阈值 | ||
| 46 | */ | 48 | */ |
| 47 | - private static int resolveThresholdMinutes(String param) { | 49 | + private int resolveThresholdMinutes(String param) { |
| 50 | + int defaultMinutes = imProperties.getRtc().getCleanupZombieThresholdMinutes(); | ||
| 48 | if (StrUtil.isBlank(param) || !NumberUtil.isInteger(param)) { | 51 | if (StrUtil.isBlank(param) || !NumberUtil.isInteger(param)) { |
| 49 | - return DEFAULT_THRESHOLD_MINUTES; | 52 | + return defaultMinutes; |
| 50 | } | 53 | } |
| 51 | int minutes = Integer.parseInt(param); | 54 | int minutes = Integer.parseInt(param); |
| 52 | - return minutes > 0 ? minutes : DEFAULT_THRESHOLD_MINUTES; | 55 | + return minutes > 0 ? minutes : defaultMinutes; |
| 53 | } | 56 | } |
| 54 | 57 | ||
| 55 | } | 58 | } |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java
| @@ -161,6 +161,14 @@ public interface ImGroupMemberService { | @@ -161,6 +161,14 @@ public interface ImGroupMemberService { | ||
| 161 | ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId); | 161 | ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId); |
| 162 | 162 | ||
| 163 | /** | 163 | /** |
| 164 | + * 批量校验用户都是该群的有效成员;任一缺失 / 禁用即抛 {@code GROUP_MEMBER_NOT_IN_GROUP} | ||
| 165 | + * | ||
| 166 | + * @param groupId 群编号 | ||
| 167 | + * @param userIds 用户编号集合;为空直接返回 | ||
| 168 | + */ | ||
| 169 | + void validateMembersInGroup(Long groupId, Collection<Long> userIds); | ||
| 170 | + | ||
| 171 | + /** | ||
| 164 | * 更新群成员信息(群内昵称、群名备注、免打扰等) | 172 | * 更新群成员信息(群内昵称、群名备注、免打扰等) |
| 165 | * <p> | 173 | * <p> |
| 166 | * 内部会校验用户是否为群的有效成员 | 174 | * 内部会校验用户是否为群的有效成员 |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java
| @@ -25,6 +25,7 @@ import java.util.*; | @@ -25,6 +25,7 @@ import java.util.*; | ||
| 25 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 25 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 26 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | 26 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 27 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; | 27 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; |
| 28 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; | ||
| 28 | import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS; | 29 | import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS; |
| 29 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; | 30 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; |
| 30 | 31 | ||
| @@ -218,6 +219,23 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | @@ -218,6 +219,23 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 218 | } | 219 | } |
| 219 | 220 | ||
| 220 | @Override | 221 | @Override |
| 222 | + public void validateMembersInGroup(Long groupId, Collection<Long> userIds) { | ||
| 223 | + if (CollUtil.isEmpty(userIds)) { | ||
| 224 | + return; | ||
| 225 | + } | ||
| 226 | + // 一次性拉取目标 userId 的成员记录,仅保留活跃状态 | ||
| 227 | + List<ImGroupMemberDO> members = groupMemberMapper.selectListByGroupIdAndUserIds(groupId, userIds); | ||
| 228 | + Set<Long> activeUserIds = convertSet(members, ImGroupMemberDO::getUserId, | ||
| 229 | + member -> CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus())); | ||
| 230 | + // 任一 userId 不在活跃集合即抛 | ||
| 231 | + for (Long userId : userIds) { | ||
| 232 | + if (!activeUserIds.contains(userId)) { | ||
| 233 | + throw exception(GROUP_MEMBER_NOT_IN_GROUP); | ||
| 234 | + } | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + @Override | ||
| 221 | public void updateGroupMemberRole(Long groupId, Collection<Long> userIds, Integer role) { | 239 | public void updateGroupMemberRole(Long groupId, Collection<Long> userIds, Integer role) { |
| 222 | if (CollUtil.isEmpty(userIds)) { | 240 | if (CollUtil.isEmpty(userIds)) { |
| 223 | return; | 241 | return; |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java
| @@ -45,22 +45,33 @@ public interface ImRtcCallService { | @@ -45,22 +45,33 @@ public interface ImRtcCallService { | ||
| 45 | /** | 45 | /** |
| 46 | * 接听通话:参与者 INVITING → JOINED;主表 CREATED → RUNNING(首次有非发起人接通时) | 46 | * 接听通话:参与者 INVITING → JOINED;主表 CREATED → RUNNING(首次有非发起人接通时) |
| 47 | * | 47 | * |
| 48 | + * @param userId 接听者用户编号 | ||
| 49 | + * @param room 业务通话编号 | ||
| 48 | * @return 通话主表 | 50 | * @return 通话主表 |
| 49 | */ | 51 | */ |
| 50 | ImRtcCallDO acceptCall(Long userId, String room); | 52 | ImRtcCallDO acceptCall(Long userId, String room); |
| 51 | 53 | ||
| 52 | /** | 54 | /** |
| 53 | * 拒绝通话;仅 INVITING 状态可拒;群通话拒绝等同于不参与,房间仍存在 | 55 | * 拒绝通话;仅 INVITING 状态可拒;群通话拒绝等同于不参与,房间仍存在 |
| 56 | + * | ||
| 57 | + * @param userId 拒接者用户编号 | ||
| 58 | + * @param room 业务通话编号 | ||
| 54 | */ | 59 | */ |
| 55 | void rejectCall(Long userId, String room); | 60 | void rejectCall(Long userId, String room); |
| 56 | 61 | ||
| 57 | /** | 62 | /** |
| 58 | * 取消邀请;主叫在 INVITING 状态主动取消 | 63 | * 取消邀请;主叫在 INVITING 状态主动取消 |
| 64 | + * | ||
| 65 | + * @param userId 取消者用户编号(必须是主叫) | ||
| 66 | + * @param room 业务通话编号 | ||
| 59 | */ | 67 | */ |
| 60 | void cancelCall(Long userId, String room); | 68 | void cancelCall(Long userId, String room); |
| 61 | 69 | ||
| 62 | /** | 70 | /** |
| 63 | * 离开通话;RUNNING 状态下离开;私聊任一方离开 = 通话结束;群通话最后一人离开才结束 | 71 | * 离开通话;RUNNING 状态下离开;私聊任一方离开 = 通话结束;群通话最后一人离开才结束 |
| 72 | + * | ||
| 73 | + * @param userId 离开者用户编号 | ||
| 74 | + * @param room 业务通话编号 | ||
| 64 | */ | 75 | */ |
| 65 | void leaveCall(Long userId, String room); | 76 | void leaveCall(Long userId, String room); |
| 66 | 77 | ||
| @@ -69,6 +80,8 @@ public interface ImRtcCallService { | @@ -69,6 +80,8 @@ public interface ImRtcCallService { | ||
| 69 | * <p> | 80 | * <p> |
| 70 | * 仅做校验;签发新 Token 由 Controller 调 {@link #signCallToken} 完成 | 81 | * 仅做校验;签发新 Token 由 Controller 调 {@link #signCallToken} 完成 |
| 71 | * | 82 | * |
| 83 | + * @param userId 操作人编号 | ||
| 84 | + * @param room 业务通话编号 | ||
| 72 | * @return 通话主表 | 85 | * @return 通话主表 |
| 73 | */ | 86 | */ |
| 74 | ImRtcCallDO validateCallParticipant(Long userId, String room); | 87 | ImRtcCallDO validateCallParticipant(Long userId, String room); |
| @@ -106,6 +119,8 @@ public interface ImRtcCallService { | @@ -106,6 +119,8 @@ public interface ImRtcCallService { | ||
| 106 | * <p> | 119 | * <p> |
| 107 | * 关键事件:participant_left(成员离开) / room_finished(房间结束)。前端正常 leave 时 | 120 | * 关键事件:participant_left(成员离开) / room_finished(房间结束)。前端正常 leave 时 |
| 108 | * 也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。 | 121 | * 也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。 |
| 122 | + * | ||
| 123 | + * @param event LiveKit Webhook 事件 | ||
| 109 | */ | 124 | */ |
| 110 | void handleLiveKitEvent(cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitWebhookEventDTO event); | 125 | void handleLiveKitEvent(cn.iocoder.yudao.module.im.framework.rtc.core.LiveKitWebhookEventDTO event); |
| 111 | 126 |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
| @@ -112,6 +112,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -112,6 +112,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 112 | 112 | ||
| 113 | /** | 113 | /** |
| 114 | * invite 锁内主体:按场景先做活跃通话 / 对端忙线校验,再做发起人自身忙线校验,最后建通话 | 114 | * invite 锁内主体:按场景先做活跃通话 / 对端忙线校验,再做发起人自身忙线校验,最后建通话 |
| 115 | + * | ||
| 116 | + * @param userId 发起人编号 | ||
| 117 | + * @param reqVO 发起请求 | ||
| 118 | + * @return 通话主表 | ||
| 115 | */ | 119 | */ |
| 116 | private ImRtcCallDO inviteCall0(Long userId, ImRtcCallInviteReqVO reqVO) { | 120 | private ImRtcCallDO inviteCall0(Long userId, ImRtcCallInviteReqVO reqVO) { |
| 117 | // 1.1 按场景检测活跃通话 | 121 | // 1.1 按场景检测活跃通话 |
| @@ -180,7 +184,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -180,7 +184,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 180 | } | 184 | } |
| 181 | rtcParticipantMapper.insertBatch(participants); | 185 | rtcParticipantMapper.insertBatch(participants); |
| 182 | 186 | ||
| 183 | - // 3.1 推送通知:RTC_CALL(INVITE) 给每个被邀请人;批量取用户避免 N 次 RPC | 187 | + // 3.1 推送通知:RTC_CALL(INVITE) 给每个被邀请人 |
| 184 | AdminUserRespDTO inviterUser = adminUserApi.getUser(inviterId); | 188 | AdminUserRespDTO inviterUser = adminUserApi.getUser(inviterId); |
| 185 | Map<Long, AdminUserRespDTO> inviteeMap = adminUserApi.getUserMap(invitees); | 189 | Map<Long, AdminUserRespDTO> inviteeMap = adminUserApi.getUserMap(invitees); |
| 186 | for (Long inviteeId : invitees) { | 190 | for (Long inviteeId : invitees) { |
| @@ -239,13 +243,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -239,13 +243,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 239 | throw exception(RTC_NOT_PARTICIPANT); | 243 | throw exception(RTC_NOT_PARTICIPANT); |
| 240 | } | 244 | } |
| 241 | // 1.4 校验被邀请人必须是该群活跃成员;非本群成员直接抛 | 245 | // 1.4 校验被邀请人必须是该群活跃成员;非本群成员直接抛 |
| 242 | - Set<Long> validMemberIds = new HashSet<>( | ||
| 243 | - groupMemberService.getActiveGroupMemberUserIdsByGroupId(call.getGroupId())); | ||
| 244 | - for (Long inviteeId : reqVO.getInviteeIds()) { | ||
| 245 | - if (!validMemberIds.contains(inviteeId)) { | ||
| 246 | - throw exception(GROUP_MEMBER_NOT_IN_GROUP); | ||
| 247 | - } | ||
| 248 | - } | 246 | + groupMemberService.validateMembersInGroup(call.getGroupId(), reqVO.getInviteeIds()); |
| 249 | // 1.5 排除已在通话池的;剩余即本次新邀请 | 247 | // 1.5 排除已在通话池的;剩余即本次新邀请 |
| 250 | Set<Long> existingUserIds = CollectionUtils.convertSet( | 248 | Set<Long> existingUserIds = CollectionUtils.convertSet( |
| 251 | rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId); | 249 | rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId); |
| @@ -257,18 +255,13 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -257,18 +255,13 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 257 | 255 | ||
| 258 | // 2. 批量 INSERT 新邀请人 | 256 | // 2. 批量 INSERT 新邀请人 |
| 259 | LocalDateTime now = LocalDateTime.now(); | 257 | LocalDateTime now = LocalDateTime.now(); |
| 260 | - // TODO @AI:convertList | ||
| 261 | - List<ImRtcParticipantDO> participants = new ArrayList<>(incomingUserIds.size()); | ||
| 262 | - for (Long inviteeId : incomingUserIds) { | ||
| 263 | - participants.add(new ImRtcParticipantDO() | ||
| 264 | - .setRoom(call.getRoom()).setUserId(inviteeId) | ||
| 265 | - .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) | ||
| 266 | - .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus()) | ||
| 267 | - .setInviteTime(now)); | ||
| 268 | - } | 258 | + List<ImRtcParticipantDO> participants = CollectionUtils.convertList(incomingUserIds, inviteeId -> |
| 259 | + new ImRtcParticipantDO().setRoom(call.getRoom()).setUserId(inviteeId) | ||
| 260 | + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) | ||
| 261 | + .setStatus(ImRtcParticipantStatusEnum.INVITING.getStatus()).setInviteTime(now)); | ||
| 269 | rtcParticipantMapper.insertBatch(participants); | 262 | rtcParticipantMapper.insertBatch(participants); |
| 270 | 263 | ||
| 271 | - // 3. 推送通知:RTC_CALL(INVITE) 给每个新邀请人;批量取用户避免 N 次 RPC | 264 | + // 3. 推送通知:RTC_CALL(INVITE) 给每个新邀请人 |
| 272 | AdminUserRespDTO inviter = adminUserApi.getUser(userId); | 265 | AdminUserRespDTO inviter = adminUserApi.getUser(userId); |
| 273 | Map<Long, AdminUserRespDTO> inviteeMap = adminUserApi.getUserMap(incomingUserIds); | 266 | Map<Long, AdminUserRespDTO> inviteeMap = adminUserApi.getUserMap(incomingUserIds); |
| 274 | for (Long inviteeId : incomingUserIds) { | 267 | for (Long inviteeId : incomingUserIds) { |
| @@ -375,6 +368,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -375,6 +368,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 375 | * 群通话至少需要 1 个 JOINED 在房才有意义;只剩 INVITING(响铃中)也无价值 | 368 | * 群通话至少需要 1 个 JOINED 在房才有意义;只剩 INVITING(响铃中)也无价值 |
| 376 | * <p> | 369 | * <p> |
| 377 | * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛 | 370 | * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛 |
| 371 | + * | ||
| 372 | + * @param room 业务通话编号 | ||
| 373 | + * @return true 表示房内已无 JOINED,需要关房 | ||
| 378 | */ | 374 | */ |
| 379 | private boolean shouldCloseGroupRoom(String room) { | 375 | private boolean shouldCloseGroupRoom(String room) { |
| 380 | List<ImRtcParticipantDO> joined = rtcParticipantMapper.selectListByRoomAndStatus(room, | 376 | List<ImRtcParticipantDO> joined = rtcParticipantMapper.selectListByRoomAndStatus(room, |
| @@ -386,6 +382,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -386,6 +382,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 386 | * 查询两人共同所在的活跃私聊通话 | 382 | * 查询两人共同所在的活跃私聊通话 |
| 387 | * <p> | 383 | * <p> |
| 388 | * 拆成 3 段简单查询:拿 A 的活跃 participant → 拿主表判私聊未结束 → 看 B 是否在同 call 活跃 | 384 | * 拆成 3 段简单查询:拿 A 的活跃 participant → 拿主表判私聊未结束 → 看 B 是否在同 call 活跃 |
| 385 | + * | ||
| 386 | + * @param userIdA 用户 A 编号 | ||
| 387 | + * @param userIdB 用户 B 编号 | ||
| 388 | + * @return 活跃私聊通话;不存在返回 null | ||
| 389 | */ | 389 | */ |
| 390 | private ImRtcCallDO getActivePrivateCallByPair(Long userIdA, Long userIdB) { | 390 | private ImRtcCallDO getActivePrivateCallByPair(Long userIdA, Long userIdB) { |
| 391 | ImRtcParticipantDO participantA = rtcParticipantMapper.selectLastOneByUserIdAndStatus(userIdA, ImRtcParticipantStatusEnum.ACTIVE_STATUSES); | 391 | ImRtcParticipantDO participantA = rtcParticipantMapper.selectLastOneByUserIdAndStatus(userIdA, ImRtcParticipantStatusEnum.ACTIVE_STATUSES); |
| @@ -485,6 +485,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -485,6 +485,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 485 | 485 | ||
| 486 | /** | 486 | /** |
| 487 | * 处理 LiveKit 「成员离开」事件:正常 {@link #leaveCall(Long, String)} 接口已经清理过的话,条件 UPDATE 自动幂等 | 487 | * 处理 LiveKit 「成员离开」事件:正常 {@link #leaveCall(Long, String)} 接口已经清理过的话,条件 UPDATE 自动幂等 |
| 488 | + * | ||
| 489 | + * @param event 事件 | ||
| 488 | */ | 490 | */ |
| 489 | private void handleParticipantLeft(LiveKitWebhookEventDTO event) { | 491 | private void handleParticipantLeft(LiveKitWebhookEventDTO event) { |
| 490 | if (ObjUtil.hasNull(event.getRoom(), event.getParticipant())) { | 492 | if (ObjUtil.hasNull(event.getRoom(), event.getParticipant())) { |
| @@ -521,6 +523,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -521,6 +523,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 521 | 523 | ||
| 522 | /** | 524 | /** |
| 523 | * 处理 LiveKit 「房间结束」事件:兜底把 call 推到 ENDED | 525 | * 处理 LiveKit 「房间结束」事件:兜底把 call 推到 ENDED |
| 526 | + * | ||
| 527 | + * @param event 事件 | ||
| 524 | */ | 528 | */ |
| 525 | private void handleRoomFinished(LiveKitWebhookEventDTO event) { | 529 | private void handleRoomFinished(LiveKitWebhookEventDTO event) { |
| 526 | if (event.getRoom() == null) { | 530 | if (event.getRoom() == null) { |
| @@ -577,6 +581,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -577,6 +581,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 577 | 581 | ||
| 578 | /** | 582 | /** |
| 579 | * 校验通话存在且活跃:不存在 / 已 ENDED 直接抛 | 583 | * 校验通话存在且活跃:不存在 / 已 ENDED 直接抛 |
| 584 | + * | ||
| 585 | + * @param room 业务通话编号 | ||
| 586 | + * @return 通话主表 | ||
| 580 | */ | 587 | */ |
| 581 | private ImRtcCallDO validateCallActive(String room) { | 588 | private ImRtcCallDO validateCallActive(String room) { |
| 582 | ImRtcCallDO call = rtcCallMapper.selectByRoom(room); | 589 | ImRtcCallDO call = rtcCallMapper.selectByRoom(room); |
| @@ -588,6 +595,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -588,6 +595,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 588 | 595 | ||
| 589 | /** | 596 | /** |
| 590 | * 校验本人是该通话的参与者:accept / reject / leave / refreshToken 的共用前置 | 597 | * 校验本人是该通话的参与者:accept / reject / leave / refreshToken 的共用前置 |
| 598 | + * | ||
| 599 | + * @param call 通话主表 | ||
| 600 | + * @param userId 用户编号 | ||
| 601 | + * @return 参与者记录 | ||
| 591 | */ | 602 | */ |
| 592 | private ImRtcParticipantDO validateParticipant(ImRtcCallDO call, Long userId) { | 603 | private ImRtcParticipantDO validateParticipant(ImRtcCallDO call, Long userId) { |
| 593 | ImRtcParticipantDO participant = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); | 604 | ImRtcParticipantDO participant = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); |
| @@ -599,6 +610,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -599,6 +610,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 599 | 610 | ||
| 600 | /** | 611 | /** |
| 601 | * 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 | 612 | * 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 |
| 613 | + * | ||
| 614 | + * @param call 通话主表 | ||
| 615 | + * @param operatorId 操作者用户编号;webhook 兜底场景可空 | ||
| 616 | + * @param reason 结束原因 | ||
| 602 | */ | 617 | */ |
| 603 | private void endSessionIfTerminal(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) { | 618 | private void endSessionIfTerminal(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) { |
| 604 | if (ImConversationTypeEnum.isPrivate(call.getConversationType()) | 619 | if (ImConversationTypeEnum.isPrivate(call.getConversationType()) |
| @@ -609,6 +624,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -609,6 +624,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 609 | 624 | ||
| 610 | /** | 625 | /** |
| 611 | * 校验 invite 入参;按场景区分必填字段,私聊补好友校验,群聊补群成员校验 | 626 | * 校验 invite 入参;按场景区分必填字段,私聊补好友校验,群聊补群成员校验 |
| 627 | + * | ||
| 628 | + * @param userId 发起人编号 | ||
| 629 | + * @param reqVO 发起请求 | ||
| 612 | */ | 630 | */ |
| 613 | private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) { | 631 | private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) { |
| 614 | Integer scene = reqVO.getScene(); | 632 | Integer scene = reqVO.getScene(); |
| @@ -626,7 +644,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -626,7 +644,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 626 | if (reqVO.getGroupId() == null) { | 644 | if (reqVO.getGroupId() == null) { |
| 627 | throw exception(RTC_GROUP_REQUIRED); | 645 | throw exception(RTC_GROUP_REQUIRED); |
| 628 | } | 646 | } |
| 629 | - // 走单行 SQL;不依赖成员列表缓存,避免新加入成员发起群通话被误判 | 647 | + // 群通话必须前端选中被邀请人(对齐微信);空集合直接拒 |
| 648 | + if (CollUtil.isEmpty(reqVO.getInviteeIds())) { | ||
| 649 | + throw exception(RTC_GROUP_INVITEE_REQUIRED); | ||
| 650 | + } | ||
| 630 | groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); | 651 | groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); |
| 631 | return; | 652 | return; |
| 632 | } | 653 | } |
| @@ -634,31 +655,23 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -634,31 +655,23 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 634 | } | 655 | } |
| 635 | 656 | ||
| 636 | /** | 657 | /** |
| 637 | - * 解析被邀请池:私聊为 peerUserId 单元素;群聊优先用前端选中的子集,否则取群活跃成员;超量直接抛错 | ||
| 638 | - * <p> | ||
| 639 | - * 群聊场景:被邀请人必须是该群活跃成员;防止恶意客户端塞任意 userId 进群房间 | 658 | + * 解析被邀请池:私聊为 peerUserId 单元素;群聊为前端选中子集(超量抛错) |
| 659 | + * | ||
| 660 | + * @param reqVO 发起请求 | ||
| 661 | + * @param inviterId 发起人编号;自己不进被邀请池 | ||
| 662 | + * @return 被邀请人 userId 集合 | ||
| 640 | */ | 663 | */ |
| 641 | private Set<Long> resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) { | 664 | private Set<Long> resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) { |
| 665 | + // 1. 私聊校验 | ||
| 642 | if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) { | 666 | if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) { |
| 643 | return CollUtil.newLinkedHashSet(reqVO.getPeerUserId()); | 667 | return CollUtil.newLinkedHashSet(reqVO.getPeerUserId()); |
| 644 | } | 668 | } |
| 645 | - List<Long> activeMemberIds = groupMemberService | ||
| 646 | - .getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); | ||
| 647 | - Set<Long> initial = new LinkedHashSet<>(); | ||
| 648 | - if (CollUtil.isNotEmpty(reqVO.getInviteeIds())) { | ||
| 649 | - Set<Long> activeMemberSet = new HashSet<>(activeMemberIds); | ||
| 650 | - for (Long inviteeId : reqVO.getInviteeIds()) { | ||
| 651 | - if (!activeMemberSet.contains(inviteeId)) { | ||
| 652 | - throw exception(GROUP_MEMBER_NOT_IN_GROUP); | ||
| 653 | - } | ||
| 654 | - initial.add(inviteeId); | ||
| 655 | - } | ||
| 656 | - } else { | ||
| 657 | - initial.addAll(activeMemberIds); | ||
| 658 | - } | ||
| 659 | - // 不论来源,发起人本人不进被邀请池 | 669 | + |
| 670 | + // 2. 群聊校验:被邀请人必须是该群活跃成员,防止恶意客户端塞任意 userId | ||
| 671 | + groupMemberService.validateMembersInGroup(reqVO.getGroupId(), reqVO.getInviteeIds()); | ||
| 672 | + Set<Long> initial = new LinkedHashSet<>(reqVO.getInviteeIds()); | ||
| 673 | + // 发起人本人不进被邀请池 | ||
| 660 | initial.remove(inviterId); | 674 | initial.remove(inviterId); |
| 661 | - // 超量直接抛错;前端按 max-1 限制选人 | ||
| 662 | int max = imProperties.getRtc().getGroupMaxParticipants(); | 675 | int max = imProperties.getRtc().getGroupMaxParticipants(); |
| 663 | if (initial.size() + 1 > max) { | 676 | if (initial.size() + 1 > max) { |
| 664 | throw exception(RTC_GROUP_INVITEE_OVER_LIMIT); | 677 | throw exception(RTC_GROUP_INVITEE_OVER_LIMIT); |
| @@ -668,6 +681,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -668,6 +681,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 668 | 681 | ||
| 669 | /** | 682 | /** |
| 670 | * 主表 CREATED → RUNNING;仅当首个非发起人加入时推进;条件 UPDATE 保幂等 | 683 | * 主表 CREATED → RUNNING;仅当首个非发起人加入时推进;条件 UPDATE 保幂等 |
| 684 | + * | ||
| 685 | + * @param call 通话主表;推进成功后会同步内存字段 | ||
| 686 | + * @param acceptorId 加入者用户编号;发起人加入不算「首次接通」 | ||
| 687 | + * @param now 当前时间 | ||
| 671 | */ | 688 | */ |
| 672 | private void maybeMarkOngoing(ImRtcCallDO call, Long acceptorId, LocalDateTime now) { | 689 | private void maybeMarkOngoing(ImRtcCallDO call, Long acceptorId, LocalDateTime now) { |
| 673 | // 1. 已 RUNNING / ENDED 直接退出;发起人加入不算「首次有人接通」 | 690 | // 1. 已 RUNNING / ENDED 直接退出;发起人加入不算「首次有人接通」 |
| @@ -696,6 +713,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -696,6 +713,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 696 | 713 | ||
| 697 | /** | 714 | /** |
| 698 | * 关闭会话:主表条件 UPDATE 推到 ENDED → 残留 INVITING 批量改 NO_ANSWER → 推 RTC_CALL_END | 715 | * 关闭会话:主表条件 UPDATE 推到 ENDED → 残留 INVITING 批量改 NO_ANSWER → 推 RTC_CALL_END |
| 716 | + * | ||
| 717 | + * @param call 通话主表 | ||
| 718 | + * @param operatorId 操作者用户编号;webhook 兜底 / Job 清理场景可空 | ||
| 719 | + * @param reason 结束原因 | ||
| 699 | */ | 720 | */ |
| 700 | private void endSession(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) { | 721 | private void endSession(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) { |
| 701 | // 1.1 更新通话主表为已结束;条件 UPDATE 仅在 status 还活跃时生效 | 722 | // 1.1 更新通话主表为已结束;条件 UPDATE 仅在 status 还活跃时生效 |
| @@ -747,6 +768,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -747,6 +768,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 747 | 768 | ||
| 748 | /** | 769 | /** |
| 749 | * RTC_CALL(INVITE):走 webSocketService 直推到被邀请人;persistent=false 不入消息流 | 770 | * RTC_CALL(INVITE):走 webSocketService 直推到被邀请人;persistent=false 不入消息流 |
| 771 | + * | ||
| 772 | + * @param call 通话主表 | ||
| 773 | + * @param inviter 发起人;可空,缺失时 payload 的 inviterNickname / Avatar 留空 | ||
| 774 | + * @param inviteeId 被邀请人用户编号 | ||
| 775 | + * @param invitee 被邀请人;可空,缺失时 token 内 displayName 降级为 userId | ||
| 750 | */ | 776 | */ |
| 751 | private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter, | 777 | private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter, |
| 752 | Long inviteeId, AdminUserRespDTO invitee) { | 778 | Long inviteeId, AdminUserRespDTO invitee) { |
| @@ -764,6 +790,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -764,6 +790,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 764 | * RTC_CALL(REJECT):仅群通话场景;走 webSocketService 直推主叫 | 790 | * RTC_CALL(REJECT):仅群通话场景;走 webSocketService 直推主叫 |
| 765 | * <p> | 791 | * <p> |
| 766 | * 私聊拒绝走 endSession → RTC_CALL_END(reason=REJECT) 入消息流,不在此推 | 792 | * 私聊拒绝走 endSession → RTC_CALL_END(reason=REJECT) 入消息流,不在此推 |
| 793 | + * | ||
| 794 | + * @param call 通话主表 | ||
| 795 | + * @param operatorUserId 拒接者用户编号 | ||
| 767 | */ | 796 | */ |
| 768 | private void pushCallRejectNotification(ImRtcCallDO call, Long operatorUserId) { | 797 | private void pushCallRejectNotification(ImRtcCallDO call, Long operatorUserId) { |
| 769 | AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId) : null; | 798 | AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId) : null; |
| @@ -796,6 +825,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -796,6 +825,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 796 | 825 | ||
| 797 | /** | 826 | /** |
| 798 | * 推送参与者事件的公共骨架;按会话类型决定收件人,单次 batch 推送扇出 | 827 | * 推送参与者事件的公共骨架;按会话类型决定收件人,单次 batch 推送扇出 |
| 828 | + * | ||
| 829 | + * @param call 通话主表 | ||
| 830 | + * @param type 消息类型;1602 / 1603 | ||
| 831 | + * @param actorUserId 触发本次事件的用户编号 | ||
| 832 | + * @param payload 业务 payload | ||
| 799 | */ | 833 | */ |
| 800 | private void pushParticipantNotification(ImRtcCallDO call, Integer type, Long actorUserId, Object payload) { | 834 | private void pushParticipantNotification(ImRtcCallDO call, Integer type, Long actorUserId, Object payload) { |
| 801 | Collection<Long> receivers = getCallAudienceUserIdList(call); | 835 | Collection<Long> receivers = getCallAudienceUserIdList(call); |
| @@ -808,6 +842,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -808,6 +842,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 808 | 842 | ||
| 809 | /** | 843 | /** |
| 810 | * RTC_CALL_START:仅群通话场景;走 imGroupMessageService.send,入 im_group_message 全群广播;与 RTC_CALL_END 两段式配对 | 844 | * RTC_CALL_START:仅群通话场景;走 imGroupMessageService.send,入 im_group_message 全群广播;与 RTC_CALL_END 两段式配对 |
| 845 | + * | ||
| 846 | + * @param call 通话主表 | ||
| 847 | + * @param inviter 发起人;可空 | ||
| 811 | */ | 848 | */ |
| 812 | private void pushCallStartNotification(ImRtcCallDO call, AdminUserRespDTO inviter) { | 849 | private void pushCallStartNotification(ImRtcCallDO call, AdminUserRespDTO inviter) { |
| 813 | ImRtcCallStartNotification payload = ImRtcCallStartNotification.of(call, inviter); | 850 | ImRtcCallStartNotification payload = ImRtcCallStartNotification.of(call, inviter); |
| @@ -820,6 +857,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -820,6 +857,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 820 | * RTC_CALL_END:私聊走 imPrivateMessageService.send;群通话走 imGroupMessageService.send | 857 | * RTC_CALL_END:私聊走 imPrivateMessageService.send;群通话走 imGroupMessageService.send |
| 821 | * <p> | 858 | * <p> |
| 822 | * senderId 始终用通话发起人,让前端按「谁发起通话」决定气泡左右;操作者从 payload.operatorUserId 拿 | 859 | * senderId 始终用通话发起人,让前端按「谁发起通话」决定气泡左右;操作者从 payload.operatorUserId 拿 |
| 860 | + * | ||
| 861 | + * @param call 通话主表 | ||
| 862 | + * @param operatorId 操作者用户编号;webhook 兜底 / Job 清理场景可空 | ||
| 863 | + * @param reason 结束原因 | ||
| 864 | + * @param durationSeconds 通话时长(秒);未接通时为 null | ||
| 823 | */ | 865 | */ |
| 824 | private void pushCallEndNotification(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason, | 866 | private void pushCallEndNotification(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason, |
| 825 | Long durationSeconds) { | 867 | Long durationSeconds) { |
| @@ -849,6 +891,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | @@ -849,6 +891,11 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 849 | 891 | ||
| 850 | /** | 892 | /** |
| 851 | * 签 LiveKit Token | 893 | * 签 LiveKit Token |
| 894 | + * | ||
| 895 | + * @param userId 用户编号;token 内 identity 由此派生 | ||
| 896 | + * @param displayName 房内显示名 | ||
| 897 | + * @param room LiveKit 房间名 | ||
| 898 | + * @return JWT 字符串 | ||
| 852 | */ | 899 | */ |
| 853 | private String signToken(Long userId, String displayName, String room) { | 900 | private String signToken(Long userId, String displayName, String room) { |
| 854 | return liveKitClient.signJoinToken(liveKitClient.buildIdentity(userId), displayName, room); | 901 | return liveKitClient.signJoinToken(liveKitClient.buildIdentity(userId), displayName, room); |