Commit 2fd201bf59bafdd2a43647e686aac32e53a3f266

Authored by YunaiV
1 parent 9e1a6b15

✨ feat(im): 振铃超时 Job 单人粒度标 NO_ANSWER + 独立 NO_ANSWER 信令推送

✨ feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java
... ... @@ -93,13 +93,12 @@ public class ImRtcCallController {
93 93 return success(true);
94 94 }
95 95  
96   - @GetMapping("/refresh-token")
97   - @Operation(summary = "重新签发 Token;客户端重连或 Token 过期续期")
  96 + @PostMapping("/no-answer-call-check")
  97 + @Operation(summary = "前端 RUNNING 端 timer 兜底;触发后端立即扫描该 room 的振铃超时(接口静默)")
98 98 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567")
99   - public CommonResult<ImRtcCallRespVO> refreshToken(@RequestParam("room") String room) {
100   - Long userId = getLoginUserId();
101   - ImRtcCallDO call = rtcCallService.validateCallParticipant(userId, room);
102   - return success(buildCallRespVO(call, userId));
  99 + public CommonResult<Boolean> noAnswerCallCheck(@RequestParam("room") String room) {
  100 + rtcCallService.noAnswerCallCheck(getLoginUserId(), room);
  101 + return success(true);
103 102 }
104 103  
105 104 @GetMapping("/get-active-call")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java
... ... @@ -34,6 +34,13 @@ public interface ImRtcParticipantMapper extends BaseMapperX&lt;ImRtcParticipantDO&gt;
34 34 .lt(ImRtcParticipantDO::getInviteTime, threshold));
35 35 }
36 36  
  37 + default List<ImRtcParticipantDO> selectListByRoomAndStatusAndInviteTimeBefore(String room, Integer status, LocalDateTime threshold) {
  38 + return selectList(new LambdaQueryWrapperX<ImRtcParticipantDO>()
  39 + .eq(ImRtcParticipantDO::getRoom, room)
  40 + .eq(ImRtcParticipantDO::getStatus, status)
  41 + .lt(ImRtcParticipantDO::getInviteTime, threshold));
  42 + }
  43 +
37 44 default ImRtcParticipantDO selectLastOneByUserIdAndStatus(Long userId, Collection<Integer> statuses) {
38 45 return selectLastOne(new LambdaQueryWrapperX<ImRtcParticipantDO>()
39 46 .eq(ImRtcParticipantDO::getUserId, userId)
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java
... ... @@ -74,17 +74,6 @@ public interface ImRtcCallService {
74 74 void leaveCall(Long userId, String room);
75 75  
76 76 /**
77   - * 校验通话活跃且本人是参与者;用于客户端重连或 Token 过期续期前的合法性检查
78   - * <p>
79   - * 仅做校验;签发新 Token 由 Controller 调 {@link #signCallToken} 完成
80   - *
81   - * @param userId 操作人编号
82   - * @param room 业务通话编号
83   - * @return 通话主表
84   - */
85   - ImRtcCallDO validateCallParticipant(Long userId, String room);
86   -
87   - /**
88 77 * 查询当前正在进行的通话;目前仅群聊场景(胶囊条),私聊未来扩展再补 peerUserId 参数
89 78 * <p>
90 79 * 鉴权:仅群活跃成员可查;防止任意用户探测群通话状态 / 拿到 inviter / inviteeIds 等敏感信息
... ... @@ -131,7 +120,7 @@ public interface ImRtcCallService {
131 120 int cleanupZombieCalls(int thresholdMinutes);
132 121  
133 122 /**
134   - * 【定时任务调用】超时未接通的 INVITING 参与者:单人粒度标 NO_ANSWER + 推 RTC_CALL(REJECT) 让前端 banner 收敛;
  123 + * 【定时任务调用】超时未接通的 INVITING 参与者:单人粒度标 NO_ANSWER + 推 RTC_CALL(NO_ANSWER) 让前端 banner 收敛;
135 124 * 若导致通话只剩主叫,由 endSessionIfTerminal 级联关房
136 125 *
137 126 * @param thresholdMinutes 邀请时间超过此分钟数才纳入扫描;调用方保证 > 0
... ... @@ -139,4 +128,14 @@ public interface ImRtcCallService {
139 128 */
140 129 int timeoutInvitingParticipants(int thresholdMinutes);
141 130  
  131 + /**
  132 + * 前端 RUNNING 端 timer 兜底;立即扫描指定 room 内超时的 INVITING 参与者,等同 Job 但限定单 room;
  133 + * 实际超时阈值由后端 {@link cn.iocoder.yudao.module.im.framework.config.ImProperties.Rtc#getInviteTimeoutMinutes()} 决定,
  134 + * 避免前后端配置不一致;接口静默,所有边界(room 不存在 / 鉴权失败 / 无超时候选)都返回 false 不抛异常
  135 + *
  136 + * @param userId 调用者用户编号;必须是该 room 的参与者
  137 + * @param room 业务通话编号
  138 + */
  139 + void noAnswerCallCheck(Long userId, String room);
  140 +
142 141 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
... ... @@ -452,16 +452,6 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
452 452 }
453 453  
454 454 @Override
455   - public ImRtcCallDO validateCallParticipant(Long userId, String room) {
456   - validateEnabled();
457   - // 1.1 校验通话存在且活跃
458   - ImRtcCallDO call = validateCallActive(room);
459   - // 1.2 校验本人是该通话的参与者
460   - validateParticipant(call, userId);
461   - return call;
462   - }
463   -
464   - @Override
465 455 public ImRtcCallDO getActiveCall(Long userId, Long groupId) {
466 456 validateEnabled();
467 457 // 1. 鉴权:仅群活跃成员能查(走单行 SQL,不依赖成员列表缓存)
... ... @@ -616,14 +606,36 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
616 606 public int timeoutInvitingParticipants(int thresholdMinutes) {
617 607 // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态
618 608 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes);
619   - List<ImRtcParticipantDO> candidates = rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(
620   - ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold);
  609 + return noAnswerCallCheck0(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(
  610 + ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold));
  611 + }
  612 +
  613 + @Override
  614 + public void noAnswerCallCheck(Long userId, String room) {
  615 + // 鉴权:仅该 room 参与者可触发;失败静默,不暴露错误
  616 + ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(room, userId);
  617 + if (operator == null) {
  618 + return;
  619 + }
  620 + // 阈值取后端配置,避免前后端配置不一致;前端 timer 仅是触发时机
  621 + LocalDateTime threshold = LocalDateTime.now()
  622 + .minusMinutes(imProperties.getRtc().getInviteTimeoutMinutes());
  623 + List<ImRtcParticipantDO> candidates = rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore(
  624 + room, ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold);
  625 + noAnswerCallCheck0(candidates);
  626 + }
  627 +
  628 + /**
  629 + * 批量超时处理:循环单参与者;同 room 复用 call、批量预查 user 避免 N+1;返回成功处理数
  630 + *
  631 + * @param candidates 已过滤的超时 INVITING 候选
  632 + * @return 成功处理(CAS 抢占)的数量
  633 + */
  634 + private int noAnswerCallCheck0(List<ImRtcParticipantDO> candidates) {
621 635 if (CollUtil.isEmpty(candidates)) {
622 636 return 0;
623 637 }
624   - // 同 room 多人同批超时时复用 call,避免 N 次 selectByRoom
625 638 Map<String, ImRtcCallDO> callCache = new HashMap<>();
626   - // 批量预查 operator user,避免循环里逐个 adminUserApi.getUser 走 N+1
627 639 Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
628 640 CollectionUtils.convertSet(candidates, ImRtcParticipantDO::getUserId));
629 641 int timedOut = 0;
... ... @@ -850,18 +862,19 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
850 862 rtcParticipantMapper.updateByRoomAndStatus(call.getRoom(), ImRtcParticipantStatusEnum.JOINED.getStatus(),
851 863 new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now));
852 864  
853   - // 2. 兜底删除 LiveKit 房间,强制断开异常残留客户端;失败仅记日志,不阻断业务
  865 + // 2. 推 RTC_CALL_END;先于 deleteRoom 异步发出,让前端按业务语义 reset(NO_ANSWER / CANCEL 等),
  866 + // 避免随后 LiveKit Disconnected 事件抢先触发前端 "通话已断开" 兜底 toast
  867 + Long durationSeconds = call.getAcceptTime() != null ?
  868 + Duration.between(call.getAcceptTime(), now).getSeconds() : null;
  869 + pushCallEndNotification(call, operatorId, reason, durationSeconds);
  870 +
  871 + // 3. 兜底删除 LiveKit 房间,强制断开异常残留客户端;失败仅记日志,不阻断业务
854 872 try {
855 873 liveKitClient.deleteRoom(call.getRoom());
856 874 } catch (Exception e) {
857 875 log.warn("[endSession][删除 LiveKit 房间失败 room={} operator={} reason={}]",
858 876 call.getRoom(), operatorId, reason, e);
859 877 }
860   -
861   - // 3. 推 RTC_CALL_END
862   - Long durationSeconds = call.getAcceptTime() != null ?
863   - Duration.between(call.getAcceptTime(), now).getSeconds() : null;
864   - pushCallEndNotification(call, operatorId, reason, durationSeconds);
865 878 log.info("[endSession][room={} operator={} reason={}]", call.getRoom(), operatorId, reason);
866 879 }
867 880  
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
  5 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
  6 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;
  7 +import jakarta.annotation.Resource;
  8 +import org.junit.jupiter.api.Test;
  9 +
  10 +import java.time.LocalDateTime;
  11 +import java.util.List;
  12 +
  13 +import static org.junit.jupiter.api.Assertions.*;
  14 +
  15 +public class ImRtcParticipantMapperTest extends BaseDbUnitTest {
  16 +
  17 + @Resource
  18 + private ImRtcParticipantMapper mapper;
  19 +
  20 + // ========== selectByRoomAndUserId ==========
  21 +
  22 + @Test
  23 + public void testSelectByRoomAndUserId_match() {
  24 + // 准备:room=r1 / userId=100 命中;同 room 其他 user 不应误命中
  25 + ImRtcParticipantDO target = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);
  26 + insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING);
  27 + insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING);
  28 +
  29 + // 调用 + 断言
  30 + ImRtcParticipantDO got = mapper.selectByRoomAndUserId("r1", 100L);
  31 + assertNotNull(got);
  32 + assertEquals(target.getId(), got.getId());
  33 + }
  34 +
  35 + @Test
  36 + public void testSelectByRoomAndUserId_miss() {
  37 + // 准备:room 不存在的查询返 null
  38 + insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);
  39 +
  40 + // 调用 + 断言
  41 + assertNull(mapper.selectByRoomAndUserId("r2", 100L));
  42 + assertNull(mapper.selectByRoomAndUserId("r1", 999L));
  43 + }
  44 +
  45 + // ========== selectListByRoom ==========
  46 +
  47 + @Test
  48 + public void testSelectListByRoom_filterByRoom() {
  49 + // 准备:r1 两条、r2 一条
  50 + insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);
  51 + insert("r1", 101L, ImRtcParticipantStatusEnum.JOINED);
  52 + insert("r2", 200L, ImRtcParticipantStatusEnum.INVITING);
  53 +
  54 + // 调用 + 断言:仅 r1 命中两条
  55 + List<ImRtcParticipantDO> result = mapper.selectListByRoom("r1");
  56 + assertEquals(2, result.size());
  57 + assertTrue(result.stream().allMatch(p -> "r1".equals(p.getRoom())));
  58 + }
  59 +
  60 + // ========== selectListByStatusAndInviteTimeBefore ==========
  61 +
  62 + @Test
  63 + public void testSelectListByStatusAndInviteTimeBefore_filterStatusAndTime() {
  64 + // 准备:3 条 INVITING(一旧两新)+ 1 条 JOINED(旧,状态不匹配)
  65 + LocalDateTime now = LocalDateTime.now();
  66 + ImRtcParticipantDO oldInviting = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5));
  67 + insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));
  68 + insert("r2", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));
  69 + insert("r3", 103L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5));
  70 +
  71 + // 调用:cutoff = now - 1 分钟,只命中早于该时间的 INVITING
  72 + List<ImRtcParticipantDO> result = mapper.selectListByStatusAndInviteTimeBefore(
  73 + ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1));
  74 +
  75 + // 断言:仅 oldInviting 命中(status + time 双重过滤)
  76 + assertEquals(1, result.size());
  77 + assertEquals(oldInviting.getId(), result.get(0).getId());
  78 + }
  79 +
  80 + @Test
  81 + public void testSelectListByStatusAndInviteTimeBefore_emptyWhenNoMatch() {
  82 + // 准备:所有 INVITING 都是新鲜的
  83 + LocalDateTime now = LocalDateTime.now();
  84 + insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));
  85 +
  86 + // 调用 + 断言:cutoff 在更早的时间,无命中
  87 + List<ImRtcParticipantDO> result = mapper.selectListByStatusAndInviteTimeBefore(
  88 + ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(5));
  89 + assertTrue(result.isEmpty());
  90 + }
  91 +
  92 + // ========== selectListByRoomAndStatusAndInviteTimeBefore ==========
  93 +
  94 + @Test
  95 + public void testSelectListByRoomAndStatusAndInviteTimeBefore_limitToRoom() {
  96 + // 准备:r1 一旧 INVITING;r2 一旧 INVITING;r1 一新 INVITING
  97 + LocalDateTime now = LocalDateTime.now();
  98 + ImRtcParticipantDO r1Old = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5));
  99 + insert("r2", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5));
  100 + insert("r1", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));
  101 +
  102 + // 调用:限定 room=r1,cutoff = now - 1 分钟
  103 + List<ImRtcParticipantDO> result = mapper.selectListByRoomAndStatusAndInviteTimeBefore(
  104 + "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1));
  105 +
  106 + // 断言:r2 的旧 INVITING 被 room 过滤掉;r1 的新 INVITING 被 time 过滤掉
  107 + assertEquals(1, result.size());
  108 + assertEquals(r1Old.getId(), result.get(0).getId());
  109 + }
  110 +
  111 + @Test
  112 + public void testSelectListByRoomAndStatusAndInviteTimeBefore_ignoresOtherStatus() {
  113 + // 准备:r1 一旧 JOINED(虽然 time 满足,status 不匹配)+ 一旧 REJECTED
  114 + LocalDateTime now = LocalDateTime.now();
  115 + insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5));
  116 + insert("r1", 101L, ImRtcParticipantStatusEnum.REJECTED, now.minusMinutes(5));
  117 +
  118 + // 调用 + 断言:仅扫 INVITING 状态,无命中
  119 + List<ImRtcParticipantDO> result = mapper.selectListByRoomAndStatusAndInviteTimeBefore(
  120 + "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1));
  121 + assertTrue(result.isEmpty());
  122 + }
  123 +
  124 + // ========== selectLastOneByUserIdAndStatus ==========
  125 +
  126 + @Test
  127 + public void testSelectLastOneByUserIdAndStatus_returnsLatestActive() {
  128 + // 准备:user 100 两条 INVITING(不同 room)+ 一条 LEFT
  129 + insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT);
  130 + insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING);
  131 + ImRtcParticipantDO latest = insert("r3", 100L, ImRtcParticipantStatusEnum.JOINED);
  132 +
  133 + // 调用:忙线检测取 ACTIVE_STATUSES,应取 id 最大那条
  134 + ImRtcParticipantDO got = mapper.selectLastOneByUserIdAndStatus(100L,
  135 + ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
  136 +
  137 + // 断言:返回最新的 JOINED 记录(id 最大)
  138 + assertNotNull(got);
  139 + assertEquals(latest.getId(), got.getId());
  140 + }
  141 +
  142 + @Test
  143 + public void testSelectLastOneByUserIdAndStatus_missWhenAllTerminal() {
  144 + // 准备:user 100 只有终态记录
  145 + insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT);
  146 + insert("r2", 100L, ImRtcParticipantStatusEnum.REJECTED);
  147 +
  148 + // 调用 + 断言:忙线检测无命中
  149 + assertNull(mapper.selectLastOneByUserIdAndStatus(100L,
  150 + ImRtcParticipantStatusEnum.ACTIVE_STATUSES));
  151 + }
  152 +
  153 + // ========== updateByIdAndStatus(振铃超时 CAS 路径关键) ==========
  154 +
  155 + @Test
  156 + public void testUpdateByIdAndStatus_inviting2NoAnswer_success() {
  157 + // 准备:一条 INVITING 候选
  158 + ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, LocalDateTime.now().minusMinutes(5));
  159 +
  160 + // 调用:CAS INVITING → NO_ANSWER;模拟 Job 单参与者抢占
  161 + int updated = mapper.updateByIdAndStatus(p.getId(),
  162 + ImRtcParticipantStatusEnum.INVITING.getStatus(),
  163 + new ImRtcParticipantDO().setId(p.getId())
  164 + .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())
  165 + .setLeaveTime(LocalDateTime.now()));
  166 +
  167 + // 断言:CAS 成功 + 状态真的落地
  168 + assertEquals(1, updated);
  169 + assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(),
  170 + mapper.selectById(p.getId()).getStatus());
  171 + }
  172 +
  173 + @Test
  174 + public void testUpdateByIdAndStatus_concurrentReject_casFails() {
  175 + // 准备:一条记录已经被用户主动 reject(状态变成 REJECTED)
  176 + ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.REJECTED, LocalDateTime.now().minusMinutes(5));
  177 +
  178 + // 调用:Job 仍按 INVITING 抢占;并发已变更应失败
  179 + int updated = mapper.updateByIdAndStatus(p.getId(),
  180 + ImRtcParticipantStatusEnum.INVITING.getStatus(),
  181 + new ImRtcParticipantDO().setId(p.getId())
  182 + .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())
  183 + .setLeaveTime(LocalDateTime.now()));
  184 +
  185 + // 断言:CAS 失败 + 原状态保留
  186 + assertEquals(0, updated);
  187 + assertEquals(ImRtcParticipantStatusEnum.REJECTED.getStatus(),
  188 + mapper.selectById(p.getId()).getStatus());
  189 + }
  190 +
  191 + // ========== updateByRoomAndStatus(endSession 批量改 INVITING → NO_ANSWER 路径关键) ==========
  192 +
  193 + @Test
  194 + public void testUpdateByRoomAndStatus_inviting2NoAnswer_batchUpdate() {
  195 + // 准备:r1 两条 INVITING + 一条 JOINED(状态不匹配,不应被改);r2 一条 INVITING(room 不匹配,不应被改)
  196 + ImRtcParticipantDO inv1 = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);
  197 + ImRtcParticipantDO inv2 = insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING);
  198 + ImRtcParticipantDO joined = insert("r1", 102L, ImRtcParticipantStatusEnum.JOINED);
  199 + ImRtcParticipantDO otherRoom = insert("r2", 103L, ImRtcParticipantStatusEnum.INVITING);
  200 +
  201 + // 调用:模拟 endSession 把残留 INVITING 批量改 NO_ANSWER
  202 + int updated = mapper.updateByRoomAndStatus("r1",
  203 + ImRtcParticipantStatusEnum.INVITING.getStatus(),
  204 + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()));
  205 +
  206 + // 断言:仅 r1 的 2 条 INVITING 被改,其它保持
  207 + assertEquals(2, updated);
  208 + assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv1.getId()).getStatus());
  209 + assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv2.getId()).getStatus());
  210 + assertEquals(ImRtcParticipantStatusEnum.JOINED.getStatus(), mapper.selectById(joined.getId()).getStatus());
  211 + assertEquals(ImRtcParticipantStatusEnum.INVITING.getStatus(), mapper.selectById(otherRoom.getId()).getStatus());
  212 + }
  213 +
  214 + @Test
  215 + public void testUpdateByRoomAndStatus_noMatch_returnsZero() {
  216 + // 准备:r1 只有 JOINED 状态
  217 + insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED);
  218 +
  219 + // 调用:找 INVITING 应无命中
  220 + int updated = mapper.updateByRoomAndStatus("r1",
  221 + ImRtcParticipantStatusEnum.INVITING.getStatus(),
  222 + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()));
  223 +
  224 + // 断言:0 行受影响
  225 + assertEquals(0, updated);
  226 + }
  227 +
  228 + private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status) {
  229 + return insert(room, userId, status, LocalDateTime.now());
  230 + }
  231 +
  232 + private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status, LocalDateTime inviteTime) {
  233 + ImRtcParticipantDO p = new ImRtcParticipantDO()
  234 + .setRoom(room)
  235 + .setUserId(userId)
  236 + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole())
  237 + .setStatus(status.getStatus())
  238 + .setInviteTime(inviteTime);
  239 + mapper.insert(p);
  240 + return p;
  241 + }
  242 +
  243 +}
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.rtc;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
  6 +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
  7 +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper;
  8 +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
  9 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
  10 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
  11 +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;
  12 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  13 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  14 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  15 +import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
  16 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  17 +import org.junit.jupiter.api.Test;
  18 +import org.mockito.ArgumentCaptor;
  19 +import org.mockito.InjectMocks;
  20 +import org.mockito.Mock;
  21 +
  22 +import java.time.Duration;
  23 +import java.time.LocalDateTime;
  24 +import java.util.Collections;
  25 +import java.util.List;
  26 +import java.util.Map;
  27 +
  28 +import static org.junit.jupiter.api.Assertions.*;
  29 +import static org.mockito.ArgumentMatchers.*;
  30 +import static org.mockito.Mockito.*;
  31 +
  32 +public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
  33 +
  34 + @InjectMocks
  35 + private ImRtcCallServiceImpl rtcCallService;
  36 +
  37 + @Mock
  38 + private ImRtcParticipantMapper rtcParticipantMapper;
  39 + @Mock
  40 + private ImRtcCallMapper rtcCallMapper;
  41 + @Mock
  42 + private AdminUserApi adminUserApi;
  43 + @Mock
  44 + private ImWebSocketService webSocketService;
  45 + @Mock
  46 + private ImProperties imProperties;
  47 +
  48 + // ========== timeoutInvitingParticipants(Job 入口)==========
  49 +
  50 + @Test
  51 + public void testTimeoutInvitingParticipants_emptyCandidates_returnsZeroAndNoDownstream() {
  52 + // 准备:无超时候选
  53 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(
  54 + eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class)))
  55 + .thenReturn(Collections.emptyList());
  56 +
  57 + // 调用
  58 + int result = rtcCallService.timeoutInvitingParticipants(1);
  59 +
  60 + // 断言:返回 0;无候选时不应触发 user 预查 / call 查询 / 推送
  61 + assertEquals(0, result);
  62 + verifyNoInteractions(adminUserApi, rtcCallMapper, webSocketService);
  63 + }
  64 +
  65 + @Test
  66 + public void testTimeoutInvitingParticipants_thresholdConvertedToCutoff() {
  67 + // 准备:阈值 5 分钟;mock 空候选避免触发后续逻辑
  68 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any(LocalDateTime.class)))
  69 + .thenReturn(Collections.emptyList());
  70 +
  71 + // 调用
  72 + LocalDateTime before = LocalDateTime.now();
  73 + rtcCallService.timeoutInvitingParticipants(5);
  74 +
  75 + // 断言:cutoff = now - 5 分钟(允许 5 秒漂移)
  76 + ArgumentCaptor<LocalDateTime> cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
  77 + verify(rtcParticipantMapper).selectListByStatusAndInviteTimeBefore(
  78 + eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture());
  79 + LocalDateTime cutoff = cutoffCaptor.getValue();
  80 + LocalDateTime expected = before.minusMinutes(5);
  81 + assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5,
  82 + "cutoff 应当约等于 now - 5 min;实际:" + cutoff);
  83 + }
  84 +
  85 + @Test
  86 + public void testTimeoutInvitingParticipants_casAllFails_noPushNoEndSession() {
  87 + // 准备:候选非空但每个 CAS 都失败(并发已变状态)
  88 + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING);
  89 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any()))
  90 + .thenReturn(List.of(p));
  91 + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L)));
  92 + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
  93 + .thenReturn(0);
  94 +
  95 + // 调用
  96 + int result = rtcCallService.timeoutInvitingParticipants(1);
  97 +
  98 + // 断言:CAS 全失败时返回 0;不查 call、不推送
  99 + assertEquals(0, result);
  100 + verify(rtcCallMapper, never()).selectByRoom(any());
  101 + verifyNoInteractions(webSocketService);
  102 + }
  103 +
  104 + @Test
  105 + public void testTimeoutInvitingParticipants_groupCall_pushesNoAnswerSkipsEndSession() {
  106 + // 准备:群通话单候选 CAS 成功;shouldCloseGroupRoom 通过 selectListByRoom 多 JOINED 让其返 false,跳过 endSession
  107 + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING);
  108 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any()))
  109 + .thenReturn(List.of(p));
  110 + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L)));
  111 + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
  112 + .thenReturn(1);
  113 + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
  114 + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
  115 + // 房内 2 个 JOINED + 1 个 INVITING → shouldCloseGroupRoom 返 false
  116 + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of(
  117 + buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
  118 + buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED),
  119 + buildParticipant(22L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING)
  120 + ));
  121 +
  122 + // 调用
  123 + int result = rtcCallService.timeoutInvitingParticipants(1);
  124 +
  125 + // 断言:成功 1 个;NO_ANSWER 信令推到主叫;不触发 endSession
  126 + assertEquals(1, result);
  127 + verify(webSocketService).sendPrivateMessageAsync(eq(200L), any(ImPrivateMessageDTO.class));
  128 + verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any());
  129 + }
  130 +
  131 + @Test
  132 + public void testTimeoutInvitingParticipants_callMissing_silentSkip() {
  133 + // 准备:CAS 成功后通话主表缺失(异常兜底场景)
  134 + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING);
  135 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any()))
  136 + .thenReturn(List.of(p));
  137 + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L)));
  138 + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
  139 + .thenReturn(1);
  140 + when(rtcCallMapper.selectByRoom("r1")).thenReturn(null);
  141 +
  142 + // 调用
  143 + int result = rtcCallService.timeoutInvitingParticipants(1);
  144 +
  145 + // 断言:CAS 已成功但 call 缺失视为部分失败返 0;不应推送
  146 + assertEquals(0, result);
  147 + verifyNoInteractions(webSocketService);
  148 + }
  149 +
  150 + // ========== noAnswerCallCheck(前端 timer 入口)==========
  151 +
  152 + @Test
  153 + public void testNoAnswerCallCheck_authFails_silentNoOp() {
  154 + // 准备:selectByRoomAndUserId 返 null 覆盖三种鉴权失败场景(非参与者 / 非法 room / null room)
  155 + when(rtcParticipantMapper.selectByRoomAndUserId(any(), eq(100L))).thenReturn(null);
  156 +
  157 + // 调用
  158 + rtcCallService.noAnswerCallCheck(100L, "r1");
  159 + rtcCallService.noAnswerCallCheck(100L, "");
  160 + rtcCallService.noAnswerCallCheck(100L, null);
  161 +
  162 + // 断言:仅鉴权查询了 3 次;不应进入后续超时扫描 / 推送
  163 + verify(rtcParticipantMapper, times(3)).selectByRoomAndUserId(any(), eq(100L));
  164 + verify(rtcParticipantMapper, never()).selectListByRoomAndStatusAndInviteTimeBefore(any(), any(), any());
  165 + verifyNoInteractions(adminUserApi, webSocketService);
  166 + }
  167 +
  168 + @Test
  169 + public void testNoAnswerCallCheck_usesBackendThreshold_notFrontend() {
  170 + // 准备:鉴权通过 + 后端配置阈值 2 分钟 + 无候选(避免触发推送)
  171 + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
  172 + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING));
  173 + ImProperties.Rtc rtcConfig = new ImProperties.Rtc();
  174 + rtcConfig.setInviteTimeoutMinutes(2);
  175 + when(imProperties.getRtc()).thenReturn(rtcConfig);
  176 + when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore(
  177 + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class)))
  178 + .thenReturn(Collections.emptyList());
  179 +
  180 + // 调用
  181 + LocalDateTime before = LocalDateTime.now();
  182 + rtcCallService.noAnswerCallCheck(100L, "r1");
  183 +
  184 + // 断言:扫描时使用 cutoff = now - 2 分钟(后端配置),而非前端 60s
  185 + ArgumentCaptor<LocalDateTime> cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
  186 + verify(rtcParticipantMapper).selectListByRoomAndStatusAndInviteTimeBefore(
  187 + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture());
  188 + LocalDateTime cutoff = cutoffCaptor.getValue();
  189 + LocalDateTime expected = before.minusMinutes(2);
  190 + assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5,
  191 + "cutoff 应当约等于 now - 2 min(后端配置);实际:" + cutoff);
  192 + }
  193 +
  194 + @Test
  195 + public void testNoAnswerCallCheck_groupCall_pushesNoAnswer() {
  196 + // 准备:鉴权通过 + 单候选 CAS 成功 + 群通话不关房
  197 + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
  198 + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING));
  199 + ImProperties.Rtc rtcConfig = new ImProperties.Rtc();
  200 + rtcConfig.setInviteTimeoutMinutes(1);
  201 + when(imProperties.getRtc()).thenReturn(rtcConfig);
  202 + ImRtcParticipantDO timeoutTarget = buildParticipant(11L, "r1", 101L, ImRtcParticipantStatusEnum.INVITING);
  203 + when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore(
  204 + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
  205 + .thenReturn(List.of(timeoutTarget));
  206 + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(101L, buildUser(101L)));
  207 + when(rtcParticipantMapper.updateByIdAndStatus(eq(11L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
  208 + .thenReturn(1);
  209 + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
  210 + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
  211 + // 让 shouldCloseGroupRoom 返 false
  212 + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of(
  213 + buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
  214 + buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED)
  215 + ));
  216 +
  217 + // 调用
  218 + rtcCallService.noAnswerCallCheck(100L, "r1");
  219 +
  220 + // 断言:NO_ANSWER 信令推到主叫 200L;不触发 endSession
  221 + verify(webSocketService).sendPrivateMessageAsync(eq(200L), any(ImPrivateMessageDTO.class));
  222 + verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any());
  223 + }
  224 +
  225 + // ========== 测试数据构造 ==========
  226 +
  227 + private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) {
  228 + return new ImRtcParticipantDO()
  229 + .setId(id)
  230 + .setRoom(room)
  231 + .setUserId(userId)
  232 + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole())
  233 + .setStatus(status.getStatus())
  234 + .setInviteTime(LocalDateTime.now());
  235 + }
  236 +
  237 + private ImRtcCallDO buildCall(String room, Long inviterUserId, ImConversationTypeEnum conversationType, Long groupId) {
  238 + return new ImRtcCallDO()
  239 + .setRoom(room)
  240 + .setConversationType(conversationType.getType())
  241 + .setMediaType(1)
  242 + .setInviterUserId(inviterUserId)
  243 + .setGroupId(groupId)
  244 + .setStatus(ImRtcCallStatusEnum.RUNNING.getStatus())
  245 + .setStartTime(LocalDateTime.now());
  246 + }
  247 +
  248 + private AdminUserRespDTO buildUser(Long id) {
  249 + AdminUserRespDTO user = new AdminUserRespDTO();
  250 + user.setId(id);
  251 + user.setNickname("user-" + id);
  252 + return user;
  253 + }
  254 +
  255 +}
... ...
yudao-module-im/src/test/resources/sql/clean.sql
... ... @@ -8,3 +8,5 @@ DELETE FROM &quot;im_group_request&quot;;
8 8 DELETE FROM "im_face_pack";
9 9 DELETE FROM "im_face_pack_item";
10 10 DELETE FROM "im_face_user_item";
  11 +DELETE FROM "im_rtc_call";
  12 +DELETE FROM "im_rtc_participant";
... ...
yudao-module-im/src/test/resources/sql/create_tables.sql
... ... @@ -179,6 +179,45 @@ CREATE TABLE IF NOT EXISTS &quot;im_face_pack_item&quot; (
179 179 PRIMARY KEY ("id")
180 180 ) COMMENT 'IM 表情包项表';
181 181  
  182 +CREATE TABLE IF NOT EXISTS "im_rtc_call" (
  183 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
  184 + "room" varchar(64) NOT NULL COMMENT '业务通话编号',
  185 + "conversation_type" tinyint NOT NULL COMMENT '会话类型',
  186 + "media_type" tinyint NOT NULL COMMENT '媒体类型',
  187 + "inviter_user_id" bigint NOT NULL COMMENT '发起人用户编号',
  188 + "group_id" bigint DEFAULT NULL COMMENT '群编号',
  189 + "status" tinyint NOT NULL COMMENT '通话状态',
  190 + "end_reason" tinyint DEFAULT NULL COMMENT '结束原因',
  191 + "start_time" timestamp NOT NULL COMMENT '发起时间',
  192 + "accept_time" timestamp DEFAULT NULL COMMENT '接通时间',
  193 + "end_time" timestamp DEFAULT NULL COMMENT '结束时间',
  194 + "creator" varchar(64) DEFAULT '',
  195 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  196 + "updater" varchar(64) DEFAULT '',
  197 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  198 + "deleted" bit NOT NULL DEFAULT FALSE,
  199 + "tenant_id" bigint NOT NULL DEFAULT 0,
  200 + PRIMARY KEY ("id")
  201 +) COMMENT 'IM 通话记录表';
  202 +
  203 +CREATE TABLE IF NOT EXISTS "im_rtc_participant" (
  204 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
  205 + "room" varchar(64) NOT NULL COMMENT '业务通话编号',
  206 + "user_id" bigint NOT NULL COMMENT '参与者用户编号',
  207 + "role" tinyint NOT NULL COMMENT '参与角色',
  208 + "status" tinyint NOT NULL COMMENT '参与状态',
  209 + "invite_time" timestamp NOT NULL COMMENT '被邀请时间',
  210 + "accept_time" timestamp DEFAULT NULL COMMENT '接听时间',
  211 + "leave_time" timestamp DEFAULT NULL COMMENT '离开时间',
  212 + "creator" varchar(64) DEFAULT '',
  213 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  214 + "updater" varchar(64) DEFAULT '',
  215 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  216 + "deleted" bit NOT NULL DEFAULT FALSE,
  217 + "tenant_id" bigint NOT NULL DEFAULT 0,
  218 + PRIMARY KEY ("id")
  219 +) COMMENT 'IM 通话参与者表';
  220 +
182 221 CREATE TABLE IF NOT EXISTS "im_face_user_item" (
183 222 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
184 223 "user_id" bigint NOT NULL COMMENT '所属用户编号',
... ...