基于ThinkPHP的在线客服系统源码开源下载:前后端分离+WebSocket实时通讯
在企业级 PHP 生态中,ThinkPHP 凭借其符合 PSR 规范、MVC 架构清晰、学习曲线平缓等特性,一直是快速构建业务系统的首选框架。然而,ThinkPHP 原生基于 HTTP 短连接,无法直接承载 WebSocket 长连接——这是做客服系统时第一个必须正视的技术现实。行业内成熟的解决方案是:ThinkPHP 负责业务逻辑与 RESTful API,Swoole 或 Workerman 作为常驻进程承载 WebSocket 长连接服务,两者通过 Redis 做连接映射与消息路由。CRMChat(Swoole4 + TP6)、php-customer-service-system(ThinkPHP 8 + Workerman)等开源项目均采用了这一架构范式。
源码:zxkfym.top
本文将以源码级视角,拆解基于 ThinkPHP 的在线客服系统是如何实现前后端分离与 WebSocket 实时通讯的,覆盖架构设计、核心模块实现、消息路由机制与生产部署要点。
一、为什么 ThinkPHP 不能直接跑 WebSocket

很多初学者会尝试在 Controller 里
new Swoole\WebSocket\Server,这是典型的认知陷阱。ThinkPHP 的 HTTP 请求是短生命周期:请求进来 → 执行完 → 进程销毁。而 WebSocket 必须长期驻留、监听端口、维护连接池。如果把
Server 实例放在控制器里,响应一结束,整个 Server 就被 GC 回收,客户端立刻收到 net::ERR_CONNECTION_REFUSED。⚠️ 真实后果:用户登录后刚点开聊天页,消息就收不到;重连逻辑再完善也救不回来。
正确的路径是:必须用独立常驻进程启动 WebSocket 服务。在 TP6/TP8 生态中,有两种主流选择:
-
topthink/think-swoole:通过
php think swoole:server启动,Swoole 4 协程引擎承载 WebSocket,适合高并发场景 -
GatewayWorker / Workerman:通过
php start.php start -d启动独立 Socket 服务,更加轻量、易调试
CRMChat 选择前者(TP6 + Swoole4 + Redis + Vue + MySQL),php-customer-service-system 选择后者(ThinkPHP 8 + Workerman + Uniapp + Vue)。两者本质都是业务层与通信层解耦。
二、系统总体架构

以 CRMChat 为代表的高性能方案,整体架构分为四层:
┌─────────────────────────────────────────────────────────┐
│ 用户接入层 │
│ PC网页 │ H5移动端 │ 微信小程序 │ 公众号 │ APP嵌入 │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Nginx 反向代理层 │
│ 负载均衡 │ SSL终止 │ 静态资源 │ WebSocket Upgrade 转发 │
└─────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Swoole WebSocket 服务 │ │ ThinkPHP 6 业务层 │
│ 消息路由 │ 会话管理 │ │ 控制器 │ 服务层 │ 模型层 │
│ 心跳检测 │ FD映射表 │ │ 验证器 │ 事件监听 │ RBAC │
└──────────────────────────┘ └──────────────────────────┘
│ │
▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Redis │ │ MySQL │ │ 队列 │
│ 缓存/映射│ │ 持久化 │ │ 异步 │
└─────────┘ └─────────┘ └─────────┘
前后端完全分离:
-
后端:ThinkPHP 6 提供 RESTful API,所有接口返回统一 JSON 格式(通过
success()/fail()方法) -
前端:Vue CLI 构建的 PC 管理后台 + H5 客服端
-
通信:Axios 调用 REST API 处理业务;WebSocket 处理实时消息
三、源码结构与核心模块
3.1 典型目录结构
参考 php-customer-service-system 的多应用模式:
app/
├── admin/ # 后台管理模块
│ ├── controller/
│ │ ├── Chats.php # 聊天管理(核心)
│ │ ├── Customer.php # 客户管理
│ │ ├── Set.php # 系统设置
│ │ └── ...
│ ├── model/ # 数据模型
│ └── validate/ # 验证器
├── api/ # API 接口模块
│ ├── controller/
│ │ ├── Chats.php # 对话接口
│ │ ├── Customer.php # 客户接口
│ │ └── Unread.php # 未读接口
├── websocket/ # WebSocket 事件处理(Swoole/Workerman)
└── common/ # 公共函数
config/
├── swoole.php # Swoole 配置(WebSocket 服务参数)
├── im.php # 即时通讯配置(端口、IP)
└── database.php # 数据库配置
public/ # Web 入口
websocket/ # Workerman 启动脚本(独立进程)
3.2 Swoole WebSocket 服务配置
config/swoole.php 是 WebSocket 服务的骨架,缺字段或路径错,服务根本起不来:return [
'http' => [
'enable' => true,
'host' => '0.0.0.0',
'port' => 9501,
'worker_num' => swoole_cpu_num(), // 建议为 CPU 核心数的 1-2 倍
'options' => [
'package_max_length' => 10 * 1024 * 1024, // 10MB
],
],
'websocket' => [
'enable' => true, // 必须显式开启
'route' => false,
'handler' => \think\swoole\websocket\Handler::class,
'ping_interval' => 25000, // 心跳间隔
'ping_timeout' => 60000, // 超时时间
'room' => [
'type' => 'table',
'table' => [
'room_rows' => 8192,
'room_size' => 2048,
'client_rows' => 4096,
'client_size' => 2048,
],
],
'listen' => [
'open' => \app\webscoket\Connect::class,
'event' => \app\webscoket\Event::class,
'close' => \app\webscoket\Close::class,
]
]
];
3.3 连接生命周期管理

WebSocket 的三个核心事件:
onOpen、onMessage、onClose。这是整个实时通信的基石。连接建立(onOpen):
public function onOpen($server, $req) {
$fd = $req->fd; // 客户端唯一标识
$uid = $req->get['uid']; // 用户 ID
$token = $req->get['token']; // 登录 Token
// 1. 校验 token 合法性(调用 TP 业务层接口或查 Redis)
if (!$this->verifyToken($token)) {
$server->push($fd, json_encode([
'status' => 2, 'message' => 'token 已过期'
]));
$server->close($fd);
return;
}
// 2. 绑定 fd ↔ uid 映射到 Redis
// 键名建议:ws:session:{user_id}
$redis->hSet('ws:conn_map', $uid, $fd);
$redis->hSet('ws:conn_map', "fd:{$fd}", $uid);
// 3. 更新用户在线状态
$redis->hSet('user:status', $uid, 'online');
echo "用户 {$uid} 建立了连接,标识为 {$fd}\n";
}
接收消息(onMessage):
public function onMessage($server, $frame) {
$fd = $frame->fd;
$message = json_decode($frame->data, true);
switch ($message['type']) {
case 'chat': // 聊天消息
$this->handleChat($server, $message);
break;
case 'ping': // 心跳
$server->push($fd, json_encode(['type' => 'pong']));
break;
case 'typing': // 正在输入
$this->broadcastTyping($message);
break;
}
}
private function handleChat($server, $message) {
$toUid = $message['to_uid'];
$redis = $this->redis;
// 1. 查询接收方 fd
$toFd = $redis->hGet('ws:conn_map', $toUid);
// 2. 在线则实时推送
if ($toFd && $server->isEstablished($toFd)) {
$server->push($toFd, json_encode([
'type' => 'chat',
'from_uid' => $message['from_uid'],
'content' => $message['content'],
'timestamp' => time()
]));
} else {
// 3. 离线则存入离线消息队列
$redis->lPush("ws:offline:{$toUid}",
json_encode($message));
}
// 4. 异步写入 MySQL(通过 Task Worker 或消息队列)
$this->saveMessageToDB($message);
}
连接关闭(onClose):
public function onClose($server, $fd) {
$redis = $this->redis;
$uid = $redis->hGet('ws:conn_map', "fd:{$fd}");
if ($uid) {
// 清理连接映射
$redis->hDel('ws:conn_map', $uid);
$redis->hDel('ws:conn_map', "fd:{$fd}");
$redis->hSet('user:status', $uid, 'offline');
// 通知相关客服用户下线
$this->notifyAgentUserOffline($uid);
}
}
四、消息路由与会话分配
客服系统的核心不是"群聊",而是精准的点对点消息路由 + 智能坐席分配。
4.1 FD 映射与消息流转
系统在 Swoole 服务中维护一个全局的 FD 映射表(借助 Redis Hash 实现跨进程共享):
ws:conn_map:{
"user_1001": 1024, // 用户 1001 的连接 fd
"agent_2001": 1025, // 客服 2001 的连接 fd
"fd:1024": "user_1001", // 反向映射
"fd:1025": "agent_2001"
}
消息流转过程:
-
访客发送消息至 WebSocket 服务
-
服务解析消息类型和目标客服 ID
-
查询 Redis 获取客服对应 FD
-
若客服在线则实时推送,否则存入离线消息队列
-
同时将消息异步写入 MySQL
4.2 坐席智能分配策略
CRMChat 支持三种会话分配策略:
-
空闲优先:选择当前会话数最少、状态为在线的客服
-
随机分派:在所有在线客服中随机选择一个
-
分组指派:根据访客来源/问题类型匹配对应技能组
分配逻辑示例:
public function dispatchAgent($visitorUid, $group = null) {
$redis = $this->redis;
// 查 Redis 中在线客服列表
$agents = $redis->hGetAll('agent:status');
$available = [];
foreach ($agents as $agentId => $status) {
if ($status !== 'online') continue;
// 计算当前会话数
$sessionCount = $redis->zScore('agent:load', $agentId);
// 分组过滤
if ($group && !$this->inGroup($agentId, $group)) continue;
$available[$agentId] = $sessionCount;
}
if (empty($available)) return null; // 无空闲坐席,进入排队
// 空闲优先:选择会话数最少的
asort($available);
return array_key_first($available);
}
五、前后端分离的接口协作

5.1 REST API 与 WebSocket 的职责划分
REST API(ThinkPHP Controller 处理):
-
用户登录、签发 JWT Token
-
客服排班、状态变更
-
历史会话查询、聊天记录分页
-
满意度评价提交
-
话术库管理、客户标签管理
WebSocket(Swoole/Workerman 处理):
-
实时消息收发
-
在线状态同步
-
输入中(typing)通知
-
新消息提醒推送
5.2 前端连接示例(Vue + WebSocket)
// 建立 WebSocket 连接
const token = store.state.user.token
const ws = new WebSocket(`ws://localhost:9501?uid=${userId}&token=${token}`)
ws.onopen = () => {
console.log('WebSocket 连接建立')
// 启动心跳
this.startHeartbeat()
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
switch (data.type) {
case 'chat':
// 追加消息到会话列表
store.commit('appendMessage', data)
// 播放提示音
this.playNotification()
break
case 'pong':
// 心跳回应
break
case 'agent_offline':
// 客服离线通知
this.handleAgentOffline(data)
break
}
}
ws.onclose = () => {
console.log('连接断开,尝试重连...')
setTimeout(() => this.reconnect(), 3000)
}
六、核心业务功能实现
6.1 数据库核心表结构
-- 访客表
CREATE TABLE `visitor` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`uuid` VARCHAR(64) UNIQUE, -- 访客唯一标识
`ip` VARCHAR(45),
`user_agent` TEXT,
`source_page` VARCHAR(255), -- 来源页
`created_at` DATETIME,
`last_active_at` DATETIME
);
-- 客服表
CREATE TABLE `agent` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` BIGINT,
`nickname` VARCHAR(50),
`max_session` INT DEFAULT 5, -- 最大并发会话数
`current_session` INT DEFAULT 0, -- 当前会话数
`status` TINYINT DEFAULT 1, -- 1-在线 0-离线 2-忙碌
`group_id` INT -- 技能组 ID
);
-- 会话表
CREATE TABLE `chat_session` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`visitor_id` BIGINT,
`agent_id` BIGINT,
`start_time` DATETIME,
`end_time` DATETIME,
`status` TINYINT, -- 1-进行中 2-已结束
`evaluate_score` TINYINT -- 评价分数
);
-- 消息表
CREATE TABLE `chat_message` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`session_id` BIGINT,
`sender_type` TINYINT, -- 1-访客 2-客服
`sender_id` BIGINT,
`receiver_id` BIGINT,
`content` TEXT,
`msg_type` TINYINT DEFAULT 1, -- 1-文本 2-图片 3-文件
`send_time` DATETIME,
`is_read` TINYINT DEFAULT 0,
INDEX idx_session (`session_id`),
INDEX idx_sender (`sender_id`)
) PARTITION BY RANGE (TO_DAYS(send_time)); -- 按月分区
6.2 会话转接实现
会话转接不是前端点个按钮就完事,而是服务端一次连接迁移 + 状态广播 + 前端重连引导的组合动作:
public function transferSession($sessionId, $fromAgentId, $toAgentId) {
// 1. 更新数据库会话归属
Db::name('chat_session')
->where('id', $sessionId)
->update(['agent_id' => $toAgentId]);
// 2. 更新 Redis 中的会话映射
$redis = $this->redis;
$visitorUid = $this->getVisitorUidBySession($sessionId);
// 3. 通知原客服
$fromFd = $redis->hGet('ws:conn_map', "agent_{$fromAgentId}");
if ($fromFd) {
$this->server->push($fromFd, json_encode([
'type' => 'transfer_out',
'session_id' => $sessionId,
'to_agent' => $toAgentId
]));
}
// 4. 通知新客服
$toFd = $redis->hGet('ws:conn_map', "agent_{$toAgentId}");
if ($toFd) {
$this->server->push($toFd, json_encode([
'type' => 'transfer_in',
'session_id' => $sessionId,
'visitor_uid' => $visitorUid,
'history' => $this->getSessionHistory($sessionId)
]));
}
// 5. 通知访客(前端引导重连到新客服)
$visitorFd = $redis->hGet('ws:conn_map', $visitorUid);
if ($visitorFd) {
$this->server->push($visitorFd, json_encode([
'type' => 'agent_changed',
'new_agent_id' => $toAgentId,
'message' => '您的问题正在转接给其他客服...'
]));
}
}
七、高可用与生产部署

7.1 Nginx 反向代理配置
前端通过
wss://yourdomain.com/ws 连接,而非裸 IP+端口(否则 HTTPS 页面无法建立 ws 连接):server {
listen 443 ssl;
server_name kefu.example.com;
# SSL 配置
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# 静态资源与 API
location / {
proxy_pass http://127.0.0.1:9501; # ThinkPHP HTTP 服务
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket 反向代理
location /ws {
proxy_pass http://127.0.0.1:9502; # Swoole WebSocket 服务
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s; # 长连接超时
}
}
7.2 服务启动与守护
Swoole 方案:
# 开发环境(前台运行,方便看日志)
php think swoole:server
# 生产环境(守护进程化)
php think swoole:server -d
Workerman 方案:
cd websocket/
php start.php start -d
7.3 性能优化要点
-
worker_num 调优:设置为 CPU 核心数的 1-2 倍
-
心跳配置:
heartbeat_check_interval=60,heartbeat_idle_time=600 -
Redis 连接池:避免每次消息都查数据库,用户会话状态缓存到 Redis
-
消息表分区:按月份分区,避免单表过大
-
异步落库:通过 Task Worker 或消息队列异步写入 MySQL,避免阻塞主进程
7.4 安全加固
-
传输层:全链路 WSS(SSL/TLS 加密)
-
鉴权:WebSocket 握手时校验 JWT Token,无效连接直接关闭
-
Origin 校验:防止跨站 WebSocket 连接
-
限流:单用户最大并发会话数限制,防止恶意占用
-
敏感词过滤:消息内容入库前做违禁词检测
-
RBAC 权限:基于角色的权限控制,细粒度到按钮级操作
-
操作审计:登录日志、操作日志全程留存
八、技术选型对比
面对不同的业务需求,如何在 Swoole 与 Workerman 之间选择?
|
维度
|
Swoole + think-swoole
|
Workerman / GatewayWorker
|
|---|---|---|
|
编程模型
|
协程异步
|
事件回调
|
|
性能
|
更高(协程调度)
|
高(事件驱动)
|
|
学习曲线
|
较陡(需理解协程)
|
平缓
|
|
生态
|
TP 官方集成好
|
独立 Socket 服务,与 TP 解耦更彻底
|
|
适用场景
|
高并发、复杂业务
|
轻量部署、快速迭代
|
|
代表项目
|
CRMChat(TP6+Swoole4)
|
php-customer-service-system(TP8+Workerman)
|
💡 选型建议:如果追求极致性能和协程特性,选 Swoole;如果希望 WebSocket 服务与 TP 完全解耦、独立部署运维更简单,选 Workerman。
九、AI 赋能与未来演进
现代 ThinkPHP 客服系统正在快速集成 AI 能力:
-
智能回复:通过后台设置机器人知识库,系统根据关键词自动回复用户
-
语义理解:基于 NLP 的意图识别,自动分配至对应技能组
-
人机协作:AI 处理常规问题,复杂问题无缝转接人工客服
-
RAG 知识库:上传企业文档训练专属知识库,回答准确率显著提升
十、源码学习路径建议
如果你想基于开源项目做二次开发,建议按以下顺序阅读源码:
-
从入口开始:
composer.json→config/swoole.php(或config/im.php)→ WebSocket 启动脚本 -
理解连接生命周期:
onOpen→onMessage→onClose三个事件处理器 -
深入业务层:
app/api/controller/Chats.php→app/admin/controller/Chats.php→ Model 层 -
前端对照:Vue 组件中的 WebSocket 客户端初始化 → Vuex/Pinia 中的消息处理 → 组件渲染
-
扩展点:消息协议定义、ChannelHandler 链、自定义中间件
⚠️ 二次开发注意:生产环境务必将setAllowedOriginPatterns("*")改为具体域名;JWT 有效期建议 2 小时并配合黑名单机制;WebSocket 端点建议加上频率限制防刷;消息落库必须异步,避免阻塞主进程。
写在最后:基于 ThinkPHP 的在线客服系统,其技术精髓在于"分"——业务与通信分离、前后端分离、连接与数据分离。ThinkPHP 本身不擅长长连接,但它擅长业务逻辑组织、ORM、验证器、RBAC 等企业级特性;Swoole/Workerman 擅长常驻内存、高并发连接管理。两者的结合不是简单的"1+1",而是通过 Redis 这个"中间件"实现了完美的职责解耦。
CRMChat 用 Swoole4 + TP6 + Redis + Vue + MySQL 证明了这套架构的工业级可行性;php-customer-service-system 用 ThinkPHP 8 + Workerman 展示了轻量方案的灵活性。无论选择哪条路径,掌握"业务层与通信层解耦"这一核心设计思想,才是读懂这类源码的真正钥匙。在这个 AI 与原生私有化部署并行的新时代,这套架构依然具有极强的生命力和扩展空间。

浙公网安备 33010602011771号