04-坐席接听秒挂的那天:浏览器软电话踩坑记

坐席接听秒挂的那天:浏览器软电话踩坑记

2026 年 10 月 15 日下午,两家租户的坐席同时报了同一个故障:外呼任务里客户接通了,坐席软电话一接听,"啪"就挂了,页面弹「接听失败:Permission denied」。任务跑不了,客服全停摆。这篇讲这次排查,也顺带讲讲软电话这条链路上我们踩过的其他坑——浏览器里跑 SIP,水比想象中深得多。

一个非常有欺骗性的现象

先入为主的判断全错了。

第一反应是网络问题——信令不通或者媒体协商失败?查 nginx,没有限制麦克风的 Permissions-Policy 头;查证书,SAN 覆盖了站点 IP。都正常。

第二反应是有人改了配置?也没有。

转机来自 Kamailio 侧的 SIP trace。把坐席腿的信令拉出来看,模式非常整齐:

100 Trying → 180 Ringing → (40~80ms 后)480 Temporarily Unavailable

坐席点了接听,振铃都还在,几十毫秒后浏览器自己回了个 480 拒绝。480 是浏览器腿主动发的——问题根本不在服务端。

顺着代码链找:sip.js 的 Invitation.accept() 应答时要先 getUserMedia({audio: true}) 拿麦克风。如果浏览器里这个站点的麦克风权限是"阻止"状态,getUserMedia 不会弹授权框,直接同步抛 NotAllowedError(message 就是 "Permission denied"),sip.js 的错误处理默认回 480 拒接,FS 那边拆桥——表现出来就是"接听秒挂"。

为什么权限会是"阻止"?因为坐席第一次遇到授权弹框时点了"拒绝",Chrome/Edge 会记住这个决定,之后不再询问。两家租户同时复现,不是因为出了什么公共故障,而是这个交互缺陷迟早会发生在任何人身上——坐席根本不知道自己一个月前随手点过的"阻止"是什么意思。

修复:把失败前移

用户侧的解决很简单:地址栏站点设置里允许麦克风,或到 chrome://settings/content/microphone 移除阻止项。但指望每个坐席都会这个不现实,代码侧做了两层加固:

一是注册前预检。软电话注册分机之前先 ensureMicAccess() 试采一次(8 秒超时、不阻塞主流程),权限被拒就直接拦截注册,页面上把"去哪开权限"的指引写清楚。核心思想是把故障从"通话中秒挂"前移成"签入时可见可修"——前者用户没法自救,后者用户一眼就懂。

二是错误分类提示。接听失败按 NotAllowedError(权限拒绝)/ NotFoundError(找不到设备)/ 设备占用分类给出不同文案,顶部告警条加"重新检测"按钮。

这次还留下一条排查经验,后来反复用上:SIP 轨迹里"180 后快速 480",第一反应就该怀疑客户端媒体采集,不用在服务端浪费时间。顺带一提,当天分析日志时还发现公网有个 IP 在高频往我们 5060 灌 INVITE sip:01085316555——SIP 盗打扫描,靠 Kamailio 的 pike 限流挡住了。端口暴露在公网,这些都是日常。

同一个症状,另一个病因:58 秒误挂

有意思的是,一个月前我们处理过一次看起来几乎一样的"接通后挂断",病因完全不同。

那次的现象是:任务外呼接通后,坐席和客户通话正常,但约 58 秒时通话被系统挂断。数字很特征化——不是用户行为,是某个 60 秒的定时器在起作用。

排查发现是两个问题叠加:坐席腿应答事件(ANSWER)里缺少 cc_task_id 和 cc_bridge_source 两个变量,导致引擎的 onAgentAnswered 没触发;同时振铃预留在 Redis 里的 TTL(60 秒)到期释放时,看门狗把还活着的通话当僵尸拆了。修复后随 v202610171806 发布,同类"接通即挂"再没出现。

两次事故对照着看很有意思:用户看到的都是"电话挂了",病因一个在浏览器权限、一个在桥接变量。软电话排障的第一课就是别信表象,把 SIP trace 和 FS 事件流拉出来对时间线。

追根溯源:第一周的七连坑

其实这套软电话链路刚上生产时(2026 年 9 月初),坑比这深得多。那是一次"坐席不来电 + 接通后双向无声"的排查,最后数出来七个叠加的根因,横跨 FS、Kamailio、RTPEngine 三层。挑几个有普遍意义的说:

给 WebRTC 坐席发了普通 SDP。 发往浏览器坐席的 offer 是普通 RTP SDP,没有 ICE/DTLS 属性——浏览器内核直接拒绝应答,连报错都懒得给。修复是 Kamailio 里对发往 WS 坐席的消息启用 WebRTC 总开关(UDP/TLS/RTP/SAVPF、公网候选、fingerprint、rtcp-mux 一次配齐),而回 FS 方向保持普通 SDP。信令层面"同一条 SIP 消息、两个方向的媒体能力完全不同",这是 Kamailio.cfg 里最烧脑的一段。

RTPEngine 配置文件同名键静默覆盖。 rtpengine 的配置里两行 interface= 键名相同,后者覆盖前者——日志里一行 Interface 'int' not found,内网接口根本没加载,FS 腿的媒体地址全通告成公网。最终放弃配置文件,改 compose entrypoint 显式传 --interface=int/内网IP --interface=ext/内网IP!公网IP。这种"配置没报错但没生效"的坑,防不胜防,只能靠读日志。

advertise 没配,Record-Route 写成 0.0.0.0。 Kamailio 内网监听 listen=udp:0.0.0.0:5090 而没配 advertise,Via/Record-Route 里写下 0.0.0.0,FS 的 ACK 和 BYE 按 this 地址发包,全部发进虚空——挂机信令到不了对端,产生僵尸通话。NAT 环境下 SIP 的 advertise 是必修课,不是选修课。

WS 坐席的 Contact 域名不可路由。 浏览器注册的 Contact 带 .invalid 域名,in-dialog 请求(ACK/BYE/re-INVITE)loose_route 之后按这个域名做 DNS 解析,必然失败。标准解法是注册时 add_path_received()(RFC 3327)+ set_contact_alias(),后续请求 handle_ruri_alias() 还原真实地址。SIP over WebSocket 的必备姿势,RFC 写得明白,但没踩过坑的人很难想到。

修复后全链路验证靠一个自写的 Python 脚本:以 WSS 注册成测试分机、自动应答、收发 RTP,拿 FS 的 &echo() 做回声反射,看到日志打出 AUDIO OK ✔ 双向媒体打通 才算过。这套验证脚本后来成了媒体层发版前的固定冒烟手段——媒体通不通,拿 RTP 包说话,不信任何"看起来正常"。

现在的软电话长什么样

经过这几轮加固,现在的坐席软电话流程是:签入时麦克风预检 → sip.js 经 WSS(nginx 终结 TLS 后转 Kamailio)注册 → 来电弹屏(业务信令走后端独立 WebSocket 通道,和 SIP 信令双通道解耦,弹屏卡了不影响振铃)→ 接听通话 → 挂机后进整理事务、自动标记客户。通话面板支持静音/保持/转接,任务模式下坐席看到的是任务上下文加客户资料面板。

不敢说没有下一个坑——浏览器这个"运行环境"本身就是半个不可控变量(权限、休眠策略、扩展插件都能搞事)。但至少现在,每个坑都有 trace 可查、有复盘文档可翻,新坑出现时的定位时间从"一下午"降到了"一刻钟"。这就是踩坑真正的复利。


本文是「云呼叫中心技术内幕」系列第 4 篇。

posted @ 2026-10-25 20:54  cliffdev  阅读(3)  评论(0)    收藏  举报