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

 feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
This commit is contained in:
YunaiV
2026-05-18 09:45:33 +08:00
parent 9e1a6b15e4
commit 2fd201bf59
8 changed files with 595 additions and 38 deletions

View File

@ -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")

View File

@ -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)

View File

@ -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);
}

View File

@ -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 异步发出,让前端按业务语义 resetNO_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);
}

View File

@ -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 一旧 INVITINGr2 一旧 INVITINGr1 一新 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=r1cutoff = 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());
}
// ========== updateByRoomAndStatusendSession 批量改 INVITING → NO_ANSWER 路径关键) ==========
@Test
public void testUpdateByRoomAndStatus_inviting2NoAnswer_batchUpdate() {
// 准备r1 两条 INVITING + 一条 JOINED状态不匹配不应被改r2 一条 INVITINGroom 不匹配,不应被改)
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;
}
}

View File

@ -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;
// ========== timeoutInvitingParticipantsJob 入口)==========
@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;
}
}

View File

@ -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";

View File

@ -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 '所属用户编号',