简介:呼叫中心项目是一套基于 Asterisk 开发的企业级 Web 呼叫中心系统,主要用于毕业设计、课程设计及实训竞赛等场景。功能覆盖自动外呼,包括电费、水费、话费、物业费催缴以及交通移车、违法通知等八类提醒业务,且支持扩展对接多种电话服务。整个压缩包共 1202 个文件,大小约 26.43MB,代码与资源分类清晰:251 个 Java 文件负责业务逻辑,137 个 JSP 与 156 个 CSS 支撑 Web 界面,128 个 JS 处理前端交互,另有 99 个 WAV 和 87 个 VOX 用于语音播报,并附带工程配置与说明文档。项目工程经过严格测试可正常运行,包含完整源码与工程文件,可轻松复现,设计报告也能直接借鉴。目前已有 78 人学习下载,适合需要完整呼叫中心解决方案的同学参考或二次开发。
1. 呼叫中心 Web 项目:用 Asterisk 做内核,毕设答辩能拿得出手的关键在哪
很多人的毕设选题写着「基于 Asterisk 的呼叫中心 Web 项目」,真正动手才发现,Asterisk 本身是个老牌开源 PBX,装起来容易,但「Web 项目」这四个字才是工作量的大头:坐席工作台、通话状态、录音回放、话务报表,每一块都要从 Asterisk 的接口里把数据抠出来。这套方案能解决的核心问题是:用 Asterisk 承担所有语音交换和信令处理,Web 端只做管理、监控和操作界面,不需要碰任何硬件。适合做毕设、课设、实训和竞赛项目的人,尤其适合想展示「前后端 + 实时通信」完整链路的团队。我先把丑话说在前面:真正让你翻车的往往不是拨号计划,而是 Web 层和 Asterisk 之间的状态同步——这也是我先帮你把坑指出来的原因。
2. 让 Asterisk 先跑起来:版本选型、最小 PJSIP 配置与一条可验证的呼叫链路
在写任何 Web 代码之前,先把语音链路做成「黑匣子里有光」:一台 Ubuntu 虚拟机,装上 Asterisk,让两个软电话能互相打通。这一步跑不通,后面所有 Web 功能都是空中楼阁。
2.1 选型:PJSIP 还是 chan_sip,18 LTS 还是 21 LTS
老教程里满屏都是 chan_sip 和 sip.conf,那是 Asterisk 13 以前的主流写法。从 16 开始,官方主推 PJSIP 通道驱动,配置文件是 pjsip.conf,模块是 chan_pjsip。两者在 Web 项目里的差异主要在配置结构和调试命令:PJSIP 把 endpoint、aor、auth、transport 拆成四个独立小节,逻辑更清楚,排查时用 pjsip show endpoints 一眼能看到分机的注册 IP 和状态;chan_sip 配置简单,但后续做 WebRTC 支持、NAT 穿透都要绕路。
版本上我一般会选 LTS 分支。当前常见的 LTS 是 18 和 21:18 的教程量最大,搜一个问题基本都有答案,适合毕设;21 更新,加密传输、WebRTC 相关的默认行为更现代,但遇到问题能搜到的案例明显少。用 apt 直接装发行版仓库里的 asterisk 包就能满足大部分课设,想要新特性再去官网下载源码编译,注意编译前把依赖装全,否则 make 到一半报缺头文件很浪费时间。下表是我的选择习惯:
| 对比项 | chan_sip(旧) | PJSIP(推荐) |
|---|---|---|
| 配置文件 | sip.conf | pjsip.conf |
| 分机模型 | 单段配置 | endpoint/aor/auth 分离 |
| WebRTC 支持 | 较弱 | 原生支持 |
| 调试命令 | sip show peers | pjsip show endpoints |
| 毕设建议 | 尽量别用 | 默认选它 |
2.2 最小 pjsip.conf 与 extensions.conf:一个分机、一条拨号规则
装好 Asterisk 后先别急着配复杂功能,我一般会先写一个能注册、能互拨的最小集合。pjsip.conf 里放一个软电话分机和一个传输配置:
[transport-udp] type=transport protocol=udp bind=0.0.0.0:5060 [6001] type=endpoint context=from-internal disallow=all allow=ulaw,alaw direct_media=no transport=transport-udp [6001] type=aor max_contacts=1 remove_existing=1 [6001] type=auth auth_type=userpass username=6001 password=your_password [6001] type=identify endpoint=6001 match=192.168.0.0/16这段配置的逻辑是:endpoint 定义分机 6001 的行为,aor 定义它能注册几个联系地址,auth 定义账号密码,identify 限制允许从哪个网段注册。注意 Asterisk 靠「同名小节的类型不同」把它们关联成一个分机,所以四个 type 的小节名必须一致。参数上,disallow=all 之后再 allow=ulaw,alaw 表示只允许这两种 G.711 编码,呼叫中心里话质和兼容性都够用;direct_media=no 强制媒体流经过 Asterisk 中转,这样录音和状态监控才完整,后面 WebRTC 坐席也依赖这个设置。
extensions.conf 里给一个最简拨号计划:来电接听播放一段语音,内部分机互拨直接 Dial。
[from-internal] exten => 6001,1,Answer() same => n,Wait(1) same => n,Playback(hello-world) same => n,Hangup() exten => _6XXX,1,Dial(PJSIP/${EXTEN},30)这里 _6XXX 是匹配 6001~6999 任意四位分机的通配符,${EXTEN} 是实际拨出的号码,30 是振铃 30 秒没人接就放弃。from-internal 这个 context 名要和 pjsip.conf 里 endpoint 的 context 保持一致,否则分机拨不出去,这是新手第一个容易踩的坑。
2.3 验证链路:从 asterisk -r 到软电话注册
配置写完后重启 Asterisk 让配置生效,然后用控制台命令验证:
sudo asterisk -rvvvv pjsip reload pjsip show endpoints pjsip show contactspjsip show endpoints 能看到 6001 的 endpoint 是否存在,但此时 Aor Contact 一栏是 0 个,说明还没注册。接着在 Windows 或手机装一个 SIP 软电话,填服务器 IP、分机号 6001 和密码,观察控制台输出。注册成功后 pjsip show contacts 里会出现 6001/sip:6001@你的IP,并且 Status 是 Avail(或者直接显示注册 IP 地址)。
验证通话我习惯拨 6001 听系统自带的 hello-world,能听到就说明放音、编码、媒体路径都正常。再在软电话里拨 6002,如果 6002 是另一个已注册分机,两边就能互相听到声音。到这一步,语音链路已经通了,可以开始接 Web 层。如果注册失败,优先检查 VMware/VirtualBox 的虚拟机网络是不是 NAT,NAT 模式下宿主机外的软电话访问不到,改成桥接网络,或者干脆在宿主机上装一个软电话用同一网段。
3. 打通 Web 和 Asterisk:AMI 是主通道,WebSocket 做状态推送
Asterisk 跑起来之后,Web 项目要解决的核心问题是:后端怎么告诉 Asterisk「发起呼叫、挂断、查状态」,以及 Asterisk 怎么把「有人来电、坐席接起、通话结束」这些实时状态告诉 Web。答案是 AMI + WebSocket 双通道:AMI 负责命令和事件,WebSocket 负责把事件推到浏览器。
3.1 AMI 不是 REST 但也不难:最小 Python 客户端能发 Originate
AMI(Asterisk Manager Interface)是 Asterisk 内置的 TCP 管理协议,默认监听 5038 端口,协议格式是文本行:Action 发命令,Event 收事件。说它像 REST 是因为每一条命令都有明确的 Action 和参数;说它不像是因为它是长连接、事件是主动推送的,初学者容易在「要不要每次请求都重新连接」上纠结。
先看 manager.conf 怎么开权限:
[general] enabled = yes port = 5038 bindaddr = 127.0.0.1 [webadmin] secret = your_ami_password read = call,cdr,user,agent write = call,originate,agentbindaddr=127.0.0.1 是安全底线,意思是 AMI 只允许本机访问,Web 后端和 Asterisk 在同一台机器时用这个配置最稳,也避免把管理口暴露到外网。read 是你允许这个账号订阅哪些事件,write 是允许执行哪些动作,比如发 Originate 外呼就必须有 originate 权限。
然后用 Python 的 socket 直接请求 AMI。很多教程会引入 pyst2 这类库,但直接走 socket 能让你真正看懂协议,排查问题也更快:
import socket def ami_action(action_lines): s = socket.create_connection(("127.0.0.1", 5038), timeout=5) s.sendall(b"Action: Login\r\nUsername: webadmin\r\nSecret: your_ami_password\r\n\r\n") s.recv(4096) # 吃掉登录响应 payload = "\r\n".join(action_lines) + "\r\n\r\n" s.sendall(payload.encode()) s.settimeout(2) resp = b"" try: while True: chunk = s.recv(4096) if not chunk: break resp += chunk except socket.timeout: pass s.close() return resp.decode(errors="ignore") resp = ami_action([ "Action: Originate", "Channel: PJSIP/6001", "Context: from-internal", "Exten: 6002", "Priority: 1", "Timeout: 30000", ]) print(resp)这段代码做了两件事:登录后发起一次 Originate。Originate 的意思是主动从 PJSIP/6001 这个通道发起呼叫,让它拨到 from-internal context 里的 6002,相当于 Web 点了一个「外呼」按钮。参数里 Channel 是发起方,Exten 是被叫方,Timeout 是 30 秒内没人接就超时。实际项目里不会每次新建连接,而是维护一条长连接持续收事件,这个下面展开。字段之间的 \r\n 和结尾空行是 AMI 协议的边界符,漏掉一个空行服务端就解析不出 Action,这是最常见的低级错误。
3.2 事件驱动:把 Newchannel/Hangup 转成 WebSocket 推给前端
Web 呼叫中心的核心体验是实时性:电话还没响完,坐席工作台已经弹出来电号码。实现方式不是前端去轮询 AMI,而是后端保持一条 AMI 长连接订阅事件,再把事件转成 WebSocket 推给浏览器。后端收到的是这样的原始事件流:
Event: Newchannel Channel: PJSIP/6001-00000001 CallerIDNum: 13800000000 ChannelState: 5典型做法是后端写一个事件循环,按行读事件,过滤出 Web 关心的类型。我一般会建一张事件过滤表,只把这几类推给前端:
| AMI 事件 | 含义 | Web 端用途 |
|---|---|---|
| Newchannel | 新通道建立 | 来电弹屏 |
| Newstate | 通道状态变化(振铃/接通) | 通话状态流转 |
| Hangup | 通道挂断 | 结束通话、释放坐席 |
| AgentConnect | 坐席接起队列来电 | 工作台分配记录 |
| CDR | 通话记录生成 | 话单归档 |
代码上,后端事件循环可以这样组织(Python 伪代码,实际项目一般用 asyncio 或线程池):
while True: line = ami_socket.recv_line() if line.startswith("Event: "): event_type = line.split(": ", 1)[1].strip() if event_type in web_events: payload = collect_event_fields(ami_socket) websocket_broadcast({"type": event_type, "data": payload})逻辑是把 AMI 的一行事件头读出来,命中白名单就把整个事件字段收齐,推给所有已连接的前端。collect_event_fields 要读到空行才算一条完整事件,这个细节依赖 AMI 的分隔规则。
前端侧,不管是 Vue 还是 React,接收逻辑都是同一套:
const ws = new WebSocket("ws://" + location.host + "/ws/events"); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === "Newstate" && msg.data.ChannelState === "6") { setCallState("接听中"); } if (msg.type === "Hangup") { setCallState("已结束"); } };ChannelState 是 AMI 的状态码:5 是振铃(Ringing),6 是接通(Up),1 是正在拨号。前端不用关心底层信令,只要维护好这个映射就行。稳定性上有两个参数要调:WebSocket 的心跳要小于 Nginx/Asterisk 的空闲超时(常见 30~60 秒发一次 Ping 帧);浏览器断线重连后,后端要重新推送一次当前所有分机的状态快照,否则前端会一直停留在旧状态。这个「重连后的快照同步」是很多毕设项目遗漏的,答辩时一断网就露馅。
3.3 AMI vs ARI vs FastAGI:毕设场景怎么选,以及 manager.conf 的安全底线
除了 AMI,Asterisk 还提供 ARI 和 FastAGI:ARI 是 Asterisk 16+ 主推的 RESTful + WebSocket 接口,用 HTTP 发命令、WebSocket 收事件,对后端是 Java/Spring Boot 或 Node 的团队更友好;FastAGI 则是把拨号计划的控制权交给外部脚本,适合做复杂业务逻辑(查数据库再决定播放什么语音)。
毕设和课设怎么选?我的建议是:如果目标是「Web 管理界面 + 坐席工作台」,AMI 足够且稳定,springboot 后端用 AMI 也是社区里很成熟的方案;如果团队对 REST 风格更熟,愿意承担多一些配置工作,选 ARI 也很合理,但 ARI 对事件流的处理比 AMI 复杂一点。一个项目里也可以混用:以 AMI 为主做话务控制和事件订阅,FastAGI 只在 IVR 需要访问业务数据库时接一个。三种方式对比见表:
| 接口 | 协议风格 | 事件推送 | 适合场景 | 学习成本 |
|---|---|---|---|---|
| AMI | TCP 文本命令 | 自带事件流 | 呼叫控制、状态监控 | 低 |
| ARI | HTTP REST + WebSocket | 需单独订阅 | 复杂呼叫流程编排 | 中 |
| FastAGI | TCP/FastAGI 协议 | 无 | IVR 中查库、动态放音 | 中 |
关于 manager.conf 的安全底线再强调一次:bindaddr 一定绑内网或 127.0.0.1;密码用独立强密码,别和系统 root 复用;AMI 账号的 read/write 权限按最小集给,Web 展示用不到 System 权限就不开。Web 后端是唯一能访问 AMI 的服务,相当于把你的 Asterisk 管理口锁在了内网里,这是 Web 安全里最基础的边界原则,也是企业级 Web 开发里常被审计的点。端口都配好后,用 telnet 127.0.0.1 5038 能通、外网访问不到,就是合格状态。
提示:AMI 端口只允许 Web 后端所在主机访问,是呼叫中心 Web 项目里最便宜的防线,别为了省事把它绑到 0.0.0.0。
4. 把呼叫中心业务装进 Web 后台:IVR、队列、录音与 CDR
呼叫中心和普通电话系统最大的区别是业务闭环:客户打进来先听 IVR,按 1 进销售队列,坐席签入队列接电话,通话自动录音,结束后 CDR 生成话单,Web 后台再把这些数据展示成报表。这一章从上到下把链路串起来。
4.1 IVR 拨号计划:按键转队列的正确写法
IVR 的核心是一个「等待按键」的拨号计划。很多新手写成 Answer 后接 Playback,播完就挂断了,按键根本收不到。正确做法是用 Background 放音并启动 WaitExten:
[ivr-main] exten => s,1,Answer() same => n,Background(welcome) same => n,WaitExten(5) exten => 1,1,Queue(sales) exten => 2,1,Queue(support) exten => i,1,Playback(invalid-option) same => n,Restart()Background 在放音的同时就开始监听按键,WaitExten(5) 是放音结束后再等 5 秒;按键 1/2 分别跳转到同一个 context 下的 1 或 2 分机定义,也就是进对应队列。i 分支处理无效按键,提示后 Restart 回到开头。welcome 和 invalid-option 是系统自带语音,换成你自己的提示音时,把录好的 wav 文件放进 /var/lib/asterisk/sounds 对应语言目录,文件名不带扩展名写在配置里即可。放音文件建议用 8kHz 16bit 单声道 WAV 或 ulaw 格式,否则 Asterisk 解码会出怪声。
注意这里 Queue() 的参数对应 queues.conf 里的队列名,如果你在 Web 后台做 IVR 配置,本质就是动态生成这段 dialplan 文本然后 reload。竞赛项目做到「后台改按键、前台立刻生效」是个不错的加分点。
4.2 坐席与队列:queues.conf 参数和签入签出的 AMI 动作
queues.conf 决定队列的振铃策略。坐席多的时候,策略选不对要么全员被骚扰要么电话没人接。我一般先配两个队列做演示:
[sales] musicclass=default strategy=ringall timeout=20 ringinuse=no member => PJSIP/6001 member => PJSIP/6002 member => PJSIP/6003 [support] strategy=leastrecent timeout=30 ringinuse=no member => PJSIP/6001 member => PJSIP/6002strategy=ringall 是多人同时振铃,谁先接谁上,适合销售团队;leastrecent 是最近最少接电话的人优先,适合技术支持,负载更均匀。timeout 是坐席振铃多久没人接就退回队列层处理。ringinuse=no 是核心参数:坐席正在通话中就不再给他分配新电话,如果不设这个,坐席通话时手机会继续振铃,体验很差。
Web 端坐席工作台的「签入/签出」就是对这个队列的成员动态增删,走后端 AMI 命令:
Action: QueueAdd Queue: sales Interface: PJSIP/6001Action: QueueRemove Queue: sales Interface: PJSIP/6001QueueAdd 把坐席加进队列,QueueRemove 移出。这两个动作配合上一章的事件推送,就能在 Web 上显示「当前队列 5 人在线、2 人通话中」。坐席接听队列来电时,如果希望话务员先看到客户资料再接听,可以在 Queue 应用里加预振铃参数,常见做法是 Queue(,,,30) 让坐席振铃 30 秒,期间 Web 弹屏加载号码对应的档案。
4.3 录音与 CDR:从 MixMonitor 到首页话务报表
录音和话单是呼叫中心 Web 项目里最容易被评委追问的两块。录音用 MixMonitor,放在 Dial 的 b() 选项里比较合适:
exten => _6XXX,1,Dial(PJSIP/${EXTEN},30,b(mixmonitor-output-${UNIQUEID}.wav))Dial 的 b() 选项表示被叫应答后才执行括号里的应用,这里就是给每条通话生成一个以通道唯一 ID 命名的录音文件,落在默认的 /var/spool/asterisk/monitor/ 目录。用的时机很关键:放在 Answer 之前启动,录进去的是没建立媒体流的静音;放在应答之后启动,才录到完整通话。MixMonitor 默认把双向混成一个文件,正好适合回放。
录音文件的属主必须是运行 Asterisk 的用户,通常是 asterisk:asterisk,否则文件创建不出来或权限报错:
sudo chown -R asterisk:asterisk /var/spool/asterisk/monitorCDR(Call Detail Record)是 Asterisk 内置的通话详单,默认写到 SQLite 或 CSV,生产项目常用 cdr_adaptive_odbc 写进 MySQL。Web 后台的话务报表,本质上就是查这张表:
SELECT calldate, src, dst, duration, disposition FROM cdr WHERE calldate >= CURDATE() - INTERVAL 7 DAY ORDER BY calldate DESC;duration 是通话时长,disposition 是结果:ANSWERED 表示接通,NO ANSWER 表示振铃没人接,FAILED 表示呼叫失败。报表页面按这个字段分桶统计就行。注意 CDR 的 duration 是按整条通道记录算的,如果一行里有多次呼叫转移,单条记录可能只代表一段,做统计时先确认你的业务里是否存在转接。
录音文件路径最好也存一张自定义表里,因为默认 CDR 没有录音文件名字段,需要你在创建 MixMonitor 时把文件名同步到数据库。这个表结构建议在写 Web 端时自己设计成 call_id、file_path、cdr_id 三列,报表页点「播放录音」时才能很快定位文件,而不是去文件系统里猜。
注意:录音和 CDR 是评委最喜欢深挖的地方,把「MixMonitor 生成文件 → 数据库记录路径 → Web 播放」这条链路走通,项目完整性会明显上台阶。
5. 避坑排查:SIP 注册、录音无声、AMI 掉线、NAT 单向音频的 5 个真实案例
这一章全是血泪经验。每个问题我都按「现象 → 原因 → 解决」的顺序写,前两个属于环境类,后三个属于 Web 项目里最常见的逻辑类,排查顺序也建议从上往下。
5.1 分机注册不上:先查 transport,再查 identify
现象:软电话一直显示 Registering,Asterisk 控制台没有任何注册请求日志。
原因排查顺序:首先看 pjsip.conf 里 endpoint 是否绑定了 transport,如果 transport 名字写错,Asterisk 会直接忽略这条注册;其次是 identify 小节里的 match 网段没包含软电话所在网段,注册会被判定为不合法;最后是防火墙,云主机安全组或 ufw 忘了放行 5060/udp,会让注册包根本到不了 Asterisk。
解决:在 asterisk -r 控制台执行 pjsip show endpoint 6001,重点看 Transport 和 Identify 两栏是否正常;再用 tcpdump -i any udp port 5060 抓包,能抓到注册请求说明网络通,抓不到就先放防火墙。把 transport 的 bind 地址改成 0.0.0.0:5060,identify 的 match 改成你实际网段,基本能解决九成注册问题。还有一类情况是软电话和 Asterisk 不在同一网段,却在 identify 里写了 /8,这种属于配置过宽,建议反过来改精确。
5.2 录音 0 字节或单声道:MixMonitor 时机和目录权限
现象:通话结束后录音文件存在,但大小是 0,或者播放出来只有一端的声音。
原因:MixMonitor 执行时机太早,比如在 Answer() 之前就录,此时通道还没建立媒体流,录进去的是空;权限问题则是 Asterisk 用户对录音目录没有写权限,文件创建不出来或者写了一半;单声道则多半是录音时编码路径不匹配,浏览器 WebRTC 通话用 opus 编码,Asterisk 转成 wav 时选错格式,回放就会缺声道。
解决:把 MixMonitor 放到 Dial 之后或使用 Dial 的 b() 选项,保证媒体流已建立;检查 /var/spool/asterisk/monitor 的属主,执行 chown -R asterisk:asterisk /var/spool/asterisk/monitor。录出来的 wav 想转成 mp3 给 Web 端播放,用 sox 一行命令处理:
sox input.wav -r 8000 -c 1 output.mp3注意 mp3 编码需要 libsox-fmt-mp3 这个插件,没装的话 sox 会报错。我发现很多项目直接拿 wav 给浏览器播放也是可以的,但文件大,网页端加载慢,做话务报表页时最好转一下。
5.3 AMI 连接被踢或发不出 Originate:权限与心跳一起查
现象:Web 后端连着连着 AMI 就断开,或者执行 Originate 返回 Error,提示 no originate privilege / authentication failed。
原因:manager.conf 里 write 权限没给 originate,属于最常见的权限遗漏;另一种是 Asterisk 对连接数有上限,反复重连会挤掉旧连接;还有一种是长连接静默太久被服务端断开,客户端没有心跳机制。
解决:先确认账号权限,把 write 至少配成 call,originate,agent;客户端做心跳,每 30 秒发一条 Action: Ping,Asterisk 会返回 Pong,顺便可以用来检测链路存活:
Action: Ping收到 Pong 说明 AMI 还活着,连续 3 次收不到就主动重连。重连时要注意先 Login 再重新订阅事件,否则会漏掉重连期间的通话状态。我在项目里会把 AMI 封装成单例服务,所有 Web 请求只通过这个服务发命令,避免多处连接把连接数耗尽。
5.4 内网演示音频单向:direct_media 和 NAT 参数一起改
现象:两个分机明明注册成功、控制台也显示通道建立,但只有一方能听到声音,或者完全不响。
原因:这是 Asterisk 最常见也最「玄学」的媒体问题。本质是 SIP 信令里携带的媒体地址与实际网络路径不一致。内网虚拟机演示时,常见原因是 Asterisk 所在机器的 IP 和软电话所在网段跨了 NAT,或者 direct_media 尝试让两端直接传媒体,绕开了 Asterisk,导致一端发来的 RTP 包另一端根本收不到。
解决:把所有 endpoint 统一加上 direct_media=no;同时确认 Asterisk 的 NAT 参数,pjsip.conf 的 endpoint 里可以设置 rtp_symmetric=yes 和 force_rport=yes,这两个参数让媒体流强制回传到来源地址,可以解决绝大部分单向音频。真机测试时,再配合 extern_ip 指定 Asterisk 的对外 IP,让它在 SIP 信令里宣告正确的地址。记住一条经验:呼叫中心项目里让所有媒体都经过 Asterisk 中转,录音和监控才稳定,为了那点延迟优化去开直连,最后都会回来改配置。
5.5 前端状态不同步:Hangup 不是终点,要有兜底查询
现象:对方已经挂断,Web 工作台上通话窗口还挂着「通话中」,坐席状态也不复位。
原因:AMI 的 Hangup 事件确实有,但如果你在事件处理里只订阅了 Newstate,没有订阅 Hangup,后端就根本不知道通话结束;另一种情况是通道经历了转移或桥接后再挂断,Hangup 事件的 Channel 字段和最初创建时不是同一个,前端按旧通道号匹配状态就匹配不上;还有可能是 WebSocket 断了,后端事件虽然收到但推不到前端。
解决:订阅列表里明确加入 Hangup、BridgeDestroy、PeerStatus;前端状态判定的原则改为「收到 Hangup 才结束」,而不是「超过几秒没新状态就结束」——但为了容错,还要加一个兜底:后端每隔 30 秒用 Action: Status 全量拉一次活动通道,和前端状态做对账,发现不一致就推送纠正信息。这个对账机制写起来不复杂,却是让演示变得「稳」的关键,竞赛现场评委让反复打电话测试时,状态一次都不飘,比什么炫酷界面都有说服力。
6. 进阶:用浏览器软电话把坐席端搬进 WebRTC,附验收步骤
到这一步,你的 Web 项目已经有 IVR、队列、录音和报表,但坐席接电话还是靠第三方软电话,答辩时说服力差一截。把坐席端做成浏览器软电话,是让整个项目「看起来完整」的临门一脚。Asterisk 对 WebRTC 的支持是原生能力,前端用 JsSIP 或 SIP.js 注册为 SIP 分机,媒体走 WebSocket 和浏览器内置的音频流,不需要安装任何客户端。
配置上,PJSIP 要加一个 wss transport 和 WebRTC 专用 endpoint,关键参数是 transport=transport-wss、webrtc=yes、ice_support=yes、direct_media=no,另外确认 res_http_websocket 模块已加载。前端代码核心就两步:
const socket = new JsSIP.WebSocketInterface("wss://your-server:8089/ws"); const ua = new JsSIP.UA({ sockets: [socket], uri: "sip:6001@your-server", password: "your_password", }); ua.on("registered", () => console.log("坐席在线")); const session = ua.call("sip:6002@your-server");这段逻辑是:先建 WebSocket 传输层,再注册分机,注册成功后直接发起呼叫。实际工程里要把 ua 保存为全局对象,坐席签入签出就对应注册和注销。
验收步骤我一般按这个清单走:第一,打开 chrome://webrtc-internals 确认 audio 流有没有实际收发,这一步能定位是麦克风权限问题还是编码问题;第二,用另一个软电话呼入队列,确认浏览器坐席能听到 IVR 放音、能正常接起;第三,挂断后回放录音,确认 WebRTC 通话也被录上——这一步经常有人漏,因为 WebRTC 用的编码可能是 opus,而录音格式是 wav,Asterisk 会做转码,转码失败录音就是坏的。如果录音有问题,把 endpoint 的 allow 加上 ulaw,强制避免转码路径。
注意:浏览器对 wss 的证书要求很严,本地自签证书调试前先计划好演示机器的信任处理。
我自己第一次把坐席搬进浏览器时,以为最难的是 ICE 协商,结果一晚上都在跟自签证书搏斗:本地测试可以加例外,但竞赛现场的评审机器不一定允许你提前装证书,建议准备一台本地 HTTPS 反代或者把证书预装进评审机器。如果时间紧,宁可用软电话演示,也别在证书上翻车。这套「AMI 控制 + WebSocket 推送 + 浏览器坐席」的架构做完,你的项目从演示到问答都经得起追问。希望帮到你。
本文还有配套的精品资源,点击获取