Commit 9e1a6b15e402de5e1f3871d9a9af01eee86ee17c

Authored by YunaiV
1 parent 1eda319e

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

✨ feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
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; @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
6 import com.baomidou.mybatisplus.core.toolkit.Wrappers; 6 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
7 import org.apache.ibatis.annotations.Mapper; 7 import org.apache.ibatis.annotations.Mapper;
8 8
  9 +import java.time.LocalDateTime;
9 import java.util.Collection; 10 import java.util.Collection;
10 import java.util.List; 11 import java.util.List;
11 12
@@ -27,10 +28,10 @@ public interface ImRtcParticipantMapper extends BaseMapperX<ImRtcParticipantDO> @@ -27,10 +28,10 @@ public interface ImRtcParticipantMapper extends BaseMapperX<ImRtcParticipantDO>
27 return selectList(ImRtcParticipantDO::getRoom, room); 28 return selectList(ImRtcParticipantDO::getRoom, room);
28 } 29 }
29 30
30 - default List<ImRtcParticipantDO> selectListByRoomAndStatus(String room, Integer status) { 31 + default List<ImRtcParticipantDO> selectListByStatusAndInviteTimeBefore(Integer status, LocalDateTime threshold) {
31 return selectList(new LambdaQueryWrapperX<ImRtcParticipantDO>() 32 return selectList(new LambdaQueryWrapperX<ImRtcParticipantDO>()
32 - .eq(ImRtcParticipantDO::getRoom, room)  
33 - .eq(ImRtcParticipantDO::getStatus, status)); 33 + .eq(ImRtcParticipantDO::getStatus, status)
  34 + .lt(ImRtcParticipantDO::getInviteTime, threshold));
34 } 35 }
35 36
36 default ImRtcParticipantDO selectLastOneByUserIdAndStatus(Long userId, Collection<Integer> statuses) { 37 default ImRtcParticipantDO selectLastOneByUserIdAndStatus(Long userId, Collection<Integer> statuses) {
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java
@@ -8,8 +8,6 @@ import java.util.Arrays; @@ -8,8 +8,6 @@ import java.util.Arrays;
8 8
9 /** 9 /**
10 * IM 通话结束原因枚举;落历史消息时计算 content 文案 10 * IM 通话结束原因枚举;落历史消息时计算 content 文案
11 - * <p>  
12 - * 不含 TIMEOUT;产品决策走微信式不超时,振铃由用户主动 cancel / reject  
13 * 11 *
14 * @author 芋道源码 12 * @author 芋道源码
15 */ 13 */
@@ -20,6 +18,7 @@ public enum ImRtcCallEndReasonEnum implements ArrayValuable&lt;Integer&gt; { @@ -20,6 +18,7 @@ public enum ImRtcCallEndReasonEnum implements ArrayValuable&lt;Integer&gt; {
20 HANGUP(1, "通话结束"), // 接通后任一方主动挂断 18 HANGUP(1, "通话结束"), // 接通后任一方主动挂断
21 REJECT(2, "已拒绝"), // 被叫接通前点拒接 19 REJECT(2, "已拒绝"), // 被叫接通前点拒接
22 CANCEL(3, "已取消"), // 主叫接通前主动取消 20 CANCEL(3, "已取消"), // 主叫接通前主动取消
  21 + NO_ANSWER(4, "无人接听"), // 振铃超时未接通;由参与者超时 Job 触发
23 BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话 22 BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话
24 ERROR(9, "通话异常"); // 网络中断、设备失败等 23 ERROR(9, "通话异常"); // 网络中断、设备失败等
25 24
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java
@@ -170,6 +170,11 @@ public class ImProperties { @@ -170,6 +170,11 @@ public class ImProperties {
170 */ 170 */
171 private int cleanupZombieThresholdMinutes = 5; 171 private int cleanupZombieThresholdMinutes = 5;
172 172
  173 + /**
  174 + * 振铃超时阈值(分钟);被叫 INVITING 超过此值未接通 → 标 NO_ANSWER + 推 RTC_CALL(REJECT) 让 banner 收敛
  175 + */
  176 + private int inviteTimeoutMinutes = 1;
  177 +
173 } 178 }
174 179
175 } 180 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/job/rtc/ImRtcParticipantTimeoutJob.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.job.rtc;
  2 +
  3 +import cn.hutool.core.util.NumberUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
  6 +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
  7 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  8 +import cn.iocoder.yudao.module.im.service.rtc.ImRtcCallService;
  9 +import jakarta.annotation.Resource;
  10 +import lombok.extern.slf4j.Slf4j;
  11 +import org.springframework.stereotype.Component;
  12 +
  13 +/**
  14 + * 振铃超时 Job:扫 INVITING 超过阈值的参与者,单人粒度标 NO_ANSWER + 推 RTC_CALL(REJECT) 让前端 banner 收敛
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Component
  19 +@Slf4j
  20 +public class ImRtcParticipantTimeoutJob implements JobHandler {
  21 +
  22 + @Resource
  23 + private ImRtcCallService rtcCallService;
  24 +
  25 + @Resource
  26 + private ImProperties imProperties;
  27 +
  28 + /**
  29 + * 执行超时扫描
  30 + *
  31 + * @param param 阈值(分钟);为空 / 非法走 {@link ImProperties.Rtc#getInviteTimeoutMinutes()} 默认值
  32 + * @return 超时处理数量描述
  33 + */
  34 + @Override
  35 + @TenantJob
  36 + public String execute(String param) {
  37 + int thresholdMinutes = resolveThresholdMinutes(param);
  38 + int timedOut = rtcCallService.timeoutInvitingParticipants(thresholdMinutes);
  39 + log.info("[execute][振铃超时参与者数量 ({}) 个]", timedOut);
  40 + return String.format("振铃超时 %s 个", timedOut);
  41 + }
  42 +
  43 + /**
  44 + * 解析 quartz param 为分钟阈值;非法 / 非正数走配置默认值
  45 + *
  46 + * @param param quartz 调度入参字符串
  47 + * @return 分钟阈值
  48 + */
  49 + private int resolveThresholdMinutes(String param) {
  50 + int defaultMinutes = imProperties.getRtc().getInviteTimeoutMinutes();
  51 + if (StrUtil.isBlank(param) || !NumberUtil.isInteger(param)) {
  52 + return defaultMinutes;
  53 + }
  54 + int minutes = Integer.parseInt(param);
  55 + return minutes > 0 ? minutes : defaultMinutes;
  56 + }
  57 +
  58 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java
@@ -130,4 +130,13 @@ public interface ImRtcCallService { @@ -130,4 +130,13 @@ public interface ImRtcCallService {
130 */ 130 */
131 int cleanupZombieCalls(int thresholdMinutes); 131 int cleanupZombieCalls(int thresholdMinutes);
132 132
  133 + /**
  134 + * 【定时任务调用】超时未接通的 INVITING 参与者:单人粒度标 NO_ANSWER + 推 RTC_CALL(REJECT) 让前端 banner 收敛;
  135 + * 若导致通话只剩主叫,由 endSessionIfTerminal 级联关房
  136 + *
  137 + * @param thresholdMinutes 邀请时间超过此分钟数才纳入扫描;调用方保证 > 0
  138 + * @return 超时处理数量
  139 + */
  140 + int timeoutInvitingParticipants(int thresholdMinutes);
  141 +
133 } 142 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
@@ -612,6 +612,72 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -612,6 +612,72 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
612 return cleaned; 612 return cleaned;
613 } 613 }
614 614
  615 + @Override
  616 + public int timeoutInvitingParticipants(int thresholdMinutes) {
  617 + // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态
  618 + LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes);
  619 + List<ImRtcParticipantDO> candidates = rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(
  620 + ImRtcParticipantStatusEnum.INVITING.getStatus(), threshold);
  621 + if (CollUtil.isEmpty(candidates)) {
  622 + return 0;
  623 + }
  624 + // 同 room 多人同批超时时复用 call,避免 N 次 selectByRoom
  625 + Map<String, ImRtcCallDO> callCache = new HashMap<>();
  626 + // 批量预查 operator user,避免循环里逐个 adminUserApi.getUser 走 N+1
  627 + Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
  628 + CollectionUtils.convertSet(candidates, ImRtcParticipantDO::getUserId));
  629 + int timedOut = 0;
  630 + for (ImRtcParticipantDO participant : candidates) {
  631 + if (timeoutInvitingParticipant(participant, callCache, userMap)) {
  632 + timedOut++;
  633 + }
  634 + }
  635 + return timedOut;
  636 + }
  637 +
  638 + /**
  639 + * 单参与者振铃超时:CAS 把 INVITING 切到 NO_ANSWER,群通话推 RTC_CALL(NO_ANSWER) + 级联关房判定;
  640 + * 私聊场景被叫超时即整通话结束,走 endSession(NO_ANSWER) 推 RTC_CALL_END
  641 + *
  642 + * @param participant INVITING 状态的超时候选
  643 + * @param callCache 按 room 缓存 call 对象,避免同批次多人重复查询
  644 + * @param userMap 候选用户预查 map;避免逐个 adminUserApi.getUser 走 N+1
  645 + * @return 是否成功处理(CAS 失败 / 通话主表缺失等场景返回 false)
  646 + */
  647 + private boolean timeoutInvitingParticipant(ImRtcParticipantDO participant,
  648 + Map<String, ImRtcCallDO> callCache,
  649 + Map<Long, AdminUserRespDTO> userMap) {
  650 + // 1. CAS:INVITING → NO_ANSWER;并发已变(用户刚接 / 拒,或 endSession 整体改)跳过
  651 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(),
  652 + ImRtcParticipantStatusEnum.INVITING.getStatus(),
  653 + new ImRtcParticipantDO().setId(participant.getId())
  654 + .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())
  655 + .setLeaveTime(LocalDateTime.now()));
  656 + if (updated == 0) {
  657 + return false;
  658 + }
  659 + Long userId = participant.getUserId();
  660 + log.info("[timeoutInvitingParticipant][参与者振铃超时 room={} userId={}]", participant.getRoom(), userId);
  661 +
  662 + // 2. 查询通话主表;同 room 复用 callCache,避免同批次多人重复查询
  663 + ImRtcCallDO call = callCache.computeIfAbsent(participant.getRoom(), rtcCallMapper::selectByRoom);
  664 + if (call == null) {
  665 + log.warn("[timeoutInvitingParticipant][通话主表缺失 room={} userId={}]", participant.getRoom(), userId);
  666 + return false;
  667 + }
  668 +
  669 + // 3.1 群通话:推 RTC_CALL(NO_ANSWER) 让前端 banner 移除该人 + 级联关房判定
  670 + // TODO DONE @AI:拆分独立 NO_ANSWER 信令,不再复用 REJECT
  671 + if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
  672 + pushCallNoAnswerNotification(call, userId, userMap.get(userId));
  673 + endSessionIfTerminal(call, userId);
  674 + return true;
  675 + }
  676 + // 3.2 私聊:被叫超时 = 整通话无人接听,走 endSession 推 RTC_CALL_END(NO_ANSWER)
  677 + endSession(call, userId, ImRtcCallEndReasonEnum.NO_ANSWER);
  678 + return true;
  679 + }
  680 +
615 // ========== 内部辅助 ========== 681 // ========== 内部辅助 ==========
616 682
617 private void validateEnabled() { 683 private void validateEnabled() {
@@ -850,6 +916,21 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -850,6 +916,21 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
850 } 916 }
851 917
852 /** 918 /**
  919 + * RTC_CALL(NO_ANSWER):仅群通话场景;振铃超时由 Job 触发;走 webSocketService 直推主叫
  920 + * <p>
  921 + * 私聊未接听走 endSession → RTC_CALL_END(reason=NO_ANSWER) 入消息流,不在此推
  922 + *
  923 + * @param call 通话主表
  924 + * @param operatorUserId 未接听者用户编号
  925 + * @param operator 未接听者预查结果;调用方批量查避免 N+1,可空
  926 + */
  927 + private void pushCallNoAnswerNotification(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) {
  928 + ImRtcCallNotification payload = ImRtcCallNotification.ofNoAnswer(call, operatorUserId, operator);
  929 + webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification(
  930 + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload));
  931 + }
  932 +
  933 + /**
853 * 通话参与者加入:LiveKit webhook participant_joined 触发;私聊推双方多端、群聊推全群成员(胶囊条 + 1) 934 * 通话参与者加入:LiveKit webhook participant_joined 触发;私聊推双方多端、群聊推全群成员(胶囊条 + 1)
854 * 935 *
855 * @param call 通话主表 936 * @param call 通话主表
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 { @@ -132,6 +132,24 @@ public class ImRtcCallNotification {
132 } 132 }
133 133
134 /** 134 /**
  135 + * 构造 NO_ANSWER 信令;仅群通话场景;推主叫;超时未接听语义独立于 REJECT
  136 + *
  137 + * @param call 通话主表
  138 + * @param operatorUserId 未接听者用户编号
  139 + * @param operator 未接听者;可空,缺失时 operatorNickname / operatorAvatar 留空
  140 + * @return NO_ANSWER 信令
  141 + */
  142 + public static ImRtcCallNotification ofNoAnswer(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) {
  143 + ImRtcCallNotification notification = baseOf(call, ImRtcParticipantStatusEnum.NO_ANSWER.getStatus());
  144 + notification.operatorUserId = operatorUserId;
  145 + if (operator != null) {
  146 + notification.operatorNickname = operator.getNickname();
  147 + notification.operatorAvatar = operator.getAvatar();
  148 + }
  149 + return notification;
  150 + }
  151 +
  152 + /**
135 * 公共骨架;填充 call 上下文 + status 153 * 公共骨架;填充 call 上下文 + status
136 */ 154 */
137 private static ImRtcCallNotification baseOf(ImRtcCallDO call, Integer status) { 155 private static ImRtcCallNotification baseOf(ImRtcCallDO call, Integer status) {