Commit 2fd201bf59bafdd2a43647e686aac32e53a3f266
1 parent
9e1a6b15
✨ feat(im): 振铃超时 Job 单人粒度标 NO_ANSWER + 独立 NO_ANSWER 信令推送
✨ feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
Showing
8 changed files
with
595 additions
and
38 deletions
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<ImRtcParticipantDO> |
| 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
yudao-module-im/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -179,6 +179,45 @@ CREATE TABLE IF NOT EXISTS "im_face_pack_item" ( |
| 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 '所属用户编号', | ... | ... |