最近群里有朋友问我,信创环境下的浏览器到底能不能做软电话。我的回答是:能,而且实现起来没有想象中那么复杂。我手上刚好有一个叫“信创电话助手”的项目,核心就是三件事——注册、拨号、接打电话,全部跑在浏览器的 JavaScript 里。前后花了两周左右,一条稳定的呼叫通道就跑通了。今天把整个过程整理成一份可复现的快速指南,希望给正在做同类事情的同学省点时间。
这个项目在信创环境里特别有代表性。一方面,国产操作系统和国产浏览器组合越来越多,传统依赖安装客户端、ActiveX 插件的软电话方案根本跑不起来;另一方面,业务系统已经网页化了,坐席人员每天盯着浏览器里的客户信息页面,再切出去拿起话机拨号,效率很受影响。把固话能力直接嵌进网页,是一个被反复验证过的刚需。
如果你正要处理类似需求,这篇文章会覆盖:为什么选浏览器方案、协议怎么选、SIP 和 WebRTC 怎么配合、注册和外呼怎么写、来电弹屏怎么跟业务系统打通,以及我踩过的那些不大不小但很折磨人的坑。
1. 项目全貌:浏览器集成固话到底在解决什么问题
1.1 信创电话助手的真实业务诉求
先还原一下业务场景。某公司内部的客服坐席,电脑上运行国产操作系统,浏览器用国产浏览器,业务系统是企业自建的网页 CRM。坐席需要打电话给客户、接听客服热线、把通话记录挂到客户名下。传统做法是给坐席配一台 SIP 话机,或者安装软电话客户端。配话机的问题在于话机不能和 CRM 联动,来电号码要靠肉眼识别,拨打时还要手动输入号码,通话结束后的记录也得手工录入。安装客户端的问题更直接——信创环境下的应用分发、兼容性适配、版本升级,每一步都可能导致坐席电脑变成“不可用”状态。
所以最初的诉求就俩:第一,电话能力必须跑在浏览器页面里,坐席打开业务系统就能用;第二,不能依赖 Windows 专属的 SDK 或插件,要能在主流国产浏览器内核环境下稳定运行。这两点决定了后续所有技术选型的方向。
我把这类需求统称为“电话助手”,它本质上是一个软电话条(Softphone Bar),挂在业务页面的侧边或者底部,提供拨号盘、来电提示、通话状态、通话记录这些能力。具体的业务动作,比如点击电话号码直接呼出、接听时自动弹出客户资料,都通过 JavaScript 和业务页面交互来完成。
1.2 为什么一定要做浏览器里的 JavaScript 方案
有人可能会问,既然以前有那么多成熟的软电话客户端,为什么非得做浏览器方案?我的理由有三条。
第一条是分发成本。客户端要打包、签名、分发、静默升级,每台机器的内核版本、系统补丁、权限策略都可能影响安装结果。在信创项目里,机器型号和系统版本还不统一,客户端适配往往是一个无底洞。浏览器方案只要打开网页就能用,业务系统发版时顺带把前端代码一起发掉,坐席刷新一下页面就是最新版本。
第二条是集成深度。浏览器页面天然和业务系统同源,电话条可以直接读取当前页面的客户编号、订单号,不需要再去和本地客户端做进程间通信或者监听本地端口。本地客户端方案要想跟 Web 页面联动,一般要走本地 WebSocket Server、浏览器扩展或者剪贴板辅助,每一招都有明显的可靠性问题。浏览器方案就没有这个负担,点击拨号本质上就是一个页面内函数调用。
第三条是信创适配的可预期性。国产浏览器大多数采用 Chromium 内核或基于 Chromium 二次开发,WebRTC 能力总体上是齐备的,getUserMedia、RTCPeerConnection 这些基础 API 都可以用。相比之下,在国产操作系统上适配一个原生软电话客户端,需要面对音频驱动、编解码库、网络权限等多种问题,工作量和维护成本完全不在一个量级。当然,浏览器方案也有自己的坑,比如信令服务器拓扑、媒体穿透、音频设备枚举,但这些坑可控,而且拆起来有规律。
1.3 信令链和媒体链的分工逻辑
在动手编码之前,先把架构想明白。浏览器做电话功能,本质上是把传统 SIP 话机做的事情搬到浏览器里,协议链路拆成三条。
信令链路:浏览器里的 JavaScript 通过 WebSocket 连接一个支持 WebSocket 接入的 SIP 网关或 SBC(会话边界控制器),把 SIP 消息封装在 WebSocket 帧里传输。SIP 负责发起呼叫、应答、挂断、转接等控制动作。浏览器没法直接发裸 UDP 的 SIP 报文,这是浏览器的安全模型决定的,所以需要 WebSocket 作为 SIP 的承载通道。
媒体链路:语音流不走 SIP 信令,走 WebRTC。浏览器采集麦克风,编码成 OPUS 音频流,通过 SRTP 传输到网关的媒体节点,再由网关转成传统 RTP 语音流送到运营商中继或对端话机。这部分对用户是透明的,但实现时要保证 WebRTC 能穿越企业 NAT,ICE、STUN、TURN 这些机制必须配好。
业务链路:浏览器页面和业务后台之间的 HTTP 请求或 WebSocket 推送,用来传递客户资料、通话记录、弹屏数据,不直接参与电话控制。
一句话概括:WebSocket 管信令,WebRTC 管声音,HTTP 管业务。三条链路各干各的,又通过状态回调在页面里统一起来。搞明白这个分工,后续代码里的各种回调就不会乱。
2. 核心协议与信创浏览器适配要点
2.1 SIP、WebSocket、WebRTC 是怎么配合的
我经常用一个类比:SIP 是打电话时的“对话语言”,WebSocket 是承载这段语言的“信使路线”,WebRTC 是真正运送声音的“卡车”。
SIP 是纯文本协议,里面写着 INVITE、ACK、BYE、REGISTER 这些指令。浏览器里的 JavaScript 不能直接创建一个 UDP Socket 去向 SIP 服务器发这些文本,所以我们要把整个 SIP 协议栈搬到浏览器里,和服务器之间建立 WebSocket 连接。SIP over WebSocket 是有标准定义的,网关侧只要实现了 WebSocket 接入,浏览器端就可以通过一个 WS 地址完成注册和呼叫。
WebRTC 负责媒体。浏览器通过 getUserMedia 拿到麦克风声音,通过 RTCPeerConnection 和网关协商出一对加密的音视频通道。这里要强调一下,WebRTC 中的“呼叫”不是一个 SIP 概念,它需要先交换 SDP。SIP.js 这类库把这两层粘合起来了:INVITE 消息里带着 SDP 内容,应答消息也带 SDP,两边 SDP 协商好了,媒体流自然就通了。
实际开发中我们不需要自己拼 SDP,SIP.js 的 SessionDescriptionHandler 会自动处理。但理解这个过程有助于排查问题。比如“呼叫通了但没声音”,大概率是 SDP 协商出的 IP 地址和端口双方不可达,或者 ICE 候选没有成功穿透,而不是 SIP 消息发不出去。
2.2 信创浏览器 WebRTC 兼容性自检
说一句大实话:信创浏览器不等于“功能阉割版”浏览器。多数国产浏览器在 WebRTC 基础能力上,跟主流 Chromium 浏览器差距不大。但差距不大不代表不用测。我在项目启动第一天就把兼容性检查脚本写进了引导页,让坐席访问页面时自动检测几项关键能力。
第一是 getUserMedia 是否存在。某些浏览器的嵌入式版本做了功能裁剪,可能不暴露这个 API。检测方式很简单:typeof navigator.mediaDevices?.getUserMedia === 'function'。如果不过,直接给用户一个明确的错误提示,不要等到点击拨号时才报莫名其妙的异常。
第二是 WebSocket 是否支持、是否支持 wss 协议。这个绝大多数浏览器都支持,但有的内网环境存在代理拦截,需要确认 WebSocket 的握手请求能不能通过防火墙。
第三是 RTCPeerConnection 是否支持,以及是否支持 OPUS 编解码。OPUS 是 WebRTC 默认音频编解码器,如果某个浏览器版本没启用,媒体协商会失败。可以在初始化时用 RTCRtpSender.getCapabilities('audio') 查看 codec 列表里有没有 opus。
我把这些检查项做成一个表格,每次上线前让测试组跑一遍,基本能挡住 80% 的兼容性雷区。
信创环境的另一个坑是 HTTPS。浏览器安全策略要求,除了 localhost,getUserMedia 只能在 HTTPS 页面里使用。有些单位的内网业务系统用的是 HTTP,这个必须提前改造。别心存侥幸,HTTP 页面里麦克风权限根本不会弹出来。
2.3 麦克风和扬声器的处理细节
电话场景对音频设备的要求比视频会议还要敏感,因为通话持续时间长,回声和噪音会被无限放大。
我在创建会话描述处理器时,默认启用三件套:回声消除、噪声抑制、自动增益。对应到 SIP.js 的 constraints 参数上,就是 audio 对象里的 echoCancellation、noiseSuppression、autoGainControl 三个布尔值,建议全部设为 true。尤其回音消除,如果你在一个开放办公区做免提通话,没有它,对方会听到自己在喇叭里的回音,体验非常糟。
另一个细节是设备枚举。很多国产浏览器里,mediaDevices.enumerateDevices() 返回的设备名称可能不是标准写法,有的甚至只有 default 设备。不要过度依赖 deviceId 来记忆用户选择,最好在每次通话前重新枚举一次,并把当前设备写进页面缓存,下次通话时默认选中。我遇到过一种情况:坐席插了 USB 话机,但浏览器枚举出来的还是板载声卡,用户在电话条里切换了设备,下一次通话又重置了。后来加了“记住本次选择”的逻辑才解决。
还有一个跟信创环境强相关的问题:设备授权和音频会话冲突。Linux 内核版本不同,音频服务(比如 PulseAudio)对多进程抢占的处理差异很大。如果浏览器标签页还开着别的音视频应用(比如会议系统),可能出现麦克风被占用的情况。代码层面无法完全避免,建议在页面里增加一个“声音自检”功能:让坐席说一句话,浏览器录制后播放给自己听,确认麦克风和扬声器都正常再开始接电话。
3. 快速实现:注册、外呼、来电弹屏与通话保持
3.1 依赖引入与离线包准备
库的选择,我用了 SIP.js。它是一个纯 JavaScript 的 SIP 协议栈,内置了 WebSocket 传输层和 WebRTC 媒体层,API 设计比较贴近现代前端习惯。官方提供 npm 包和 UMD 构建版本,两种方式都可以。
norm 里推荐优先用 npm 安装:
npm install sip.js如果项目没有构建工具,直接用 UMD 版本:
<script src="/assets/js/sip.js"></script>但这里有一个一定要提前踩的坑——信创项目大多在内网环境,外网访问受限。npm install 的时候可能还能通过代理拉取,但生产环境的静态资源服务器往往无法访问公网 CDN。所以依赖包必须在开发完成后立即把仓库里的 js 文件拷到本地静态目录,作为离线资源使用。不要等到部署时才想到这个问题,到时候在隔离网络的机器上补下载依赖,会痛苦到怀疑人生。
另外,SIP.js 版本更新比较快,建议锁定版本号,不要用最新版,也不要跟着升级。我吃过一次亏:某次升级后,外层包里用到的某个内部 API 被移除了,编译在构建阶段没有报错,结果运行时注册总是不成功,排查花了整整一天。锁版本,能让你少很多无关变量。
3.2 初始化 SIP 用户代理并完成注册
注册是电话功能的第一步,相当于让分机在服务器上“上线”。用 SIP.js 的写法如下:
import { UserAgent, Registerer, SessionState } from "sip.js"; const userAgent = new UserAgent({ uri: "sip:1001@sbc.local", transportOptions: { server: "wss://sbc.local:7443", connectionTimeout: 5, keepAliveInterval: 10 }, authorizationUsername: "1001", authorizationPassword: "your-password", sessionDescriptionHandlerOptions: { constraints: { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } } } }); await userAgent.start(); const registerer = new Registerer(userAgent); await registerer.register();说明几个关键参数。uri是分机的 SIP 地址,由分机号和域名组合。transportOptions.server是 SIP over WebSocket 的服务端地址,信创环境一般用 wss 加密连接,不要用明文 ws,否则账号密码等于是裸奔。connectionTimeout是连接超时时间,单位秒,网络不稳时设太短会频繁报错,我一般设 5 秒。keepAliveInterval是 WebSocket 心跳间隔,单位秒,防止 NAT 空闲超时发呆,建议 10 到 15 秒。
注册成功后,要把监听来电的代码挂上。SIP.js 的 UserAgent 有一个 delegate 对象,可以拦截各种会话事件:
userAgent.delegate = { onInvite(invitation) { const remoteUri = invitation.remoteIdentity.uri; const phoneNumber = remoteUri.user; showIncomingPopup(phoneNumber, invitation); } };注意,这个回调只是告诉你“来了一个呼叫”,不等于自动接听。要不要接听,听用户操作。把invitation对象存起来,等用户点击页面上的“接听”按钮时再调用accept()方法。
3.3 外呼的状态机设计与音视频参数
外呼比注册多一个会话状态管理。我先给一个最小实现:
const targetUri = `sip:${phoneNumber}@sbc.local`; const session = userAgent.invite(targetUri, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); session.stateChange.addListener((newState) => { if (newState === SessionState.Establishing) { updateCallStatus("正在接通..."); } else if (newState === SessionState.Established) { updateCallStatus("通话中"); startCallTimer(); } else if (newState === SessionState.Terminated) { updateCallStatus("已挂断"); stopCallTimer(); } });这里的核心是状态机。一个电话会话会经过:Initial(创建后)、Establishing(正在建链)、Established(通话中)、Terminated(已结束)等状态,还可能经过 Early 状态表示远端振铃或播放提示音。页面上的每一个按钮、每一秒计费、每一次落库,都应该基于状态机驱动,而不是基于用户的点击顺序。这样做的好处是,电话呼入被对方拒绝、无应答、线路忙等情况出现时,界面能自动回到初始状态,不会出现“明明电话已经断了,按钮还停留在挂机状态”这种智障表现。
挂断电话调用:
session.bye();如果对方还没应答但通道已经建立(比如正在振铃),想取消本次呼叫可以调session.cancel()。很多初学的人混淆了 bye 和 cancel,在这里特别提醒一下。
媒体参数方面,SIP.js 的 invite 方法里可以传会话描述处理选项,跟上节注册时类似。如果只想做语音,video: false必须写清楚。有的浏览器默认 video 是 true,会在协商时带上视频媒体,网关侧如果没配视频支持,整个呼叫都会失败。
3.4 来电弹屏与业务接口联动
来电弹屏是电话助手最受欢迎的功能,没有之一。坐席看到来电号码,还没接电话,客户资料就已经弹出来了。这个功能有两个关键点。
第一个关键点是“来电号码从哪来”。最简单的方式就是从invitation.remoteIdentity.uri.user拿到主叫号码,这是 SIP 信令里的主叫信息。但是只拿到一个号码是不够的,要弹出客户资料,还需要拿号码去业务后台查。
第二个关键点是“弹屏数据怎么查”。推荐在页面里调用业务系统的 REST API,把主叫号码作为参数传过去,后台返回客户姓名、历史订单、最近通话记录等信息。示意的调用方式如下:
function showIncomingPopup(phoneNumber, invitation) { const customerInfo = await fetch(`/api/customer?phone=${phoneNumber}`); renderPopup(customerInfo); // 用户点击接听按钮时 acceptButton.onclick = () => { invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true } } }); }; }这里有几个容易出问题的细节。第一,来电号码可能会经过运营商和网关改号,比如加前缀 0、隐藏号码、匿名来电,所以后台查询时要做号码归一化,去掉特殊前缀,识别匿名号码。第二,查询接口要有超时保护,不能用无限等待阻塞用户接听电话。第三,弹屏数据不要阻塞主叫接听动作,用户哪怕 1 秒后就想接电话,也要先能接,弹屏数据来了再渲染。
如果业务系统支持 WebSocket 推送,还可以做更复杂的场景:管理员在后台把某个坐席设为忙碌,所有呼入电话自动排队,前端收到推送后显示排队人数。这些都是额外的业务增强,核心链路不变,先把弹屏打通,后续扩展就顺了。
3.5 保持与转接的常见实现办法
通话保持和转接是企业电话的标准能力,浏览器里实现也不复杂,但要注意接口差异。
SIP.js 里保持通话有两种常见的处理方式:一种是用会话的hold()方法,让媒体协商进入 sendonly/recvonly 状态,暂停双方语音传输;另一种是发 INVITE 重新协商 SDP。我一般用hold(),因为代码更简洁:
session.hold();恢复则调用:
session.unhold();保持时的页面状态一定要把计时器暂停。实测中很多坐席反馈“保持之后通话时间还在涨”,这就是计时器没有跟着状态机走导致的。正确做法是监听会话状态变化,只有Established且非 hold 状态时才计费计时。
转接稍微复杂一点,分成“咨询转”(先把第三方接通再转)和“直接转”(直接转过去,当前通话结束)。咨询转在代码里通常要先创建一个新的外呼会话,等新会话进入Established后,再对两个会话分别执行refer()或transfer()。SIP.js 的refer()方法在不同版本里写法有差异,建议先查当前版本的 type 定义。不然编译能过,运行时报refer is not a function,会比较抓狂。
转接失败的情况也要处理,比如目标分机占线。实现上要在转接发起后监听旧会话的状态,如果长时间没有转成功,要把旧会话恢复回通话态,同时页面提示用户转接失败。这个兜底逻辑上线前必须测,否则坐席在真实业务里会遇到“电话转了但不知道转没转”的尴尬。
4. 常见问题排查与实战避坑
4.1 高频问题速查表
这几个问题是我在实际项目里反复遇到的,做成一张速查表,方便大家直接翻。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 注册成功但呼叫建立失败 | 网关未配置媒体端口,或 TCP/UDP 端口受限 | 检查网关媒体端口范围,开放防火墙 UDP 端口 |
| 呼叫通了但双方无声音 | ICE 穿透失败,或远端设备没有音频编解码 | 启用 TURN 服务器,检查 SDP 中的编码协商结果 |
| 浏览器不弹麦克风权限 | 页面不是 HTTPS,或 mediaDevices 不存在 | 迁移到 HTTPS,检查浏览器功能裁剪 |
| 通话一段时间后自动断线 | WebSocket 心跳丢失,NAT 超时 | 设置 keepAliveInterval,建议 10s 左右 |
| 点击拨号没有反应 | 前端异常拦截事件,或 SIP 账号未注册成功 | 检查控制台,确认注册状态后重新绑定按钮 |
| 来电弹屏数据为空 | 号码格式不规范,或后台查询超时 | 做号码归一化,接口增加超时兜底 |
| 挂断后页面状态未复位 | 状态机事件遗漏 | 统一监听 Terminated 状态,重置页面 |
4.2 四个容易忽略的细节
第一个是 TURN 服务器。很多人觉得内网环境不需要穿透,直接连内网 SIP 网关就行。但企业网络远比想象复杂,坐席可能在办公网,网关在机房,中间还有安全域隔离,WebRTC 的 UDP 媒体流经常被拦。最稳妥的策略是:不管内网外网,都配一个 TURN 服务器作为兜底。它不会影响正常呼叫的速度,只在 P2P 直连失败时才会被用到。配好之后,无声和单通的问题会大幅减少。
第二个是会话状态监听别只挂一遍。SIP.js 的stateChange是一个事件发射器,如果你在每次外呼前都重新addListener,老监听器不会自动销毁,多次呼叫后监听器堆积,会带来内存泄漏和状态错乱。最保险的做法是把监听器绑定放到会话创建之后、invite 之前,并且在通话结束的Terminated回调里显式移除无用引用。
第三个是号码格式。不要指望用户输入的都是标准号码。电话条里要统一做格式化处理:去掉空格、括号、多余前缀,保留号码核心部分。呼叫失败时,日志里要能打印出最终发出去的 SIP URI,方便定位问题是号码格式还是网络链路。我见过太多因为号码里混入空格导致呼叫失败的情况了,空格在 URI 里会被解析成非法字符,整条 INVITE 被网关直接丢弃。
第四个是权限最小化。浏览器页面只要 getUserMedia 一次,麦克风权限就会一直保留到标签页关闭,这符合用户预期。但要注意,页面在后台标签页运行时,有些浏览器会暂停定时器,影响心跳和来电闪烁提示。如果电话条需要常驻,建议用 Web Worker 处理心跳逻辑,同时提醒用户保持标签页前台运行。
4.3 上线前的测试清单
测试环节决定这个电话助手能不能真正投入使用。分享一份我每次上线前都会执行的清单。
基础功能:注册成功、外呼成功、来电接听成功、挂断返回正常状态。
音视频质量:接通后连续通话十分钟,检查有无回声、丢字、单通;切换麦克风和扬声器设备后再次通话。
异常场景:对方挂断、对方不接、对方占线、通话中网络闪断、重新注册。
业务联动:点击弹屏后客户资料正确展示;通话结束写入业务系统的记录正确;保持后计时器暂停。
浏览器适配:在项目要求的每一个浏览器内核上执行一遍完整用例,包括 WebView 内嵌场景。
信创环境专项:防跳过 HTTPS 检查、离线资源加载验证、设备授权弹窗点击测试。
清单看着多,其实跑起来很快。我习惯用桌面浏览器自动化工具录制一遍点击拨号和挂断的流程,再手动做异常场景。前前后后两小时能跑完,但这两小时能拦住绝大多数上线后的低级问题。
最后再分享一个心得。不要把“通话状态”和“页面操作”混在一起。页面按钮只是操作入口,真正的业务逻辑要完全依赖 SIP 会话状态,这样未来即使换网关、换协议库,你的页面代码也能基本不动。我在实际项目中就是因为一开始没有遵守这个原则,页面倒腾了三次重写。先想清楚状态机,再写 UI,能少走很多弯路。