diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java index ea8d5d1..714fd45 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import org.apache.ibatis.annotations.Mapper; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; @@ -27,10 +28,10 @@ public interface ImRtcParticipantMapper extends BaseMapperX return selectList(ImRtcParticipantDO::getRoom, room); } - default List selectListByRoomAndStatus(String room, Integer status) { + default List selectListByStatusAndInviteTimeBefore(Integer status, LocalDateTime threshold) { return selectList(new LambdaQueryWrapperX() - .eq(ImRtcParticipantDO::getRoom, room) - .eq(ImRtcParticipantDO::getStatus, status)); + .eq(ImRtcParticipantDO::getStatus, status) + .lt(ImRtcParticipantDO::getInviteTime, threshold)); } default ImRtcParticipantDO selectLastOneByUserIdAndStatus(Long userId, Collection statuses) { diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java index ca192fb..51099b4 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java @@ -8,8 +8,6 @@ import java.util.Arrays; /** * IM 通话结束原因枚举;落历史消息时计算 content 文案 - *

- * 不含 TIMEOUT;产品决策走微信式不超时,振铃由用户主动 cancel / reject * * @author 芋道源码 */ @@ -20,6 +18,7 @@ public enum ImRtcCallEndReasonEnum implements ArrayValuable { HANGUP(1, "通话结束"), // 接通后任一方主动挂断 REJECT(2, "已拒绝"), // 被叫接通前点拒接 CANCEL(3, "已取消"), // 主叫接通前主动取消 + NO_ANSWER(4, "无人接听"), // 振铃超时未接通;由参与者超时 Job 触发 BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话 ERROR(9, "通话异常"); // 网络中断、设备失败等 diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java index 19e8f56..7c206dc 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java @@ -170,6 +170,11 @@ public class ImProperties { */ private int cleanupZombieThresholdMinutes = 5; + /** + * 振铃超时阈值(分钟);被叫 INVITING 超过此值未接通 → 标 NO_ANSWER + 推 RTC_CALL(REJECT) 让 banner 收敛 + */ + private int inviteTimeoutMinutes = 1; + } } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcParticipantTimeoutJob.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcParticipantTimeoutJob.java new file mode 100644 index 0000000..f551dee --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcParticipantTimeoutJob.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.im.job.rtc; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 振铃超时 Job:扫 INVITING 超过阈值的参与者,单人粒度标 NO_ANSWER + 推 RTC_CALL(REJECT) 让前端 banner 收敛 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class ImRtcParticipantTimeoutJob implements JobHandler { + + @Resource + private ImRtcCallService rtcCallService; + + @Resource + private ImProperties imProperties; + + /** + * 执行超时扫描 + * + * @param param 阈值(分钟);为空 / 非法走 {@link ImProperties.Rtc#getInviteTimeoutMinutes()} 默认值 + * @return 超时处理数量描述 + */ + @Override + @TenantJob + public String execute(String param) { + int thresholdMinutes = resolveThresholdMinutes(param); + int timedOut = rtcCallService.timeoutInvitingParticipants(thresholdMinutes); + log.info("[execute][振铃超时参与者数量 ({}) 个]", timedOut); + return String.format("振铃超时 %s 个", timedOut); + } + + /** + * 解析 quartz param 为分钟阈值;非法 / 非正数走配置默认值 + * + * @param param quartz 调度入参字符串 + * @return 分钟阈值 + */ + private int resolveThresholdMinutes(String param) { + int defaultMinutes = imProperties.getRtc().getInviteTimeoutMinutes(); + if (StrUtil.isBlank(param) || !NumberUtil.isInteger(param)) { + return defaultMinutes; + } + int minutes = Integer.parseInt(param); + return minutes > 0 ? minutes : defaultMinutes; + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java index 6903a93..8a88bac 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java @@ -130,4 +130,13 @@ public interface ImRtcCallService { */ int cleanupZombieCalls(int thresholdMinutes); + /** + * 【定时任务调用】超时未接通的 INVITING 参与者:单人粒度标 NO_ANSWER + 推 RTC_CALL(REJECT) 让前端 banner 收敛; + * 若导致通话只剩主叫,由 endSessionIfTerminal 级联关房 + * + * @param thresholdMinutes 邀请时间超过此分钟数才纳入扫描;调用方保证 > 0 + * @return 超时处理数量 + */ + int timeoutInvitingParticipants(int thresholdMinutes); + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java index d34667d..6767f93 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java @@ -612,6 +612,72 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { return cleaned; } + @Override + public int timeoutInvitingParticipants(int thresholdMinutes) { + // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态 + LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes); + List candidates = rtcParticipantMapper.selectListByStatusAndInviteTimeBefore( + ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold); + if (CollUtil.isEmpty(candidates)) { + return 0; + } + // 同 room 多人同批超时时复用 call,避免 N 次 selectByRoom + Map callCache = new HashMap<>(); + // 批量预查 operator user,避免循环里逐个 adminUserApi.getUser 走 N+1 + Map userMap = adminUserApi.getUserMap( + CollectionUtils.convertSet(candidates, ImRtcParticipantDO::getUserId)); + int timedOut = 0; + for (ImRtcParticipantDO participant : candidates) { + if (timeoutInvitingParticipant(participant, callCache, userMap)) { + timedOut++; + } + } + return timedOut; + } + + /** + * 单参与者振铃超时:CAS 把 INVITING 切到 NO_ANSWER,群通话推 RTC_CALL(NO_ANSWER) + 级联关房判定; + * 私聊场景被叫超时即整通话结束,走 endSession(NO_ANSWER) 推 RTC_CALL_END + * + * @param participant INVITING 状态的超时候选 + * @param callCache 按 room 缓存 call 对象,避免同批次多人重复查询 + * @param userMap 候选用户预查 map;避免逐个 adminUserApi.getUser 走 N+1 + * @return 是否成功处理(CAS 失败 / 通话主表缺失等场景返回 false) + */ + private boolean timeoutInvitingParticipant(ImRtcParticipantDO participant, + Map callCache, + Map userMap) { + // 1. CAS:INVITING → NO_ANSWER;并发已变(用户刚接 / 拒,或 endSession 整体改)跳过 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), + ImRtcParticipantStatusEnum.INVITING.getStatus(), + new ImRtcParticipantDO().setId(participant.getId()) + .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()) + .setLeaveTime(LocalDateTime.now())); + if (updated == 0) { + return false; + } + Long userId = participant.getUserId(); + log.info("[timeoutInvitingParticipant][参与者振铃超时 room={} userId={}]", participant.getRoom(), userId); + + // 2. 查询通话主表;同 room 复用 callCache,避免同批次多人重复查询 + ImRtcCallDO call = callCache.computeIfAbsent(participant.getRoom(), rtcCallMapper::selectByRoom); + if (call == null) { + log.warn("[timeoutInvitingParticipant][通话主表缺失 room={} userId={}]", participant.getRoom(), userId); + return false; + } + + // 3.1 群通话:推 RTC_CALL(NO_ANSWER) 让前端 banner 移除该人 + 级联关房判定 + // TODO DONE @AI:拆分独立 NO_ANSWER 信令,不再复用 REJECT + if (ImConversationTypeEnum.isGroup(call.getConversationType())) { + pushCallNoAnswerNotification(call, userId, userMap.get(userId)); + endSessionIfTerminal(call, userId); + return true; + } + // 3.2 私聊:被叫超时 = 整通话无人接听,走 endSession 推 RTC_CALL_END(NO_ANSWER) + endSession(call, userId, ImRtcCallEndReasonEnum.NO_ANSWER); + return true; + } + // ========== 内部辅助 ========== private void validateEnabled() { @@ -850,6 +916,21 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { } /** + * RTC_CALL(NO_ANSWER):仅群通话场景;振铃超时由 Job 触发;走 webSocketService 直推主叫 + *

+ * 私聊未接听走 endSession → RTC_CALL_END(reason=NO_ANSWER) 入消息流,不在此推 + * + * @param call 通话主表 + * @param operatorUserId 未接听者用户编号 + * @param operator 未接听者预查结果;调用方批量查避免 N+1,可空 + */ + private void pushCallNoAnswerNotification(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) { + ImRtcCallNotification payload = ImRtcCallNotification.ofNoAnswer(call, operatorUserId, operator); + webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification( + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload)); + } + + /** * 通话参与者加入:LiveKit webhook participant_joined 触发;私聊推双方多端、群聊推全群成员(胶囊条 + 1) * * @param call 通话主表 diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallNotification.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallNotification.java index 9279025..efa8037 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallNotification.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/rtc/ImRtcCallNotification.java @@ -132,6 +132,24 @@ public class ImRtcCallNotification { } /** + * 构造 NO_ANSWER 信令;仅群通话场景;推主叫;超时未接听语义独立于 REJECT + * + * @param call 通话主表 + * @param operatorUserId 未接听者用户编号 + * @param operator 未接听者;可空,缺失时 operatorNickname / operatorAvatar 留空 + * @return NO_ANSWER 信令 + */ + public static ImRtcCallNotification ofNoAnswer(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) { + ImRtcCallNotification notification = baseOf(call, ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()); + notification.operatorUserId = operatorUserId; + if (operator != null) { + notification.operatorNickname = operator.getNickname(); + notification.operatorAvatar = operator.getAvatar(); + } + return notification; + } + + /** * 公共骨架;填充 call 上下文 + status */ private static ImRtcCallNotification baseOf(ImRtcCallDO call, Integer status) {