ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

全开源PHP聚合客服系统:部署、核心链路与二次开发

全开源PHP聚合客服系统:部署、核心链路与二次开发

简介:聚合客服(万能客服)cy163_customerservice 22.0.0 全开源版安装更新一体包,面向需要自建客服系统的中小企业、站长与二次开发者,整合网页在线聊天、工单流转、知识库、智能机器人、数据报表与权限管理等常见客服环节,全部源码开放,便于按业务场景改造与集成。压缩包共269个文件,约2.93MB,以68个html页面模板、30个js脚本、28个php服务端文件、16个css样式及40个png、76个gif界面素材为主,另含字体图标与json、xml配置,基本覆盖前端界面、后端逻辑与静态资源三层结构。已有1061人浏览学习。安装更新一体包省去逐版本打补丁的麻烦,读者可直接查看渠道接入、工单分配、机器人应答等模块的实现方式,也可借助开放的API思路对接CRM、ERP等系统;对想研究客服系统前后端组织、权限设计与多语言支持的开发者,这是一份可直接部署、便于对照调试的完整样本。

1. 从一个真实场景说起:三个后台切到手软

有做电商的小团队跟我吐槽,网页咨询、公众号消息、App 内反馈、邮件工单散在四个后台,客服一天切几十次窗口,漏消息、重复回复、交接靠截图。聚合客服(万能客服)这类系统解决的就是这件事:把多渠道消息收进一个统一工作台,按技能组和坐席负载做分配,配上工单、知识库、访客轨迹和会话统计。这套 22.0.0 是“安装更新一体包”形态,全开源,典型 PHP 技术栈,适合两类人:一类是团队想自建客服中台、不想从零写接入层和坐席端;另一类是已有业务系统,想把在线客服当成一个模块嵌进去做二次开发。下面按“能干什么、怎么跑起来、核心链路怎么走、怎么改”一路拆下去。

2. 技术栈与目录结构:先摸清这套 PHP 客服系统的骨架

拿到一个全开源包,最省时间的做法不是急着点安装,而是先花十分钟把目录和表结构扫一遍。看清楚入口在哪、配置在哪、渠道代码放在哪,后面出问题才知道该翻哪个文件。这套包的结构属于典型的“单入口 + 模块化控制器 + 模板渲染”,没有上重型框架,改起来门槛低,坏处是约定全靠目录名,不看一遍容易改错地方。

2.1 技术选型与运行时依赖

这套系统的运行时组合,绝大多数同类产品都差不多,我按实际部署会遇到的顺序列一下:

组件常见版本说明
PHP7.2 至 7.47.4 最稳,8.x 上部分老函数被移除,容易在安装页白屏
MySQL5.7 / 8.0InnoDB,字符集必须 utf8mb4,否则表情消息入库直接截断
Web 服务Nginx 或 ApacheNginx 要配伪静态,Apache 要有 .htaccess
实时通道Workerman 或 Swoole 常驻进程也有纯轮询的降级方案,取决于包内是否带 ws 服务
缓存Redis(可选)坐席在线状态、排队队列放 Redis 比查库快一个量级

PHP 扩展这块别偷懒,pdo_mysql、mbstring、curl、gd、fileinfo、openssl少一个都可能在某一步炸掉:gd缺了验证码出不来,fileinfo缺了图片上传校验直接报错,openssl缺了对接第三方渠道的 https 请求会失败。这些错误往往不写在安装页上,只在runtime/log里留一行。

提示:安装前先跑一遍php -m,把扩展清单截图存档。以后换服务器迁移,照着清单装,比事后一个个试错快得多。

2.2 目录结构与各层职责

下面这份清单是我按包内常见布局整理的,具体文件名可能略有出入,但分层逻辑基本一致:

路径职责二次开发时的改动频率
public/唯一 Web 入口,index.php在这低,一般只加静态资源
app/controller/后台管理、坐席端、访客端的控制器中,加接口常在这
app/channel/各渠道适配器,网页、公众号、App、邮件高,接新渠道必改
app/service/会话分配、消息落库、统计等业务服务高,路由规则都在这
config/数据库、渠道密钥、系统参数中,注意别提交进版本库
runtime/日志、缓存、模板编译产物低,但要保证可写
public/upload/访客与坐席上传的图片、附件低,注意备份策略
install/安装向导与初始 SQL低,装完建议改名或加锁

关键认知只有一句:Web 根目录必须指向public/,不是项目根目录。指向根目录的后果是config/database.php、install/*.sql这些文件能被浏览器直接下载,属于事故级配置错误。

2.3 会话表设计:先把索引看明白

数据库里最核心的是会话表和消息表,很多“坐席端卡”“工作台加载慢”的问题根子都在索引上。下面是我从包里抽出来的会话表结构,字段做了精简:

CREATE TABLE `cs_session` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `session_no` varchar(32) NOT NULL COMMENT '会话编号,前端展示用', `channel_id` int NOT NULL COMMENT '渠道ID,对应 cs_channel.id', `visitor_id` bigint NOT NULL COMMENT '访客ID', `group_id` int NOT NULL DEFAULT 0 COMMENT '技能组ID', `agent_id` int NOT NULL DEFAULT 0 COMMENT '0 表示排队中未分配', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0排队 1进行中 2已结束', `last_msg_at` int NOT NULL DEFAULT 0 COMMENT '最后一条消息时间戳', PRIMARY KEY (`id`), KEY `idx_agent_status` (`agent_id`, `status`), KEY `idx_group_status` (`group_id`, `status`), KEY `idx_last_msg` (`last_msg_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:agent_id + status这个联合索引是给坐席工作台的“我的进行中会话”列表用的,走一次索引就能出结果;group_id + status给排队池和技能组视图用;last_msg_at单独建索引是为了按最近活跃排序,工作台列表默认就是这个排序。三个索引覆盖了 90% 的查询,剩下的统计类查询一般跑定时任务,不压在实时链路上。

参数说明:status用 tinyint 而不是 enum,是为了以后加“转接中”“机器人接待”这类状态不用改表;last_msg_at存整型时间戳而不是 datetime,排序和比较都更快,也避开了时区转换的坑。

如果你手上有这套包,第一件事就是拿EXPLAIN跑一遍工作台列表的 SQL,看看有没有走全表扫描。我见过不少二次开发版本,被人加了一堆LEFT JOIN查访客资料,索引直接失效,几百个会话就开始转圈。

3. 安装与更新:一体包里的向导、锁文件和增量 SQL 怎么走

“安装更新一体包”的意思是把首次安装和后续版本升级合并在一套程序里,靠版本号判断走哪条分支。省事,但也意味着安装逻辑和升级逻辑共用一批文件,一旦中途失败,状态容易不伦不类。所以这一章我按“环境准备、安装向导、更新机制”三段分开讲,每段都给出失败时该看什么。

3.1 环境准备与权限配置

先把运行环境拉起来,命令不多,但每条都有讲究:

# 1. 版本与扩展自检 php -v # 建议 7.4,别用 8.x 直接上 php -m | grep -E 'pdo_mysql|mbstring|curl|gd|fileinfo|openssl' mysql --version # 2. 代码落地,注意 Web 根目录要指到 public mkdir -p /data/www/cs unzip cy163_customerservice_22.0.0.zip -d /data/www/cs chown -R www:www /data/www/cs # 3. 权限:只放开需要写的目录,其他保持只读 chmod -R 755 /data/www/cs chmod -R 775 /data/www/cs/runtime /data/www/cs/public/upload /data/www/cs/install

命令含义逐条说:php -m | grep那行是为了在安装前就把扩展缺失暴露出来,别等向导跑到一半报“数据库连接失败”才发现是pdo_mysql没装;chown -R www:www里的用户要跟你实际 FPM 运行用户一致,不一致的典型现象是安装页提示“目录不可写”,但你ls -l看权限明明是 777,这就是属主不对;最后一行只给runtime、upload、install三个目录写权限,其余保持只读,减少被植入文件的风险。

Nginx 配置是翻车高发区,完整贴一份:

server { listen 80; server_name cs.example.local; root /data/www/cs/public; # 关键:指向 public,不是项目根 index index.php; client_max_body_size 20m; # 不配这个,后台上传大图直接 413 location / { try_files $uri $uri/ /index.php?s=$uri&$args; # 伪静态,缺了后台菜单全 404 } location ~ \.php$ { fastcgi_pass unix:/run/php/php7.4-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } # 升级脚本、日志、env 一律不对外 location ~* \.(sql|log|lock|env)$ { deny all; } }

try_files这一行是必须的,这套系统用的是?s=/path/to/action形式的伪静态路由,Nginx 不转发的话,首页能开,点进任何菜单都是 404,很容易被误判成“包不完整”。client_max_body_size默认 1m,访客发张手机截图就超了,报的还是 413,前端只显示“发送失败”,排查起来很绕。

3.2 安装向导的实际流程

浏览器访问域名,会跳到install/下的向导,一般是四步:环境检测、数据库配置、管理员账号、完成。这里面有两个点值得注意。

第一步环境检测会检查目录权限和扩展,页面上是绿勾红叉。红叉没处理完别点下一步,否则后面的错误信息会掩盖真正原因。

第二步填数据库信息,常见做法是同时填库名、用户名、密码、前缀。这里的坑是数据库账号权限:只给了SELECT/INSERT/UPDATE/DELETE而没有CREATE,向导在导入初始 SQL 建表时就会失败,报的还是“数据库连接失败”,误导性极强。我一般直接给这个库的全部权限,装完再收窄。

第三步设置管理员账号,密码别用弱口令,这类系统后台地址是公开路径,扫描器天天光顾。

第四步完成后,向导会生成一个install/install.lock之类的锁文件,防止重复安装。这个锁文件的作用要记牢:想重装必须先删它,不删的话再访问安装页会提示“已安装”,很多人卡在这以为程序坏了。

3.3 更新机制:版本比对、增量 SQL 与回滚

一体包的更新逻辑,代码骨架大致长这样:

// 升级入口的伪代码,字段名以实际为准 $pkgVersion = $this->readManifest(); // 包内 manifest 里的版本,如 22.0.0 $localVersion = Db::name('config')->where('key', 'sys_version')->value('value'); if (version_compare($pkgVersion, $localVersion, '<=')) { $this->out("当前已是最新版本 {$localVersion}"); return; } // 1. 先备份:整库导出 + 代码目录打包,没备份不许往下走 $this->backup($localVersion); // 2. 按版本号从小到大执行增量 SQL,一个文件一个事务 foreach ($this->pendingSql($localVersion, $pkgVersion) as $file) { $this->execSqlInTransaction($file); } // 3. 覆盖程序文件,但跳过上传目录和本地配置 $this->copyFiles([ 'skip' => ['public/upload', 'config/database.php', 'config/app.php'], ]); // 4. 清缓存并写入新版本号,最后一步才落版本,保证失败可重试 $this->clearCache(); Db::name('config')->where('key', 'sys_version')->update(['value' => $pkgVersion]);

逻辑说明:version_compare做字符串版本比较,能正确处理22.0.0和22.0.10的大小关系,别自己写explode('.')比大小,容易在两位数版本号上翻车。备份放在最前面,是因为升级失败最常见的场景不是 SQL 报错,而是文件覆盖到一半中断,程序处于“半新半旧”状态,没有备份只能重装。SQL 执行放在文件覆盖之前,是保证老代码配新表结构时至少不会缺字段。

参数说明:skip列表是重点,public/upload里是用户上传的图片,config/database.php是你本地的数据库密码,config/app.php里可能是自定义的渠道密钥,这三样被覆盖等于重置了生产环境。

回滚策略我一般这么定:升级前记下当前版本号,升级失败就还原代码目录 + 导入备份 SQL + 把sys_version改回原值,三步缺一不可。只还原代码不改版本号,下次升级会跳过该版本的增量 SQL,留下永久性结构缺失。

4. 核心链路拆解:一条访客消息从渠道网关到坐席工作台的完整路径

光会装不算会用,真正决定你能不能改的是这条链路:访客在网页上敲一句话,到坐席屏幕上弹出来,中间经过哪些类、哪些表、哪些线程。这条链路拆明白了,加渠道、改分配规则、接自建系统都是顺水推舟。

4.1 渠道接入层:把四种报文压成一种结构

不同渠道进来的数据长得完全不一样:网页端是 JSON,公众号是 XML,邮件是 MIME,App 走的是自定义协议。上层业务不该关心这些差异,所以接入层的第一件事就是归一化。常见做法是抽象一个适配器基类,每个渠道实现自己的解析和渲染:

<?php // app/channel/ChannelAdapter.php abstract class ChannelAdapter { protected int $channelId; public function __construct(int $channelId) { $this->channelId = $channelId; } // 子类负责把渠道原始报文解析成数组 abstract public function parse(array $raw): array; // 子类负责把坐席回复渲染成渠道能懂的格式 abstract public function render(array $message): array; // 统一出口:所有渠道最终都产出这个结构 public function normalize(array $raw): array { $msg = $this->parse($raw); return [ 'channel_id' => $this->channelId, 'visitor_id' => $msg['visitor_id'], 'trace_id' => $msg['trace_id'] ?? md5(uniqid('', true)), 'content' => trim((string) $msg['content']), 'msg_type' => $msg['msg_type'] ?? 'text', // text/image/file/card 'extra' => json_encode($msg['extra'] ?? [], JSON_UNESCAPED_UNICODE), 'created_at' => time(), ]; } }

逻辑说明:normalize()是模板方法,把公共字段拼装固定下来,子类只实现parse()和render()两个钩子。这样新增一个渠道,改动量就是新增一个几十行的类,主流程一行不动。

参数说明:trace_id用来串联同一条消息在各层的日志,排查“消息丢了”时全靠它;msg_type必须限定枚举,不要直接透传渠道自己的类型值,否则前端渲染层要写一堆 if;extra存渠道特有的字段(比如公众号的 OpenID、App 的设备号),用 JSON 存比加列灵活,代价是不能在这个字段上建索引做精确查询。

4.2 会话分配:权重、负载、等待时长三个维度的取舍

分配逻辑是整套系统最容易被业务方提需求的地方——“VIP 优先给老客服”“某人今天只接售后”“新人不接投诉”。核心分配函数一般长这样:

// app/service/DispatchService.php public function pick(array $session): ?int { // 1. 技能组过滤:渠道绑定的组 + 访客来源(比如英文站进英文组) $agents = $this->onlineAgents($session['group_id']); if (empty($agents)) { $this->enqueue($session); // 无人可接,进排队表 return null; } // 2. 剔除已满负荷的坐席 $agents = array_filter($agents, fn($a) => $a['current_load'] < $a['max_load']); if (empty($agents)) { $this->enqueue($session); return null; } // 3. 三级排序:权重优先,其次当前负载,最后看上次接单时间 usort($agents, function ($a, $b) { if ($a['weight'] !== $b['weight']) { return $b['weight'] <=> $a['weight']; } if ($a['current_load'] !== $b['current_load']) { return $a['current_load'] <=> $b['current_load']; } return $a['last_accept_at'] <=> $b['last_accept_at']; // 早接单的优先,避免冷落 }); return (int) $agents[0]['id']; }

逻辑说明:先过滤再排序,不要先排序再逐个 try,后者在坐席多的时候是一次 N 次循环。enqueue()走的是排队表加定时重试,常见做法是每 5 秒扫一次排队池,有新坐席上线就批量分配。第三级排序用last_accept_at升序,是为了让长时间没接单的坐席排前面,否则权重和负载都一样时会一直落在同一个人身上,血泪经验。

参数说明:weight是坐席权重,后台可配,值越大越优先;max_load是最大同时接待数,网页客服一般 5 到 8,工单类可以到 20;current_load建议实时从 Redis 计数器读,每次会话状态变更时增减,别用COUNT(*)现查,坐席一多就是慢查询。

有一段容易忽略:分配成功后要立刻把会话状态从“排队”改成“进行中”并写agent_id,这一步和读取在线坐席之间要加锁或走原子操作,否则两个并发的访客请求可能选中同一个坐席的最后一个空位,坐席端瞬间弹两个会话,体验很差。

4.3 消息推送:WebSocket 客户端与心跳重连

坐席端要实时收到新消息,常见做法是 WebSocket 常驻进程,配一套轮询兜底。前端代码一般是这样:

// public/static/js/agent/socket.js const scheme = location.protocol === 'https:' ? 'wss://' : 'ws://'; const ws = new WebSocket(scheme + location.host + '/ws?token=' + TOKEN); let heartbeat = null; ws.onopen = () => { // 30 秒一次心跳,服务端超过 90 秒没收到就踢掉,防止半开连接堆积 heartbeat = setInterval(() => ws.send(JSON.stringify({ cmd: 'ping' })), 30000); }; ws.onmessage = (ev) => { const pkt = JSON.parse(ev.data); switch (pkt.cmd) { case 'push': // 新消息到达 store.appendMessage(pkt.data); unread.bump(pkt.data.session_id); // 未读数 +1 break; case 'assign': // 会话被分配给自己 store.addSession(pkt.data); break; case 'kick': // 同账号在别处登录 ws.close(); break; } }; ws.onclose = () => { clearInterval(heartbeat); // 指数退避重连,别固定 1 秒,服务端重启时会被打爆 setTimeout(connect, Math.min(30000, 1000 * (++retry))); };

逻辑说明:onmessage只做分发,不写业务,真正的渲染交给store和视图层,这样以后加一个notice之类的指令不用改结构。kick指令是必需的,客服账号多人共用很常见,不踢的话同一会话会被两个人回复。

参数说明:心跳间隔 30 秒、超时 90 秒是经验值,间隔太短会白白耗连接数,太长则服务端感知不到掉线。重连用指数退避,上限 30 秒,避免服务端重启瞬间几百个坐席同时握手。

Nginx 反代 WebSocket 必须带这两个头,不带的话握手直接 400:

location /ws { proxy_pass http://127.0.0.1:8282; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 120s; }

4.4 坐席工作台的渲染取舍

工作台左侧是会话列表,右侧是消息流,常见实现是列表按last_msg_at倒序,每条只加载最后一条消息摘要,点进去再拉完整历史。历史消息分页拉,别一次性全查,一个老会话几千条消息,全渲染浏览器直接卡死。

未读数这块我一般不走轮询查库,而是靠 4.3 的推送在内存里维护,页面刷新时再调一次接口拿全量。这样工作台的接口 QPS 能降一个数量级,坐席规模上去之后差别很明显。

5. 避坑与排查:部署上线前后最容易翻车的 5 个点

下面这五条都是我在类似系统上真实撞过的,按发生频率排序。

第一,首页正常,点任何菜单都 404。现象是安装完成跳转到后台登录页正常,登录后左侧菜单点一个 404 一个。原因是 Nginx 没配伪静态,或者root指到了项目根目录。解决:root改成public,加try_files $uri $uri/ /index.php?s=$uri&$args;,重载 Nginx。判断方法很直接,看一眼 404 页面的样式是不是你系统自己的,如果是系统内置的 404 页,说明请求到了 PHP 只是路由没匹配,那就纯是伪静态问题。

第二,安装向导第二步报“数据库连接失败”,但命令行能连。原因通常有三种:扩展没装(pdo_mysql)、账号没有建表权限、或者config/目录不可写导致配置写不进去。解决:先php -m确认扩展,再给库账号全权限,最后检查config/属主跟 FPM 用户是否一致。这一类问题八成是权限,别看错误信息写的是“连接失败”就往网络上查。

第三,消息发出了,坐席端不弹。现象是访客侧提示发送成功,数据库里也有记录,坐席端就是没反应。原因大概率是 WebSocket 反代没转发Upgrade头,或者防火墙没放行常驻进程的端口。解决:补齐第 4 章那两行proxy_set_header,然后用浏览器开发者工具的 WS 面板看握手返回码,400/426 基本都是头的问题。

第四,坐席在线状态乱跳,明明在线显示离线。原因是服务端和 MySQL 时区不一致,last_accept_at、last_heartbeat这些时间戳比较错乱。解决:PHP 里统一date_default_timezone_set('Asia/Shanghai'),MySQL 也设成同一时区,重连一次让心跳重新写入。这个坑很玄学,改完你会发现问题“自己好了”,其实是时间对齐了。

第五,升级后白屏或后台 500。常见原因是增量 SQL 没跑完、模板编译缓存没清、或者本地config被覆盖后密钥对不上。解决:先看runtime/log当天的错误日志,再手动删掉runtime/cache和模板编译目录,最后核对sys_version是否已更新。如果还是不行,别硬扛,直接走 3.3 的回滚三步。

注意:每次升级前把runtime/log清空再操作,日志里只留本次的错误,排查效率完全不一样。

6. 二次开发:加一个渠道适配器,把客服嵌进自己的业务系统

真正落地时,需求往往不是改界面,而是“我们自己的 App 要接进来”和“访客身份要跟我们的用户体系对上”。这两件事都落在渠道适配器上,改造成本比想象中低。

第一步,在渠道表里插一条记录,拿到channel_id。第二步,新建一个继承ChannelAdapter的类,实现parse()和render()。第三步,在渠道工厂里注册这个类,通常是按channel_id映射类名的一个数组。第四步,前端页面引入访客端 SDK,把visitor_id和签名带过去。

一个最小可用的自建 App 适配器大概是这样:

<?php // app/channel/AppChannel.php class AppChannel extends ChannelAdapter { public function parse(array $raw): array { // App 走的是我们自己的签名协议,raw 里带 uid、device、content if (!$this->verifySign($raw)) { throw new RuntimeException('签名校验失败'); } return [ 'visitor_id' => (int) $raw['uid'], // 直接复用业务系统的用户ID 'content' => $raw['content'], 'msg_type' => $raw['type'] ?? 'text', 'extra' => ['device' => $raw['device'] ?? '', 'app_ver' => $raw['app_ver'] ?? ''], ]; } public function render(array $message): array { // 坐席回复推回 App,用长连接通道下发 return [ 'cmd' => 'cs_reply', 'uid' => $message['visitor_id'], 'content' => $message['content'], 'time' => $message['created_at'], ]; } private function verifySign(array $raw): bool { $secret = config('channel.app_secret'); $sign = md5($raw['uid'] . $raw['ts'] . $secret); return hash_equals($sign, $raw['sign'] ?? '') && abs(time() - $raw['ts']) < 300; } }

这里有两个设计取舍值得说。一是visitor_id直接用业务系统的用户 ID,不要另起一套访客表再映射,否则后面查“这个用户历史咨询过几次”要跨库 join。二是verifySign里加了 300 秒的时间窗,防重放,签名用hash_equals做定时攻击安全的比较,这是我在别处踩过坑之后固定下来的写法。

访客身份打通之后,坐席工作台右侧的“访客资料”面板就有内容了。常见做法是提供一个getVisitorProfile($visitorId)的回调,返回订单数、会员等级这类字段,模板里渲染成卡片。这里别做成同步远程调用,业务系统一慢,整个工作台都跟着卡,我一般加一层本地缓存,缓存时间设 5 分钟,够用。

改造点涉及文件工作量风险
新增 App 渠道app/channel/新增一个类 + 渠道表插一条半天低,不影响既有渠道
访客资料打通工作台模板 + 一个回调接口一天中,注意加缓存和超时
分配规则定制DispatchService::pick半天中,改完要用并发压一次
单点登录对接后台登录控制器一天高,别把原有登录逻辑删了

最后提一个验证习惯:改完分配逻辑,别只用一两个坐席试。写个脚本模拟 50 个访客同时进来,看会话是不是均匀落在各个坐席上、排队池有没有正确出入队、Redis 计数器有没有归零。我早年图省事,改完分配规则直接上线,结果负载计数漏减,跑了两小时所有坐席都被判成满负荷,新会话全进排队池,客服打电话来问系统是不是挂了。从那以后我每次动分配相关的代码,都强制走一遍并发模拟加计数器核对,宁可多花二十分钟,也别在业务高峰当黑匣子。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表