Commit 27685b99c8c7ff467382f80119f6d479caff6d3a

Authored by YunaiV
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
```
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);