跳到正文

浏览器软电话与坐席 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
sipPasswordSIP 注册密码
sipRealmSIP realm / domain
sipWsUrlWebSocket 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. 会话生命周期 ​

  1. 创建:前端 POST /api/softphone-sessions/create(携带客户号码与线路选择)。 callout-server 先落一条 agent_call_records,再请求 callflow-esl 创建 内存 Hub 会话,返回 sessionId、serviceNumber 与 sipExtraHeaders。
  2. 拨号:浏览器 SIP UA 拨 serviceNumber(配置 softphone.serviceNumber), 必须原样携带 sipExtraHeaders——callflow-esl 业务靠这些 Header 里的 sessionId / 客户号码找回会话;缺失会被播错误提示后挂断。
  3. 接通:browser-softphone-assist-business 建会议(坐席=host)、开录音、 originate 客户入会(conference-guest-business),开启会议转写。
  4. 实时事件:前端订阅 GET /api/softphone-sessions/events?sessionId=...(SSE), 事件类型:session(状态 / 会议 / 录音信息)、message(按角色归并的 对话消息,实时字幕)、tts(打字消息的 queued/playing/completed/failed)、 error。
  5. 控制:messages(打字文本 TTS 注入客户腿)、controls (hold / resume 对坐席腿 deaf/undeaf、dtmf 发客户腿、transfer 转接)、 notes(备注落库)。
  6. 结束与录音持久化:
    • 任一侧挂机或 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": "获取媒体播放配置成功"
    }
  • 解析决策逻辑:
    1. 读取通话记录中记录的 cellId;
    2. 若 cellId 存在且包含在 freeswitchRecordingBaseUrlsByCell 字典中,使用匹配的 Cell 前缀拼接短文件名;
    3. 若 cellId 为空(历史单机时代老记录),自动平滑回退至兼容默认值 freeswitchRecordingBaseUrl;
    4. 若 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:

  1. 调度与互斥:默认开启,每 30 秒执行一轮;由全局分布式锁保证多实例部署下同一时刻只有一个节点执行;
  2. 全量 Cell 探测:并行遍历所有配置了 user/ 分机模板的 FreeSWITCH cells,执行 show registrations 命令,建立全网已注册分机视野;
  3. 判定与对账动作:
    • 更新 Cell 归属:若某坐席分机在某 Cell 成功注册,将该 Cell ID 刷入 agents.cellId,为转人工就近分配坐席提供依据;
    • 只降级不升级:对「数据库为 idle / online,但在所有可达 Cell 中均未注册」的坐席,将其状态安全降级为 offline,并记录 agent_registration_offline 运行事件;重新上线必须由坐席重新登录或前端显式置闲驱动;
    • 通话中(busy)保护:处于 busy 通话态的坐席不参与降级,状态由通话挂机链路复位;
    • 故障隔离(Fail-Safe):若某个 FreeSWITCH 节点网络不可达或命令超时,该 Cell 被视为状态未决并直接跳过,绝不因通信异常误将全员判为离线。

7. 关联文档 ​

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。