Files
training-system/cc-center/FreeSWITCH技术架构与ESL开发指南.txt
2026-05-12 12:24:11 +08:00

3599 lines
127 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 目录
1. [FreeSWITCH技术架构](#第一部分-freeswitch技术架构)
2. [核心模块详解](#第二部分-核心模块详解)
3. [ESL开发指南](#第三部分-esl开发指南)
4. [呼叫中心场景开发实践](#第四部分-呼叫中心场景开发实践)
5. [高可用部署方案](#第五部分-高可用部署方案)
6. [性能优化指南](#第六部分-性能优化指南)
---
## 第一部分 FreeSWITCH技术架构
### 1.1 整体架构概览
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 外部系统/应用层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ CTI中间件 │ │ Web应用 │ │ 业务系统 │ │ 监控系统 │ │ 第三方App│ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
│ │ │ │ │ │ │
│ └─────────────┴─────────────┴──────┬──────┴─────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ ESL接口 │ │
│ │ (TCP:8021) │ │
│ └──────┬──────┘ │
└───────────────────────────────────────────┼─────────────────────────────────┘
┌───────────────────────────────────────────▼─────────────────────────────────┐
│ FreeSWITCH 核心层 │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Core核心引擎 │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │
│ │ │ 状态机管理 │ │ 内存管理 │ │ 线程池管理 │ │ │
│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │
│ │ │ 事件系统 │ │ 数据库抽象 │ │ 日志系统 │ │ │
│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Module Interface模块接口层 │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Endpoint │ │ Application │ │ Codec │ │ Dialplan │ │ │
│ │ │ 终端模块 │ │ 应用模块 │ │ 编解码 │ │ 拨号计划 │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Event │ │ Format │ │ Logger │ │ XML │ │ │
│ │ │ 事件模块 │ │ 文件格式 │ │ 日志模块 │ │ XML处理 │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────▼─────────────────────────────────┐
│ 协议/媒体层 │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ mod_sofia │ │ mod_verto │ │ mod_rtmp │ │ mod_skinny │ │
│ │ (SIP协议) │ │ (WebRTC) │ │ (RTMP) │ │ (Cisco) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 媒体处理引擎 │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ RTP处理 │ │ 编解码转换 │ │ 录音处理 │ │ DTMF检测 │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────▼─────────────────────────────────┐
│ 网络层 │
│ SIP/UDP/TCP RTP/SRTP WebSocket HTTP │
│ :5060 :16384-32768 :8081 :8080 │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 1.2 核心概念
#### 1.2.1 Channel通道
Channel是FreeSWITCH中最核心的概念代表一路通话的端点。
```
┌─────────────────────────────────────────────────────────────────┐
│ 一通电话 │
│ │
│ ┌─────────────┐ Bridge ┌─────────────┐ │
│ │ Channel A │◄─────────────────────────►│ Channel B │ │
│ │ (主叫方) │ │ (被叫方) │ │
│ │ │ │ │ │
│ │ UUID: xxx-a │ │ UUID: xxx-b │ │
│ │ State: CS_ │ │ State: CS_ │ │
│ │ Variables │ │ Variables │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**Channel状态机**
```
┌──────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──┴──────┐
│ NEW │───►│ INIT │───►│ ROUTING │───►│ EXECUTE │───►│ HANGUP │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │
│ │
┌────▼────┐ ┌────▼────┐
│ CONSUME │ │ EXCHANGE│
│ MEDIA │ │ MEDIA │
└─────────┘ └─────────┘
Channel状态说明
├── CS_NEW : 新建通道
├── CS_INIT : 初始化
├── CS_ROUTING : 路由中执行dialplan
├── CS_EXECUTE : 执行应用中
├── CS_EXCHANGE_MEDIA : 媒体交换中bridged
├── CS_CONSUME_MEDIA : 媒体消费中
├── CS_HIBERNATE : 休眠状态
├── CS_HANGUP : 挂断中
└── CS_DESTROY : 销毁
```
#### 1.2.2 Session会话
Session包含Channel及其相关上下文信息
```c
// Session结构概念图
Session {
├── Channel // 通道信息
│ ├── UUID // 唯一标识
│ ├── State // 当前状态
│ ├── Direction // 呼叫方向(inbound/outbound)
│ └── Variables // 通道变量
├── Caller Profile // 主叫信息
│ ├── caller_id_name
│ ├── caller_id_number
│ ├── destination_number
│ └── ...
├── Codec // 编解码信息
├── RTP Session // RTP会话
└── Bridge Session // 桥接会话(如果有)
}
```
#### 1.2.3 Event事件
FreeSWITCH基于事件驱动架构所有状态变化都产生事件
```
┌─────────────────────────────────────────────────────────────────┐
│ FreeSWITCH Event System │
│ │
│ ┌───────────────┐ │
│ │ Event Producer│ ──────┐ │
│ │ (模块/核心) │ │ │
│ └───────────────┘ │ ┌─────────────────────────┐ │
│ ├─────►│ Event Dispatcher │ │
│ ┌───────────────┐ │ │ (事件分发器) │ │
│ │ Event Producer│ ──────┤ └───────────┬─────────────┘ │
│ │ (模块/核心) │ │ │ │
│ └───────────────┘ │ ┌───────────┴─────────────┐ │
│ │ │ │ │ │
│ ┌───────────────┐ │ ▼ ▼ ▼ │
│ │ Event Producer│ ──────┘ ┌───────┐ ┌───────┐ ┌───────┐│
│ │ (模块/核心) │ │ ESL │ │Internal│ │ Lua/ ││
│ └───────────────┘ │Consumer│ │Handler │ │ JS ││
│ └───────┘ └───────┘ └───────┘│
└─────────────────────────────────────────────────────────────────┘
```
**常用事件类型:**
| 事件名称 | 触发时机 | 用途 |
|---------|---------|------|
| CHANNEL_CREATE | 通道创建 | 新呼叫进入 |
| CHANNEL_ANSWER | 通话接听 | 通话建立 |
| CHANNEL_BRIDGE | 通道桥接 | 双方接通 |
| CHANNEL_UNBRIDGE | 取消桥接 | 桥接断开 |
| CHANNEL_HANGUP | 挂断 | 通话结束 |
| CHANNEL_HANGUP_COMPLETE | 挂断完成 | 通话完全结束 |
| CHANNEL_STATE | 状态变化 | 状态监控 |
| DTMF | 按键检测 | IVR交互 |
| RECORD_START | 开始录音 | 录音控制 |
| RECORD_STOP | 停止录音 | 录音控制 |
| CUSTOM | 自定义事件 | 业务扩展 |
### 1.3 目录结构
```
/usr/local/freeswitch/
├── bin/ # 可执行文件
│ └── freeswitch # 主程序
├── conf/ # 配置文件目录
│ ├── freeswitch.xml # 主配置文件
│ ├── vars.xml # 全局变量
│ ├── sip_profiles/ # SIP配置
│ │ ├── internal.xml # 内部Profile坐席注册
│ │ └── external.xml # 外部Profile中继对接
│ ├── dialplan/ # 拨号计划
│ │ ├── default.xml # 默认拨号计划
│ │ └── public.xml # 公共拨号计划(呼入)
│ ├── directory/ # 用户目录(分机注册)
│ │ └── default/
│ │ └── 1000.xml # 分机1000配置
│ ├── autoload_configs/ # 模块自动加载配置
│ │ ├── modules.conf.xml # 模块加载列表
│ │ ├── sofia.conf.xml # SIP模块配置
│ │ ├── event_socket.conf.xml# ESL配置
│ │ └── ...
│ └── lang/ # 语音提示
├── lib/ # 库文件
├── mod/ # 模块文件(.so)
├── log/ # 日志目录
├── db/ # 数据库文件
├── recordings/ # 录音文件
├── sounds/ # 语音文件
├── scripts/ # 脚本目录
└── storage/ # 存储目录
```
---
## 第二部分 核心模块详解
### 2.1 mod_sofiaSIP模块
mod_sofia是FreeSWITCH的SIP协议栈实现基于Sofia-SIP库。
#### 2.1.1 架构
```
┌─────────────────────────────────────────────────────────────────────┐
│ mod_sofia │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Sofia-SIP Stack │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ NUA │ │ NTA │ │ TPORT │ │ SDP │ │ │
│ │ │ User Agent│ │Transaction│ │ Transport │ │ Parser │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Profile Management │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ Internal Profile │ │ External Profile │ │ │
│ │ │ (坐席/分机注册) │ │ (中继/网关) │ │ │
│ │ │ UDP:5060 │ │ UDP:5080 │ │ │
│ │ └─────────────────────┘ └─────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Gateway Management │ │ │
│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ │ │Gateway A│ │Gateway B│ │Gateway C│ ... │ │ │
│ │ │ │(运营商1) │ │(运营商2) │ │(备用) │ │ │ │
│ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
#### 2.1.2 配置示例
**Internal Profile坐席注册**
```xml
<!-- conf/sip_profiles/internal.xml -->
<profile name="internal">
<settings>
<!-- 监听地址和端口 -->
<param name="sip-ip" value="$${local_ip_v4}"/>
<param name="sip-port" value="5060"/>
<param name="rtp-ip" value="$${local_ip_v4}"/>
<!-- 编解码 -->
<param name="inbound-codec-prefs" value="PCMA,PCMU,G729"/>
<param name="outbound-codec-prefs" value="PCMA,PCMU,G729"/>
<!-- RTP端口范围 -->
<param name="rtp-start-port" value="16384"/>
<param name="rtp-end-port" value="32768"/>
<!-- 注册相关 -->
<param name="accept-blind-reg" value="false"/>
<param name="accept-blind-auth" value="false"/>
<!-- NAT穿透 -->
<param name="local-network-acl" value="localnet.auto"/>
<param name="apply-nat-acl" value="nat.auto"/>
<!-- 媒体相关 -->
<param name="inbound-late-negotiation" value="true"/>
<param name="inbound-zrtp-passthru" value="true"/>
<!-- 安全相关 -->
<param name="auth-calls" value="true"/>
<param name="log-auth-failures" value="true"/>
</settings>
</profile>
```
**External Profile中继对接**
```xml
<!-- conf/sip_profiles/external.xml -->
<profile name="external">
<gateways>
<!-- 运营商SIP中继 -->
<gateway name="carrier_primary">
<param name="username" value="your_username"/>
<param name="password" value="your_password"/>
<param name="realm" value="sip.carrier.com"/>
<param name="proxy" value="sip.carrier.com"/>
<param name="register" value="true"/>
<param name="retry-seconds" value="30"/>
<param name="caller-id-in-from" value="true"/>
</gateway>
<!-- 备用中继 -->
<gateway name="carrier_backup">
<param name="username" value="backup_username"/>
<param name="password" value="backup_password"/>
<param name="realm" value="sip.backup-carrier.com"/>
<param name="proxy" value="sip.backup-carrier.com"/>
<param name="register" value="true"/>
</gateway>
</gateways>
<settings>
<param name="sip-ip" value="$${local_ip_v4}"/>
<param name="sip-port" value="5080"/>
<param name="rtp-ip" value="$${local_ip_v4}"/>
<param name="context" value="public"/>
</settings>
</profile>
```
### 2.2 Dialplan拨号计划
拨号计划定义了呼叫的路由和处理逻辑。
#### 2.2.1 执行流程
```
┌──────────────────────────────────────────────────────────────────┐
│ Dialplan 执行流程 │
│ │
│ 来电进入 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Context 选择 ││
│ │ (根据Profile配置的context) ││
│ │ 例如: public (呼入) / default (内部) ││
│ └──────────────────────────┬──────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Extension 匹配 ││
│ │ (按顺序匹配condition) ││
│ │ ││
│ │ Extension 1: ──┐ ││
│ │ Condition ───┼─► 不匹配 ───► 下一个Extension ││
│ │ │ ││
│ │ └─► 匹配 ───► 执行Actions ││
│ └──────────────────────────┬──────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Action 执行 ││
│ │ (按顺序执行应用) ││
│ │ ││
│ │ action: answer ││
│ │ action: playback ││
│ │ action: bridge ││
│ │ ... ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
└──────────────────────────────────────────────────────────────────┘
```
#### 2.2.2 配置示例
**呼入路由public.xml**
```xml
<!-- conf/dialplan/public.xml -->
<context name="public">
<!-- 400号码呼入 - 道路救援 -->
<extension name="inbound_rescue">
<condition field="destination_number" expression="^(4001234561)$">
<!-- 设置业务线标识 -->
<action application="set" data="business_type=rescue"/>
<action application="set" data="skill_group=rescue_group"/>
<!-- 进入IVR -->
<action application="transfer" data="main_ivr XML default"/>
</condition>
</extension>
<!-- 400号码呼入 - 家政服务 -->
<extension name="inbound_housekeeping">
<condition field="destination_number" expression="^(4001234562)$">
<action application="set" data="business_type=housekeeping"/>
<action application="set" data="skill_group=housekeeping_group"/>
<action application="transfer" data="main_ivr XML default"/>
</condition>
</extension>
<!-- 400号码呼入 - 代驾服务 -->
<extension name="inbound_designated_driver">
<condition field="destination_number" expression="^(4001234563)$">
<action application="set" data="business_type=driver"/>
<action application="set" data="skill_group=driver_group"/>
<action application="transfer" data="main_ivr XML default"/>
</condition>
</extension>
</context>
```
**IVR和呼叫处理default.xml**
```xml
<!-- conf/dialplan/default.xml -->
<context name="default">
<!-- 主IVR入口 -->
<extension name="main_ivr">
<condition field="destination_number" expression="^main_ivr$">
<action application="answer"/>
<action application="sleep" data="500"/>
<!-- 播放欢迎语 -->
<action application="playback" data="ivr/welcome.wav"/>
<!-- 语音识别或按键导航 -->
<action application="play_and_get_digits"
data="1 1 3 5000 # ivr/menu_prompt.wav ivr/invalid.wav
ivr_selection \d 3000"/>
<!-- 根据按键转接 -->
<action application="transfer" data="route_by_selection"/>
</condition>
</extension>
<!-- 按键路由 -->
<extension name="route_by_selection">
<condition field="destination_number" expression="^route_by_selection$">
<!-- 按1转道路救援 -->
<condition field="${ivr_selection}" expression="^1$">
<action application="set" data="skill_group=rescue_group"/>
<action application="transfer" data="queue_call"/>
</condition>
<!-- 按2转家政服务 -->
<condition field="${ivr_selection}" expression="^2$">
<action application="set" data="skill_group=housekeeping_group"/>
<action application="transfer" data="queue_call"/>
</condition>
<!-- 按0转人工 -->
<condition field="${ivr_selection}" expression="^0$">
<action application="transfer" data="queue_call"/>
</condition>
</condition>
</extension>
<!-- 排队等待ACD由ESL处理 -->
<extension name="queue_call">
<condition field="destination_number" expression="^queue_call$">
<!-- 通知ESL有新呼叫需要排队 -->
<action application="set" data="api_hangup_hook=uuid_broadcast ${uuid}
queue_exit.wav both"/>
<!-- 播放等待音乐等待ESL分配坐席 -->
<action application="playback" data="local_stream://moh"/>
</condition>
</extension>
<!-- 坐席外呼 -->
<extension name="agent_outbound">
<condition field="destination_number" expression="^9(\d{11})$">
<action application="set" data="effective_caller_id_number=
${outbound_caller_id}"/>
<action application="set" data="effective_caller_id_name=公司名称"/>
<!-- 录音 -->
<action application="set" data="RECORD_STEREO=true"/>
<action application="set" data="recording_path=/recordings/${strftime(%Y/%m/%d)}"/>
<action application="record_session" data="${recording_path}/${uuid}.wav"/>
<!-- 通过网关外呼 -->
<action application="bridge" data="sofia/gateway/carrier_primary/$1"/>
</condition>
</extension>
<!-- 坐席互转 -->
<extension name="transfer_to_agent">
<condition field="destination_number" expression="^(\d{4})$">
<action application="bridge" data="user/$1@${domain_name}"/>
</condition>
</extension>
</context>
```
### 2.3 mod_event_socketESL模块
ESL是FreeSWITCH对外提供的控制接口是开发呼叫中心的核心。
#### 2.3.1 工作模式
```
┌─────────────────────────────────────────────────────────────────────┐
│ ESL 工作模式 │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Inbound 模式 │ │
│ │ (客户端主动连接) │ │
│ │ │ │
│ │ ┌───────────┐ TCP:8021 ┌──────────────────┐ │ │
│ │ │ CTI中间件 │ ──────────────────────► │ FreeSWITCH │ │ │
│ │ │ (Client) │ ◄────────────────────── │ (Server) │ │ │
│ │ └───────────┘ Event Stream └──────────────────┘ │ │
│ │ │ │
│ │ 特点: │ │
│ │ - 客户端主动连接FreeSWITCH │ │
│ │ - 可以订阅事件、发送命令 │ │
│ │ - 适合集中控制、监控场景 │ │
│ │ - 推荐呼叫中心使用此模式 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Outbound 模式 │ │
│ │ (FreeSWITCH主动连接) │ │
│ │ │ │
│ │ ┌──────────────────┐ TCP ┌───────────────┐ │ │
│ │ │ FreeSWITCH │ ──────────────────►│ 应用服务器 │ │ │
│ │ │ (每个呼叫) │ ◄────────────────── │ (Server) │ │ │
│ │ └──────────────────┘ └───────────────┘ │ │
│ │ │ │
│ │ 特点: │ │
│ │ - 每个呼叫FreeSWITCH主动连接应用 │ │
│ │ - 在dialplan中使用socket应用触发 │ │
│ │ - 适合简单IVR场景 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
#### 2.3.2 ESL配置
```xml
<!-- conf/autoload_configs/event_socket.conf.xml -->
<configuration name="event_socket.conf" description="Socket Client">
<settings>
<!-- 监听地址生产环境建议限制IP -->
<param name="nat-map" value="false"/>
<param name="listen-ip" value="0.0.0.0"/>
<param name="listen-port" value="8021"/>
<!-- 认证密码 -->
<param name="password" value="your_secure_password"/>
<!-- ACL限制可选 -->
<!-- <param name="apply-inbound-acl" value="lan"/> -->
</settings>
</configuration>
```
---
## 第三部分 ESL开发指南
### 3.1 ESL协议详解
#### 3.1.1 协议格式
ESL使用类HTTP的文本协议
```
┌─────────────────────────────────────────────────────────────────────┐
│ ESL 消息格式 │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 请求格式: │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ command [arguments] │ │ │
│ │ │ Header-Name: header-value │ │ │
│ │ │ Header-Name: header-value │ │ │
│ │ │ │ │ │
│ │ │ [body if Content-Length > 0] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 响应格式: │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Content-Type: text/event-plain │ │ │
│ │ │ Content-Length: 1234 │ │ │
│ │ │ Reply-Text: +OK [message] │ │ │
│ │ │ │ │ │
│ │ │ [body] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
#### 3.1.2 常用命令
| 命令类型 | 命令 | 说明 |
|---------|------|------|
| **认证** | `auth <password>` | 连接认证 |
| **事件订阅** | `event plain <event_types>` | 订阅事件 |
| | `event plain ALL` | 订阅所有事件 |
| | `event plain CHANNEL_CREATE CHANNEL_ANSWER CHANNEL_HANGUP` | 订阅指定事件 |
| | `nixevent <event_type>` | 取消订阅 |
| | `noevents` | 取消所有订阅 |
| **API命令** | `api <command> [args]` | 同步执行API |
| | `bgapi <command> [args]` | 异步执行API |
| **过滤器** | `filter <header> <value>` | 过滤特定事件 |
| | `filter Unique-ID <uuid>` | 只接收指定UUID事件 |
| **呼叫控制** | `sendmsg <uuid>` | 向指定通道发送消息 |
| | `execute` | 在通道上执行应用 |
| **其他** | `log <level>` | 设置日志级别 |
| | `linger` | 保持连接 |
| | `nolinger` | 取消保持 |
| | `exit` | 断开连接 |
### 3.2 Java ESL开发
#### 3.2.1 项目结构
```
callcenter-cti/
├── pom.xml
├── src/main/java/
│ └── com/callcenter/
│ ├── Application.java
│ ├── esl/
│ │ ├── EslConnectionManager.java # ESL连接管理
│ │ ├── EslEventHandler.java # 事件处理
│ │ ├── EslCommandExecutor.java # 命令执行
│ │ └── InboundClient.java # Inbound客户端
│ ├── service/
│ │ ├── AgentService.java # 坐席服务
│ │ ├── CallService.java # 呼叫服务
│ │ ├── QueueService.java # 排队服务
│ │ └── AcdService.java # 话务分配服务
│ ├── model/
│ │ ├── Agent.java # 坐席模型
│ │ ├── Call.java # 呼叫模型
│ │ └── Queue.java # 队列模型
│ ├── api/
│ │ ├── AgentController.java # 坐席API
│ │ └── CallController.java # 呼叫API
│ └── websocket/
│ └── EventPushHandler.java # 事件推送
└── src/main/resources/
└── application.yml
```
#### 3.2.2 Maven依赖
```xml
<!-- pom.xml -->
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- FreeSWITCH ESL Client -->
<dependency>
<groupId>org.freeswitch.esl.client</groupId>
<artifactId>org.freeswitch.esl.client</artifactId>
<version>0.9.2</version>
</dependency>
<!-- Netty (ESL底层通信) -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.86.Final</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.23</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
```
#### 3.2.3 ESL连接管理
```java
package com.callcenter.esl;
import lombok.extern.slf4j.Slf4j;
import org.freeswitch.esl.client.inbound.Client;
import org.freeswitch.esl.client.inbound.InboundConnectionFailure;
import org.freeswitch.esl.client.transport.event.EslEvent;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* ESL连接管理器
* 负责与FreeSWITCH建立和维护连接
*/
@Slf4j
@Component
public class EslConnectionManager {
@Value("${freeswitch.esl.host:127.0.0.1}")
private String eslHost;
@Value("${freeswitch.esl.port:8021}")
private int eslPort;
@Value("${freeswitch.esl.password:ClueCon}")
private String eslPassword;
@Value("${freeswitch.esl.timeout:30}")
private int timeout;
private Client eslClient;
private final AtomicBoolean connected = new AtomicBoolean(false);
private final AtomicBoolean reconnecting = new AtomicBoolean(false);
private ScheduledExecutorService scheduler;
private ExecutorService eventExecutor;
private EslEventHandler eventHandler;
@PostConstruct
public void init() {
// 初始化线程池
scheduler = Executors.newSingleThreadScheduledExecutor(
r -> new Thread(r, "esl-reconnect"));
eventExecutor = new ThreadPoolExecutor(
4, 16, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
r -> new Thread(r, "esl-event-handler"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// 建立连接
connect();
// 启动心跳检测
startHeartbeat();
}
/**
* 建立ESL连接
*/
public synchronized void connect() {
if (connected.get()) {
log.info("ESL已连接无需重复连接");
return;
}
try {
log.info("正在连接FreeSWITCH ESL: {}:{}", eslHost, eslPort);
eslClient = new Client();
eslClient.connect(eslHost, eslPort, eslPassword, timeout);
// 订阅事件
subscribeEvents();
// 设置事件监听器
eslClient.setEventSubscriptions("plain", "all");
eslClient.addEventListener((ctx, event) -> {
handleEvent(event);
});
connected.set(true);
reconnecting.set(false);
log.info("ESL连接成功");
} catch (InboundConnectionFailure e) {
log.error("ESL连接失败: {}", e.getMessage());
connected.set(false);
scheduleReconnect();
}
}
/**
* 订阅所需事件
*/
private void subscribeEvents() {
String events = String.join(" ",
"CHANNEL_CREATE",
"CHANNEL_ANSWER",
"CHANNEL_BRIDGE",
"CHANNEL_UNBRIDGE",
"CHANNEL_HANGUP",
"CHANNEL_HANGUP_COMPLETE",
"CHANNEL_STATE",
"DTMF",
"RECORD_START",
"RECORD_STOP",
"CUSTOM"
);
eslClient.setEventSubscriptions("plain", events);
eslClient.addEventFilter("Event-Name", "CHANNEL_CREATE");
log.info("已订阅事件: {}", events);
}
/**
* 处理事件
*/
private void handleEvent(EslEvent event) {
eventExecutor.submit(() -> {
try {
if (eventHandler != null) {
eventHandler.handle(event);
}
} catch (Exception e) {
log.error("事件处理异常: {}", e.getMessage(), e);
}
});
}
/**
* 启动心跳检测
*/
private void startHeartbeat() {
scheduler.scheduleAtFixedRate(() -> {
if (connected.get()) {
try {
// 发送status命令检测连接
eslClient.sendSyncApiCommand("status", "");
} catch (Exception e) {
log.warn("心跳检测失败,连接可能已断开");
connected.set(false);
scheduleReconnect();
}
}
}, 30, 30, TimeUnit.SECONDS);
}
/**
* 计划重连
*/
private void scheduleReconnect() {
if (reconnecting.compareAndSet(false, true)) {
scheduler.schedule(() -> {
log.info("尝试重新连接ESL...");
connect();
}, 5, TimeUnit.SECONDS);
}
}
/**
* 同步执行API命令
*/
public String sendSyncApiCommand(String command, String args) {
checkConnection();
try {
return eslClient.sendSyncApiCommand(command, args).getBodyLines().get(0);
} catch (Exception e) {
log.error("执行API命令失败: {} {}", command, args, e);
throw new RuntimeException("ESL命令执行失败", e);
}
}
/**
* 异步执行API命令
*/
public CompletableFuture<String> sendAsyncApiCommand(String command, String args) {
return CompletableFuture.supplyAsync(() ->
sendSyncApiCommand(command, args), eventExecutor);
}
/**
* 向指定UUID发送消息执行应用
*/
public void sendExecute(String uuid, String application, String args) {
checkConnection();
try {
eslClient.sendSyncApiCommand("uuid_broadcast",
uuid + " " + application + "::" + args + " both");
} catch (Exception e) {
log.error("发送Execute失败: uuid={}, app={}", uuid, application, e);
}
}
/**
* 检查连接状态
*/
private void checkConnection() {
if (!connected.get()) {
throw new RuntimeException("ESL未连接");
}
}
/**
* 获取连接状态
*/
public boolean isConnected() {
return connected.get();
}
/**
* 设置事件处理器
*/
public void setEventHandler(EslEventHandler handler) {
this.eventHandler = handler;
}
@PreDestroy
public void destroy() {
try {
connected.set(false);
if (eslClient != null) {
eslClient.close();
}
if (scheduler != null) {
scheduler.shutdown();
}
if (eventExecutor != null) {
eventExecutor.shutdown();
}
log.info("ESL连接已关闭");
} catch (Exception e) {
log.error("关闭ESL连接异常", e);
}
}
}
```
#### 3.2.4 事件处理器
```java
package com.callcenter.esl;
import com.callcenter.model.Call;
import com.callcenter.model.CallDirection;
import com.callcenter.model.CallState;
import com.callcenter.service.AcdService;
import com.callcenter.service.CallService;
import com.callcenter.websocket.EventPushService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.freeswitch.esl.client.transport.event.EslEvent;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Map;
/**
* ESL事件处理器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EslEventHandler {
private final CallService callService;
private final AcdService acdService;
private final EventPushService eventPushService;
/**
* 处理ESL事件
*/
public void handle(EslEvent event) {
String eventName = event.getEventName();
Map<String, String> headers = event.getEventHeaders();
log.debug("收到事件: {}, UUID: {}", eventName, headers.get("Unique-ID"));
switch (eventName) {
case "CHANNEL_CREATE":
handleChannelCreate(headers);
break;
case "CHANNEL_ANSWER":
handleChannelAnswer(headers);
break;
case "CHANNEL_BRIDGE":
handleChannelBridge(headers);
break;
case "CHANNEL_UNBRIDGE":
handleChannelUnbridge(headers);
break;
case "CHANNEL_HANGUP":
handleChannelHangup(headers);
break;
case "CHANNEL_HANGUP_COMPLETE":
handleChannelHangupComplete(headers);
break;
case "DTMF":
handleDtmf(headers);
break;
case "RECORD_START":
handleRecordStart(headers);
break;
case "RECORD_STOP":
handleRecordStop(headers);
break;
case "CUSTOM":
handleCustomEvent(headers);
break;
default:
log.trace("未处理的事件类型: {}", eventName);
}
}
/**
* 处理通道创建事件(新呼叫)
*/
private void handleChannelCreate(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
String direction = headers.get("Call-Direction");
String callerNumber = headers.get("Caller-Caller-ID-Number");
String calleeNumber = headers.get("Caller-Destination-Number");
log.info("新呼叫: uuid={}, direction={}, caller={}, callee={}",
uuid, direction, callerNumber, calleeNumber);
// 创建呼叫记录
Call call = new Call();
call.setUuid(uuid);
call.setDirection("inbound".equals(direction) ?
CallDirection.INBOUND : CallDirection.OUTBOUND);
call.setCallerNumber(callerNumber);
call.setCalleeNumber(calleeNumber);
call.setState(CallState.RINGING);
call.setCreateTime(LocalDateTime.now());
// 获取业务标识(从通道变量)
String businessType = headers.get("variable_business_type");
String skillGroup = headers.get("variable_skill_group");
call.setBusinessType(businessType);
call.setSkillGroup(skillGroup);
callService.saveCall(call);
// 如果是呼入触发ACD排队
if ("inbound".equals(direction)) {
acdService.enqueue(call);
}
// 推送事件到前端
eventPushService.pushCallEvent("CALL_RINGING", call);
}
/**
* 处理通话接听事件
*/
private void handleChannelAnswer(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
log.info("通话接听: uuid={}", uuid);
Call call = callService.getCallByUuid(uuid);
if (call != null) {
call.setState(CallState.ANSWERED);
call.setAnswerTime(LocalDateTime.now());
callService.updateCall(call);
// 推送事件
eventPushService.pushCallEvent("CALL_ANSWERED", call);
}
}
/**
* 处理通道桥接事件(双方接通)
*/
private void handleChannelBridge(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
String otherUuid = headers.get("Other-Leg-Unique-ID");
log.info("通道桥接: uuid={}, otherUuid={}", uuid, otherUuid);
Call call = callService.getCallByUuid(uuid);
if (call != null) {
call.setState(CallState.BRIDGED);
call.setBridgeUuid(otherUuid);
call.setBridgeTime(LocalDateTime.now());
callService.updateCall(call);
// 推送事件
eventPushService.pushCallEvent("CALL_BRIDGED", call);
}
}
/**
* 处理取消桥接事件
*/
private void handleChannelUnbridge(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
log.info("取消桥接: uuid={}", uuid);
Call call = callService.getCallByUuid(uuid);
if (call != null) {
call.setState(CallState.UNBRIDGED);
callService.updateCall(call);
eventPushService.pushCallEvent("CALL_UNBRIDGED", call);
}
}
/**
* 处理挂断事件
*/
private void handleChannelHangup(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
String hangupCause = headers.get("Hangup-Cause");
log.info("通话挂断: uuid={}, cause={}", uuid, hangupCause);
Call call = callService.getCallByUuid(uuid);
if (call != null) {
call.setState(CallState.HANGUP);
call.setHangupCause(hangupCause);
call.setHangupTime(LocalDateTime.now());
// 计算通话时长
if (call.getAnswerTime() != null) {
long duration = java.time.Duration.between(
call.getAnswerTime(), call.getHangupTime()).getSeconds();
call.setDuration(duration);
}
callService.updateCall(call);
// 推送事件
eventPushService.pushCallEvent("CALL_HANGUP", call);
// 更新坐席状态
if (call.getAgentId() != null) {
acdService.onCallEnd(call.getAgentId(), call);
}
}
}
/**
* 处理挂断完成事件(获取完整话单)
*/
private void handleChannelHangupComplete(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
String billsec = headers.get("variable_billsec");
String duration = headers.get("variable_duration");
String recordingPath = headers.get("variable_recording_path");
log.info("通话结束: uuid={}, billsec={}, recording={}",
uuid, billsec, recordingPath);
Call call = callService.getCallByUuid(uuid);
if (call != null) {
call.setBillsec(billsec != null ? Long.parseLong(billsec) : 0);
call.setRecordingPath(recordingPath);
callService.updateCall(call);
// 推送话单
eventPushService.pushCdr(call);
}
}
/**
* 处理DTMF按键事件
*/
private void handleDtmf(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
String digit = headers.get("DTMF-Digit");
log.info("DTMF按键: uuid={}, digit={}", uuid, digit);
// 推送按键事件
eventPushService.pushDtmf(uuid, digit);
}
/**
* 处理录音开始事件
*/
private void handleRecordStart(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
String recordPath = headers.get("Record-File-Path");
log.info("开始录音: uuid={}, path={}", uuid, recordPath);
Call call = callService.getCallByUuid(uuid);
if (call != null) {
call.setRecordingPath(recordPath);
callService.updateCall(call);
}
}
/**
* 处理录音停止事件
*/
private void handleRecordStop(Map<String, String> headers) {
String uuid = headers.get("Unique-ID");
String recordPath = headers.get("Record-File-Path");
log.info("停止录音: uuid={}, path={}", uuid, recordPath);
}
/**
* 处理自定义事件
*/
private void handleCustomEvent(Map<String, String> headers) {
String subclass = headers.get("Event-Subclass");
log.debug("自定义事件: subclass={}", subclass);
// 处理特定的自定义事件
if ("callcenter::info".equals(subclass)) {
// 处理呼叫中心相关事件
}
}
}
```
#### 3.2.5 呼叫服务
```java
package com.callcenter.service;
import com.callcenter.esl.EslConnectionManager;
import com.callcenter.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* 呼叫服务
* 提供呼叫控制相关功能
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CallService {
private final EslConnectionManager eslManager;
private final RedisTemplate<String, Object> redisTemplate;
private static final String CALL_KEY_PREFIX = "call:";
private static final int CALL_EXPIRE_HOURS = 24;
/**
* 发起外呼
* @param agentId 坐席ID
* @param callerNumber 主叫号码(外显号码)
* @param calleeNumber 被叫号码
* @return 呼叫UUID
*/
public String originate(String agentId, String callerNumber, String calleeNumber) {
log.info("发起外呼: agent={}, caller={}, callee={}",
agentId, callerNumber, calleeNumber);
// 获取坐席分机号
Agent agent = getAgent(agentId);
if (agent == null || agent.getState() != AgentState.READY) {
throw new RuntimeException("坐席不可用");
}
// 构建originate命令
// originate {变量}呼叫字符串 &应用(参数)
StringBuilder cmd = new StringBuilder();
cmd.append("{");
cmd.append("origination_caller_id_number=").append(callerNumber).append(",");
cmd.append("origination_caller_id_name=公司名称").append(",");
cmd.append("agent_id=").append(agentId).append(",");
cmd.append("direction=outbound").append(",");
cmd.append("RECORD_STEREO=true");
cmd.append("}");
// A-leg: 先呼叫坐席分机
cmd.append("user/").append(agent.getExtension());
// B-leg: 坐席接听后呼叫客户
cmd.append(" &bridge({origination_caller_id_number=")
.append(callerNumber)
.append("}sofia/gateway/carrier_primary/")
.append(calleeNumber)
.append(")");
String result = eslManager.sendSyncApiCommand("originate", cmd.toString());
if (result.startsWith("+OK")) {
String uuid = result.substring(4).trim();
log.info("外呼成功: uuid={}", uuid);
return uuid;
} else {
log.error("外呼失败: {}", result);
throw new RuntimeException("外呼失败: " + result);
}
}
/**
* 接听来电
*/
public void answer(String uuid) {
log.info("接听来电: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_answer", uuid);
}
/**
* 挂断通话
*/
public void hangup(String uuid) {
log.info("挂断通话: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_kill", uuid);
}
/**
* 挂断通话(带原因)
*/
public void hangup(String uuid, String cause) {
log.info("挂断通话: uuid={}, cause={}", uuid, cause);
eslManager.sendSyncApiCommand("uuid_kill", uuid + " " + cause);
}
/**
* 保持通话
*/
public void hold(String uuid) {
log.info("保持通话: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_hold", uuid);
}
/**
* 取回保持
*/
public void unhold(String uuid) {
log.info("取回通话: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_hold", "off " + uuid);
}
/**
* 静音
*/
public void mute(String uuid) {
log.info("静音: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_audio", uuid + " start mute");
}
/**
* 取消静音
*/
public void unmute(String uuid) {
log.info("取消静音: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_audio", uuid + " stop");
}
/**
* 盲转(直接转接)
*/
public void blindTransfer(String uuid, String destination) {
log.info("盲转: uuid={}, dest={}", uuid, destination);
eslManager.sendSyncApiCommand("uuid_transfer", uuid + " " + destination);
}
/**
* 咨询转(先咨询再转接)
*/
public String consultTransfer(String uuid, String destination) {
log.info("咨询转: uuid={}, dest={}", uuid, destination);
// 先保持当前通话
hold(uuid);
// 发起咨询呼叫
String consultCmd = "{origination_uuid=consult_" + uuid + "}user/" + destination;
String result = eslManager.sendSyncApiCommand("originate",
consultCmd + " &park()");
if (result.startsWith("+OK")) {
return result.substring(4).trim();
}
throw new RuntimeException("咨询转失败");
}
/**
* 完成咨询转接
*/
public void completeTransfer(String originalUuid, String consultUuid) {
log.info("完成咨询转: original={}, consult={}", originalUuid, consultUuid);
eslManager.sendSyncApiCommand("uuid_bridge", originalUuid + " " + consultUuid);
}
/**
* 三方通话
*/
public void conference(String uuid1, String uuid2, String conferenceName) {
log.info("三方通话: uuid1={}, uuid2={}, conf={}", uuid1, uuid2, conferenceName);
// 将两个通道加入会议
eslManager.sendSyncApiCommand("uuid_transfer",
uuid1 + " conference:" + conferenceName + " inline");
eslManager.sendSyncApiCommand("uuid_transfer",
uuid2 + " conference:" + conferenceName + " inline");
}
/**
* 播放语音
*/
public void playback(String uuid, String filePath) {
log.info("播放语音: uuid={}, file={}", uuid, filePath);
eslManager.sendSyncApiCommand("uuid_broadcast",
uuid + " " + filePath + " both");
}
/**
* 停止播放
*/
public void stopPlayback(String uuid) {
log.info("停止播放: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_break", uuid);
}
/**
* 开始录音
*/
public void startRecording(String uuid, String filePath) {
log.info("开始录音: uuid={}, file={}", uuid, filePath);
eslManager.sendSyncApiCommand("uuid_record", uuid + " start " + filePath);
}
/**
* 停止录音
*/
public void stopRecording(String uuid, String filePath) {
log.info("停止录音: uuid={}", uuid);
eslManager.sendSyncApiCommand("uuid_record", uuid + " stop " + filePath);
}
/**
* 桥接两个通道
*/
public void bridge(String uuid1, String uuid2) {
log.info("桥接通道: uuid1={}, uuid2={}", uuid1, uuid2);
eslManager.sendSyncApiCommand("uuid_bridge", uuid1 + " " + uuid2);
}
/**
* 设置通道变量
*/
public void setVariable(String uuid, String name, String value) {
eslManager.sendSyncApiCommand("uuid_setvar",
uuid + " " + name + " " + value);
}
/**
* 获取通道变量
*/
public String getVariable(String uuid, String name) {
return eslManager.sendSyncApiCommand("uuid_getvar", uuid + " " + name);
}
// ============ 呼叫记录管理 ============
/**
* 保存呼叫记录
*/
public void saveCall(Call call) {
String key = CALL_KEY_PREFIX + call.getUuid();
redisTemplate.opsForValue().set(key, call, CALL_EXPIRE_HOURS, TimeUnit.HOURS);
}
/**
* 获取呼叫记录
*/
public Call getCallByUuid(String uuid) {
String key = CALL_KEY_PREFIX + uuid;
return (Call) redisTemplate.opsForValue().get(key);
}
/**
* 更新呼叫记录
*/
public void updateCall(Call call) {
saveCall(call);
}
/**
* 获取坐席信息
*/
private Agent getAgent(String agentId) {
// TODO: 从坐席服务获取
return null;
}
}
```
#### 3.2.6 ACD话务分配服务
```java
package com.callcenter.service;
import com.callcenter.esl.EslConnectionManager;
import com.callcenter.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* ACD话务分配服务
* 实现多种分配策略:历史服务优先、最长空闲优先、轮询
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcdService {
private final EslConnectionManager eslManager;
private final RedisTemplate<String, Object> redisTemplate;
private final AgentService agentService;
private final CallService callService;
// 队列Key前缀
private static final String QUEUE_KEY_PREFIX = "queue:";
// 坐席最后服务客户映射
private static final String AGENT_CUSTOMER_MAP_KEY = "agent:customer:map";
// 坐席最后空闲时间
private static final String AGENT_IDLE_TIME_KEY = "agent:idle:time:";
// 轮询索引
private final Map<String, Integer> roundRobinIndex = new ConcurrentHashMap<>();
/**
* 呼叫入队
*/
public void enqueue(Call call) {
String skillGroup = call.getSkillGroup();
String queueKey = QUEUE_KEY_PREFIX + skillGroup;
QueueItem item = new QueueItem();
item.setCallUuid(call.getUuid());
item.setCallerNumber(call.getCallerNumber());
item.setSkillGroup(skillGroup);
item.setEnqueueTime(LocalDateTime.now());
item.setPriority(calculatePriority(call));
// 使用SortedSet按优先级和入队时间排序
double score = item.getPriority() * 1000000000L +
System.currentTimeMillis() / 1000;
redisTemplate.opsForZSet().add(queueKey, item, score);
log.info("呼叫入队: uuid={}, queue={}, priority={}",
call.getUuid(), skillGroup, item.getPriority());
// 尝试立即分配
tryAssign(skillGroup);
}
/**
* 计算优先级(数字越小优先级越高)
*/
private int calculatePriority(Call call) {
// VIP客户优先级高
if ("VIP".equals(call.getCustomerLevel())) {
return 1;
}
// 投诉优先
if ("complaint".equals(call.getBusinessType())) {
return 2;
}
// 普通客户
return 5;
}
/**
* 尝试分配队列中的呼叫
*/
public void tryAssign(String skillGroup) {
String queueKey = QUEUE_KEY_PREFIX + skillGroup;
// 获取可用坐席
List<Agent> availableAgents = agentService.getAvailableAgents(skillGroup);
if (availableAgents.isEmpty()) {
log.debug("技能组{}无可用坐席", skillGroup);
return;
}
// 获取队列中的呼叫
Set<Object> queueItems = redisTemplate.opsForZSet().range(queueKey, 0, 0);
if (queueItems == null || queueItems.isEmpty()) {
log.debug("技能组{}队列为空", skillGroup);
return;
}
QueueItem item = (QueueItem) queueItems.iterator().next();
Call call = callService.getCallByUuid(item.getCallUuid());
if (call == null || call.getState() == CallState.HANGUP) {
// 呼叫已结束,移除队列
redisTemplate.opsForZSet().remove(queueKey, item);
tryAssign(skillGroup); // 继续处理下一个
return;
}
// 选择坐席
Agent selectedAgent = selectAgent(availableAgents, call);
if (selectedAgent != null) {
// 从队列移除
redisTemplate.opsForZSet().remove(queueKey, item);
// 分配呼叫
assignCallToAgent(call, selectedAgent);
}
}
/**
* 选择坐席(多策略)
*/
private Agent selectAgent(List<Agent> agents, Call call) {
if (agents.isEmpty()) {
return null;
}
// 策略1: 历史服务优先
Agent lastAgent = findLastServingAgent(call.getCallerNumber(), agents);
if (lastAgent != null) {
log.info("历史服务优先: 客户{}分配给上次服务坐席{}",
call.getCallerNumber(), lastAgent.getId());
return lastAgent;
}
// 策略2: 最长空闲优先
Agent longestIdleAgent = findLongestIdleAgent(agents);
if (longestIdleAgent != null) {
log.info("最长空闲优先: 选择坐席{}", longestIdleAgent.getId());
return longestIdleAgent;
}
// 策略3: 轮询
Agent roundRobinAgent = selectByRoundRobin(agents, call.getSkillGroup());
log.info("轮询分配: 选择坐席{}", roundRobinAgent.getId());
return roundRobinAgent;
}
/**
* 查找上次服务该客户的坐席
*/
private Agent findLastServingAgent(String customerNumber, List<Agent> agents) {
String lastAgentId = (String) redisTemplate.opsForHash()
.get(AGENT_CUSTOMER_MAP_KEY, customerNumber);
if (lastAgentId != null) {
return agents.stream()
.filter(a -> a.getId().equals(lastAgentId))
.findFirst()
.orElse(null);
}
return null;
}
/**
* 查找空闲时间最长的坐席
*/
private Agent findLongestIdleAgent(List<Agent> agents) {
return agents.stream()
.max(Comparator.comparing(agent -> {
String key = AGENT_IDLE_TIME_KEY + agent.getId();
Long idleTime = (Long) redisTemplate.opsForValue().get(key);
return idleTime != null ? idleTime : 0L;
}))
.orElse(null);
}
/**
* 轮询选择坐席
*/
private Agent selectByRoundRobin(List<Agent> agents, String skillGroup) {
int currentIndex = roundRobinIndex.getOrDefault(skillGroup, 0);
int nextIndex = (currentIndex + 1) % agents.size();
roundRobinIndex.put(skillGroup, nextIndex);
return agents.get(currentIndex);
}
/**
* 分配呼叫给坐席
*/
private void assignCallToAgent(Call call, Agent agent) {
log.info("分配呼叫: uuid={} -> agent={}", call.getUuid(), agent.getId());
// 更新坐席状态
agentService.updateAgentState(agent.getId(), AgentState.RINGING);
// 更新呼叫信息
call.setAgentId(agent.getId());
call.setAgentExtension(agent.getExtension());
callService.updateCall(call);
// 将呼叫转接到坐席分机
// 方式1: 使用uuid_transfer
eslManager.sendSyncApiCommand("uuid_transfer",
call.getUuid() + " " + agent.getExtension() + " XML default");
// 方式2: 使用originate + bridge更灵活
// String bridgeCmd = String.format(
// "{origination_uuid=%s,agent_id=%s}user/%s",
// UUID.randomUUID(), agent.getId(), agent.getExtension());
// eslManager.sendSyncApiCommand("uuid_broadcast",
// call.getUuid() + " bridge::" + bridgeCmd + " both");
// 记录坐席-客户关系(用于历史服务优先)
redisTemplate.opsForHash().put(AGENT_CUSTOMER_MAP_KEY,
call.getCallerNumber(), agent.getId());
}
/**
* 通话结束处理
*/
public void onCallEnd(String agentId, Call call) {
log.info("通话结束: agent={}, call={}", agentId, call.getUuid());
// 更新坐席状态为后处理
agentService.updateAgentState(agentId, AgentState.WRAP_UP);
// 设置后处理超时30秒后自动置闲
// 由定时任务处理
}
/**
* 坐席置闲时记录空闲时间
*/
public void onAgentReady(String agentId, String skillGroup) {
String key = AGENT_IDLE_TIME_KEY + agentId;
redisTemplate.opsForValue().set(key, System.currentTimeMillis());
// 尝试分配队列中的呼叫
tryAssign(skillGroup);
}
/**
* 定时处理队列
*/
@Scheduled(fixedDelay = 1000)
public void processQueues() {
// 获取所有技能组
Set<String> skillGroups = agentService.getAllSkillGroups();
for (String skillGroup : skillGroups) {
tryAssign(skillGroup);
}
}
/**
* 获取队列状态
*/
public QueueStatus getQueueStatus(String skillGroup) {
String queueKey = QUEUE_KEY_PREFIX + skillGroup;
QueueStatus status = new QueueStatus();
status.setSkillGroup(skillGroup);
status.setWaitingCount(redisTemplate.opsForZSet().size(queueKey));
status.setAvailableAgents(agentService.getAvailableAgents(skillGroup).size());
// 计算最长等待时间
Set<Object> items = redisTemplate.opsForZSet().range(queueKey, 0, 0);
if (items != null && !items.isEmpty()) {
QueueItem firstItem = (QueueItem) items.iterator().next();
long waitSeconds = java.time.Duration.between(
firstItem.getEnqueueTime(), LocalDateTime.now()).getSeconds();
status.setLongestWaitTime(waitSeconds);
}
return status;
}
/**
* 溢出处理
* 当技能组无可用坐席且等待时间超过阈值时,溢出到其他技能组
*/
@Scheduled(fixedDelay = 5000)
public void handleOverflow() {
// 获取溢出配置
Map<String, String> overflowConfig = getOverflowConfig();
for (Map.Entry<String, String> entry : overflowConfig.entrySet()) {
String sourceGroup = entry.getKey();
String targetGroup = entry.getValue();
String queueKey = QUEUE_KEY_PREFIX + sourceGroup;
Set<Object> items = redisTemplate.opsForZSet().range(queueKey, 0, -1);
if (items == null || items.isEmpty()) {
continue;
}
// 检查是否需要溢出
List<Agent> sourceAgents = agentService.getAvailableAgents(sourceGroup);
List<Agent> targetAgents = agentService.getAvailableAgents(targetGroup);
if (sourceAgents.isEmpty() && !targetAgents.isEmpty()) {
for (Object obj : items) {
QueueItem item = (QueueItem) obj;
long waitSeconds = java.time.Duration.between(
item.getEnqueueTime(), LocalDateTime.now()).getSeconds();
// 等待超过60秒溢出
if (waitSeconds > 60) {
log.info("溢出处理: uuid={}, {}->{}",
item.getCallUuid(), sourceGroup, targetGroup);
// 移出原队列
redisTemplate.opsForZSet().remove(queueKey, item);
// 加入目标队列
item.setSkillGroup(targetGroup);
String targetQueueKey = QUEUE_KEY_PREFIX + targetGroup;
double score = item.getPriority() * 1000000000L +
System.currentTimeMillis() / 1000;
redisTemplate.opsForZSet().add(targetQueueKey, item, score);
// 尝试分配
tryAssign(targetGroup);
}
}
}
}
}
private Map<String, String> getOverflowConfig() {
// 配置:源技能组 -> 目标技能组
Map<String, String> config = new HashMap<>();
config.put("rescue_group", "default_group");
config.put("housekeeping_group", "default_group");
config.put("driver_group", "default_group");
return config;
}
}
```
#### 3.2.7 坐席服务
```java
package com.callcenter.service;
import com.callcenter.esl.EslConnectionManager;
import com.callcenter.model.*;
import com.callcenter.websocket.EventPushService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 坐席服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentService {
private final RedisTemplate<String, Object> redisTemplate;
private final EslConnectionManager eslManager;
private final EventPushService eventPushService;
private final AcdService acdService;
private static final String AGENT_KEY_PREFIX = "agent:";
private static final String AGENT_SET_KEY = "agents:all";
private static final String SKILL_GROUP_AGENTS_KEY = "skillgroup:agents:";
/**
* 坐席签入
*/
public void signIn(String agentId, String extension, List<String> skillGroups) {
log.info("坐席签入: agentId={}, extension={}, skills={}",
agentId, extension, skillGroups);
// 检查分机是否注册
String regStatus = eslManager.sendSyncApiCommand("sofia_contact",
"internal/" + extension + "@${domain}");
if (regStatus.contains("error")) {
throw new RuntimeException("分机未注册: " + extension);
}
Agent agent = new Agent();
agent.setId(agentId);
agent.setExtension(extension);
agent.setSkillGroups(skillGroups);
agent.setState(AgentState.NOT_READY);
agent.setSignInTime(LocalDateTime.now());
// 保存坐席信息
String key = AGENT_KEY_PREFIX + agentId;
redisTemplate.opsForValue().set(key, agent);
// 加入坐席集合
redisTemplate.opsForSet().add(AGENT_SET_KEY, agentId);
// 加入技能组
for (String skillGroup : skillGroups) {
redisTemplate.opsForSet().add(SKILL_GROUP_AGENTS_KEY + skillGroup, agentId);
}
// 推送状态变更
eventPushService.pushAgentState(agent);
}
/**
* 坐席签出
*/
public void signOut(String agentId) {
log.info("坐席签出: agentId={}", agentId);
Agent agent = getAgent(agentId);
if (agent == null) {
return;
}
// 检查是否在通话中
if (agent.getState() == AgentState.BUSY ||
agent.getState() == AgentState.RINGING) {
throw new RuntimeException("坐席正在通话中,无法签出");
}
// 从技能组移除
for (String skillGroup : agent.getSkillGroups()) {
redisTemplate.opsForSet().remove(SKILL_GROUP_AGENTS_KEY + skillGroup, agentId);
}
// 从坐席集合移除
redisTemplate.opsForSet().remove(AGENT_SET_KEY, agentId);
// 删除坐席信息
String key = AGENT_KEY_PREFIX + agentId;
redisTemplate.delete(key);
// 推送签出事件
agent.setState(AgentState.OFFLINE);
eventPushService.pushAgentState(agent);
}
/**
* 坐席置闲
*/
public void ready(String agentId) {
log.info("坐席置闲: agentId={}", agentId);
Agent agent = getAgent(agentId);
if (agent == null) {
throw new RuntimeException("坐席不存在");
}
if (agent.getState() == AgentState.BUSY) {
throw new RuntimeException("坐席正在通话中");
}
updateAgentState(agentId, AgentState.READY);
// 通知ACD有坐席可用
for (String skillGroup : agent.getSkillGroups()) {
acdService.onAgentReady(agentId, skillGroup);
}
}
/**
* 坐席置忙
*/
public void notReady(String agentId, String reason) {
log.info("坐席置忙: agentId={}, reason={}", agentId, reason);
Agent agent = getAgent(agentId);
if (agent == null) {
throw new RuntimeException("坐席不存在");
}
if (agent.getState() == AgentState.BUSY) {
throw new RuntimeException("坐席正在通话中");
}
agent.setNotReadyReason(reason);
updateAgentState(agentId, AgentState.NOT_READY);
}
/**
* 坐席小休
*/
public void pause(String agentId, String reason) {
log.info("坐席小休: agentId={}, reason={}", agentId, reason);
Agent agent = getAgent(agentId);
if (agent == null) {
throw new RuntimeException("坐席不存在");
}
if (agent.getState() == AgentState.BUSY) {
throw new RuntimeException("坐席正在通话中");
}
agent.setPauseReason(reason);
updateAgentState(agentId, AgentState.PAUSED);
}
/**
* 更新坐席状态
*/
public void updateAgentState(String agentId, AgentState newState) {
Agent agent = getAgent(agentId);
if (agent == null) {
return;
}
AgentState oldState = agent.getState();
agent.setState(newState);
agent.setStateTime(LocalDateTime.now());
// 保存
String key = AGENT_KEY_PREFIX + agentId;
redisTemplate.opsForValue().set(key, agent);
log.info("坐席状态变更: agent={}, {} -> {}", agentId, oldState, newState);
// 推送状态变更
eventPushService.pushAgentState(agent);
}
/**
* 获取坐席信息
*/
public Agent getAgent(String agentId) {
String key = AGENT_KEY_PREFIX + agentId;
return (Agent) redisTemplate.opsForValue().get(key);
}
/**
* 获取技能组可用坐席
*/
public List<Agent> getAvailableAgents(String skillGroup) {
Set<Object> agentIds = redisTemplate.opsForSet()
.members(SKILL_GROUP_AGENTS_KEY + skillGroup);
if (agentIds == null || agentIds.isEmpty()) {
return Collections.emptyList();
}
return agentIds.stream()
.map(id -> getAgent((String) id))
.filter(agent -> agent != null && agent.getState() == AgentState.READY)
.collect(Collectors.toList());
}
/**
* 获取所有技能组
*/
public Set<String> getAllSkillGroups() {
Set<String> keys = redisTemplate.keys(SKILL_GROUP_AGENTS_KEY + "*");
if (keys == null) {
return Collections.emptySet();
}
return keys.stream()
.map(key -> key.replace(SKILL_GROUP_AGENTS_KEY, ""))
.collect(Collectors.toSet());
}
/**
* 获取所有坐席状态(监控大屏)
*/
public List<Agent> getAllAgents() {
Set<Object> agentIds = redisTemplate.opsForSet().members(AGENT_SET_KEY);
if (agentIds == null) {
return Collections.emptyList();
}
return agentIds.stream()
.map(id -> getAgent((String) id))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
/**
* 获取坐席状态统计
*/
public Map<AgentState, Long> getAgentStateStats() {
return getAllAgents().stream()
.collect(Collectors.groupingBy(Agent::getState, Collectors.counting()));
}
}
```
### 3.3 Go ESL开发
如果您的团队更熟悉Go语言以下是Go版本的ESL开发示例
#### 3.3.1 项目结构
```
callcenter-cti-go/
├── go.mod
├── go.sum
├── main.go
├── config/
│ └── config.go
├── esl/
│ ├── client.go # ESL客户端
│ ├── event.go # 事件定义
│ └── handler.go # 事件处理器
├── service/
│ ├── agent.go # 坐席服务
│ ├── call.go # 呼叫服务
│ └── acd.go # ACD服务
├── model/
│ └── models.go # 数据模型
├── api/
│ ├── router.go # 路由
│ ├── agent_handler.go # 坐席API
│ └── call_handler.go # 呼叫API
└── websocket/
└── hub.go # WebSocket推送
```
#### 3.3.2 ESL客户端
```go
// esl/client.go
package esl
import (
"bufio"
"fmt"
"log"
"net"
"net/textproto"
"strconv"
"strings"
"sync"
"time"
)
// ESL客户端
type Client struct {
conn net.Conn
reader *textproto.Reader
writer *bufio.Writer
mu sync.Mutex
connected bool
password string
eventChan chan *Event
handlers []EventHandler
reconnect bool
}
// 事件处理器接口
type EventHandler interface {
Handle(event *Event)
}
// 创建ESL客户端
func NewClient(host string, port int, password string) (*Client, error) {
client := &Client{
password: password,
eventChan: make(chan *Event, 1000),
handlers: make([]EventHandler, 0),
reconnect: true,
}
err := client.connect(host, port)
if err != nil {
return nil, err
}
// 启动事件读取协程
go client.readLoop()
// 启动事件分发协程
go client.dispatchLoop()
return client, nil
}
// 连接FreeSWITCH
func (c *Client) connect(host string, port int) error {
addr := fmt.Sprintf("%s:%d", host, port)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("连接失败: %v", err)
}
c.conn = conn
c.reader = textproto.NewReader(bufio.NewReader(conn))
c.writer = bufio.NewWriter(conn)
// 读取欢迎消息
_, err = c.readMessage()
if err != nil {
return err
}
// 认证
err = c.auth()
if err != nil {
return err
}
c.connected = true
log.Println("ESL连接成功")
return nil
}
// 认证
func (c *Client) auth() error {
_, err := c.sendCommand(fmt.Sprintf("auth %s", c.password))
if err != nil {
return fmt.Errorf("认证失败: %v", err)
}
return nil
}
// 发送命令
func (c *Client) sendCommand(command string) (*Message, error) {
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.writer.WriteString(command + "\n\n")
if err != nil {
return nil, err
}
c.writer.Flush()
return c.readMessage()
}
// 发送API命令
func (c *Client) API(command string, args string) (string, error) {
cmd := fmt.Sprintf("api %s %s", command, args)
msg, err := c.sendCommand(cmd)
if err != nil {
return "", err
}
return msg.Body, nil
}
// 发送后台API命令
func (c *Client) BgAPI(command string, args string) (string, error) {
cmd := fmt.Sprintf("bgapi %s %s", command, args)
msg, err := c.sendCommand(cmd)
if err != nil {
return "", err
}
return msg.GetHeader("Job-UUID"), nil
}
// 订阅事件
func (c *Client) Subscribe(events ...string) error {
eventStr := strings.Join(events, " ")
_, err := c.sendCommand(fmt.Sprintf("event plain %s", eventStr))
return err
}
// 订阅所有事件
func (c *Client) SubscribeAll() error {
_, err := c.sendCommand("event plain ALL")
return err
}
// 读取消息
func (c *Client) readMessage() (*Message, error) {
msg := &Message{
Headers: make(map[string]string),
}
// 读取headers
for {
line, err := c.reader.ReadLine()
if err != nil {
return nil, err
}
if line == "" {
break
}
parts := strings.SplitN(line, ": ", 2)
if len(parts) == 2 {
msg.Headers[parts[0]] = parts[1]
}
}
// 读取body
if lenStr := msg.GetHeader("Content-Length"); lenStr != "" {
length, _ := strconv.Atoi(lenStr)
if length > 0 {
body := make([]byte, length)
_, err := c.reader.R.Read(body)
if err != nil {
return nil, err
}
msg.Body = string(body)
}
}
return msg, nil
}
// 事件读取循环
func (c *Client) readLoop() {
for c.connected {
msg, err := c.readMessage()
if err != nil {
log.Printf("读取消息错误: %v", err)
c.connected = false
if c.reconnect {
c.tryReconnect()
}
return
}
contentType := msg.GetHeader("Content-Type")
if contentType == "text/event-plain" {
event := parseEvent(msg)
c.eventChan <- event
}
}
}
// 事件分发循环
func (c *Client) dispatchLoop() {
for event := range c.eventChan {
for _, handler := range c.handlers {
go handler.Handle(event)
}
}
}
// 尝试重连
func (c *Client) tryReconnect() {
for i := 0; i < 10; i++ {
time.Sleep(5 * time.Second)
log.Printf("尝试重连ESL (第%d次)...", i+1)
// TODO: 重新连接
}
}
// 添加事件处理器
func (c *Client) AddHandler(handler EventHandler) {
c.handlers = append(c.handlers, handler)
}
// 关闭连接
func (c *Client) Close() {
c.connected = false
c.reconnect = false
if c.conn != nil {
c.conn.Close()
}
close(c.eventChan)
}
// 消息结构
type Message struct {
Headers map[string]string
Body string
}
func (m *Message) GetHeader(name string) string {
if v, ok := m.Headers[name]; ok {
return v
}
return ""
}
// 解析事件
func parseEvent(msg *Message) *Event {
event := &Event{
Headers: make(map[string]string),
}
// 解析body中的headers
lines := strings.Split(msg.Body, "\n")
for _, line := range lines {
parts := strings.SplitN(line, ": ", 2)
if len(parts) == 2 {
// URL解码
value := strings.ReplaceAll(parts[1], "%20", " ")
event.Headers[parts[0]] = value
}
}
event.Name = event.GetHeader("Event-Name")
event.UUID = event.GetHeader("Unique-ID")
return event
}
```
#### 3.3.3 事件定义
```go
// esl/event.go
package esl
// 事件结构
type Event struct {
Name string
UUID string
Headers map[string]string
}
func (e *Event) GetHeader(name string) string {
if v, ok := e.Headers[name]; ok {
return v
}
return ""
}
// 事件名称常量
const (
EventChannelCreate = "CHANNEL_CREATE"
EventChannelAnswer = "CHANNEL_ANSWER"
EventChannelBridge = "CHANNEL_BRIDGE"
EventChannelUnbridge = "CHANNEL_UNBRIDGE"
EventChannelHangup = "CHANNEL_HANGUP"
EventChannelHangupComplete = "CHANNEL_HANGUP_COMPLETE"
EventChannelState = "CHANNEL_STATE"
EventDTMF = "DTMF"
EventRecordStart = "RECORD_START"
EventRecordStop = "RECORD_STOP"
EventCustom = "CUSTOM"
)
```
#### 3.3.4 事件处理器
```go
// esl/handler.go
package esl
import (
"callcenter/service"
"log"
)
// 呼叫中心事件处理器
type CallCenterHandler struct {
callService *service.CallService
agentService *service.AgentService
acdService *service.AcdService
}
func NewCallCenterHandler(
callService *service.CallService,
agentService *service.AgentService,
acdService *service.AcdService,
) *CallCenterHandler {
return &CallCenterHandler{
callService: callService,
agentService: agentService,
acdService: acdService,
}
}
func (h *CallCenterHandler) Handle(event *Event) {
switch event.Name {
case EventChannelCreate:
h.handleChannelCreate(event)
case EventChannelAnswer:
h.handleChannelAnswer(event)
case EventChannelBridge:
h.handleChannelBridge(event)
case EventChannelHangup:
h.handleChannelHangup(event)
case EventChannelHangupComplete:
h.handleChannelHangupComplete(event)
case EventDTMF:
h.handleDTMF(event)
}
}
func (h *CallCenterHandler) handleChannelCreate(event *Event) {
uuid := event.UUID
direction := event.GetHeader("Call-Direction")
callerNumber := event.GetHeader("Caller-Caller-ID-Number")
calleeNumber := event.GetHeader("Caller-Destination-Number")
log.Printf("新呼叫: uuid=%s, direction=%s, caller=%s, callee=%s",
uuid, direction, callerNumber, calleeNumber)
// 创建呼叫记录
call := &service.Call{
UUID: uuid,
Direction: direction,
CallerNumber: callerNumber,
CalleeNumber: calleeNumber,
State: "ringing",
}
h.callService.SaveCall(call)
// 呼入排队
if direction == "inbound" {
skillGroup := event.GetHeader("variable_skill_group")
call.SkillGroup = skillGroup
h.acdService.Enqueue(call)
}
}
func (h *CallCenterHandler) handleChannelAnswer(event *Event) {
uuid := event.UUID
log.Printf("通话接听: uuid=%s", uuid)
call := h.callService.GetCall(uuid)
if call != nil {
call.State = "answered"
h.callService.UpdateCall(call)
}
}
func (h *CallCenterHandler) handleChannelBridge(event *Event) {
uuid := event.UUID
otherUUID := event.GetHeader("Other-Leg-Unique-ID")
log.Printf("通道桥接: uuid=%s, other=%s", uuid, otherUUID)
call := h.callService.GetCall(uuid)
if call != nil {
call.State = "bridged"
call.BridgeUUID = otherUUID
h.callService.UpdateCall(call)
}
}
func (h *CallCenterHandler) handleChannelHangup(event *Event) {
uuid := event.UUID
hangupCause := event.GetHeader("Hangup-Cause")
log.Printf("通话挂断: uuid=%s, cause=%s", uuid, hangupCause)
call := h.callService.GetCall(uuid)
if call != nil {
call.State = "hangup"
call.HangupCause = hangupCause
h.callService.UpdateCall(call)
// 更新坐席状态
if call.AgentID != "" {
h.acdService.OnCallEnd(call.AgentID, call)
}
}
}
func (h *CallCenterHandler) handleChannelHangupComplete(event *Event) {
uuid := event.UUID
billsec := event.GetHeader("variable_billsec")
recordingPath := event.GetHeader("variable_recording_path")
log.Printf("通话结束: uuid=%s, billsec=%s, recording=%s",
uuid, billsec, recordingPath)
call := h.callService.GetCall(uuid)
if call != nil {
call.Billsec = billsec
call.RecordingPath = recordingPath
h.callService.UpdateCall(call)
}
}
func (h *CallCenterHandler) handleDTMF(event *Event) {
uuid := event.UUID
digit := event.GetHeader("DTMF-Digit")
log.Printf("DTMF按键: uuid=%s, digit=%s", uuid, digit)
}
```
---
## 第四部分 呼叫中心场景开发实践
### 4.1 预测式外呼实现
```java
package com.callcenter.service;
import com.callcenter.esl.EslConnectionManager;
import com.callcenter.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 预测式外呼服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PredictiveDialerService {
private final EslConnectionManager eslManager;
private final AgentService agentService;
private final CallService callService;
// 外呼任务
private final Map<String, DialTask> activeTasks = new ConcurrentHashMap<>();
// 外呼线程池
private final ExecutorService dialerExecutor = Executors.newFixedThreadPool(10);
/**
* 创建外呼任务
*/
public String createTask(DialTaskRequest request) {
String taskId = UUID.randomUUID().toString();
DialTask task = new DialTask();
task.setId(taskId);
task.setName(request.getName());
task.setSkillGroup(request.getSkillGroup());
task.setCallerIdNumber(request.getCallerIdNumber());
task.setNumbers(new LinkedBlockingQueue<>(request.getNumbers()));
task.setMaxConcurrent(request.getMaxConcurrent());
task.setState(TaskState.CREATED);
task.setStats(new DialStats());
activeTasks.put(taskId, task);
log.info("创建外呼任务: taskId={}, name={}, numbers={}",
taskId, request.getName(), request.getNumbers().size());
return taskId;
}
/**
* 启动任务
*/
public void startTask(String taskId) {
DialTask task = activeTasks.get(taskId);
if (task == null) {
throw new RuntimeException("任务不存在");
}
task.setState(TaskState.RUNNING);
log.info("启动外呼任务: {}", taskId);
// 启动外呼循环
dialerExecutor.submit(() -> dialLoop(task));
}
/**
* 暂停任务
*/
public void pauseTask(String taskId) {
DialTask task = activeTasks.get(taskId);
if (task != null) {
task.setState(TaskState.PAUSED);
log.info("暂停外呼任务: {}", taskId);
}
}
/**
* 恢复任务
*/
public void resumeTask(String taskId) {
DialTask task = activeTasks.get(taskId);
if (task != null && task.getState() == TaskState.PAUSED) {
task.setState(TaskState.RUNNING);
dialerExecutor.submit(() -> dialLoop(task));
log.info("恢复外呼任务: {}", taskId);
}
}
/**
* 停止任务
*/
public void stopTask(String taskId) {
DialTask task = activeTasks.get(taskId);
if (task != null) {
task.setState(TaskState.STOPPED);
log.info("停止外呼任务: {}", taskId);
}
}
/**
* 外呼循环
*/
private void dialLoop(DialTask task) {
log.info("开始外呼循环: taskId={}", task.getId());
while (task.getState() == TaskState.RUNNING && !task.getNumbers().isEmpty()) {
try {
// 计算应该发起的呼叫数
int dialCount = calculateDialCount(task);
if (dialCount <= 0) {
// 没有空闲坐席,等待
Thread.sleep(1000);
continue;
}
// 发起外呼
for (int i = 0; i < dialCount; i++) {
String number = task.getNumbers().poll();
if (number == null) {
break;
}
dial(task, number);
}
// 短暂等待,控制拨打速率
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("外呼异常: {}", e.getMessage(), e);
}
}
// 任务完成
if (task.getNumbers().isEmpty()) {
task.setState(TaskState.COMPLETED);
}
log.info("外呼循环结束: taskId={}, state={}", task.getId(), task.getState());
}
/**
* 计算应该发起的呼叫数(预测算法)
*/
private int calculateDialCount(DialTask task) {
// 获取可用坐席数
List<Agent> availableAgents = agentService.getAvailableAgents(task.getSkillGroup());
int availableAgentCount = availableAgents.size();
if (availableAgentCount == 0) {
return 0;
}
// 当前正在拨打的数量
int currentDialing = task.getStats().getCurrentDialing().get();
// 历史接通率(动态计算)
double connectRate = calculateConnectRate(task);
// 预测式算法:
// 目标:让坐席接到电话时,刚好有客户接通
// 预测需要拨打数 = 空闲坐席数 / 接通率 * 系数
double factor = 1.2; // 超拨系数,避免坐席空闲
int predictedDial = (int) Math.ceil(availableAgentCount / connectRate * factor);
// 限制最大并发
int maxAllowed = task.getMaxConcurrent() - currentDialing;
return Math.min(predictedDial, maxAllowed);
}
/**
* 计算接通率
*/
private double calculateConnectRate(DialTask task) {
DialStats stats = task.getStats();
int total = stats.getTotalDialed().get();
int connected = stats.getConnected().get();
if (total < 10) {
// 样本太少,使用默认值
return 0.3;
}
double rate = (double) connected / total;
// 限制范围
return Math.max(0.1, Math.min(0.8, rate));
}
/**
* 发起单个外呼
*/
private void dial(DialTask task, String number) {
log.info("外呼: taskId={}, number={}", task.getId(), number);
task.getStats().getTotalDialed().incrementAndGet();
task.getStats().getCurrentDialing().incrementAndGet();
try {
// 构建originate命令
// 先拨打客户,客户接听后转到队列等待坐席
StringBuilder cmd = new StringBuilder();
cmd.append("{");
cmd.append("origination_caller_id_number=").append(task.getCallerIdNumber()).append(",");
cmd.append("task_id=").append(task.getId()).append(",");
cmd.append("dial_number=").append(number).append(",");
cmd.append("dialer_mode=predictive");
cmd.append("}");
// 通过网关外呼
cmd.append("sofia/gateway/carrier_primary/").append(number);
// 客户接听后的处理
cmd.append(" &lua(predictive_handler.lua)");
String result = eslManager.sendSyncApiCommand("originate", cmd.toString());
if (!result.startsWith("+OK")) {
log.warn("外呼失败: number={}, result={}", number, result);
task.getStats().getFailed().incrementAndGet();
}
} catch (Exception e) {
log.error("外呼异常: number={}", number, e);
task.getStats().getFailed().incrementAndGet();
} finally {
task.getStats().getCurrentDialing().decrementAndGet();
}
}
/**
* 客户接听回调(由事件触发)
*/
public void onCustomerAnswer(String uuid, String taskId) {
DialTask task = activeTasks.get(taskId);
if (task == null) {
eslManager.sendSyncApiCommand("uuid_kill", uuid + " NO_ROUTE_DESTINATION");
return;
}
task.getStats().getConnected().incrementAndGet();
// 查找可用坐席
List<Agent> agents = agentService.getAvailableAgents(task.getSkillGroup());
if (agents.isEmpty()) {
// 无可用坐席,播放等待音乐
eslManager.sendSyncApiCommand("uuid_broadcast",
uuid + " ivr/please_wait.wav both");
// 放入等待队列
// TODO: 实现等待逻辑
// 超时处理
task.getStats().getAbandoned().incrementAndGet();
} else {
// 转接坐席
Agent agent = agents.get(0);
agentService.updateAgentState(agent.getId(), AgentState.RINGING);
eslManager.sendSyncApiCommand("uuid_transfer",
uuid + " " + agent.getExtension() + " XML default");
task.getStats().getTransferred().incrementAndGet();
}
}
/**
* 获取任务统计
*/
public DialStats getTaskStats(String taskId) {
DialTask task = activeTasks.get(taskId);
return task != null ? task.getStats() : null;
}
}
/**
* 外呼任务
*/
@Data
class DialTask {
private String id;
private String name;
private String skillGroup;
private String callerIdNumber;
private BlockingQueue<String> numbers;
private int maxConcurrent;
private TaskState state;
private DialStats stats;
}
/**
* 任务状态
*/
enum TaskState {
CREATED, RUNNING, PAUSED, STOPPED, COMPLETED
}
/**
* 外呼统计
*/
@Data
class DialStats {
private AtomicInteger totalDialed = new AtomicInteger(0);
private AtomicInteger currentDialing = new AtomicInteger(0);
private AtomicInteger connected = new AtomicInteger(0);
private AtomicInteger transferred = new AtomicInteger(0);
private AtomicInteger failed = new AtomicInteger(0);
private AtomicInteger abandoned = new AtomicInteger(0);
}
```
### 4.2 机器人外呼实现
```java
package com.callcenter.service;
import com.callcenter.esl.EslConnectionManager;
import com.callcenter.model.*;
import com.callcenter.tts.TtsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.*;
/**
* 机器人外呼服务
* 用于批量通知、回访、满意度调查
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RobotDialerService {
private final EslConnectionManager eslManager;
private final TtsService ttsService;
private final Map<String, RobotTask> activeTasks = new ConcurrentHashMap<>();
private final ExecutorService executor = Executors.newFixedThreadPool(20);
/**
* 创建机器人外呼任务
*/
public String createTask(RobotTaskRequest request) {
String taskId = UUID.randomUUID().toString();
RobotTask task = new RobotTask();
task.setId(taskId);
task.setType(request.getType()); // NOTIFICATION, SURVEY, CALLBACK
task.setNumbers(new LinkedBlockingQueue<>(request.getNumbers()));
task.setTtsText(request.getTtsText());
task.setVariables(request.getVariables()); // 每个号码的变量
task.setMaxConcurrent(request.getMaxConcurrent());
task.setRetryTimes(request.getRetryTimes());
task.setCollectDtmf(request.isCollectDtmf());
task.setState(TaskState.CREATED);
task.setResults(new ConcurrentHashMap<>());
activeTasks.put(taskId, task);
return taskId;
}
/**
* 启动任务
*/
public void startTask(String taskId) {
RobotTask task = activeTasks.get(taskId);
if (task == null) {
throw new RuntimeException("任务不存在");
}
task.setState(TaskState.RUNNING);
executor.submit(() -> robotDialLoop(task));
}
/**
* 机器人外呼循环
*/
private void robotDialLoop(RobotTask task) {
log.info("开始机器人外呼: taskId={}", task.getId());
Semaphore semaphore = new Semaphore(task.getMaxConcurrent());
while (task.getState() == TaskState.RUNNING) {
String number = task.getNumbers().poll();
if (number == null) {
break;
}
try {
semaphore.acquire();
executor.submit(() -> {
try {
robotCall(task, number);
} finally {
semaphore.release();
}
});
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
// 等待所有呼叫完成
try {
semaphore.acquire(task.getMaxConcurrent());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
task.setState(TaskState.COMPLETED);
log.info("机器人外呼完成: taskId={}", task.getId());
}
/**
* 单个机器人呼叫
*/
private void robotCall(RobotTask task, String number) {
log.info("机器人呼叫: taskId={}, number={}", task.getId(), number);
RobotCallResult result = new RobotCallResult();
result.setNumber(number);
result.setStartTime(System.currentTimeMillis());
try {
// 替换TTS文本中的变量
String ttsText = replaceTtsVariables(task.getTtsText(),
task.getVariables().get(number));
// 生成语音文件
String audioFile = ttsService.synthesize(ttsText);
// 构建外呼命令
StringBuilder cmd = new StringBuilder();
cmd.append("{");
cmd.append("task_id=").append(task.getId()).append(",");
cmd.append("dial_number=").append(number).append(",");
cmd.append("robot_mode=true").append(",");
cmd.append("audio_file=").append(audioFile);
cmd.append("}");
cmd.append("sofia/gateway/carrier_primary/").append(number);
if (task.isCollectDtmf()) {
// 需要收集按键
cmd.append(" &lua(robot_survey.lua)");
} else {
// 纯播报
cmd.append(" &playback(").append(audioFile).append(")");
}
String apiResult = eslManager.sendSyncApiCommand("originate", cmd.toString());
if (apiResult.startsWith("+OK")) {
result.setStatus("SUCCESS");
result.setCallUuid(apiResult.substring(4).trim());
} else {
result.setStatus("FAILED");
result.setError(apiResult);
// 重试逻辑
handleRetry(task, number);
}
} catch (Exception e) {
log.error("机器人呼叫异常: number={}", number, e);
result.setStatus("ERROR");
result.setError(e.getMessage());
handleRetry(task, number);
}
result.setEndTime(System.currentTimeMillis());
task.getResults().put(number, result);
}
/**
* 替换TTS变量
*/
private String replaceTtsVariables(String template, Map<String, String> vars) {
if (vars == null || vars.isEmpty()) {
return template;
}
String result = template;
for (Map.Entry<String, String> entry : vars.entrySet()) {
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
}
return result;
}
/**
* 处理重试
*/
private void handleRetry(RobotTask task, String number) {
int retryCount = task.getRetryCount().getOrDefault(number, 0);
if (retryCount < task.getRetryTimes()) {
task.getRetryCount().put(number, retryCount + 1);
task.getNumbers().offer(number);
log.info("加入重试队列: number={}, retry={}", number, retryCount + 1);
}
}
/**
* 处理DTMF按键结果由事件触发
*/
public void onDtmfResult(String uuid, String taskId, String number, String digit) {
RobotTask task = activeTasks.get(taskId);
if (task == null) {
return;
}
RobotCallResult result = task.getResults().get(number);
if (result != null) {
result.setDtmfDigit(digit);
log.info("收到按键: taskId={}, number={}, digit={}", taskId, number, digit);
}
}
/**
* 获取任务结果
*/
public List<RobotCallResult> getTaskResults(String taskId) {
RobotTask task = activeTasks.get(taskId);
if (task == null) {
return Collections.emptyList();
}
return new ArrayList<>(task.getResults().values());
}
}
```
### 4.3 班长监控实现
```java
package com.callcenter.service;
import com.callcenter.esl.EslConnectionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 班长监控服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SupervisorService {
private final EslConnectionManager eslManager;
/**
* 监听坐席通话
* 班长可以听到双方说话,但双方听不到班长
*/
public void spy(String supervisorExtension, String targetUuid) {
log.info("开始监听: supervisor={}, target={}", supervisorExtension, targetUuid);
// 使用eavesdrop应用
// originate user/supervisor &eavesdrop(target_uuid)
String cmd = String.format(
"{origination_caller_id_name=监听}user/%s &eavesdrop(%s)",
supervisorExtension, targetUuid
);
String result = eslManager.sendSyncApiCommand("originate", cmd);
if (!result.startsWith("+OK")) {
throw new RuntimeException("监听失败: " + result);
}
}
/**
* 强插通话
* 班长加入通话,三方都可以听到
*/
public void barge(String supervisorExtension, String targetUuid) {
log.info("强插通话: supervisor={}, target={}", supervisorExtension, targetUuid);
// 使用三方会议实现
String confName = "barge_" + targetUuid;
// 1. 将目标通话转入会议
eslManager.sendSyncApiCommand("uuid_transfer",
targetUuid + " conference:" + confName + "+flags{mute} inline");
// 2. 呼叫班长加入会议
String cmd = String.format(
"{origination_caller_id_name=强插}user/%s &conference(%s)",
supervisorExtension, confName
);
eslManager.sendSyncApiCommand("originate", cmd);
}
/**
* 强拆通话
* 强制挂断坐席通话
*/
public void hangup(String targetUuid, String reason) {
log.info("强拆通话: target={}, reason={}", targetUuid, reason);
eslManager.sendSyncApiCommand("uuid_kill", targetUuid + " " + reason);
}
/**
* 密语(耳语)
* 班长对坐席说话,客户听不到
*/
public void whisper(String supervisorExtension, String agentUuid) {
log.info("密语: supervisor={}, agent={}", supervisorExtension, agentUuid);
// 使用eavesdrop的whisper模式
String cmd = String.format(
"{origination_caller_id_name=密语}user/%s &eavesdrop(%s whisper)",
supervisorExtension, agentUuid
);
eslManager.sendSyncApiCommand("originate", cmd);
}
/**
* 获取实时通话列表
*/
public String getActiveCalls() {
return eslManager.sendSyncApiCommand("show", "calls");
}
/**
* 获取坐席通道信息
*/
public String getChannelInfo(String uuid) {
return eslManager.sendSyncApiCommand("uuid_dump", uuid);
}
}
```
---
## 第五部分 高可用部署方案
### 5.1 主备架构
```
┌─────────────────┐
│ Keepalived │
│ VIP漂移管理 │
└────────┬────────┘
┌───────────────────────────┴───────────────────────────┐
│ VIP: 10.10.1.100 │
│ (SIP/ESL统一入口) │
└───────────────────────────┬───────────────────────────┘
┌─────────────────────┴─────────────────────┐
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Master节点 │ 心跳检测 │ Backup节点 │
│ 10.10.1.10 │◄──────────────────►│ 10.10.1.11 │
├────────────────────┤ ├────────────────────┤
│ FreeSWITCH │ │ FreeSWITCH │
│ CTI中间件 │ │ CTI中间件 │
│ API服务 │ │ API服务 │
└────────────────────┘ └────────────────────┘
│ │
│ 共享存储 / 数据同步 │
│ │
└─────────────────────┬─────────────────────┘
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MySQL 主从 │ │ Redis Sentinel │ │ NAS存储 │
│ 10.10.2.10/11 │ │ 10.10.2.20-22 │ │ 10.10.2.30 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### 5.2 Keepalived配置
**Master节点配置**
```bash
# /etc/keepalived/keepalived.conf (Master)
global_defs {
router_id FS_MASTER
}
# 检测FreeSWITCH状态的脚本
vrrp_script check_freeswitch {
script "/etc/keepalived/check_freeswitch.sh"
interval 2
weight -20
fall 3
rise 2
}
vrrp_instance VI_FS {
state MASTER
interface eth0
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass your_password
}
virtual_ipaddress {
10.10.1.100/24 dev eth0
}
track_script {
check_freeswitch
}
notify_master "/etc/keepalived/notify_master.sh"
notify_backup "/etc/keepalived/notify_backup.sh"
notify_fault "/etc/keepalived/notify_fault.sh"
}
```
**检测脚本:**
```bash
#!/bin/bash
# /etc/keepalived/check_freeswitch.sh
# 检测FreeSWITCH进程
if ! pgrep -x "freeswitch" > /dev/null; then
exit 1
fi
# 检测ESL端口
if ! nc -z localhost 8021; then
exit 1
fi
# 检测SIP端口
if ! nc -z localhost 5060; then
exit 1
fi
# 执行简单API命令测试
result=$(fs_cli -x "status" 2>/dev/null)
if [[ $? -ne 0 ]]; then
exit 1
fi
exit 0
```
**主节点切换通知脚本:**
```bash
#!/bin/bash
# /etc/keepalived/notify_master.sh
# 记录日志
echo "$(date): Becoming MASTER" >> /var/log/keepalived-state.log
# 启动FreeSWITCH如果未运行
systemctl start freeswitch
# 启动CTI服务
systemctl start callcenter-cti
# 发送告警通知
curl -X POST "http://your-alert-api/notify" \
-d "message=FreeSWITCH切换到Master节点"
```
### 5.3 配置同步方案
```bash
#!/bin/bash
# sync_config.sh - 配置同步脚本
MASTER_HOST="10.10.1.10"
BACKUP_HOST="10.10.1.11"
FS_CONF_DIR="/usr/local/freeswitch/conf"
# 使用rsync同步配置
sync_config() {
rsync -avz --delete \
${FS_CONF_DIR}/ \
${BACKUP_HOST}:${FS_CONF_DIR}/
# 通知备机重载配置
ssh ${BACKUP_HOST} "fs_cli -x 'reloadxml'"
}
# 监控配置变化
inotifywait -m -r -e modify,create,delete ${FS_CONF_DIR} | while read path action file; do
echo "配置变化: $path$file ($action)"
sync_config
done
```
### 5.4 FreeSWITCH集群配置
对于需要更高并发的场景可以部署FreeSWITCH集群
```xml
<!-- conf/autoload_configs/sofia.conf.xml -->
<configuration name="sofia.conf">
<global_settings>
<!-- 启用分布式设备状态 -->
<param name="debug-presence" value="0"/>
</global_settings>
<profiles>
<profile name="internal">
<!-- ... -->
<!-- 分布式配置 -->
<gateways>
<!-- 集群内其他节点 -->
<gateway name="cluster_node_2">
<param name="realm" value="10.10.1.12"/>
<param name="register" value="false"/>
</gateway>
</gateways>
</profile>
</profiles>
</configuration>
```
---
## 第六部分 性能优化指南
### 6.1 FreeSWITCH优化
**系统级优化:**
```bash
# /etc/security/limits.conf
freeswitch soft nofile 65536
freeswitch hard nofile 65536
freeswitch soft core unlimited
freeswitch hard core unlimited
# /etc/sysctl.conf
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
net.core.netdev_max_backlog = 30000
net.ipv4.tcp_max_syn_backlog = 10240
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
```
**FreeSWITCH配置优化**
```xml
<!-- conf/autoload_configs/switch.conf.xml -->
<configuration name="switch.conf">
<settings>
<!-- 核心线程数根据CPU核心数调整 -->
<param name="min-idle-cpu" value="25"/>
<param name="max-sessions" value="2000"/>
<param name="sessions-per-second" value="100"/>
<!-- 日志级别(生产环境降低日志级别) -->
<param name="loglevel" value="warning"/>
<!-- RTP端口范围 -->
<param name="rtp-start-port" value="16384"/>
<param name="rtp-end-port" value="32768"/>
</settings>
</configuration>
```
### 6.2 CTI中间件优化
```java
// 连接池配置
@Configuration
public class ConnectionPoolConfig {
@Bean
public ThreadPoolTaskExecutor eslEventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(10000);
executor.setThreadNamePrefix("esl-event-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://10.10.2.20:6379")
.addNodeAddress("redis://10.10.2.21:6379")
.addNodeAddress("redis://10.10.2.22:6379")
.setMasterConnectionPoolSize(64)
.setSlaveConnectionPoolSize(64);
return Redisson.create(config);
}
}
```
### 6.3 监控指标
```yaml
# prometheus配置
scrape_configs:
- job_name: 'freeswitch'
static_configs:
- targets: ['10.10.1.10:9282', '10.10.1.11:9282']
- job_name: 'callcenter-cti'
static_configs:
- targets: ['10.10.1.10:8080', '10.10.1.11:8080']
metrics_path: '/actuator/prometheus'
```
**关键监控指标:**
| 指标 | 说明 | 阈值 |
|------|------|------|
| freeswitch_sessions_active | 当前活跃会话数 | <80%最大值 |
| freeswitch_sessions_per_second | 每秒新建会话数 | <100 |
| freeswitch_cpu_usage | CPU使用率 | <70% |
| freeswitch_memory_usage | 内存使用率 | <80% |
| esl_event_queue_size | 事件队列大小 | <5000 |
| acd_queue_wait_time | 平均等待时间 | <30s |
| agent_utilization | 坐席利用率 | 目标>70% |
---
## 总结
本文档详细介绍了FreeSWITCH的技术架构和ESL开发实践涵盖了
1. **核心概念**Channel、Session、Event等基础概念
2. **模块详解**mod_sofia、Dialplan、ESL等核心模块
3. **ESL开发**Java和Go两种语言的开发示例
4. **场景实践**ACD、预测式外呼、机器人外呼、班长监控
5. **高可用部署**主备架构、Keepalived配置
6. **性能优化**:系统优化、应用优化、监控指标
建议开发团队在正式开发前:
- 搭建FreeSWITCH测试环境熟悉操作
- 仔细研读FreeSWITCH官方文档
- 从简单场景(如点击拨号)开始,逐步实现复杂功能
- 重视测试,特别是高可用切换测试和压力测试