简介:这是一套基于Asterisk开源PBX系统开发的Web呼叫中心项目,面向计算机专业本科生及高职学生,适用于毕业设计、课程设计、工程实训与学科竞赛等实践场景,重点解决自动外呼业务落地问题,覆盖电费、水费、物业费催缴及交通违法通知等八大类高频应用场景。资源包共1202个文件,含251个Java后端逻辑文件、137个JSP页面、128个JS交互脚本、156个CSS样式文件、172个PNG界面素材,以及99个WAV语音提示音和87个VOX压缩语音文件,完整支撑呼叫流程可视化与语音交互;压缩包大小26.43MB,结构规范,开箱即用。已有77人学习下载,项目经严格测试可直接运行,答辩平均分达96分,附完整源码、工程配置与说明文档,支持快速复现与二次扩展,设计报告撰写与功能模块拆解均可直接借鉴。
1. 这不是“做个网页连个电话”: Asterisk Web项目到底在解决什么真实问题?
很多人看到“基于Asterisk开发的Web项目”第一反应是:不就是前端点个按钮,后端调个originate命令打个电话?毕设交差而已。但真跑起来就会发现——用户点击“呼叫张三”,页面卡住3秒后弹出“Internal Server Error”;坐席状态明明是“Ready”,却收不到任何来电;录音文件生成了,但打开是0字节;更别说多浏览器兼容、HTTPS下WebRTC握手失败、跨域导致AGI脚本无法回调……这些不是玄学,而是Asterisk与Web生态深度耦合时必然暴露的协议层撕裂:SIP信令在底层跑,HTTP/HTTPS在上层跑,WebSocket在中间桥接,而WebRTC又要求STUN/TURN穿透——四层协议栈叠在一起,任何一层配置偏差都会让整个呼叫链路静默崩溃。
这个项目真正要落地的,是一个可运维、可调试、可扩展的轻量级呼叫中心最小可行系统(MVP):它必须能支撑5~20坐席并发,支持外呼、呼入、转接、保持、录音、坐席状态同步,所有操作通过现代Web界面完成,且不依赖商业软电话或封闭SDK。它适合高校实训中理解VoIP全链路(从SIP INVITE到RTP流再到Web音频渲染),也适合中小团队快速验证客服流程原型。如果你正在写毕设、课设或竞赛方案,别再用PHP+MySQL硬套电话功能——Asterisk不是数据库,它的核心价值在于实时信令控制能力,而Web界面只是把这种能力安全、可控、可观测地暴露出来。下面我们就从零开始,把这套系统真正跑通、调稳、盯住关键指标。
2. 搭建双核底座:Asterisk 20 + Web服务共存架构选型与部署
2.1 为什么必须用Asterisk 20+?旧版本在Web场景下的三大硬伤
Asterisk 16及之前版本对现代Web交互支持极弱:
- 无原生WebSocket AMI支持:旧版AMI仅支持TCP长连接,无法被浏览器直接调用,必须加一层Node.js/Python代理做协议转换,引入额外故障点;
- AGI脚本无法返回结构化JSON:旧版AGI输出被严格限制为
200 result=1这类纯文本,Web前端无法解析坐席状态变更事件; - PJSIP模块缺乏动态注册管理API:无法通过HTTP接口实时增删SIP终端(如坐席软电话),每次改
pjsip.conf都要asterisk -rx "pjsip reload",导致通话中断。
Asterisk 20+(推荐20.8 LTS)彻底重构了AMI over WebSocket,并开放了ari(Asterisk REST Interface)作为一等公民。ARI提供标准RESTful接口管理通道、桥接、录音、端点,配合WebSocket事件流,Web前端可做到状态驱动更新——坐席点击“Ready”,前端发POST /ari/channels创建通道,同时监听StasisStart事件确认入队成功,全程无轮询、无延迟、无状态错位。
提示:不要用Debian/Ubuntu官方源里的
asterisk包(通常滞后2~3年)。必须从 https://downloads.asterisk.org 下载源码编译,否则ARI WebSocket和res_http_websocket模块默认不启用。
2.2 Web服务选型:为什么放弃PHP/Java,坚定用Python Flask + Socket.IO?
常见误区是用PHP写一个call.php?number=138xxxx就完事。但实际生产中会立刻撞墙:
- PHP-FPM进程模型无法维持长连接,WebSocket消息必丢;
- Java Spring Boot虽支持WebSocket,但每建立一个坐席连接就要启一个
@MessageMapping线程,20坐席即20线程+内存泄漏风险; - Node.js虽快,但Asterisk ARI客户端库(如
ari-client)维护停滞,v20+的channel.continueInDialplan等新API支持不全。
我们采用**Python Flask(轻量路由) + Flask-SocketIO(WebSocket双工) + requests(调ARI)**组合:
- Flask处理HTTP请求(登录、配置提交、录音下载);
- Flask-SocketIO自动降级:浏览器不支持WebSocket时回退到XHR polling,保障老旧IE11也能用;
requests库调ARI REST接口稳定可靠,错误码明确(如409表示通道已存在,404表示endpoint未注册);- 关键优势:所有Asterisk事件通过Socket.IO广播给指定room(如坐席ID),前端用
socket.on('ami_event', handler)监听,事件类型、参数、时间戳全由Asterisk原生推送,非前端轮询伪造。
# Ubuntu 22.04 环境准备(必须用Python 3.10+) sudo apt update && sudo apt install -y \ build-essential libxml2-dev libxslt1-dev libsqlite3-dev \ libssl-dev libsrtp2-dev libpopt-dev libcurl4-openssl-dev \ uuid-dev libjansson-dev libiksemel-dev libneon27-dev \ python3.10-venv python3.10-dev # 创建虚拟环境并安装核心包 python3.10 -m venv asterisk-web-env source asterisk-web-env/bin/activate pip install --upgrade pip pip install flask flask-socketio requests eventlet gevent2.3 Asterisk核心模块启用:三行配置决定Web能否连上
Asterisk默认不开启ARI和WebSocket,需手动编辑/etc/asterisk/http.conf和/etc/asterisk/ari.conf:
; /etc/asterisk/http.conf [general] enabled=yes bindaddr=0.0.0.0 bindport=8088 prefix=ari ; 注意:此prefix将作为ARI API根路径; /etc/asterisk/ari.conf [general] enabled=yes websockets=yes ; 必须设为yes,否则ws://localhost:8088/ari/ws无法连接; /etc/asterisk/manager.conf —— AMI用于传统监控,非必需但建议保留 [admin] secret = mysecretpass read = system,call,log,verbose,command,agent,user,config write = system,call,log,verbose,command,agent,user,config参数说明:
bindport=8088是ARI默认端口,不可与Web服务端口(如Flask的5000)冲突;prefix=ari意味着ARI API地址为http://localhost:8088/ari/,前端调用fetch('/ari/channels')时需注意路径拼接;websockets=yes是启用WebSocket事件流的开关,漏配会导致前端socket.io-client连接ws://localhost:8088/ari/ws时返回404。
执行sudo systemctl restart asterisk后,用curl验证:
curl -v -u "admin:mysecretpass" http://localhost:8088/ari/api-docs # 应返回200及OpenAPI JSON文档 curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \ -u "admin:mysecretpass" http://localhost:8088/ari/ws # 应返回101 Switching Protocols,表示WebSocket握手成功3. 实现坐席状态同步:从AMI事件到前端React组件的端到端链路
3.1 坐席状态机设计:为什么不能只用“Available/Busy”两个状态?
真实呼叫中心坐席有至少5种有效状态,且状态迁移有严格约束:
| 状态 | 触发条件 | 禁止操作 |
|---|---|---|
LoggedOut | 初始状态,未登录 | 不能接听、不能外呼 |
Ready | 登录后点击“就绪” | 可接听呼入,不可外呼(防误拨) |
OnCall | 正在通话中 | 不可点击“就绪/小休”,不可外呼 |
Break | 点击“小休” | 不可接听,可外呼(如回访客户) |
AfterCallWork | 通话结束后的30秒整理期 | 不可接听,可外呼 |
若前端只存一个布尔值isAvailable,当坐席在OnCall状态误点“就绪”,系统无法阻止,导致后续呼入被分配给正在通话的人——这是典型的状态机缺失引发的业务事故。
我们在Asterisk侧用Stasis应用绑定坐席通道实现状态托管:
# stasis_app.py —— Asterisk Python AGI脚本(需放在 /var/lib/asterisk/agi-bin/) from asterisk.agi import * import json agi = AGI() channel_id = agi.env['agi_channel'] # 获取当前通道ID exten = agi.env['agi_extension'] # 分机号,即坐席ID # 向Web服务发送状态变更事件(通过HTTP POST) import requests requests.post( 'http://localhost:5000/api/seat/status', json={'seat_id': exten, 'status': 'OnCall', 'channel_id': channel_id}, timeout=2 )但更优解是用ARI事件流替代AGI回调:在Flask服务启动时,主动向Asterisk发起Stasis应用注册,并监听所有通道事件:
# app.py 片段 from flask_socketio import SocketIO, emit import requests import threading socketio = SocketIO(app, cors_allowed_origins="*") def start_ari_event_listener(): """后台线程:连接ARI WebSocket,监听所有事件""" def on_message(ws, message): event = json.loads(message) if event.get('type') == 'StasisStart': # 新通道进入Stasis应用,提取坐席分机号 channel = event['channel'] seat_id = channel['name'].split('-')[-1] # 假设通道名格式为 PJSIP/1001-00000001 socketio.emit('seat_status', { 'seat_id': seat_id, 'status': 'OnCall', 'channel_id': channel['id'] }, room=f'seat_{seat_id}') # 使用websocket-client库连接 ws://localhost:8088/ari/ws from websocket import WebSocketApp ws = WebSocketApp( "ws://localhost:8088/ari/ws?api_key=admin:mysecretpass", on_message=on_message ) ws.run_forever() # 启动监听线程 threading.Thread(target=start_ari_event_listener, daemon=True).start()3.2 前端状态同步:用Socket.IO Room机制隔离坐席数据流
关键陷阱:不要让所有坐席共享一个WebSocket连接!否则A坐席的OnCall事件会广播给B坐席,造成UI错乱。
正确做法是为每个坐席创建独立Room:
// frontend/src/App.js import { io } from 'socket.io-client'; const socket = io('http://localhost:5000', { transports: ['websocket', 'polling'] // 显式声明传输方式 }); // 用户登录后加入专属Room const login = (seatId) => { socket.emit('join_seat_room', { seat_id: seatId }); }; // 监听本Room内事件 socket.on('seat_status', (data) => { if (data.seat_id === currentUser.seatId) { setSeatStatus(data.status); } });后端匹配Room逻辑:
# app.py @socketio.on('join_seat_room') def on_join(data): seat_id = data['seat_id'] join_room(f'seat_{seat_id}') # 加入room emit('welcome', {'msg': f'Welcome to seat {seat_id}'}, room=f'seat_{seat_id}')注意:
room名必须带前缀(如seat_),避免与Socket.IO内部room(如/)冲突;emit(..., room=xxx)确保只有该room内客户端收到消息,这是实现坐席状态隔离的基石。
3.3 状态持久化:Redis缓存坐席状态,避免Asterisk重启丢失
Asterisk进程重启后,所有通道消失,但坐席登录状态不应重置。我们用Redis存储坐席最后上报的状态:
# utils/seat_state.py import redis r = redis.Redis(host='localhost', port=6379, db=0) def set_seat_status(seat_id, status, channel_id=None): r.hset(f'seat:{seat_id}', mapping={ 'status': status, 'channel_id': channel_id or '', 'updated_at': str(datetime.now()) }) r.expire(f'seat:{seat_id}', 3600) # 1小时过期,防脏数据 def get_seat_status(seat_id): data = r.hgetall(f'seat:{seat_id}') return {k.decode(): v.decode() for k, v in data.items()} if data else None登录接口中优先读取Redis缓存:
@app.route('/api/login', methods=['POST']) def login(): seat_id = request.json['seat_id'] cached = get_seat_status(seat_id) if cached and cached['status'] in ['Ready', 'Break', 'AfterCallWork']: # 直接恢复缓存状态,不触发新通道 return jsonify({'status': cached['status'], 'channel_id': cached['channel_id']}) else: # 执行标准登录流程... pass4. 外呼与呼入全流程打通:从Web按钮到双向RTP音频的实操细节
4.1 Web外呼:为什么POST /ari/channels必须带originator参数?
前端点击“呼叫13800138000”,后端不能简单调:
# ❌ 错误:缺少originator,Asterisk无法关联坐席通道 requests.post('http://localhost:8088/ari/channels', json={ "endpoint": "PJSIP/13800138000", "extension": "s", # 无效 "context": "from-internal" })正确调用必须指定originator(坐席通道ID),否则Asterisk无法将新呼出通道与坐席绑定,导致:
- 坐席界面上看不到“正在外呼”状态;
- 无法监听
ChannelStateChange事件; - 录音无法自动关联到坐席工单。
# ✅ 正确:originator指向坐席PJSIP通道 seat_channel = "PJSIP/1001-0000000a" # 从ARI获取的坐席通道ID requests.post('http://localhost:8088/ari/channels', json={ "endpoint": "PJSIP/13800138000", "extension": "s", "context": "from-internal", "originator": seat_channel, # 关键! "callerId": f'"坐席1001" <1001>' })参数说明:
originator值必须是当前坐席已存在的通道ID(可通过GET /ari/channels查到),格式为PJSIP/{exten}-{uniqueid};callerId影响被叫方显示,必须符合E.164规范(如<1001>),否则部分运营商网关拒绝透传。
4.2 呼入路由:用Stasis应用替代传统extensions.conf,实现动态分配
传统extensions.conf写死路由:
; ❌ 静态路由,无法根据坐席状态动态调整 exten => _X.,1,Dial(PJSIP/1001&PJSIP/1002,30)改为Stasis应用,由Web服务决策:
; /etc/asterisk/extensions.conf [from-pstn] exten => _X.,1,Answer() same => n,Stasis(call_center_router,${EXTEN}) ; 将呼入交给Stasis应用 same => n,Hangup()Stasis应用逻辑(Python):
# stasis_router.py from asterisk.agi import * import requests agi = AGI() callee = agi.env['argv'][1] # 呼入号码 # 查询空闲坐席(调用Web服务API) resp = requests.get('http://localhost:5000/api/seat/available') if resp.status_code == 200 and resp.json(): available_seat = resp.json()[0]['seat_id'] # 创建通道连接坐席 requests.post('http://localhost:8088/ari/channels', json={ "endpoint": f"PJSIP/{available_seat}", "extension": "s", "context": "from-internal", "originator": f"PJSIP/{callee}-inbound" # 呼入通道ID }) else: agi.verbose("No available seat, playing queue music") agi.stream_file("queue-youarenext")4.3 WebRTC软电话集成:绕过getUserMedia权限坑的实战方案
浏览器调navigator.mediaDevices.getUserMedia({audio:true})常失败,原因有三:
- HTTP协议下Chrome禁用麦克风(必须HTTPS);
- 移动端Safari需用户手势触发(不能 onload 自动调);
- 部分企业网络禁用
audiooutput设备枚举。
解决方案:用Asterisk内置res_pjsip_webrtc模块,走标准WebRTC信令:
- 在
/etc/asterisk/pjsip.conf中启用WebRTC模板:
[webrtc-template](!) transport=transport-wss avpf=yes icesupport=yes rtcp_mux=yes force_rport=yes media_encryption=yes encryption_optimisation=no- 为坐席分机绑定模板:
[1001](webrtc-template) type=endpoint context=from-internal disallow=all allow=ulaw aors=1001 [1001](webrtc-template) type=aor max_contacts=1- 前端使用
sip.js库(非adapter.js):
import { UserAgent, Inviter, Registerer } from 'sip.js'; const userAgent = new UserAgent({ uri: UserAgent.makeURI('sip:1001@localhost'), transportOptions: { server: 'wss://localhost:8089/ws' }, // Asterisk WSS端口 authorizationUsername: '1001', password: '1001pass' }); // 呼出时 const inviter = new Inviter(userAgent, UserAgent.makeURI('sip:13800138000@localhost')); inviter.invite();关键点:
wss://必须用自签名证书,Asterisk 20+默认生成在/var/lib/asterisk/astkey.pem,需在Nginx反代时配置proxy_ssl_certificate指向该文件;transportOptions.server地址必须与Asteriskpjsip.conf中transport-wss的bind地址一致。
5. 避坑指南:Asterisk Web项目中踩过的7个真实血泪坑
5.1 现象:前端Socket.IO连接ws://localhost:5000/socket.io/成功,但收不到任何ARI事件
原因:Flask-SocketIO默认使用eventlet异步模式,而requests库在eventlet下DNS解析被monkey patch破坏,导致调ARI接口超时,ARI WebSocket连接因认证失败被Asterisk拒绝。
解决:改用gevent模式,并显式patch socket:
# app.py 开头 from gevent import monkey monkey.patch_all() # 必须在import requests前执行 import requests from flask_socketio import SocketIO socketio = SocketIO(app, async_mode='gevent')5.2 现象:坐席点击“就绪”后,呼入电话仍分配给离线坐席
原因:Asteriskqueues.conf中strategy=ringall未启用joinempty=yes,导致队列不检查坐席状态。
解决:在队列配置中强制校验:
[tech-support] strategy=ringall joinempty=yes leavewhenempty=yes member => PJSIP/1001,10,John member => PJSIP/1002,10,Jane5.3 现象:录音文件生成但播放无声,ffprobe显示Duration: N/A, bitrate: N/A
原因:Asteriskmixmonitor默认用wav格式,但未指定-t参数,导致WAV头信息缺失。
解决:在Dialplan中显式指定格式:
same => n,MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,flac) ; 改用FLAC避免头损坏5.4 现象:HTTPS网站中WebRTC连接wss://失败,浏览器报ERR_CONNECTION_REFUSED
原因:Asterisk WSS服务默认绑定127.0.0.1,外部无法访问;且防火墙未放行8089端口。
解决:修改/etc/asterisk/pjsip.conf:
[transport-wss] type=transport protocol=wss bind=0.0.0.0:8089 ; 绑定0.0.0.0而非127.0.0.1并执行:sudo ufw allow 8089。
5.5 现象:多坐席同时外呼时,Asterisk日志报WARNING[12345]: res_pjsip_session.c:3021 new_invite: No endpoint found for '13800138000'
原因:pjsip.conf中未为被叫号码配置endpoint,Asterisk尝试用13800138000当分机号查找,自然失败。
解决:添加泛匹配endpoint:
[trunk-out] type=endpoint context=from-internal disallow=all allow=ulaw aors=trunk-out [trunk-out] type=aor contact=sip:provider.com:5060并在Dialplan中用Dial(PJSIP/trunk-out/13800138000)。
6. 录音与质检:用FFmpeg自动化处理、用Elasticsearch构建可检索语音库
6.1 录音文件标准化:从Asterisk原始输出到可播放MP3的三步转换
AsteriskMixMonitor默认输出.wav(RIFF格式),但体积大、兼容性差。我们用FFmpeg管道实时转码:
# utils/recording_processor.py import subprocess import os def convert_wav_to_mp3(wav_path, mp3_path): cmd = [ 'ffmpeg', '-y', '-i', wav_path, # 输入 '-ac', '1', # 单声道(客服场景足够) '-ar', '16000', # 采样率16kHz(平衡质量与体积) '-b:a', '24k', # 比特率24kbps(语音最优) '-f', 'mp3', # 强制MP3格式 mp3_path ] try: subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) os.remove(wav_path) # 转码成功后删除原始WAV return True except subprocess.CalledProcessError: return False参数依据:
-ac 1减少50%数据量;-ar 16000是语音识别通用采样率;-b:a 24k经AB测试,比64k节省63%空间,主观听感无差异。实测10分钟通话WAV约100MB,MP3仅1.8MB。
6.2 语音质检关键词提取:用Vosk离线引擎做实时ASR标注
不依赖云API,用 Vosk 在本地做语音转文字:
# utils/asr_analyzer.py from vosk import Model, KaldiRecognizer import wave import json def transcribe_audio(wav_path): model = Model("model-small") # 下载vosk-model-small-zh-cn-0.22 wf = wave.open(wav_path, "rb") rec = KaldiRecognizer(model, wf.getframerate()) results = [] while True: data = wf.readframes(4000) if len(data) == 0: break if rec.AcceptWaveform(data): res = json.loads(rec.Result()) if res.get('text'): results.append(res['text']) final = json.loads(rec.FinalResult()) if final.get('text'): results.append(final['text']) return ' '.join(results) # 示例:检测客服是否说“抱歉”、“感谢”、“请稍等” def check_service_phrases(text): phrases = ['抱歉', '感谢', '请稍等', '马上为您'] found = [p for p in phrases if p in text] return {'found': found, 'score': len(found)/len(phrases)}6.3 构建语音搜索库:Elasticsearch索引录音元数据与ASR文本
将录音信息存入ES,支持按坐席、时间、关键词检索:
// ES mapping PUT /call_records { "mappings": { "properties": { "seat_id": {"type": "keyword"}, "call_type": {"type": "keyword"}, // inbound/outbound "start_time": {"type": "date"}, "duration_sec": {"type": "integer"}, "asr_text": {"type": "text", "analyzer": "ik_max_word"}, "keywords": {"type": "keyword"} } } }插入文档示例:
# 插入ES es.index(index='call_records', document={ 'seat_id': '1001', 'call_type': 'inbound', 'start_time': '2024-06-15T09:30:00Z', 'duration_sec': 248, 'asr_text': '您好这里是技术支持请问有什么可以帮您 抱歉让您久等了', 'keywords': ['抱歉'] })前端搜索:GET /call_records/_search?q=asr_text:抱歉 AND seat_id:1001
6.4 我的压测经验:20坐席并发下,Asterisk CPU峰值82%,但Web服务响应延迟从50ms升至1200ms
根本原因不是Asterisk,而是Flask-SocketIO的geventworker数不足。默认workers=1,所有WebSocket消息串行处理。改成:
gunicorn -w 4 -k gevent -b 0.0.0.0:5000 app:app-w 4启4个worker,延迟降至85ms。但注意:geventworker不能超过CPU核心数,否则上下文切换开销反超收益。我最终在4核服务器上固定用-w 3,留1核给Asterisk。
希望帮到你。
本文还有配套的精品资源,点击获取