mirror of
https://gitee.com/zhijiantianya/ruoyi-vue-pro.git
synced 2026-06-06 02:38:38 +08:00
✨ feat(im): 振铃超时 Job 单人粒度标 NO_ANSWER + 独立 NO_ANSWER 信令推送
✨ feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
This commit is contained in:
@ -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<ImRtcCallRespVO> refreshToken(@RequestParam("room") String room) {
|
||||
Long userId = getLoginUserId();
|
||||
ImRtcCallDO call = rtcCallService.validateCallParticipant(userId, room);
|
||||
return success(buildCallRespVO(call, userId));
|
||||
public CommonResult<Boolean> noAnswerCallCheck(@RequestParam("room") String room) {
|
||||
rtcCallService.noAnswerCallCheck(getLoginUserId(), room);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/get-active-call")
|
||||
|
||||
@ -34,6 +34,13 @@ public interface ImRtcParticipantMapper extends BaseMapperX<ImRtcParticipantDO>
|
||||
.lt(ImRtcParticipantDO::getInviteTime, threshold));
|
||||
}
|
||||
|
||||
default List<ImRtcParticipantDO> selectListByRoomAndStatusAndInviteTimeBefore(String room, Integer status, LocalDateTime threshold) {
|
||||
return selectList(new LambdaQueryWrapperX<ImRtcParticipantDO>()
|
||||
.eq(ImRtcParticipantDO::getRoom, room)
|
||||
.eq(ImRtcParticipantDO::getStatus, status)
|
||||
.lt(ImRtcParticipantDO::getInviteTime, threshold));
|
||||
}
|
||||
|
||||
default ImRtcParticipantDO selectLastOneByUserIdAndStatus(Long userId, Collection<Integer> statuses) {
|
||||
return selectLastOne(new LambdaQueryWrapperX<ImRtcParticipantDO>()
|
||||
.eq(ImRtcParticipantDO::getUserId, userId)
|
||||
|
||||
@ -73,17 +73,6 @@ public interface ImRtcCallService {
|
||||
*/
|
||||
void leaveCall(Long userId, String room);
|
||||
|
||||
/**
|
||||
* 校验通话活跃且本人是参与者;用于客户端重连或 Token 过期续期前的合法性检查
|
||||
* <p>
|
||||
* 仅做校验;签发新 Token 由 Controller 调 {@link #signCallToken} 完成
|
||||
*
|
||||
* @param userId 操作人编号
|
||||
* @param room 业务通话编号
|
||||
* @return 通话主表
|
||||
*/
|
||||
ImRtcCallDO validateCallParticipant(Long userId, String room);
|
||||
|
||||
/**
|
||||
* 查询当前正在进行的通话;目前仅群聊场景(胶囊条),私聊未来扩展再补 peerUserId 参数
|
||||
* <p>
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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<ImRtcParticipantDO> 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<ImRtcParticipantDO> candidates = rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore(
|
||||
room, ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold);
|
||||
noAnswerCallCheck0(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量超时处理:循环单参与者;同 room 复用 call、批量预查 user 避免 N+1;返回成功处理数
|
||||
*
|
||||
* @param candidates 已过滤的超时 INVITING 候选
|
||||
* @return 成功处理(CAS 抢占)的数量
|
||||
*/
|
||||
private int noAnswerCallCheck0(List<ImRtcParticipantDO> candidates) {
|
||||
if (CollUtil.isEmpty(candidates)) {
|
||||
return 0;
|
||||
}
|
||||
// 同 room 多人同批超时时复用 call,避免 N 次 selectByRoom
|
||||
Map<String, ImRtcCallDO> callCache = new HashMap<>();
|
||||
// 批量预查 operator user,避免循环里逐个 adminUserApi.getUser 走 N+1
|
||||
Map<Long, AdminUserRespDTO> 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);
|
||||
}
|
||||
|
||||
|
||||
@ -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<ImRtcParticipantDO> 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<ImRtcParticipantDO> 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<ImRtcParticipantDO> 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<ImRtcParticipantDO> 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<ImRtcParticipantDO> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<LocalDateTime> 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<LocalDateTime> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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 '所属用户编号',
|
||||
|
||||
Reference in New Issue
Block a user