浏览器软电话与坐席 SIP 接入
系统二的坐席可以直接在浏览器(callout-webpage 坐席通话台)用 SIP 软电话外呼客户, 全程获得实时字幕、打字转语音、保持 / 恢复、DTMF 与结束控制。本文说明这条链路的 组成、配置与接口;单个接口的请求 / 响应结构见 api-reference.md 「浏览器软电话与坐席 SIP」一组。
1. 链路组成
text
坐席浏览器 (callout-webpage 坐席通话台)
│ 1. GET /api/agents/me/sip-config ← 读取本坐席 SIP 注册参数
│ 2. POST /api/softphone-sessions/create ← 创建会话,拿 sessionId + sipExtraHeaders
│ 3. SIP UA 经 sipWsUrl 注册后拨 softphone.serviceNumber(携带 sipExtraHeaders)
▼
FreeSWITCH ──socket──▶ callflow-esl `browser-softphone-assist-business`
│ 按 SIP Header 里的 sessionId 找回会话 → 建独立会议(坐席=host)
│ → originate 客户入会 → 开会议转写
▼
callout-server(控制面代理,全部落 agent_call_records)
│ GET /api/softphone-sessions/events?sessionId=... ← SSE:session / message / tts / error
│ POST /api/softphone-sessions/messages ← 坐席打字文本 → TTS 注入客户腿
│ POST /api/softphone-sessions/controls ← hold / resume / dtmf / transfer
│ POST /api/softphone-sessions/notes ← 会话备注
│ POST /api/softphone-sessions/end ← 结束会话callout-server 自己不碰媒体流:会话的实时状态由 callflow-esl 的 browser-softphone-session-hub 维护(callflow-esl HTTP 端口上有一组同名内部端点, token 头鉴权),callout-server 只做代理 + 权限校验 + 旁路落库。
会话与 cell 是粘性的。Hub 是 callflow-esl 的实例内存态(SSE 订阅者与命令队列无法序列化), 所以会话创建时按坐席的 agents.cellId 选定一台 callflow-esl,之后这个会话的消息、控制、结束和 事件流请求都必须回到同一台。映射写在 Redis(<keyPrefix>:softphone:<sessionId>,TTL 12 小时, 读取时续期),未命中时回落 agent_call_records.payload.cellId;坐席还没被观测到注册(cellId 为空) 时用默认 cell,而归属 cell 已从配置里移除时直接报错,不会转投别的实例。
2. 坐席 SIP 配置
callout.agents 表为每个坐席保存一组浏览器 SIP 注册参数:
| 字段 | 说明 |
|---|---|
phoneExtension | 唯一 SIP 注册账号,同时也是 FreeSWITCH directory 用户、拨号分机和转人工标识 |
cellId | 该坐席分机实际注册在哪个 FS cell,由坐席注册对账 worker 写入;决定软电话会话落到哪台 callflow-esl |
sipPassword | SIP 注册密码 |
sipRealm | SIP realm / domain |
sipWsUrl | WebSocket SIP 地址(wss://.../ws,浏览器 UA 用) |
sipDisplayName | 显示名 |
sipEnabled | 是否启用浏览器软电话(0/1) |
读取接口(均要求 agent:operate 权限):
GET /api/agents/me/sip-config:当前登录用户绑定坐席的 SIP 配置 (坐席与登录用户是一对一硬绑定,只认agents.userId)。GET /api/agents/sip-configs:当前用户可见的已启用 SIP 配置列表 (admin / supervisor 可见全部)。
坐席的创建 / 编辑接口(POST /api/agents/create、POST /api/agents/update) 可写入上述 SIP 字段。启用软电话时,phoneExtension、sipPassword、sipRealm 与 sipWsUrl 必须完整。
3. 会话生命周期
- 创建:前端
POST /api/softphone-sessions/create(携带客户号码与线路选择)。 callout-server 先落一条agent_call_records,再请求 callflow-esl 创建 内存 Hub 会话,返回sessionId、serviceNumber与sipExtraHeaders。 - 拨号:浏览器 SIP UA 拨
serviceNumber(配置softphone.serviceNumber), 必须原样携带sipExtraHeaders——callflow-esl 业务靠这些 Header 里的 sessionId / 客户号码找回会话;缺失会被播错误提示后挂断。 - 接通:
browser-softphone-assist-business建会议(坐席=host)、开录音、 originate 客户入会(conference-guest-business),开启会议转写。 - 实时事件:前端订阅
GET /api/softphone-sessions/events?sessionId=...(SSE), 事件类型:session(状态 / 会议 / 录音信息)、message(按角色归并的 对话消息,实时字幕)、tts(打字消息的 queued/playing/completed/failed)、error。 - 控制:
messages(打字文本 TTS 注入客户腿)、controls(hold/resume对坐席腿 deaf/undeaf、dtmf发客户腿、transfer转接)、notes(备注落库)。 - 结束与录音持久化:
- 任一侧挂机或
POST /api/softphone-sessions/end触发收尾。 - 录音文件名规范:
browser-softphone-assist-business在通话接通建会时,通过安全录音机制自动生成格式为softphone-${sessionId.slice(0, 8)}-${timestamp}.wav的物理录音文件(例如softphone-a1b2c3d4-20260916-220000.wav)。 - 短文件名回写(toStoredMediaName):无论是本地全路径还是外部 URL,服务端统一通过
toStoredMediaName()提取标准短文件名,写入agent_call_records.payload.recordingFileName与呼叫尝试记录;数据库不绑定物理绝对路径或临时主机域名。 - 转写片段写入
transcript_segments,通话记录保留在agent_call_records,事后可通过POST /api/softphone-calls/list(列表)与POST /api/softphone-calls/detail(详情)查询。
- 任一侧挂机或
4. 配置与跨 Cell 录音播放地址解析
4.1 核心配置
callout-server config.toml。callflow-esl 地址是按 cell 配置的(顶层 callflowEsl 只作为 cell 省略该字段时的继承默认值),softphone 是全局的:
toml
[softphone]
serviceNumber = "5020"
[[cells]]
id = "cell-1"
[cells.callflowEsl]
baseUrl = "http://127.0.0.1:9912"
token = "esl-0x21"
[cells.media]
freeswitchRecordingBaseUrl = "https://fs-cell1.domain.com/recordings/"| 字段 | 说明 |
|---|---|
cells[].callflowEsl.baseUrl | 该 cell 的 callflow-esl HTTP 地址(其 http-server 端口,默认 9912),软电话会话代理的目标 |
cells[].callflowEsl.token | 与该 callflow-esl http-server.authToken 对应的共享令牌,代理请求放在 token 头 |
cells[].media.freeswitchRecordingBaseUrl | 本 cell 录音文件的 HTTP 访问基础前缀 |
softphone.serviceNumber | 浏览器 SIP UA 实际拨打的业务号(代码默认 8800,仓库配置 5020) |
4.2 跨 Cell 录音播放地址解析(GET /api/media/config)
多 Cell 部署下,不同 FreeSWITCH 实例产生的录音文件分散在各自的媒体服务器或存储卷上。前端播放与后台情绪分析通过 GET /api/media/config 动态解析录音真实地址:
- 接口响应结构:json
{ "code": "0000", "data": { "freeswitchRecordingBaseUrl": "https://media.domain.com/recordings/", "freeswitchRecordingBaseUrlsByCell": { "cell-1": "https://fs-cell1.domain.com/recordings/", "cell-2": "https://fs-cell2.domain.com/recordings/" }, "emotionAudioBaseUrl": "https://emotion.domain.com/emotion-audio/" }, "msg": "获取媒体播放配置成功" } - 解析决策逻辑:
- 读取通话记录中记录的
cellId; - 若
cellId存在且包含在freeswitchRecordingBaseUrlsByCell字典中,使用匹配的 Cell 前缀拼接短文件名; - 若
cellId为空(历史单机时代老记录),自动平滑回退至兼容默认值freeswitchRecordingBaseUrl; - 若
cellId非空但在配置字典中找不到(如已退役下线的失效 Cell),则前端不生成错误链接,界面优雅禁用播放按钮,防止死链报错。
- 读取通话记录中记录的
5. ICE / TURN
两个软电话页面在 SIP 注册前调用受登录鉴权保护的 GET /api/softphone/ice-config。 仓库默认 turn.enabled=false,响应为 { enabled: false, iceServers: [], expiresAt: null },浏览器使用 host candidate 完成本地 直连。关闭状态不缓存、不安排刷新,后续注册、拨号或接听会重新请求,因此服务端启用 TURN 后无需等待旧状态过期。
生产环境启用 TURN 时,响应为 { enabled: true, iceServers: [...], expiresAt: "<ISO time>" },其中包含 STUN、 TURN/UDP、TURN/TCP 和 coturn REST 短期凭据。前端在过期前 5 分钟刷新,同一 callout 标签页的多个软电话组件共用一份凭据缓存。旧凭据过期且刷新失败时,新的注册、拨出与接听 会显示“ICE/TURN 配置获取失败”并在 SDP 创建前停止,不会静默降级为空数组。
TURN 直接监听 123.57.205.60:3478,relay 范围为 31000–31040/UDP,不经过 FRP。 shared secret 只能由服务器私有配置或 CALLOUT_TURN_SHARED_SECRET 注入。 必须同时提供 turn.enabled=true 与真实 shared secret;启用但配置不完整时接口返回 503 ICE_CONFIG_UNAVAILABLE。两种成功响应均使用 Cache-Control: no-store。
6. 坐席分机注册对账(agentRegistrationReconcile)
坐席在数据库中的状态(agents.status)由手动示闲、离线与系统呼叫占用、释放共同维护,忙碌期间不能手动切换。若坐席直接关闭浏览器标签页或网络中断,SIP 分机会在 FreeSWITCH 中掉线,但数据库仍显示 idle,导致 ACD 派单时发生 USER_NOT_REGISTERED 呼叫失败。
为此,系统内嵌了 agentRegistrationReconcile 后台守护 worker:
- 调度与互斥:默认开启,每 30 秒执行一轮;由全局分布式锁保证多实例部署下同一时刻只有一个节点执行;
- 全量 Cell 探测:并行遍历所有配置了
user/分机模板的 FreeSWITCH cells,执行show registrations命令,建立全网已注册分机视野; - 判定与对账动作:
- 更新 Cell 归属:若某坐席分机在某 Cell 成功注册,将该 Cell ID 刷入
agents.cellId,为转人工就近分配坐席提供依据; - 只降级不升级:对「数据库为
idle/online,但在所有可达 Cell 中均未注册」的坐席,将其状态安全降级为offline,并记录agent_registration_offline运行事件;重新上线必须由坐席重新登录或前端显式置闲驱动; - 通话中(busy)保护:处于
busy通话态的坐席不参与降级,状态由通话挂机链路复位; - 故障隔离(Fail-Safe):若某个 FreeSWITCH 节点网络不可达或命令超时,该 Cell 被视为状态未决并直接跳过,绝不因通信异常误将全员判为离线。
- 更新 Cell 归属:若某坐席分机在某 Cell 成功注册,将该 Cell ID 刷入
7. 关联文档
- 通话侧业务实现:
apps/callflow-esl/docs/existing-businesses.md的browser-softphone-assist-business一节 - 接口请求 / 响应结构:api-reference.md
- AI 通话转人工(会议 ACD,与本链路共用会议与坐席模型):transfer-to-human.md
- 会议生命周期与对账机制:conference-lifecycle-and-reconcile.md