简介:这是一套面向企业级Web应用开发者的即时通讯客服系统PHP源码,基于ThinkPHP5框架与FastAdmin后台快速开发平台构建,并深度集成Swoole实现高性能长连接通信,专为解决多站点统一客服接入、智能应答与高并发IM交互等实际业务需求而设计。资源包共2000个文件,涵盖1215个JS脚本(含前端通信逻辑与uni-app适配层)、172个HTML页面(客服端/用户端/管理后台视图)、155个JSON配置与接口定义、107个CSS样式文件(含fastadmin.min.css、bootstrap.min.css等主题资源)及43个Vue组件,整体压缩后仅25.56MB,结构清晰、模块解耦度高。已有297人学习下载,开发者可直接部署获得完整可运行系统,包含多客服坐席分配、群聊管理(禁言/免打扰/成员操作)、知识库驱动的智能客服、WSS加密消息传输、第三方云存储对接及CDN资源分发等生产级功能,开箱即用且支持二次深度定制。 做企业IM客服系统,最头疼的不是写功能,而是选型。几年前接到一个客户需求:一套能扛住数千人在线、会话记录完整、还得能二次开发的客服系统。我第一反应就是基于PHP生态做,当时对比过Workerman、ReactPHP和Swoole,最后锁定了ThinkPHP5 + FastAdmin + Swoole这套组合。这篇文章就把整个项目的设计思路、核心实现、部署过程和踩坑记录分享一下,给打算自己搞客服系统的朋友一条可复现的路。
1. 技术选型与整体架构思路
1.1 为什么是ThinkPHP5 + FastAdmin + Swoole
先说结论:这套组合特别适合那种“既要快速交付,又要长期可控”的PHP客服项目。很多团队喜欢直接用现成的开源客服系统改,但改到后面往往发现核心业务逻辑被框架绑死,新增一个工单字段都得翻半天代码。自己基于成熟框架搭,反而更稳妥。
ThinkPHP5的优势在于生态成熟、文档齐全,而且FastAdmin本身就是基于它开发的,两者属于原生搭配。FastAdmin自带后台权限管理、菜单管理、表单构建器和插件机制,能省掉客服工作台、管理员角色这类基础后台功能的大量开发时间。它的“一键生成CRUD”功能,我拿来生成了会话记录表、用户表、工单表的管理界面,半小时就完成了传统方案两三天的工作量。
Swoole则是整个系统的通信底座。客服系统核心是“实时”,用传统Nginx + PHP-FPM只能靠前端轮询模拟实时,一旦在线人数上千,轮询请求能把服务器CPU打满。Swoole作为常驻内存的协程框架,直接接管WebSocket连接,服务端可以主动推送消息,真正做到毫秒级触达。
1.2 整体架构拓扑与数据流向
整个系统我用逻辑上拆成三块:
- 用户端(访客聊天窗口):用户在网页端发起咨询,通过WebSocket连接Swoole服务。
- 客服端(客服工作台):客服登录FastAdmin后台,同样通过WebSocket连接,实时接收访客消息、转接会话。
- 管理端(管理员后台):负责客服账号分配、会话监控、数据统计、知识库维护。
消息数据流向是这样:访客发送消息 -> Nginx反向代理(WebSocket升级) -> Swoole Server -> 消息写入MySQL(异步落库) -> 推送消息给客服端 -> 客服回复 -> Swoole Server -> 推送给访客 -> 聊天记录落库。如果客服不在线,消息会写入等待队列并发送站内信通知。
架构上我特意把WebSocket服务和后台业务分开,Swoole只负责长连接和实时转发,业务逻辑(如会话分配、关键词回复)通过HTTP接口调用FastAdmin的API。这样设计的好处是,如果Swoole服务挂了,后台管理仍能正常访问,不至于整个系统瘫痪。
2. FastAdmin后台管理端的核心开发
2.1 一键生成CRUD与客服工单管理
FastAdmin最香的功能就是php think crud -t kefu_chat_log这类命令,指定表名就能生成控制器、模型、视图和JS。但这里有个坑:默认生成的是单表CRUD,而IM系统里经常要联表查询(比如查会话日志时联客服表拿客服昵称)。我的做法是生成后用with关联模型手动补充查询条件,保留FastAdmin的搜索和排序逻辑。
客服工单管理这块,我用FastAdmin的“自定义搜索”功能做了多条件组合查询:按客服ID、会话状态(进行中/已结束/排队)、时间范围筛选。状态字段建议用int类型存(0排队,1进行中,2已结束),不要用字符串,否则后续做统计SQL时要写一堆CASE WHEN,自找麻烦。
2.2 自定义按钮绑定AJAX操作
FastAdmin默认操作按钮是“编辑/删除”,做客服系统肯定不够。比如“转接会话”这个操作,需要在聊天记录详情的行内,加一个下拉菜单选择目标客服。我踩过坑之后总结出标准做法:
在对应的index.html里自定义按钮:
{:build_icon('fa fa-exchange')}然后在JS里绑定事件:
$(document).on('click', '.btn-transfer', function () { var ids = $(this).data("ids"); if (ids.length < 1) { toastr.error("请选择要转接的会话"); return false; } layer.prompt({title: '输入目标客服ID'}, function(val, index) { $.ajax({ url: 'kefu/chatlog/transfer', type: 'POST', data: {ids: ids, target: val}, dataType: 'json', success: function (res) { if (res.code === 1) { layer.msg("转接成功"); location.reload(); } else { layer.msg(res.msg); } } }); layer.close(index); }); });注意FastAdmin所有AJAX请求都建议带token参数,否则会报Token verification failed。我习惯在页面初始化时拿到token存到全局变量里,请求时统一带上。
2.3 权限控制:客服角色与访客数据隔离
客服系统权限设计要比普通管理系统细。我的方案是:在FastAdmin的角色表里新建“客服”角色,只分配会话管理、访客管理的权限;再用数据权限(data permission)控制每个客服只能看自己接待的会话。FastAdmin自带的数据权限在标准CRUD里好用,但IM系统里很多查询是自定义SQL,所以我在模型里用before_select钩子手动加上“客服ID = 当前登录用户ID”的条件:
protected $beforeSelectList = []; protected function beforeSelect($query) { $admin_id = session('admin')['id']; if ($this->auth->getRuleIds() !== [1]) { // 不是超管 $query->where('kefu_id', $admin_id); } return $query; }这块我在一期上线时还出过问题:客服A能搜到客服B的会话记录,排查半天发现是FastAdmin默认数据权限只对index方法生效,对自定义的search方法不生效。所以权限过滤必须放在模型层,而不是控制器层。
3. 基于Swoole的IM通信服务实现
3.1 用Swoole扩展搭建WebSocket服务器
Swoole要单独装扩展。我用的是Swoole 4.8版本,支持协程(Coroutine),比老版本Swoole 2的异步回调写起来舒服太多。核心启动脚本server.php大概长这样:
use Swoole\WebSocket\Server; $server = new Server("0.0.0.0", 9503); $server->on('open', function ($server, $req) { echo "连接开启: {$req->fd}\n"; }); $server->on('message', function ($server, $frame) { $data = json_decode($frame->data, true); // 处理不同的消息类型:chat, online, offline, read $server->push($frame->fd, json_encode(['type' => 'chat', 'data' => '收到消息'])); }); $server->on('close', function ($server, $fd) { echo "连接关闭: {$fd}\n"; }); $server->start();启动后把这个Server常驻后台:
nohup php server.php > /var/log/swoole_im.log 2>&1 &需要说明的是,Swoole的WebSocket服务至少要PHP 7.2以上,建议用PHP 8.0或8.1,性能和内存管理更好。我一开始在PHP 7.1上跑,报了一堆兼容性错误,后来升级到PHP 8.0整个世界清净了。
3.2 连接管理:如何把用户和FD(连接描述符)绑定
客服系统里最核心的一个数据结构是“用户身份到FD的连接表”。一个用户可能开了两个标签页,就有两个FD;一个FD也可能在连接后收到用户登录信息,才能确定它是谁。
我设计了一个Redis Hash来维护:
HSET im_user_connections user_123 7 # 用户user_123的fd是7 HDEL im_user_connections user_123 # 断开时删除然后在Swoole的message事件里,解析消息附带user_id和token,用Redis查一下认证信息。这里一定要做身份校验,否则任何人都能伪造消息往别人的会话里发。我踩过这个坑,后来用了JWT Token,每次连接时校验签名,才彻底解决。
3.3 消息推送与离线消息机制
聊天的核心流程不复杂:收到一条消息,根据to_user_id找到目标FD,直接push推送;如果目标用户不在线,就写入MySQL的offline_message表,等用户上线后主动拉取。
这里有几个细节值得注意:
第一,推送时一定要判断$server->isEstablished($fd),因为FD可能已经断开,直接push会返回false或者触发警告。
第二,多客服同时在线时,会话分配要用到策略。我用的哈希分配:根据访客的用户ID取模,落到客服列表上,保证同一个访客始终由同一个客服接待。
第三,消息全量实时写MySQL会导致压力很大,尤其高峰期。我的方案是:先写Redis的List作为消息队列,然后由Swoole的tick定时器每2秒批量把消息刷到MySQL。这样既保证了不丢消息,又减轻了数据库压力。
$server->tick(2000, function () use ($server) { $msgs = Redis::lRange('im_message_queue', 0, 99); Redis::lTrim('im_message_queue', 100, -1); // 批量插入数据库 });4. 客户聊天窗口与前端交互
4.1 基于JavaScript的WebSocket客户端封装
前端我写了一个轻量的KefuSDK,负责管理连接、心跳、断线重连。基本结构:
class KefuSDK { constructor(options) { this.url = options.url; this.userId = options.userId; this.token = options.token; this.ws = null; } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { // 发送认证消息 this.ws.send(JSON.stringify({ type: 'auth', user_id: this.userId, token: this.token })); }; this.ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'chat') { this.onMessage(msg.data); } }; this.ws.onclose = () => { // 断线重连,指数退避 setTimeout(() => this.connect(), 3000); }; } sendMessage(content) { this.ws.send(JSON.stringify({ type: 'chat', to_user_id: this.toUserId, content: content })); } }心跳机制我用的是前端定时每30秒发送ping,服务端收到后返回pong,超过3次心跳没响应就主动重连。不要依赖WebSocket底层的TCP KeepAlive,时长太久,不适合聊天场景。
4.2 消息实时回显与未读消息
从用户角度,发出一条消息后最好立即在聊天界面显示,不要等服务端确认再显示。所以我在发送时就先插入一条“发送中”状态的消息,等服务端确认成功后再把状态改成“已送达”。这样可以极大提升交互体验,也方便做消息重发(失败时自动重试)。
未读消息数量我用Redi s的INCR实现:当访客发消息,对应客服的未读数+1;客服读取会话时,批量清零。在FastAdmin后台顶部菜单栏,我还加了未读消息数额的显示,通过一个简单的轮询接口(30秒一次)拉到最新的未读数,这个接口压力不大,因为只是查Redis。
4.3 聊天记录的历史加载与分页
聊天记录不能一次性全加载。我按会话ID分页,前端滚动到顶部时加载更早的消息。后端用MySQL的id倒序分页:
SELECT * FROM im_chat_log WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT 20这里要建好索引:(session_id, id)联合索引,否则数据量上来后查询会非常慢。第一版我忘了建联合索引,在几十万条数据时,翻聊天记录直接卡死,后来补了这个索引,查询时间从2秒降到几十毫秒。
5. 安装部署与配置(详细版)
5.1 环境要求与依赖安装
这套系统的运行环境,我用的是Linux服务器(CentOS 7.9) + Nginx 1.20 + PHP 8.0 + MySQL 5.7 + Redis 6.2。PHP需要安装以下扩展:
swoole(4.8+,必须启用openssl、sockets)redis(用于缓存和在线状态)pdo_mysqlfileinfoopcache(建议开启,提高性能)
如果用的是宝塔或1Panel这类面板,安装swoole扩展相对简单,直接装PHP后在扩展列表里选swoole编译安装。但有个坑:swoole扩展需要跟PHP版本匹配,面板自动编译有时会失败,我遇到过提示缺少phpize的情况,需要先装上php7.4-dev这类包。
5.2 源码部署与FastAdmin初始化
代码拿到手后,按以下步骤依次操作:
- 把源码放到Web根目录(如
/www/wwwroot/kefu)。 - 将项目根目录的
xxx.sql导入MySQL数据库,创建好数据库名和账号。 - 修改数据库配置:
/www/wwwroot/kefu/config/database.php,填入数据库名、用户、密码。 - 打开后台安装页面:
http://你的域名/install.php,按提示填写管理员账号和数据库信息。 - 安装完成后删除
install.php文件,避免被恶意重装。
FastAdmin的目录结构里,application/是业务代码所在,public/是Web根目录。Nginx配置要把网站根目录指向public,如果指到项目根目录,会暴露很多配置文件和内部结构,非常不安全。
5.3 Nginx反向代理WebSocket配置
由于Swoole的WebSocket端口是9503,但浏览器一般只能通过80/443访问,所以Nginx必须做反向代理:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 80; server_name your-domain.com; location /wss { proxy_pass http://127.0.0.1:9503; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } }这段配置里最关键是proxy_set_header Upgrade $http_upgrade;,如果没有这行,WebSocket握手会一直失败。proxy_read_timeout也建议设成3600秒以上,否则Nginx默认60秒没数据传输就会断开连接。
5.4 启动Swoole服务并配置定时任务
Swoole服务建议用系统服务方式管理,不用nohup,因为意外挂掉没人管。我写了一个简单的systemd服务文件:
[Unit] Description=Kefu IM Server After=network.target [Service] ExecStart=/www/server/php/80/bin/php /www/wwwroot/kefu/server.php Restart=always RestartSec=5 [Install] WantedBy=multi-user.target保存到/etc/systemd/system/kefu-im.service后:
systemctl daemon-reload systemctl enable kefu-im.service systemctl start kefu-im.service这样宕机后会自动拉起。同时我还设了一个crontab定时任务,每分钟检查Swoole进程是否存在:
* * * * * /usr/bin/ps aux | /bin/grep -v grep | /bin/grep server.php || (nohup php /www/wwwroot/kefu/server.php > /dev/null 2>&1 &)双保险,确保它一直活着。
6. 常见问题排查与避坑技巧
6.1 Swoole无法启动的排查矩阵
我遇到过不同类型的环境差异,整理成速查表:
| 报错现象 | 可能原因 | 解决办法 |
|---|---|---|
Class 'Swoole\WebSocket\Server' not found | 未安装swoole扩展或版本过低 | php -m确认扩展已加载,升级swoole到4.8+ |
Address already in use | 端口9503被占用 | `netstat -anp |
| 连接后马上断开,无报错 | Nginx未配置Upgrade头 | 检查proxy_set_header Upgrade和Connection配置 |
| 客户端能连上但消息不同步 | 服务端推送时FD已失效 | isEstablished($fd)判断后再push |
| 高并发下协程内存暴涨 | 未开启协程钩子或未释放变量 | 检查Co::set(['hook_flags' => SWOOLE_HOOK_ALL]) |
6.2 FastAdmin后台500错误的常见原因
FastAdmin项目最常见的问题是PHP版本过高导致的兼容性错误。FastAdmin最初是为PHP 7.x开发的,在PHP 8.x下会出现“未定义函数each()”之类的报错。解决办法:切换PHP版本到7.4,或者在使用PHP 8时对each函数做兼容处理:
if (!function_exists('each')) { function each(&$array) { $key = key($array); $result = ($key === null) ? false : [$key, current($array), 'key' => $key, 'value' => current($array)]; next($array); return $result; } }另外,FastAdmin插件后台如果提示“请从官网渠道下载插件压缩包 (code:2)”,一般有两种情况:一是服务器无法访问官方插件服务器;二是FastAdmin版本过低,需要去官网升级到最新版。离线服务器上建议直接下载插件包后手动解压到addons目录。
6.3 消息延迟或丢失的排查思路
我在上线初期遇到过消息偶尔丢失,看起来像是随机丢。后来查了一圈,罪魁祸首是Redis消息队列和MySQL刷盘中间出现竞态:
- 先用Redis的
RPUSH写入队列,Swoole定时器每2秒LRANGE + LTRIM读取并入库。 - 如果消息在写入Redis后、定时器读取前,进程崩溃,Redis里的数据还在,不会丢。
- 真正丢消息的问题是:
LTRIM命令用的是相对索引,如果同时有其它客户端在写队列,LTRIM可能误删新数据。
我的修复方案是使用Redis的LPOP+ 管道批量拉取,不用LTRIM:
$count = Redis::lLen('im_message_queue'); for ($i = 0; $i < $count && $i < 100; $i++) { $msg = Redis::lPop('im_message_queue'); if (!$msg) break; $allMsgs[] = $msg; }这样保证每次取的就是实际出队的消息,不会误删。
6.4 数据库连接数被打满
Swoole常驻进程连接MySQL,如果用进程内直连,每个Worker占一个连接,容易把MySQL的连接数打满。我的方案是启用连接池:
use Swoole\Coroutine\Channel; $pool = new Channel(10); // 初始填充连接 for ($i = 0; $i < 10; $i++) { $pool->push(createMysqlConnection()); } // 取连接 $conn = $pool->pop(); try { // 执行SQL } finally { $pool->push($conn); }配合协程,高峰期可以轻松扛住上千并发。
7. 二次开发扩展建议
客服系统的扩展空间很大,从我这个项目后续接的需求来看,最常被要求的功能有这几个方向:
第一是机器人自动回复。可以用关键词匹配 + 编辑好的知识库,也可以接入第三方AI接口,做更自然的对话。实现上不复杂,在Swoole收到消息后,先走一遍匹配规则,如果命中就自动回复,不命中再转人工。这能显著降低客服压力。
第二是会话统计报表。FastAdmin后台用echarts做图表很方便。我在kefu_chat_log表上增加了session_date和kefu_id字段,每天用crontab跑一次汇总,生成前一天各客服的接单量、平均响应时长、满意度评分数据,存在独立的统计表里,前台直接展示。
第三是消息已读回执和输入状态。想做得更精细,可以加两类消息:read(已读)和typing(输入中)。这两类消息在WebSocket里频繁推送,要注意频率控制,比如输入中状态在真实场景里客户端做去重,300毫秒内只发一次。
第四是图片和文件发。上传走FastAdmin的上传接口,返回URL后把URL放聊天消息里。注意要限制文件大小,一般客服系统传大文件不现实,我限制在10M以内,并做图片缩略图。
这些扩展如果一开始没设计好接口,后面会比较被动。建议在架构里预留好消息类型的扩展位,比如在消息数据结构里加一个type字段,前端根据不同类型渲染不同样式,服务端对不同类型走不同处理逻辑,这样扩展起来就不用推翻重来。
我个人在实际部署这套系统的过程中,最大的感受是:IM客服系统的难点其实不在单点技术,而在于把WebSocket长连接、MySQL读写、Redis缓存、后台权限这四样东西串起来。每个环节单独看都不算难,但组合在一起,对细节要求极高——连接状态管理不严谨会漏消息,消息队列设计不好会堵库,权限过滤不到位会出安全事故。按上面这套方案做下来,我这边的系统在800人同时在线、每天3万条消息的负载下,服务器资源占用稳定在30%以内。如果你也在做类似项目,可以把这套架构作为蓝图,再根据你的业务场景做调整,能省掉不少自己摸索的时间。
本文还有配套的精品资源,点击获取