From 2fd201bf59bafdd2a43647e686aac32e53a3f266 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 18 May 2026 09:45:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E6=8C=AF=E9=93=83?= =?UTF-8?q?=E8=B6=85=E6=97=B6=20Job=20=E5=8D=95=E4=BA=BA=E7=B2=92=E5=BA=A6?= =?UTF-8?q?=E6=A0=87=20NO=5FANSWER=20+=20=E7=8B=AC=E7=AB=8B=20NO=5FANSWER?= =?UTF-8?q?=20=E4=BF=A1=E4=BB=A4=E6=8E=A8=E9=80=81=20=E2=9C=A8=20feat(im):?= =?UTF-8?q?=20=E5=A4=84=E7=90=86=20RTC=5FCALL(NO=5FANSWER)=20=E4=BF=A1?= =?UTF-8?q?=E4=BB=A4=EF=BC=9B=E7=A7=81=E8=81=8A=E6=B0=94=E6=B3=A1=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=8C=E6=9C=AA=E6=8E=A5=E5=90=AC=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/rtc/ImRtcCallController.java | 11 +- .../dal/mysql/rtc/ImRtcParticipantMapper.java | 7 + .../im/service/rtc/ImRtcCallService.java | 23 +- .../im/service/rtc/ImRtcCallServiceImpl.java | 53 ++-- .../mysql/rtc/ImRtcParticipantMapperTest.java | 243 +++++++++++++++++ .../service/rtc/ImRtcCallServiceImplTest.java | 255 ++++++++++++++++++ .../src/test/resources/sql/clean.sql | 2 + .../src/test/resources/sql/create_tables.sql | 39 +++ 8 files changed, 595 insertions(+), 38 deletions(-) create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java index 1604417416..bebabf6297 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java @@ -93,13 +93,12 @@ public class ImRtcCallController { return success(true); } - @GetMapping("/refresh-token") - @Operation(summary = "重新签发 Token;客户端重连或 Token 过期续期") + @PostMapping("/no-answer-call-check") + @Operation(summary = "前端 RUNNING 端 timer 兜底;触发后端立即扫描该 room 的振铃超时(接口静默)") @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") - public CommonResult refreshToken(@RequestParam("room") String room) { - Long userId = getLoginUserId(); - ImRtcCallDO call = rtcCallService.validateCallParticipant(userId, room); - return success(buildCallRespVO(call, userId)); + public CommonResult noAnswerCallCheck(@RequestParam("room") String room) { + rtcCallService.noAnswerCallCheck(getLoginUserId(), room); + return success(true); } @GetMapping("/get-active-call") diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java index 714fd459d6..0e9e087ee9 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java +++ b/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::getInviteTime, threshold)); } + default List selectListByRoomAndStatusAndInviteTimeBefore(String room, Integer status, LocalDateTime threshold) { + return selectList(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getRoom, room) + .eq(ImRtcParticipantDO::getStatus, status) + .lt(ImRtcParticipantDO::getInviteTime, threshold)); + } + default ImRtcParticipantDO selectLastOneByUserIdAndStatus(Long userId, Collection statuses) { return selectLastOne(new LambdaQueryWrapperX() .eq(ImRtcParticipantDO::getUserId, userId) diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java index 8a88bacb5d..fb98b29a64 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java @@ -73,17 +73,6 @@ public interface ImRtcCallService { */ void leaveCall(Long userId, String room); - /** - * 校验通话活跃且本人是参与者;用于客户端重连或 Token 过期续期前的合法性检查 - *

- * 仅做校验;签发新 Token 由 Controller 调 {@link #signCallToken} 完成 - * - * @param userId 操作人编号 - * @param room 业务通话编号 - * @return 通话主表 - */ - ImRtcCallDO validateCallParticipant(Long userId, String room); - /** * 查询当前正在进行的通话;目前仅群聊场景(胶囊条),私聊未来扩展再补 peerUserId 参数 *

@@ -131,7 +120,7 @@ public interface ImRtcCallService { int cleanupZombieCalls(int thresholdMinutes); /** - * 【定时任务调用】超时未接通的 INVITING 参与者:单人粒度标 NO_ANSWER + 推 RTC_CALL(REJECT) 让前端 banner 收敛; + * 【定时任务调用】超时未接通的 INVITING 参与者:单人粒度标 NO_ANSWER + 推 RTC_CALL(NO_ANSWER) 让前端 banner 收敛; * 若导致通话只剩主叫,由 endSessionIfTerminal 级联关房 * * @param thresholdMinutes 邀请时间超过此分钟数才纳入扫描;调用方保证 > 0 @@ -139,4 +128,14 @@ public interface ImRtcCallService { */ int timeoutInvitingParticipants(int thresholdMinutes); + /** + * 前端 RUNNING 端 timer 兜底;立即扫描指定 room 内超时的 INVITING 参与者,等同 Job 但限定单 room; + * 实际超时阈值由后端 {@link cn.iocoder.yudao.module.im.framework.config.ImProperties.Rtc#getInviteTimeoutMinutes()} 决定, + * 避免前后端配置不一致;接口静默,所有边界(room 不存在 / 鉴权失败 / 无超时候选)都返回 false 不抛异常 + * + * @param userId 调用者用户编号;必须是该 room 的参与者 + * @param room 业务通话编号 + */ + void noAnswerCallCheck(Long userId, String room); + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java index 6767f93a65..a3b1980e59 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java @@ -451,16 +451,6 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { return call; } - @Override - public ImRtcCallDO validateCallParticipant(Long userId, String room) { - validateEnabled(); - // 1.1 校验通话存在且活跃 - ImRtcCallDO call = validateCallActive(room); - // 1.2 校验本人是该通话的参与者 - validateParticipant(call, userId); - return call; - } - @Override public ImRtcCallDO getActiveCall(Long userId, Long groupId) { validateEnabled(); @@ -616,14 +606,36 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { public int timeoutInvitingParticipants(int thresholdMinutes) { // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes); - List candidates = rtcParticipantMapper.selectListByStatusAndInviteTimeBefore( - ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold); + return noAnswerCallCheck0(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore( + ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold)); + } + + @Override + public void noAnswerCallCheck(Long userId, String room) { + // 鉴权:仅该 room 参与者可触发;失败静默,不暴露错误 + ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(room, userId); + if (operator == null) { + return; + } + // 阈值取后端配置,避免前后端配置不一致;前端 timer 仅是触发时机 + LocalDateTime threshold = LocalDateTime.now() + .minusMinutes(imProperties.getRtc().getInviteTimeoutMinutes()); + List candidates = rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore( + room, ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold); + noAnswerCallCheck0(candidates); + } + + /** + * 批量超时处理:循环单参与者;同 room 复用 call、批量预查 user 避免 N+1;返回成功处理数 + * + * @param candidates 已过滤的超时 INVITING 候选 + * @return 成功处理(CAS 抢占)的数量 + */ + private int noAnswerCallCheck0(List candidates) { if (CollUtil.isEmpty(candidates)) { return 0; } - // 同 room 多人同批超时时复用 call,避免 N 次 selectByRoom Map callCache = new HashMap<>(); - // 批量预查 operator user,避免循环里逐个 adminUserApi.getUser 走 N+1 Map userMap = adminUserApi.getUserMap( CollectionUtils.convertSet(candidates, ImRtcParticipantDO::getUserId)); int timedOut = 0; @@ -850,18 +862,19 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { rtcParticipantMapper.updateByRoomAndStatus(call.getRoom(), ImRtcParticipantStatusEnum.JOINED.getStatus(), new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now)); - // 2. 兜底删除 LiveKit 房间,强制断开异常残留客户端;失败仅记日志,不阻断业务 + // 2. 推 RTC_CALL_END;先于 deleteRoom 异步发出,让前端按业务语义 reset(NO_ANSWER / CANCEL 等), + // 避免随后 LiveKit Disconnected 事件抢先触发前端 "通话已断开" 兜底 toast + Long durationSeconds = call.getAcceptTime() != null ? + Duration.between(call.getAcceptTime(), now).getSeconds() : null; + pushCallEndNotification(call, operatorId, reason, durationSeconds); + + // 3. 兜底删除 LiveKit 房间,强制断开异常残留客户端;失败仅记日志,不阻断业务 try { liveKitClient.deleteRoom(call.getRoom()); } catch (Exception e) { log.warn("[endSession][删除 LiveKit 房间失败 room={} operator={} reason={}]", call.getRoom(), operatorId, reason, e); } - - // 3. 推 RTC_CALL_END - Long durationSeconds = call.getAcceptTime() != null ? - Duration.between(call.getAcceptTime(), now).getSeconds() : null; - pushCallEndNotification(call, operatorId, reason, durationSeconds); log.info("[endSession][room={} operator={} reason={}]", call.getRoom(), operatorId, reason); } diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java new file mode 100644 index 0000000000..f5ade6d140 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java @@ -0,0 +1,243 @@ +package cn.iocoder.yudao.module.im.dal.mysql.rtc; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class ImRtcParticipantMapperTest extends BaseDbUnitTest { + + @Resource + private ImRtcParticipantMapper mapper; + + // ========== selectByRoomAndUserId ========== + + @Test + public void testSelectByRoomAndUserId_match() { + // 准备:room=r1 / userId=100 命中;同 room 其他 user 不应误命中 + ImRtcParticipantDO target = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); + insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING); + insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING); + + // 调用 + 断言 + ImRtcParticipantDO got = mapper.selectByRoomAndUserId("r1", 100L); + assertNotNull(got); + assertEquals(target.getId(), got.getId()); + } + + @Test + public void testSelectByRoomAndUserId_miss() { + // 准备:room 不存在的查询返 null + insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); + + // 调用 + 断言 + assertNull(mapper.selectByRoomAndUserId("r2", 100L)); + assertNull(mapper.selectByRoomAndUserId("r1", 999L)); + } + + // ========== selectListByRoom ========== + + @Test + public void testSelectListByRoom_filterByRoom() { + // 准备:r1 两条、r2 一条 + insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); + insert("r1", 101L, ImRtcParticipantStatusEnum.JOINED); + insert("r2", 200L, ImRtcParticipantStatusEnum.INVITING); + + // 调用 + 断言:仅 r1 命中两条 + List result = mapper.selectListByRoom("r1"); + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(p -> "r1".equals(p.getRoom()))); + } + + // ========== selectListByStatusAndInviteTimeBefore ========== + + @Test + public void testSelectListByStatusAndInviteTimeBefore_filterStatusAndTime() { + // 准备:3 条 INVITING(一旧两新)+ 1 条 JOINED(旧,状态不匹配) + LocalDateTime now = LocalDateTime.now(); + ImRtcParticipantDO oldInviting = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5)); + insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); + insert("r2", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); + insert("r3", 103L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5)); + + // 调用:cutoff = now - 1 分钟,只命中早于该时间的 INVITING + List result = mapper.selectListByStatusAndInviteTimeBefore( + ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1)); + + // 断言:仅 oldInviting 命中(status + time 双重过滤) + assertEquals(1, result.size()); + assertEquals(oldInviting.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByStatusAndInviteTimeBefore_emptyWhenNoMatch() { + // 准备:所有 INVITING 都是新鲜的 + LocalDateTime now = LocalDateTime.now(); + insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); + + // 调用 + 断言:cutoff 在更早的时间,无命中 + List result = mapper.selectListByStatusAndInviteTimeBefore( + ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(5)); + assertTrue(result.isEmpty()); + } + + // ========== selectListByRoomAndStatusAndInviteTimeBefore ========== + + @Test + public void testSelectListByRoomAndStatusAndInviteTimeBefore_limitToRoom() { + // 准备:r1 一旧 INVITING;r2 一旧 INVITING;r1 一新 INVITING + LocalDateTime now = LocalDateTime.now(); + ImRtcParticipantDO r1Old = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5)); + insert("r2", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5)); + insert("r1", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); + + // 调用:限定 room=r1,cutoff = now - 1 分钟 + List result = mapper.selectListByRoomAndStatusAndInviteTimeBefore( + "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1)); + + // 断言:r2 的旧 INVITING 被 room 过滤掉;r1 的新 INVITING 被 time 过滤掉 + assertEquals(1, result.size()); + assertEquals(r1Old.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByRoomAndStatusAndInviteTimeBefore_ignoresOtherStatus() { + // 准备:r1 一旧 JOINED(虽然 time 满足,status 不匹配)+ 一旧 REJECTED + LocalDateTime now = LocalDateTime.now(); + insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5)); + insert("r1", 101L, ImRtcParticipantStatusEnum.REJECTED, now.minusMinutes(5)); + + // 调用 + 断言:仅扫 INVITING 状态,无命中 + List result = mapper.selectListByRoomAndStatusAndInviteTimeBefore( + "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1)); + assertTrue(result.isEmpty()); + } + + // ========== selectLastOneByUserIdAndStatus ========== + + @Test + public void testSelectLastOneByUserIdAndStatus_returnsLatestActive() { + // 准备:user 100 两条 INVITING(不同 room)+ 一条 LEFT + insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT); + insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING); + ImRtcParticipantDO latest = insert("r3", 100L, ImRtcParticipantStatusEnum.JOINED); + + // 调用:忙线检测取 ACTIVE_STATUSES,应取 id 最大那条 + ImRtcParticipantDO got = mapper.selectLastOneByUserIdAndStatus(100L, + ImRtcParticipantStatusEnum.ACTIVE_STATUSES); + + // 断言:返回最新的 JOINED 记录(id 最大) + assertNotNull(got); + assertEquals(latest.getId(), got.getId()); + } + + @Test + public void testSelectLastOneByUserIdAndStatus_missWhenAllTerminal() { + // 准备:user 100 只有终态记录 + insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT); + insert("r2", 100L, ImRtcParticipantStatusEnum.REJECTED); + + // 调用 + 断言:忙线检测无命中 + assertNull(mapper.selectLastOneByUserIdAndStatus(100L, + ImRtcParticipantStatusEnum.ACTIVE_STATUSES)); + } + + // ========== updateByIdAndStatus(振铃超时 CAS 路径关键) ========== + + @Test + public void testUpdateByIdAndStatus_inviting2NoAnswer_success() { + // 准备:一条 INVITING 候选 + ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, LocalDateTime.now().minusMinutes(5)); + + // 调用:CAS INVITING → NO_ANSWER;模拟 Job 单参与者抢占 + int updated = mapper.updateByIdAndStatus(p.getId(), + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setId(p.getId()) + .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()) + .setLeaveTime(LocalDateTime.now())); + + // 断言:CAS 成功 + 状态真的落地 + assertEquals(1, updated); + assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), + mapper.selectById(p.getId()).getStatus()); + } + + @Test + public void testUpdateByIdAndStatus_concurrentReject_casFails() { + // 准备:一条记录已经被用户主动 reject(状态变成 REJECTED) + ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.REJECTED, LocalDateTime.now().minusMinutes(5)); + + // 调用:Job 仍按 INVITING 抢占;并发已变更应失败 + int updated = mapper.updateByIdAndStatus(p.getId(), + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setId(p.getId()) + .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()) + .setLeaveTime(LocalDateTime.now())); + + // 断言:CAS 失败 + 原状态保留 + assertEquals(0, updated); + assertEquals(ImRtcParticipantStatusEnum.REJECTED.getStatus(), + mapper.selectById(p.getId()).getStatus()); + } + + // ========== updateByRoomAndStatus(endSession 批量改 INVITING → NO_ANSWER 路径关键) ========== + + @Test + public void testUpdateByRoomAndStatus_inviting2NoAnswer_batchUpdate() { + // 准备:r1 两条 INVITING + 一条 JOINED(状态不匹配,不应被改);r2 一条 INVITING(room 不匹配,不应被改) + ImRtcParticipantDO inv1 = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); + ImRtcParticipantDO inv2 = insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING); + ImRtcParticipantDO joined = insert("r1", 102L, ImRtcParticipantStatusEnum.JOINED); + ImRtcParticipantDO otherRoom = insert("r2", 103L, ImRtcParticipantStatusEnum.INVITING); + + // 调用:模拟 endSession 把残留 INVITING 批量改 NO_ANSWER + int updated = mapper.updateByRoomAndStatus("r1", + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())); + + // 断言:仅 r1 的 2 条 INVITING 被改,其它保持 + assertEquals(2, updated); + assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv1.getId()).getStatus()); + assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv2.getId()).getStatus()); + assertEquals(ImRtcParticipantStatusEnum.JOINED.getStatus(), mapper.selectById(joined.getId()).getStatus()); + assertEquals(ImRtcParticipantStatusEnum.INVITING.getStatus(), mapper.selectById(otherRoom.getId()).getStatus()); + } + + @Test + public void testUpdateByRoomAndStatus_noMatch_returnsZero() { + // 准备:r1 只有 JOINED 状态 + insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED); + + // 调用:找 INVITING 应无命中 + int updated = mapper.updateByRoomAndStatus("r1", + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())); + + // 断言:0 行受影响 + assertEquals(0, updated); + } + + private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status) { + return insert(room, userId, status, LocalDateTime.now()); + } + + private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status, LocalDateTime inviteTime) { + ImRtcParticipantDO p = new ImRtcParticipantDO() + .setRoom(room) + .setUserId(userId) + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) + .setStatus(status.getStatus()) + .setInviteTime(inviteTime); + mapper.insert(p); + return p; + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java new file mode 100644 index 0000000000..3f2714a6f6 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java @@ -0,0 +1,255 @@ +package cn.iocoder.yudao.module.im.service.rtc; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; +import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper; +import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper; +import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; +import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImRtcCallServiceImpl rtcCallService; + + @Mock + private ImRtcParticipantMapper rtcParticipantMapper; + @Mock + private ImRtcCallMapper rtcCallMapper; + @Mock + private AdminUserApi adminUserApi; + @Mock + private ImWebSocketService webSocketService; + @Mock + private ImProperties imProperties; + + // ========== timeoutInvitingParticipants(Job 入口)========== + + @Test + public void testTimeoutInvitingParticipants_emptyCandidates_returnsZeroAndNoDownstream() { + // 准备:无超时候选 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore( + eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:返回 0;无候选时不应触发 user 预查 / call 查询 / 推送 + assertEquals(0, result); + verifyNoInteractions(adminUserApi, rtcCallMapper, webSocketService); + } + + @Test + public void testTimeoutInvitingParticipants_thresholdConvertedToCutoff() { + // 准备:阈值 5 分钟;mock 空候选避免触发后续逻辑 + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // 调用 + LocalDateTime before = LocalDateTime.now(); + rtcCallService.timeoutInvitingParticipants(5); + + // 断言:cutoff = now - 5 分钟(允许 5 秒漂移) + ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(rtcParticipantMapper).selectListByStatusAndInviteTimeBefore( + eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture()); + LocalDateTime cutoff = cutoffCaptor.getValue(); + LocalDateTime expected = before.minusMinutes(5); + assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5, + "cutoff 应当约等于 now - 5 min;实际:" + cutoff); + } + + @Test + public void testTimeoutInvitingParticipants_casAllFails_noPushNoEndSession() { + // 准备:候选非空但每个 CAS 都失败(并发已变状态) + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any())) + .thenReturn(List.of(p)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(0); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:CAS 全失败时返回 0;不查 call、不推送 + assertEquals(0, result); + verify(rtcCallMapper, never()).selectByRoom(any()); + verifyNoInteractions(webSocketService); + } + + @Test + public void testTimeoutInvitingParticipants_groupCall_pushesNoAnswerSkipsEndSession() { + // 准备:群通话单候选 CAS 成功;shouldCloseGroupRoom 通过 selectListByRoom 多 JOINED 让其返 false,跳过 endSession + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any())) + .thenReturn(List.of(p)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(1); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + // 房内 2 个 JOINED + 1 个 INVITING → shouldCloseGroupRoom 返 false + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( + buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(22L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING) + )); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:成功 1 个;NO_ANSWER 信令推到主叫;不触发 endSession + assertEquals(1, result); + verify(webSocketService).sendPrivateMessageAsync(eq(200L), any(ImPrivateMessageDTO.class)); + verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any()); + } + + @Test + public void testTimeoutInvitingParticipants_callMissing_silentSkip() { + // 准备:CAS 成功后通话主表缺失(异常兜底场景) + ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any())) + .thenReturn(List.of(p)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(100L, buildUser(100L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(1); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(null); + + // 调用 + int result = rtcCallService.timeoutInvitingParticipants(1); + + // 断言:CAS 已成功但 call 缺失视为部分失败返 0;不应推送 + assertEquals(0, result); + verifyNoInteractions(webSocketService); + } + + // ========== noAnswerCallCheck(前端 timer 入口)========== + + @Test + public void testNoAnswerCallCheck_authFails_silentNoOp() { + // 准备:selectByRoomAndUserId 返 null 覆盖三种鉴权失败场景(非参与者 / 非法 room / null room) + when(rtcParticipantMapper.selectByRoomAndUserId(any(), eq(100L))).thenReturn(null); + + // 调用 + rtcCallService.noAnswerCallCheck(100L, "r1"); + rtcCallService.noAnswerCallCheck(100L, ""); + rtcCallService.noAnswerCallCheck(100L, null); + + // 断言:仅鉴权查询了 3 次;不应进入后续超时扫描 / 推送 + verify(rtcParticipantMapper, times(3)).selectByRoomAndUserId(any(), eq(100L)); + verify(rtcParticipantMapper, never()).selectListByRoomAndStatusAndInviteTimeBefore(any(), any(), any()); + verifyNoInteractions(adminUserApi, webSocketService); + } + + @Test + public void testNoAnswerCallCheck_usesBackendThreshold_notFrontend() { + // 准备:鉴权通过 + 后端配置阈值 2 分钟 + 无候选(避免触发推送) + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING)); + ImProperties.Rtc rtcConfig = new ImProperties.Rtc(); + rtcConfig.setInviteTimeoutMinutes(2); + when(imProperties.getRtc()).thenReturn(rtcConfig); + when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore( + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // 调用 + LocalDateTime before = LocalDateTime.now(); + rtcCallService.noAnswerCallCheck(100L, "r1"); + + // 断言:扫描时使用 cutoff = now - 2 分钟(后端配置),而非前端 60s + ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(rtcParticipantMapper).selectListByRoomAndStatusAndInviteTimeBefore( + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture()); + LocalDateTime cutoff = cutoffCaptor.getValue(); + LocalDateTime expected = before.minusMinutes(2); + assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5, + "cutoff 应当约等于 now - 2 min(后端配置);实际:" + cutoff); + } + + @Test + public void testNoAnswerCallCheck_groupCall_pushesNoAnswer() { + // 准备:鉴权通过 + 单候选 CAS 成功 + 群通话不关房 + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING)); + ImProperties.Rtc rtcConfig = new ImProperties.Rtc(); + rtcConfig.setInviteTimeoutMinutes(1); + when(imProperties.getRtc()).thenReturn(rtcConfig); + ImRtcParticipantDO timeoutTarget = buildParticipant(11L, "r1", 101L, ImRtcParticipantStatusEnum.INVITING); + when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore( + eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(List.of(timeoutTarget)); + when(adminUserApi.getUserMap(anySet())).thenReturn(Map.of(101L, buildUser(101L))); + when(rtcParticipantMapper.updateByIdAndStatus(eq(11L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any())) + .thenReturn(1); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + // 让 shouldCloseGroupRoom 返 false + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( + buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), + buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED) + )); + + // 调用 + rtcCallService.noAnswerCallCheck(100L, "r1"); + + // 断言:NO_ANSWER 信令推到主叫 200L;不触发 endSession + verify(webSocketService).sendPrivateMessageAsync(eq(200L), any(ImPrivateMessageDTO.class)); + verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any()); + } + + // ========== 测试数据构造 ========== + + private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) { + return new ImRtcParticipantDO() + .setId(id) + .setRoom(room) + .setUserId(userId) + .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) + .setStatus(status.getStatus()) + .setInviteTime(LocalDateTime.now()); + } + + private ImRtcCallDO buildCall(String room, Long inviterUserId, ImConversationTypeEnum conversationType, Long groupId) { + return new ImRtcCallDO() + .setRoom(room) + .setConversationType(conversationType.getType()) + .setMediaType(1) + .setInviterUserId(inviterUserId) + .setGroupId(groupId) + .setStatus(ImRtcCallStatusEnum.RUNNING.getStatus()) + .setStartTime(LocalDateTime.now()); + } + + private AdminUserRespDTO buildUser(Long id) { + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setId(id); + user.setNickname("user-" + id); + return user; + } + +} diff --git a/yudao-module-im/src/test/resources/sql/clean.sql b/yudao-module-im/src/test/resources/sql/clean.sql index d0d7e3e04d..1c74601499 100644 --- a/yudao-module-im/src/test/resources/sql/clean.sql +++ b/yudao-module-im/src/test/resources/sql/clean.sql @@ -8,3 +8,5 @@ DELETE FROM "im_group_request"; DELETE FROM "im_face_pack"; DELETE FROM "im_face_pack_item"; DELETE FROM "im_face_user_item"; +DELETE FROM "im_rtc_call"; +DELETE FROM "im_rtc_participant"; diff --git a/yudao-module-im/src/test/resources/sql/create_tables.sql b/yudao-module-im/src/test/resources/sql/create_tables.sql index e6034617ba..8e688e9be5 100644 --- a/yudao-module-im/src/test/resources/sql/create_tables.sql +++ b/yudao-module-im/src/test/resources/sql/create_tables.sql @@ -179,6 +179,45 @@ CREATE TABLE IF NOT EXISTS "im_face_pack_item" ( PRIMARY KEY ("id") ) COMMENT 'IM 表情包项表'; +CREATE TABLE IF NOT EXISTS "im_rtc_call" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "room" varchar(64) NOT NULL COMMENT '业务通话编号', + "conversation_type" tinyint NOT NULL COMMENT '会话类型', + "media_type" tinyint NOT NULL COMMENT '媒体类型', + "inviter_user_id" bigint NOT NULL COMMENT '发起人用户编号', + "group_id" bigint DEFAULT NULL COMMENT '群编号', + "status" tinyint NOT NULL COMMENT '通话状态', + "end_reason" tinyint DEFAULT NULL COMMENT '结束原因', + "start_time" timestamp NOT NULL COMMENT '发起时间', + "accept_time" timestamp DEFAULT NULL COMMENT '接通时间', + "end_time" timestamp DEFAULT NULL COMMENT '结束时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 通话记录表'; + +CREATE TABLE IF NOT EXISTS "im_rtc_participant" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "room" varchar(64) NOT NULL COMMENT '业务通话编号', + "user_id" bigint NOT NULL COMMENT '参与者用户编号', + "role" tinyint NOT NULL COMMENT '参与角色', + "status" tinyint NOT NULL COMMENT '参与状态', + "invite_time" timestamp NOT NULL COMMENT '被邀请时间', + "accept_time" timestamp DEFAULT NULL COMMENT '接听时间', + "leave_time" timestamp DEFAULT NULL COMMENT '离开时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 通话参与者表'; + CREATE TABLE IF NOT EXISTS "im_face_user_item" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', "user_id" bigint NOT NULL COMMENT '所属用户编号',