Commit 9e1a6b15e402de5e1f3871d9a9af01eee86ee17c
1 parent
1eda319e
✨ feat(im): 振铃超时 Job 单人粒度标 NO_ANSWER + 独立 NO_ANSWER 信令推送
✨ feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
Showing
7 changed files
with
176 additions
and
5 deletions
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 | import com.baomidou.mybatisplus.core.toolkit.Wrappers; |
| 7 | 7 | import org.apache.ibatis.annotations.Mapper; |
| 8 | 8 | |
| 9 | +import java.time.LocalDateTime; | |
| 9 | 10 | import java.util.Collection; |
| 10 | 11 | import java.util.List; |
| 11 | 12 | |
| ... | ... | @@ -27,10 +28,10 @@ public interface ImRtcParticipantMapper extends BaseMapperX<ImRtcParticipantDO> |
| 27 | 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 | 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 | 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 | |
| 9 | 9 | /** |
| 10 | 10 | * IM 通话结束原因枚举;落历史消息时计算 content 文案 |
| 11 | - * <p> | |
| 12 | - * 不含 TIMEOUT;产品决策走微信式不超时,振铃由用户主动 cancel / reject | |
| 13 | 11 | * |
| 14 | 12 | * @author 芋道源码 |
| 15 | 13 | */ |
| ... | ... | @@ -20,6 +18,7 @@ public enum ImRtcCallEndReasonEnum implements ArrayValuable<Integer> { |
| 20 | 18 | HANGUP(1, "通话结束"), // 接通后任一方主动挂断 |
| 21 | 19 | REJECT(2, "已拒绝"), // 被叫接通前点拒接 |
| 22 | 20 | CANCEL(3, "已取消"), // 主叫接通前主动取消 |
| 21 | + NO_ANSWER(4, "无人接听"), // 振铃超时未接通;由参与者超时 Job 触发 | |
| 23 | 22 | BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话 |
| 24 | 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 | 170 | */ |
| 171 | 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 | 130 | */ |
| 131 | 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 | 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 | 683 | private void validateEnabled() { |
| ... | ... | @@ -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 | 934 | * 通话参与者加入:LiveKit webhook participant_joined 触发;私聊推双方多端、群聊推全群成员(胶囊条 + 1) |
| 854 | 935 | * |
| 855 | 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 | 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 | 153 | * 公共骨架;填充 call 上下文 + status |
| 136 | 154 | */ |
| 137 | 155 | private static ImRtcCallNotification baseOf(ImRtcCallDO call, Integer status) { | ... | ... |