## 目录
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_sofia(SIP模块)
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
```
**External Profile(中继对接):**
```xml
```
### 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
```
**IVR和呼叫处理(default.xml):**
```xml
```
### 2.3 mod_event_socket(ESL模块)
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
```
---
## 第三部分 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 ` | 连接认证 |
| **事件订阅** | `event plain ` | 订阅事件 |
| | `event plain ALL` | 订阅所有事件 |
| | `event plain CHANNEL_CREATE CHANNEL_ANSWER CHANNEL_HANGUP` | 订阅指定事件 |
| | `nixevent ` | 取消订阅 |
| | `noevents` | 取消所有订阅 |
| **API命令** | `api [args]` | 同步执行API |
| | `bgapi [args]` | 异步执行API |
| **过滤器** | `filter ` | 过滤特定事件 |
| | `filter Unique-ID ` | 只接收指定UUID事件 |
| **呼叫控制** | `sendmsg ` | 向指定通道发送消息 |
| | `execute` | 在通道上执行应用 |
| **其他** | `log ` | 设置日志级别 |
| | `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
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-websocket
org.freeswitch.esl.client
org.freeswitch.esl.client
0.9.2
io.netty
netty-all
4.1.86.Final
org.springframework.boot
spring-boot-starter-data-redis
com.alibaba
fastjson
2.0.23
org.apache.commons
commons-lang3
org.projectlombok
lombok
```
#### 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 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 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 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 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 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 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 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 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 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 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 headers) {
String uuid = headers.get("Unique-ID");
String recordPath = headers.get("Record-File-Path");
log.info("停止录音: uuid={}, path={}", uuid, recordPath);
}
/**
* 处理自定义事件
*/
private void handleCustomEvent(Map 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 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 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 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 availableAgents = agentService.getAvailableAgents(skillGroup);
if (availableAgents.isEmpty()) {
log.debug("技能组{}无可用坐席", skillGroup);
return;
}
// 获取队列中的呼叫
Set