SIP软电话实现
SIP 软电话实现:从协议到全栈落地
本文基于我实际落地的一套 SIP 软电话系统(Flutter 安卓 App + Go API + FreeSWITCH + MySQL + Caddy),先讲清楚 SIP 协议本身是什么,再拆解整个项目的架构、关键链路与踩坑记录。所有内容均来自真实可运行的服务器部署,而非纸上谈兵。
一、什么是 SIP
SIP(Session Initiation Protocol,RFC 3261)是一个应用层信令协议,只负责”建立、修改、终止会话”,本身不传输语音。可以把它理解为电话世界的”HTTP”——文本协议、请求/响应模型、状态码语义都高度相似。
1.1 请求方法
| 方法 | 作用 |
|---|---|
| REGISTER | 注册当前位置(软电话上线) |
| INVITE | 发起呼叫 |
| ACK | 确认最终响应 |
| BYE | 挂断 |
| CANCEL | 取消未应答的呼叫 |
| UPDATE/INFO | 会话中透传信息 |
1.2 响应码
| 类别 | 含义 | 常见 |
|---|---|---|
| 1xx | 临时响应 | 100 Trying、180 Ringing(振铃)、183 进展 |
| 2xx | 成功 | 200 OK |
| 3xx | 重定向 | 302 |
| 4xx | 客户端失败 | 401 Unauthorized(发起摘要认证挑战)、403、486 Busy |
| 5xx/6xx | 服务端/全局失败 | 503、603 Decline |
注意 401 不是错误:SIP 的注册认证就是”客户端先 REGISTER → 服务器回 401 携带 nonce → 客户端带上 MD5 摘要重发 → 200 OK”,与 HTTP Digest 同源。
1.3 信令与媒体分离
- 信令:SIP 报文(文本),走 UDP/TCP,或 WebSocket(WS/WSS)
- 媒体:语音由 RTP(实时传输协议)承载,端口、编解码等参数通过 SDP(随 INVITE/200 OK 的正文)协商
- 软电话场景常用 WebRTC 做媒体层:DTLS-SRTP 加密、ICE 打洞、回声消除都白送,浏览器和移动端支持完善
二、系统总体架构
flowchart TB
subgraph app["Flutter App"]
UI["UI层"]
ENGINE["SIP状态机"]
DBL[("SQLite")]
end
subgraph gateway["Caddy网关"]
R1["HTTPS API"]
R2["WSS"]
end
subgraph core["Docker网络"]
FS["FreeSWITCH"]
API["Go API"]
MYSQL[("MySQL 8")]
end
UI --> ENGINE
UI --> DBL
ENGINE --> R2
R2 --> FS
FS --> ENGINE
UI --> R1
R1 --> API
API --> MYSQL
2.1 组件职责
| 组件 | 技术 | 职责 |
|---|---|---|
| Flutter App | sip_ua + flutter_webrtc + Riverpod + drift | UI、SIP 注册/呼叫、本地通话记录缓存与同步 |
| FreeSWITCH | safarov/freeswitch 镜像 | SIP 注册认证、呼叫桥接、RTP 转发 |
| Go API | Gin + GORM + JWT | 用户注册登录、分机分配、话单存储、动态目录回调 |
| MySQL | 8.0 | users / sip_accounts / call_records |
| Caddy | 复用同机已有实例 | HTTPS 反代 API、WebSocket 反代 /ws → FS |
核心设计点:FreeSWITCH 不静态配置分机,而是通过 mod_xml_curl 把目录查询实时回调给 Go API——注册一个用户就自动拥有一个 SIP 分机,零同步成本。
2.2 关键链路
注册链路(App 上线):
1 | |
呼叫链路:
1 | |
拨号计划只需一条:4 位数字 → bridge user/$1@$${domain}。
话单链路:通话结束 → App 本地 SQLite 先落库(保证离线不丢)→ 尽力上报 POST /calls → 游标分页拉取合并(按 serverId 去重回填)。
三、服务端实现要点(Go)
3.1 分机原子分配
注册即分配最小空闲分机(从 1001 起),必须处理并发:
1 | |
配合 gorm.Config{TranslateError: true},把用户名唯一冲突翻译成 gorm.ErrDuplicatedKey → 精确返回 409 而不是笼统 500。
3.2 xml_curl 目录回调(认证的核心)
FreeSWITCH 收到 REGISTER 时会 POST 表单过来,Go 侧按协议返回 XML 即完成”数据库即目录”:
1 | |
四、Flutter 端实现要点
| 模块 | 方案 |
|---|---|
| SIP 封装 | sip_ua(内置 flutter_webrtc),抽象 CallEngine 接口 + Fake 便于测试 |
| 状态机 | idle → outgoing → active(计时) → ended;来信未接自动记 missed |
| 本地存储 | drift(SQLite):通话记录镜像 + 未上报队列 |
| 同步 | pushPending 逐条尽力上报 + pullMerge 按 serverId/三元组回填合并 |
| 会话 | flutter_secure_storage 存 JWT 与分机凭证 |
| 保活 | flutter_background_service 前台麦克风服务 + 通话通知 |
UI 遵循「Minimal & Direct」设计系统:Inter 字体、单一金色 CTA、底部三 Tab(历史/拨号/账号)、触控目标 ≥48dp、明暗双主题语义色 token。
五、部署与端口
1 | |
Caddy 侧三条路由:/sipapi/* 剥前缀反代 API、/ws 反代 FS 的 5066、/sipapi/api/v1/fsw/* 直接 403(该接口下发 SIP 密码,绝不能公网可达)。
安全组只需:443(TCP)、5060(UDP)、7443(TCP)、16384-16404(UDP)。
六、踩坑记录(全是真金白银的时间)
Can't register a pointer403:容器从go-api改名sip-api后,xml_curl 的 gateway-url 没同步,DNS 解析失败 → FS 静默回退静态目录的指针文件 → 认证失败。教训:FS 目录回调查不到时不会报错,会悄悄兜底。- Caddyfile 单文件 bind mount:宿主机
mv替换文件换了 inode,容器里永远读旧内容。要么原地写,要么重启容器。 - WS 未连上就 register:
helper.start()后立刻register(),transport 还在 connecting,REGISTER 直接丢。改为依赖settings.register=true自动注册 + 失败指数退避重试。 - 注册并发竞态:分配分机与插入分两步,并发下两人同抢一个号。锁 + 事务合并为原子操作。
- 服务器构建 Go 被墙:
go mod download卡死。本地交叉编译GOOS=linux GOARCH=amd64上传二进制,Dockerfile 退化成FROM alpine + COPY,秒级构建。 - Dart TLS 被本机 VPN 掐断:
HandshakeException: Connection terminated during handshake,浏览器/curl 都正常,唯独 Dart 被按域名分流拦了。换明文 HTTP 路由绕开(生产记得上正规证书)。
七、效果
双端(两台模拟器/真机)注册 → 互拨分机号 → 振铃接听 → 双向语音 → 挂断后双方历史自动落库并同步云端。整套系统单台 2G 内存小服务器即可承载,语音走 WebRTC,局域网与公网均可部署。