简介:基于ThinkPHP框架打造的运营级在线客服系统源码,面向需要快速搭建网页客服、多坐席协作与智能客服平台的开发者和企业技术团队,可直接用于电商、官网、SaaS产品等场景的客户服务模块。系统包含实时聊天、坐席状态管理、访客分配、智能自动回复与智能推荐等核心功能,采用前后端分离架构,适配Nginx+PHP7.3+MySQL5.6环境,并附详细的部署与二次开发教程,便于快速上手。整套资源共2010个文件,以JS、HTML、CSS等前端资源为主,配合PHP后端逻辑、SQL建表脚本以及Markdown/TXT说明文档,压缩后约67.91MB,目录层级完整,适合本地搭建与对照学习。目前已有134人浏览/学习,源码覆盖完整客服业务链路,并保留了可扩展的模块化结构,对希望深入掌握ThinkPHP企业级项目实践、或需要快速交付客服方案的团队来说,是一份扎实的参考资料。
1. 在线客服系统的选型与架构背景
在线客户服务被验证为一道“慢半秒就流失”的转化环节。电商大促期间,访客同时在线、坐席排队、消息乱序几乎同时爆发,而自研即时通信的成本往往被严重低估,团队急需一个现成可改、能扛住并发的基础底座。
这套基于 ThinkPHP 框架开发的运营级在线客服系统源码,把网页客服、多坐席分配、智能自动回复三条主干功能打包在一起,运行在 Nginx + PHP 7.3 + MySQL 5.6 这套经典组合上。前端资源完整,后台具备规则配置能力,附带的教程文档覆盖了安装部署和基本使用。
对已有 PHP 运维基础、想绕开商业 SaaS 客服数据边界的团队来说,核心功能层和扩展点都在框架层留好,不用从零写会话和消息表。下文按架构设计、部署调优、二次开发三个层次把这套系统拆开讲。
2. ThinkPHP 路由与客服核心模块的实现思路
2.1 路由设计与访客会话建立
访客侧的会话建立,本质上是“匿名用户身份标识 + 会话 ID 绑定”的过程。ThinkPHP 框架里,路由定义决定了客服端入口和访客端入口如何分流。常见做法是在route.php中显式声明路由规则,而不是依赖默认的 controller/action 拼接:
// 访客端入口 Route::rule('visit/index', 'index/Visitor/index'); // 坐席端登录 Route::rule('agent/login', 'agent/Account/login'); // 前端轮询接口 Route::rule('chat/poll', 'index/Chat/poll'); // 坐席分配接口 Route::rule('chat/assign', 'index/Chat/assign');这里把访客端和坐席端拆成两个控制器分组,核心原因是两者挂载的中间件不同。访客端只需要校验会话 token,坐席端还要校验登录态和坐席角色权限。Route::rule第一个参数是外部访问路径,第二个参数是内部控制器映射,例如index/Visitor/index表示 index 模块下 Visitor 控制器的 index 方法。部署后我会在公共入口先判断是否携带有效的visitor_token,没有则先创建会话记录,再进入消息拉取逻辑,避免访客直接访问轮询接口时拿不到 session_id。
会话 token 的生成不建议直接用自增 ID,容易被遍历。我一般用Session::getId()拼接一段随机串,再按 user_id 或 visitor_ip 维度绑定到会话表。访客会话记录本身需要带上来源页 URL、User-Agent、IP 三段信息,后续做来源统计和防骚扰时,这三段是最基础的维度。注意 IP 段在 Nginx 反代场景下要从HTTP_X_FORWARDED_FOR读取,直接在 PHP 里读REMOTE_ADDR拿到的是代理服务器地址,坐席端展示的归属地会全部错乱。
2.2 多坐席分配策略与排队逻辑
多坐席分配是运营级客服系统和 demo 级项目的分水岭。demo 通常把新会话直接派给最后登录的坐席,但真实场景下会出现某个坐席被塞满、其他人闲置的情况。比较常见的分配策略有三种:轮询、最少会话数优先、空闲时长优先,实际运用中还会叠加“忙碌中不参与分配”的判断。这套源码的默认分配逻辑是取在线且当前会话数最少的坐席:
// 默认分配策略:在线坐席中取当前负载最小者 public function dispatch($visitorId) { $agents = Db::name('agent') ->where('online_status', 1) ->order('current_load ASC, last_active_time ASC') ->limit(10) ->select(); if (empty($agents)) { return $this->enqueue($visitorId); // 进入排队队列 } $target = $agents[0]; Db::name('agent')->where('id', $target['id']) ->setInc('current_load'); return $target['id']; }current_load的维护点有两处:坐席点击“结束会话”时减一,访客长时间不发言触发超时回收时减一。最容易踩坑的是并发下的原子性,setInc生成的是UPDATE agent SET current_load = current_load + 1,行级原子操作,直接调用即可;不要先select出来加一再update,高并发下会丢更新。如果坐席规模超过 50 人,建议把在线坐席列表缓存在 Redis 中,分配时只读缓存,减少数据库读压力。
三种策略的选型对比如下:
| 策略 | 适用规模 | 优点 | 缺点 |
|---|---|---|---|
| 轮询分配 | 10 人以下小团队 | 实现最简单,逻辑透明 | 无法感知坐席实际忙碌度 |
| 最少会话数优先 | 10-50 人客服团队 | 负载均衡效果最直观 | 需要维护实时会话计数字段 |
| 空闲时长优先 | 排班制团队 | 响应速度更稳定 | 空闲统计本身有额外开销 |
实际业务里,轮询和空闲时长往往组合使用:新会话先检查坐席的online_status,再按current_load升序取第一个。排队队列用 MySQL 表实现时,要记得给status = 0的排队记录加索引,否则访客量上来后enqueue和dequeue都会变慢。排队中的访客建议每分钟推一次“当前排队位置”,从体验上说比干等要好很多。
2.3 消息表与 MySQL 5.6 的兼容性设计
MySQL 5.6 对 JSON 字段类型的支持不理想,JSON 类型要到 5.7 才正式可用。这套源码里,消息内容、访客信息、扩展字段全部拆成平铺字段,避免依赖 JSON 函数:
CREATE TABLE `chat_message` ( `id` int(11) NOT NULL AUTO_INCREMENT, `session_id` int(11) NOT NULL COMMENT '会话ID', `from_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0访客 1坐席 2系统', `from_user` varchar(64) NOT NULL COMMENT '发送方标识', `content` text COMMENT '消息内容', `msg_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0文本 1图片 2文件', `create_time` int(11) NOT NULL COMMENT 'Unix时间戳', PRIMARY KEY (`id`), KEY `idx_session_time` (`session_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';设计上的几个细节值得注意。create_time用 int 时间戳而不是 datetime,是为了会话归档和冷热数据分离时区间查询更快,且不受时区配置影响。from_type和from_user分开存,是因为系统转接消息、坐席回复、访客留言在展示层走完全不同的渲染逻辑。复合索引idx_session_time服务于消息翻页和未读计数,避免每次会话打开都做全表扫描。
MySQL 5.6 下有两点很容易踩:一是 utf8mb4 的索引长度限制,varchar 超过 191 字符再建索引会报 767 字节上限错误;二是 5.6 默认的 sql_mode 对严格模式支持不完整,插入超长字符串可能只是截断而不是报错,导致消息内容莫名丢失结尾字符。会话表也建议加一个channel字段标记访客来源是 PC 端还是 H5 端,后续统计网页客服转化率时特别有用。
3. Nginx 与 PHP 7.3 环境下的部署与参数调优
3.1 环境版本匹配与站点配置
先把环境对齐:PHP 7.3、MySQL 5.6、Nginx 1.16 以上。PHP 7.3 对 ThinkPHP 的兼容性很好,闭包路由、标量类型声明、list()解构都能正常使用。部署时优先确认 PHP 是否装了pdo_mysql、mbstring、curl三个扩展,缺任何一个都会在安装向导或首次请求时报错。
Nginx 站点配置需要处理 pathinfo 形式的 URL 重写,否则首页可以打开、但聊天接口全部 404:
server { listen 80; server_name kefu.example.com; root /www/wwwroot/kefu/public; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } access_log /data/logs/kefu.access.log main; }rewrite ^(.*)$ /index.php?s=$1 last;是把 ThinkPHP 的 PATH_INFO 转换成 query 参数模式,适用于未开启cgi.fix_pathinfo的环境。fastcgi_pass 127.0.0.1:9000对应 PHP-FPM 的 TCP 监听,如果改成 unix socket 方式,需要同步调整 fastcgi_pass 的路径。root 指向public目录而不是项目根目录,因为 ThinkPHP 的入口文件只在public下,这样可避免外部直接访问application、runtime目录。
源码包上传时还要注意一个细节:runtime目录不要整个覆盖到线上。本地调试产生的编译缓存、日志文件如果跟着上线,会和新环境配置冲突,表现是登录跳转异常、SESSION 写入失败。我一般会把runtime目录清空后保留空目录加写入权限,再同步代码。
3.2 PHP-FPM 进程数与并发兜底
客服系统的并发模型和普通门户站不同。访客会长时间保持轮询请求,占用 PHP-FPM worker 的时间远长于普通请求,默认的 50 个max_children在大促场景下很快被打满。可以按下面的参数起步:
pm = dynamic pm.max_children = 80 pm.start_servers = 20 pm.min_spare_servers = 10 pm.max_spare_servers = 30 pm.max_requests = 1000pm.max_requests = 1000是为了定期回收可能发生内存泄漏的 worker,这个值不要设太小,否则 worker 频繁重启反而增加 CPU 开销。更合理的max_children要根据单进程内存反推,先用下面的命令统计:
ps aux | grep php-fpm | awk '{sum+=$6; n++} END {print sum/n/1024 "MB"}'这条命令把所有 php-fpm 进程的 RSS 内存求和再除以进程数,得到单进程平均内存占用,然后按(总内存 - 系统预留) / 单进程内存计算 max_children。比如 8G 内存的机器,单进程 80MB,max_children 设在 80 左右是安全的。
轮询接口的空转消耗也要处理。我一般会在消息拉取接口里加上set_time_limit(25),让请求在服务端保持 25 秒再返回,配合前端 30 秒的轮询间隔,能把无效请求量减少一半。上线后观察 Nginx access log,如果 502 和 503 比例超过 1%,先看max_children是不是被打满了,再决定是加机器还是换长连接方案。
3.3 部署阶段高频报错定位
部署阶段的问题大多集中在 runtime 目录权限和 PHP 扩展缺失两块。常见现象与处理方式如下:
| 报错现象 | 原因 | 处理方式 |
|---|---|---|
| 首页正常,接口 404 | Nginx 伪静态规则未生效 | 检查 location / 的 rewrite 后 reload |
| 提示 mbstring 扩展缺失 | PHP 7.3 未安装 mbstring | 安装对应扩展并重启 PHP-FPM |
| 数据库连接失败 | MySQL 5.6 用户名密码或 auth 插件不匹配 | 确认使用 mysql_native_password |
| 登录后立刻退出 | runtime 会话文件写入失败 | 修正 runtime 目录属主与写权限 |
定位时先把 PHP 错误日志打开,入口文件顶部加error_reporting(E_ALL);,再复现一次请求,看具体报错。注意 ThinkPHP 自带调试模式,.env里把app_debug设为 true 能看到完整的调用栈和 SQL 日志,但上线前必须关掉,否则会把表结构、缓存路径暴露给访客。
4. 智能自动回复与前端实时交互的实现
4.1 关键字匹配与自动回复触发链路
智能客服平台在源码层面通常落成一套规则匹配引擎。后台配置自动回复规则后,访客消息进来先走匹配,命中就直接回复;没命中才进入人工坐席队列。触发链路是:消息入库 → 调用匹配服务 → 命中则生成系统回复 → 未命中则进入坐席分配。
public function matchReply($content) { $rules = Db::name('auto_reply') ->where('status', 1) ->order('priority DESC, id ASC') ->select(); foreach ($rules as $rule) { if ($rule['match_type'] == 1 && strpos($content, $rule['keyword']) !== false) { return $rule['reply_content']; } if ($rule['match_type'] == 2 && preg_match('/' . $rule['keyword'] . '/', $content)) { return $rule['reply_content']; } } return ''; }match_type = 1是包含匹配,strpos判断消息里是否出现关键词;match_type = 2是正则匹配,适用于“订单号+退款”这类组合条件。规则表里的priority字段控制多条规则同时命中的优先级,数字越大越先执行。需要注意,正则规则不要直接拼接用户输入作为preg_match模式,后台配置时要做转义校验,否则一个错误的正则可以打挂整个 FPM 进程。自动回复命中后建议在消息表里写入from_type = 2的系统消息,前端渲染时显示为机器人头像,访客感知更自然。
4.2 前端实时通信的轮询与 WebSocket 取舍
网页客服的前端消息拉取有两种主流方案:短轮询和 WebSocket。这套源码默认是轮询,因为 PHP-FPM 是短生命周期模型,跑 WebSocket 需要额外的常驻进程;轮询的代价是 30 秒一次的请求频率在坐席量大的时候会形成固定 QPS 底座。前端实现通常这样写:
function pollMessage(sessionId, lastMsgId) { setInterval(async () => { const res = await fetch('/index.php?s=/chat/poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, last_msg_id: lastMsgId }) }); const data = await res.json(); if (data.list && data.list.length > 0) { renderMessages(data.list); lastMsgId = data.list[data.list.length - 1].id; } }, 30000); }last_msg_id作为游标传给服务端,服务端只返回大于该 ID 的消息,避免重复渲染。轮询接口里要加一个上限,比如一次最多返回 200 条,超出的引导访客刷新页面加载历史消息。两种方案的对比可以简单概括为:
| 对比项 | 轮询方案 | WebSocket 推送 |
|---|---|---|
| 实时性 | 最长延迟一个轮询间隔 | 毫秒级 |
| 服务端占用 | PHP-FPM worker 被请求占用 | 独立常驻进程承载 |
| 部署复杂度 | 直接可用 | 需要进程守护和端口放行 |
| 适用规模 | 坐席数 30 人以内 | 消息量大、延迟敏感 |
4.3 访客会话状态维持与超时回收
多坐席客服场景里,访客关闭浏览器但没点“结束会话”很常见,会话记录会一直占用坐席负载。系统里通常会设定一个空闲超时,比如 10 分钟没有新消息就把会话标记为status = 3的已超时状态,同时释放坐席的current_load。超时回收可以用定时任务也可以惰性检查:轮询接口每次先查update_time,超过阈值就自动关闭会话并返回特定的 code,前端收到后显示“会话已结束”。
超时时间的配置入口一般在应用配置文件的session_timeout参数,调成 600 表示 10 分钟。线上运营时要注意,超时回收不是越短越好,访客可能只是切去看别的页面,回来发现会话被关闭会明显降低满意度。我一般会在前端监听visibilitychange事件,页面重新可见时先调一次状态查询接口,让服务端把会话重新激活。
还有一个产品细节需要想清楚:访客离开页面后又回来,系统是重建会话还是复用旧会话?推荐的做法是保留同一个session_id,只把历史消息重新拉出来,这样访客不用重复描述问题。实现上只需要在访客 token 里带上session_id,服务端做一次状态恢复即可。释放坐席负载的时机要和超时回收一致,否则会出现坐席明明没在聊天、却显示忙碌的假象。
5. 二次开发的扩展点与链路验证技巧
5.1 把轮询升级为主动推送
源码里/chat/poll是轮询入口,升级推送的侵入点有两个:一是消息入库后触发事件,把新消息写入 Redis 的频道;二是增加一个 Workerman 常驻进程订阅频道并推送给前端。PHP 7.3 下实现最小推送服务只需要几条命令:
composer require workerman/workerman php think workerman:start --channel=kefu_msg这里不需要改消息表结构,只把chat_message表的插入操作后面加一次Redis::publish('kefu_msg', json_encode($msg))。坐席端通过 WebSocket 连接 Workerman 端口,收到kefu_msg频道的消息后再校验 session 归属。注意 Workerman 进程要由 supervisor 托管,崩溃后自动拉起,否则推送链路会静默断开。
5.2 验证分配与推送链路是否真正打通
验证多坐席分配是否生效,最直接的方法是用命令行模拟两个访客同时发起会话。先查数据库确认会话分配给了不同的坐席:
SELECT session_id, agent_id, status FROM chat_session WHERE create_time > UNIX_TIMESTAMP() - 60;如果两个会话分给了同一个坐席,检查坐席的online_status和current_load字段是不是没被正确维护。再验证自动回复链路,可以直接在 MySQL 里插入一条带触发词的访客消息,然后调轮询接口看是否返回from_type = 2的系统回复。消息推送延迟用curl测一轮最直接:
curl -X POST http://127.0.0.1/index.php?s=/chat/poll \ -H "Content-Type: application/json" \ -d '{"session_id": 1, "last_msg_id": 0}'本文还有配套的精品资源,点击获取