简介:一份完整的商业版Workerman在线客服系统源码及搭建教程,面向需要快速部署在线客服能力的企业开发者与PHP工程师。系统采用常驻内存架构并基于模块化设计,具备一键生成、响应式布局、灵活子级权限分配等特性,同时整合会员与API统一验证体系,并提供可在线安装卸载的应用市场,适合二次开发或直接投入生产环境。资源共2000个文件,以JavaScript脚本、HTML页面与CSS样式等前端代码为主,辅以JSON配置、Markdown文档、PHP服务端源码及SQL数据库脚本,压缩包约19MB,内容结构非常清晰,目录划分明确,便于按需检索与二次开发。已有204人学习下载。配套搭建教程覆盖Nginx 1.21.4、PHP 7.2与MySQL 5.7.40环境配置,可帮助读者快速跑通系统,并根据业务场景扩展云存储、云短信、富文本编辑器等功能模块。
1. Workerman 在线客服系统:为什么 PHP 常驻进程才是实时客服的地基
第一次把商业版 Workerman 客服系统跑起来的人,通常都会发现它和传统 LNMP 项目的启动方式完全不同:PHP 不在 Nginx 里通过 php-fpm 处理请求后退出,而是在 CLI 模式常驻内存,自己监听 WebSocket 端口,把会话推送、在线状态、消息路由全部接管。这个源码包的后台搭建在 FastAdmin 模块化结构上,权限、会员、API、应用市场这些企业客服高频需求都能直接在后台配置,实时层则交给 Workerman 系列组件处理。对想私有化部署客服平台、或者在现有 PHP 项目里补长连接能力的团队来说,这套源码的价值在于把「后台管理」和「长连接服务」做进了同一个工程,搭建时不需要跨多个语言栈维护。
2. 搭建环境:Workerman 客服系统对 Nginx、PHP CLI、MySQL 的边界要求
源码里的数据库脚本和配置基准是按 Nginx 1.21.4、PHP 7.2、MySQL 5.7.40 写入的。如果把 PHP 直接换到 8.x,Workerman 本身不会有太大问题,但 FastAdmin 后台里很多旧插件会出现 deprecated 提示,应用市场组件也未必兼容。所以先按这个组合把环境复现出来,跑通后再考虑升级。
2.1 PHP 扩展:pcntl、posix、redis 决定常驻进程能不能启动
Workerman 的进程管理依赖pcntl_fork,信号处理依赖posix,这两个扩展在 LNMP 默认 php-fpm 安装里经常被砍掉。拿到源码后第一时间检查php -m,缺少扩展时按当前 PHP 版本安装:
# CentOS 7 + Remi 源示例 yum install -y php72-php-cli php72-php-pdo php72-php-mysqlnd \ php72-php-mbstring php72-php-bcmath php72-php-pcntl \ php72-php-posix php72-php-redis php72-php-json如果不是通过包管理器安装的 PHP,可以进入源码目录重新编译,在./configure时加上--enable-pcntl --enable-posix。注意 Workerman 必须在 CLI 模式下运行,所以php-cli必须存在;php-redis不是 Workerman 的硬依赖,但这个客服源码的会话缓存、在线状态、消息队列都会读 Redis,建议一并装上,否则启动后大量 DB 查询会把 MySQL 拖垮。
PHP 的常驻进程没有请求生命周期,不能沿用 Web 的max_execution_time。调整php.ini里以下参数:
memory_limit = 512M max_execution_time = 0 max_input_time = 0max_execution_time = 0表示脚本不因超时退出,这是长连接服务的基本要求。还有一个小细节:opcache.enable_cli如果为 0,常驻进程每次 reload 都要重新编译 PHP 文件,启动速度会明显变慢,至少在本地环境打开它。
2.2 Nginx 反代:WebSocket 升级必须保留 Upgrade 头
客户端在前台页面通过ws://连接,你的服务器如果只暴露 80/443 端口,Nginx 需要把 WebSocket 流量反代到 Workerman 监听的 TCP 端口。常见配置是把/socket.io/或者/ws/路径转发到127.0.0.1:7272:
location /socket.io/ { proxy_pass http://127.0.0.1:7272; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; }关键在proxy_set_header Upgrade和Connection "upgrade"。浏览器发起 WebSocket 握手时会带Upgrade: websocket,Nginx 只有把这两个头原样转给后端,Workerman 才能完成 101 协议切换;缺少其中任意一个,前端会一直停在“连接中”,或者握手成功后几秒内断开。proxy_read_timeout 86400是防止 Nginx 按默认 60 秒杀掉空闲长连接。这里有一个容易忽略的对应关系:Nginx 的超时时间必须大于 Gateway 的pingInterval + pingNotResponseLimit之和,也就是至少两个心跳周期,否则服务端还在正常踢空闲连接,Nginx 先动手断开。
需要 WSS 时,把证书配置放在刚才的 server 块中,在listen 443 ssl;下加同样的 location。证书路径、HTTP/2 这些不是客服系统特有的,不展开。
2.3 MySQL 初始化:utf8mb4 和长连接超时
进入源码包先找到 SQL 文件,一般叫install.sql或kefu.sql。MySQL 端准备数据库和账号:
CREATE DATABASE `kefu` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER 'kefu'@'localhost' IDENTIFIED BY 'kefu_password'; GRANT ALL PRIVILEGES ON kefu.* TO 'kefu'@'localhost'; FLUSH PRIVILEGES;导入时看报错。如果出现 1366 字符集错误,说明 SQL 文件本身不是 utf8mb4 编码,或者 MySQL 服务端默认字符集不是 utf8mb4,在my.cnf的[mysqld]段加上character_set_server=utf8mb4。导入命令:
mysql -h127.0.0.1 -ukefu -pkefu_password kefu < kefu.sql长连接场景下,MySQL 的wait_timeout默认 8 小时,Workerman 进程跨天运行后连接会被 MySQL 服务端回收,触发SQLSTATE[HY000] General error: 2006 MySQL server has gone away。可以调大超时,但更建议用框架的断线重连机制。配置参考:
[mysqld] max_connections = 512 wait_timeout = 31536000 interactive_timeout = 31536000wait_timeout调成一年后,空闲连接不会被服务端回收,但要注意连接数:每个 BusinessWorker 进程默认持有自己的 MySQL 连接,按count=4计算,连接数压力不大。如果应用市场里装了很多组件,有些组件会自己创建连接池,这时候max_connections适当调高。
下面把环境基准整理成一张表,方便后续排错对照:
| 参数 | 配置位置 | 推荐值 | 作用 |
|---|---|---|---|
max_execution_time | php.ini | 0 | 防止常驻 PHP 进程超时退出 |
proxy_read_timeout | Nginx location | 86400 | 等待后端响应超时,必须大于心跳周期 |
pingInterval | start_gateway.php | 55 秒 | Gateway 发送心跳的间隔 |
pingNotResponseLimit | start_gateway.php | 2 | 连续 N 次无响应则断开客户端 |
wait_timeout | my.cnf | 31536000 | 防止 MySQL 主动回收空闲连接 |
3. 源码拆解:权限、会员/API、一键生成在客服系统里的实际落点
这一层对应的是后台管理部分。FastAdmin 的模块化思路是把每个业务域拆成控制器、模型、视图三层,权限和会员是公共基础,一键生成负责把表结构快速变成可维护的后台页面。
3.1 多级权限与同账号多组:auth_group 的表关系
“一个管理员同时属于多个组别”这个需求,在源码里由三张表完成:fa_admin存管理员账号,fa_auth_group存组别和规则,fa_auth_group_access做多对多关联。
CREATE TABLE `fa_auth_group` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `pid` int(10) unsigned NOT NULL DEFAULT '0', `name` varchar(100) NOT NULL, `rules` text NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `fa_auth_group_access` ( `admin_id` int(10) unsigned NOT NULL, `group_id` int(10) unsigned NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;pid让组别形成树形结构,子级组别自动继承父级的rules。rules字段存的是权限规则 ID 的逗号分隔字符串,规则 ID 来自fa_auth_rule表。后台分配子级权限时,直接修改rules并清理缓存,不需要动表结构。
读取一个管理员的权限时,先查他所属的所有组别,再合并组别里的规则 ID,最后查规则表:
$adminId = 1; $groupIds = Db::name('auth_group_access') ->where('admin_id', $adminId) ->column('group_id'); $ruleLines = Db::name('auth_group') ->whereIn('id', $groupIds) ->column('rules'); $ruleSet = []; foreach ($ruleLines as $line) { $ruleSet = array_merge($ruleSet, explode(',', $line)); } $ruleSet = array_unique($ruleSet); $menuList = Db::name('auth_rule') ->whereIn('id', $ruleSet) ->where('status', 1) ->order('weigh desc') ->select();fa_auth_group_access不设主键,靠admin_id和group_id联合唯一索引保证不重复。这个设计在源码里被封装成Auth类,里面有缓存逻辑,实际生产环境不建议每次请求都直接查三张表。另外注意:编辑组别时不要删掉当前管理员所属组的rules,否则那个管理员的后台菜单会瞬间消失,这种权限事故很隐蔽。
3.2 会员与 API 共用账号:轻量 Token 验证
前端会员中心会话验证和 API 接口验证共用同一套账号体系,后台通过type区分:type=admin走管理员表,type=user走会员表。登录成功后会生成一个 token,Web 端写入 cookie,API 端要求请求头带Authorization: Bearer <token>。
简易实现可以这样写:
public function makeToken($uid) { $header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); $payload = base64_encode(json_encode([ 'uid' => $uid, 'iat' => time(), 'exp' => time() + 24 * 3600, ])); $key = config('site.kefu_token_key'); $signature = hash_hmac('sha256', "$header.$payload", $key); return "$header.$payload.$signature"; }这里不是完整 JWT,但思路一致:签名用 HMAC-SHA256,密钥放在站点配置项里,exp控制过期时间。验证端解析 payload,判断exp是否在当前时间之前。注意 base64 生成的字符串可能包含+和/,放进 URL 时要进行 urlencode,否则 API 网关可能把请求路径截断。
这个客服系统把会员中心和客服坐席放在同一个user类型下,区别在profile里加了一个role字段:customer或agent。这样做的好处是客服和访客在 WebSocket 层使用同一套 UID 映射,不需要单独维护客服身份表。
3.3 一键生成:将客服话术表变成后台 CRUD
一键生成基于 FastAdmin 的crud命令,它的能力是把 MySQL 表结构反向生成控制器、模型、视图、JS 和菜单权限节点。客服系统里最常见的话术管理、常见问题、工单分类,都可以先用这个命令铺底。
cd /www/wwwroot/kefu php think crud -t kefu_word -c KefuWord -i id,title,content,category_id,sort,status -u 1-t指定表名,-c是控制器名,-i是参与生成的字段列表,-u 1表示生成后自动写入菜单和权限节点。执行完,application/admin/controller/KefuWord.php、view/kefu_word/以及public/assets/js/backend/kefuword.js都会被创建,后台菜单栏直接出现“话术管理”。
常用参数含义:
| 参数 | 作用 | 本场景建议 |
|---|---|---|
-t | 数据库表名 | kefu_word |
-c | 控制器名称 | KefuWord |
-i | 参与生成的字段 | 只保留需要维护的字段 |
-u | 是否生成权限节点和菜单 | 1 表示是 |
-d | 软删除字段名 | 有deletetime时传入 |
生成后要二次调整的是数据权限。默认index返回全表数据,客服坐席应该只能看到自己的话术,需要在控制器 index 方法里追加过滤条件:
$where['admin_id'] = $this->auth->id;同时把admin_id字段加到表结构里。这个改动必须放在生成之后做,否则再次执行 crud 命令会把修改覆盖掉。一键生成解决的是重复性 CRUD,业务过滤、消息推送、状态流转这些还是要手写。
4. 实时会话实现:Workerman 事件回调、uid 绑定与消息落库
客服系统的实时层是 Workerman,源码里通常包含 GatewayWorker 的启动文件:start_gateway.php、start_business.php、start_register.php。这里按 GatewayWorker 的事件回调模型拆解消息链路。它的核心是三个事件:onConnect建立连接、onMessage处理业务消息、onClose释放连接。
4.1 事件回调边界:onConnect、onMessage、onClose 各管什么
use GatewayWorker\Gateway; class Events { public static function onConnect($client_id) { // 连接建立,此时还没有用户身份 Gateway::sendToClient($client_id, json_encode([ 'type' => 'welcome', 'msg' => 'please auth' ])); } public static function onMessage($client_id, $message) { $data = json_decode($message, true); if (json_last_error() !== JSON_ERROR_NONE) { return; } switch ($data['type']) { case 'auth': Gateway::bindUid($client_id, $data['uid']); break; case 'chat': self::chat($client_id, $data); break; } } public static function onClose($client_id) { $session = Gateway::getSession($client_id); if (!empty($session['uid'])) { Gateway::sendToUid($session['uid'], json_encode(['type' => 'offline'])); } } }onConnect只做握手成功后的欢迎消息,不能在这里直接写业务逻辑,因为这时候连接还没有绑定任何用户身份。onMessage是业务入口,按type字段分发到不同处理函数。auth事件里调用的Gateway::bindUid是关键:它会把这个$client_id和用户 ID 绑定,之后服务端就能用sendToUid($uid, ...)给指定用户推送。onClose里先读 session 再广播离线,不要把$_SESSION当成所有场景都可靠,调用Gateway::getSession更稳。
4.2 bindUid 与会话绑定:把连接映射到客服和用户
bindUid之后的 session 数据存在 Gateway 进程内存中,重启便消失,所以只适合存放会话临时态,例如当前连接的用户 ID、登录时间。把在线状态和未读消息放到 Redis 里,避免服务重启后丢失。
private static function bindUser($client_id, $uid) { Gateway::bindUid($client_id, $uid); Gateway::setSession($client_id, [ 'uid' => $uid, 'client_id' => $client_id, 'type' => 'user', 'logined' => time(), ]); // 广播在线人数,频率要控制 $onlineCount = count(Gateway::getAllUidList()); Gateway::sendToAll(json_encode([ 'type' => 'online_count', 'count' => $onlineCount ])); }这里注意setSession的第三个参数是 session 数组,会覆盖之前写入的数据;如果要保留历史字段,先getSession再合并。同一个 UID 从新设备登录时,GatewayWorker 默认不会踢掉旧连接,需要业务层处理,否则一个客服账号在两台电脑登录,消息会推送到旧连接上,用户感知不到。
一个实用的做法是维护uid => client_id的映射,当同一个 UID 再次 bind 时,主动Gateway::closeClient旧连接。这个逻辑建议放在auth事件之前。
4.3 消息落库与推送顺序:先写库还是先推送
客服消息必须能重放,所以消息要先落库,再推送。客户端收到消息后如果发现msg_id已经存在,自动去重,这样即使服务端重试推了两次,界面也不会出现重复气泡。
private static function chat($client_id, $data) { $msgId = Db::name('kefu_message')->insertGetId([ 'conversation_id' => $data['conversation_id'], 'from_uid' => $data['from_uid'], 'to_uid' => $data['to_uid'], 'content' => $data['content'], 'createtime' => time(), ]); $data['msg_id'] = $msgId; Gateway::sendToUid($data['to_uid'], json_encode($data)); }先写数据库再推送的缺点是多一次数据库事务开销,在客服场景下完全可接受。高并发下可以把插入操作丢给订阅的异步队列,但从零搭建时建议保持简单。conversation_id的生成规则一般是:用户 ID 和客服 ID 先排序,再用min(uid)_max(uid)拼接,确保同一对会话的conversation_id稳定。
事件对整个连接生命周期的影响可以归纳成下面这张表:
| 事件 | 触发时机 | 常用动作 | 注意点 |
|---|---|---|---|
onConnect | TCP/WebSocket 握手完成 | 发送欢迎消息 | 不要在这里查询 DB |
onMessage | 收到客户端消息 | 业务分发、消息落库 | 校验 JSON 格式与字段完整性 |
onClose | 客户端断开或超时 | 清理 session、广播离线 | 不保证一定收到 |
onWorkerStart | BusinessWorker 进程启动 | 初始化 DB/Redis 连接 | 配合worker->id判断是否重复初始化 |
4.4 Gateway 进程参数:count、pingInterval 与 startPort 的配合
start_gateway.php里的参数决定了实际吞吐能力。拿到源码后建议先做一次本机压测,再调整进程数。
use Workerman\Worker; use GatewayWorker\Gateway; use GatewayWorker\Register; $register = new Register('text://0.0.0.0:1238'); $gateway = new Gateway("websocket://0.0.0.0:7272"); $gateway->name = 'KefuGateway'; $gateway->count = 4; $gateway->lanIp = '127.0.0.1'; $gateway->startPort = 2900; $gateway->pingInterval = 55; $gateway->pingNotResponseLimit = 2; $gateway->registerAddress = '127.0.0.1:1238';count是 Gateway 进程数,不是连接数上限;startPort是给内部通信用的端口区间起始值,每增加一个 Gateway 进程,内部通信会占用相邻一个端口,所以count=4时startPort=2900会占 2900、2901、2902、2903。lanIp如果是单机部署就用127.0.0.1,多机部署要换成内网 IP,否则 BusinessWorker 和 Gateway 无法互相发现。pingInterval=55配合pingNotResponseLimit=2,意味着 Gateway 在 110 秒内没有收到客户端的任何数据包,就主动断开该连接。
客户端 JS 侧的心跳也要对得上,我一般让浏览器每隔 50 秒发送一次ping帧,服务端收到后回pong。Nginx 的proxy_read_timeout设成 86400 秒,不会干扰这个节奏。
5. 进阶排错:心跳、连接数、应用市场扩展的现场调优
5.1 心跳参数与超时链路的一致性检查
遇到“连接几秒后自动断开”的问题,先按 心跳包 → Gateway → Nginx → 浏览器 这条链路排查。Gateway 心跳是服务端主动发 ping,客户端如果实现了onmessage但没有回 pong,Gateway 的onClose会在两个周期后被触发。这时日志里会出现类似client close in 110s的记录。
| 故障现象 | 常见原因 | 检查方式 |
|---|---|---|
| 握手成功,60 秒左右断开 | Nginxproxy_read_timeout过短 | 查看 Nginx error.log |
| 110–120 秒断开 | 客户端没有回 pong | 在浏览器 Network 面板观察 WebSocket 帧 |
| 偶发连接 ID 冲突 | 同一个 UID 从多个设备登录 | 在auth事件中主动关闭旧连接 |
| 消息推送到错误目标 | Gateway 与 BusinessWorker 网络不通 | 检查lanIp是否为内网地址 |
| 数据库连接丢失 | MySQLwait_timeout过短 | 查看 error.log 里的MySQL server has gone away |
修改参数后必须重启 Workerman,不要用reload,因为start_gateway.php里的进程模型变更需要重新 fork:
php think workerman restart5.2 应用市场扩展安装后的缓存清理
源码自带的应用市场把云存储、云短信、富文本编辑器这类组件做成了可安装插件。安装过程会写数据库配置,并往composer.json里追加 autoload 依赖。安装完成后如果后台功能没有按预期生效,先清掉 runtime 缓存,再重启 Workerman:
php think clear php think workerman restart如果某个插件和 GatewayWorker 的自动加载冲突,优先检查插件是否依赖 ThinkPHP 5.0 的老命名空间。这种情况我会先手动删除runtime目录下的类映射文件,再观察php think workerman start的启动日志,插件扩展一般不直接写在 events 回调里,所以在不开启调试模式时,问题会被框架缓存掩盖。
本文还有配套的精品资源,点击获取