简介:一款基于PHP的辰光客服系统全开源源码,面向多商户环境,适合需要自建客服平台或对客服系统做深度二次开发的PHP开发者,也适合作为PHP项目架构学习的实战样本。该系统覆盖用户端实时聊天、商户独立管理、客服工作台、API接口对接、日志统计等模块,并包含数据加密、防SQL注入、XSS攻击防护等安全设计,源码完全开放,可按实际业务灵活扩展。压缩包共约2000个文件,大小25.07MB,以PHP程序文件、JavaScript脚本、HTML页面、CSS样式表为主,辅以SQL数据库初始化脚本、JSON配置、Markdown说明文档及少量Shell工具脚本,能够支撑前端交互、后端逻辑、数据库部署与自动化配置。目前已有291人学习/下载,适合对多商户客服业务有需求的开发者直接部署使用,也可通过阅读源码理解客服系统常见的并发处理、会话管理与权限控制思路,并在此基础上增加第三方系统集成或移动端适配。
1. 基于PHP的辰光客服系统,为什么还要聊多商户拆分?
一个压缩包放到面前,目录里大概率是 application、public、sql、Dockerfile,第一次打开的人容易把它当成普通单站客服:一个后台、一排客服、一个聊天窗口。但看到“多商户”这三个字,思路就要立刻切换。这不是给一家公司用的在线客服,而是一套让多个商家共用代码、每个商家都有自己的客服和访客会话的 SaaS 形态系统。对做外包或准备在此基础上接二手项目的人来说,最有价值的不是登录页长什么样,而是商户维度如何在数据库、权限、消息推送三层贯穿始终。下面直接从部署和二次开发的角度拆这套基于 PHP 的辰光PHP客服源码,讲透数据模型、队列消费和多商户隔离的落地姿势。
2. 多商户客服的数据模型:tenant_id、会话状态和客服账号边界
2.1 三张表定生死:merchant、kefu、session
凡是带“多商户”的 PHP 客服项目,最先翻的一定是数据库结构。常见做法是把商户表作为顶层租户,客服表通过 merchant_id 归属商户,会话表承载访客与客服的每次沟通。很多二次开发的翻车不是死在聊天逻辑,而是死在一开始没搞清楚“哪些表有 merchant_id,哪些表被认为是全局表”。
我一般会用下面这套表结构做基线,实际项目里可以按需加字段,但边界不要乱移:
CREATE TABLE `merchant` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `merchant_name` varchar(100) NOT NULL COMMENT '商户名称', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `expire_time` int NOT NULL DEFAULT '0' COMMENT '到期时间戳', `created_at` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户表'; CREATE TABLE `kefu` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `merchant_id` int unsigned NOT NULL COMMENT '所属商户', `username` varchar(50) NOT NULL, `real_name` varchar(50) NOT NULL DEFAULT '', `avatar` varchar(255) NOT NULL DEFAULT '', `max_concurrent` tinyint NOT NULL DEFAULT '5' COMMENT '最大同时接待会话数', `online_status` tinyint NOT NULL DEFAULT '0' COMMENT '0离线 1在线', `password_hash` varchar(255) NOT NULL, PRIMARY KEY (`id`), KEY `idx_merchant_status` (`merchant_id`, `online_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服账号表'; CREATE TABLE `chat_session` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `merchant_id` int unsigned NOT NULL, `kefu_id` int unsigned NOT NULL DEFAULT '0' COMMENT '0表示未分配', `visitor_id` varchar(64) NOT NULL COMMENT '访客唯一标识', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0等待分配 1进行中 2已关闭', `channel` varchar(20) NOT NULL DEFAULT 'web' COMMENT '来源:web h5 app 等', `last_message_at` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_merchant_status` (`merchant_id`, `status`, `kefu_id`), KEY `idx_visitor` (`visitor_id`, `merchant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话表';这套设计的核心是三条:商户表没有 parent_id,因为当前架构只做两层;客服表带 max_concurrent,把“能挤进多少会话”从写死在代码里改为可配置;会话表用 visitor_id 而非自增用户 ID,是因为访客在未登录场景下只能靠浏览器埋点生成唯一 ID。chat_session 的状态字段是后面分配和统计的基础,0 表示排队、1 表示被客服接管、2 表示关闭,比字符串状态更省空间,但代价是代码里要维护常量。
2.2 会话状态机不能只在 SQL 里改
很多 PHP 客服项目翻数据库文档时会看到 status 字段注释,但代码里直接写save(['status' => 2])。一旦有人连续点击“关闭按钮”,状态就可能在短时间内从 1 变成 2 再变成 2,虽然结果一样,但操作日志里会留下抖动。更稳妥的办法是给状态迁移做一层校验,比如专门写一个 SessionService。
class SessionService { const WAITING = 0; const ACTIVE = 1; const CLOSED = 2; private array $allowedTransitions = [ self::WAITING => [self::ACTIVE, self::CLOSED], self::ACTIVE => [self::CLOSED], self::CLOSED => [self::ACTIVE], // 允许重新打开 ]; public function transition(int $current, int $next, int $merchantId, int $sessionId): bool { if ($current === $next) return true; if (!in_array($next, $this->allowedTransitions[$current] ?? [], true)) { return false; } // 更新条件必须带 merchant_id,防止跨商户改状态 $affected = db()->exec( 'UPDATE chat_session SET status = ? WHERE id = ? AND merchant_id = ?', [$next, $sessionId, $merchantId] ); return $affected > 0; } }注意$allowedTransitions是个二维数组,它把“谁可以变成谁”收敛在一个地方。常见误用是只判断$next是否合法,不判断$current,于是会出现“等待中的会话直接变成已关闭后再被误操作重新打开”这类状态漂移。这里每个状态变化都要求带 merchant_id,就是防止一个商户的客服更新到另一个商户的会话。多商户系统里的安全问题大部分不是溢出或注入,而是行级权限缺了条件。
2.3 权限校验放在控制器还是服务层
源码里如果看到控制器里到处写where(['merchant_id' => session('merchant_id')]),这是个危险信号。比较稳的做法是封装一个TenantScope,在查询构造器层面统一追加商户条件,这样控制器代码里看不到 merchant_id 也能保证隔离。常见做法是在 Model 的booted方法里加全局作用域,ThinkPHP 和 Laravel 都有对应机制。
我在按这套思路重构时,会做两件事:第一,所有与商户相关的模型都实现TenantModelInterface;第二,模型事件里强制给新增数据补 merchant_id,改数据时强制给 update 条件补 merchant_id。这样即使某个人后来在控制器里忘了写,SQL 也不会跨商户。隔离做得稳不稳,不是看登录鉴权多复杂,而是看数据库访问层有没有兜底。下面是一段可控的查询校验原则:
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
| 查客服列表 | 只按 kefu 表的 merchant_id 分页 | 先校验该商户 ID 是否有效,再在库层加租户条件 |
| 查历史消息 | 只按 session_id 查 | 必须 join chat_session 并带上 merchant_id 条件 |
| 关闭会话 | 只更新 chat_session.status | 更新条件同时限制 id 和 merchant_id |
| 统计面板 | 查所有会话再按商户分组 | 直接按 merchant_id 聚合,并限制到当前商户 |
表格里的四个场景覆盖了客服系统 80% 的数据访问路径。实际生产里最容易漏的是历史消息查询,因为消息表往往只设计了 session_id,开发时顺手就按 session_id 去查了,结果两个商户的数据在 PHP 层没有做二次过滤,等访客投诉“看到别人的聊天记录”时,日志里往往已经积累了成百上千次越权查询。
3. 从源码包到可运行实例:辰光PHP客服的环境准备与队列启动
3.1 解压后先看这三个目录
拿到基于PHP的辰光PHP客服tb多商户全开源源码.zip,解压后不要急着配置数据库。包名里的 tb 多商户,本质是让多个商家共享一套客服后台。一般这类源码会包含 application(或 app)、public、config、sql 或 install 目录。先花两分钟确认三点:public 目录下有没有 index.php 作为入口,runtime 或 storage 目录是否可写,sql 目录里是不是只有建表语句而没有种子数据。如果缺少种子数据,后面多商户后台的默认账号会找不到。
我一般会把整个目录放到/data/www/kefu,并立刻执行一个命令看关键目录权限:
cd /data/www/kefu chown -R www-data:www-data runtime/ storage/ public/uploads/ ls -la runtime/ storage/参数说明:www-data是 PHP-FPM 的运行用户,必须让runtime和storage对它可写,否则框架会报“目录没有写入权限”或缓存目录不可写。public/uploads/用于客服头像和聊天图片,如果没有写权限,用户上传消息图片时会直接 500。
3.2 PHP 扩展和进程配置
这套客服系统对 PHP 的依赖不算刁钻,常见做法是 PHP 7.4 以上,必须装 pdo_mysql、redis、mbstring、openssl,如果要跑长连接推送,可能还需要 pcntl 和 posix。如果你用的是宝塔或自己的编译环境,确认扩展最简单的方法是执行php -m过滤:
php -m | grep -E 'PDO|mysqli|redis|mbstring|openssl|pcntl|posix'看到PDO、redis、mbstring、openssl是基本要求。其中redis扩展负责连接 Redis,几乎每个客服项目的消息队列和在线状态都要它。pcntl只在启动命令行常驻进程时需要,Windows 环境下没有这个扩展,所以生产环境要么用 Linux,要么改造为 Supervisord 管理的进程。
3.3 Nginx 伪静态配置
PHP 客服系统的动态路由一般都要走index.php入口,如果 Nginx 里面没有把不存在的文件转发给入口,刷新页面就会出现 404。下面是兼容 ThinkPHP 和常见框架的站点配置:
server { listen 80; server_name kefu.example.com; root /data/www/kefu/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?s=$uri&$args; } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~ \.(js|css|png|jpg|gif|ico)$ { expires 7d; } }try_files $uri $uri/ /index.php?s=$uri&$args是这套配置里的关键参数。把不存在的 URI 交给index.php处理,同时把原始路径放到s参数里,框架路由就能解析出控制器和方法。fastcgi_pass只有在 PHP-FPM 监听 9000 端口时有效,如果使用 Unix socket,要改成unix:/run/php/php7.4-fpm.sock,否则会出现 502。
3.4 初始化数据库、Redis 和 .env
很多这类源码包里会带一个.env.example,复制成.env再修改。数据库初始化那一步不要手工去执行 SQL 片段,直接导入完整文件:
cp .env.example .env mysql -uroot -p -e "CREATE DATABASE kefu DEFAULT CHARACTER SET utf8mb4;" mysql -uroot -p kefu < sql/kefu_init.sql.env里主要改DB_HOST、DB_DATABASE、DB_USERNAME、DB_PASSWORD和REDIS_HOST、REDIS_PORT。有一点要注意:数据库连接串里的字符集要写成utf8mb4,不是utf8,否则 emoji 表情在消息里会被替换成问号,客服聊天里这是致命伤。项目源码里如果没给.env.example,那就要对照 config/database.php 把数据库参数补齐。
3.5 启动队列:后台不再“转圈”
多商户客服系统里最容易被忽略的是队列进程。访客发消息后,PHP 接口只负责写 Redis 或消息表,真正的推送和会话分配都靠独立队列进程消费。如果只启动 Nginx 和 PHP-FPM,后台会看到消息发送成功,但客服窗口里一直没有新消息,因为消费进程没跑起来。
一般这套代码会用php think queue:work或 GatewayWorker 提供长连接能力。以 ThinkPHP 为例,最小启动命令是:
nohup php think queue:work --queue message --tries 3 --sleep 2 > logs/queue_message.log 2>&1 & nohup php think queue:work --queue session --tries 3 --sleep 2 > logs/queue_session.log 2>&1 &--queue message表示只消费 message 队列,--tries 3是消费失败最多重试三次,--sleep 2是队列为空时休眠两秒再取。把两个消费进程分开的好处是,消息队列阻塞不会影响会话分配,至少客服还能用后台看到等待数。生产环境不要用 nohup,改用 Supervisord 管理,参数写在配置文件里,这样进程崩了会自动拉起。
3.6 验证部署是否成功
启动完成后,在浏览器打开站点首页,正常情况下会看到访客端悬浮客服按钮。再打开/admin目录,用源码自带的初始商户账号登录。如果没有进入客服工作台,而是在首页跳转,优先检查.env里的APP_DEBUG是否设为true,打开调试看报错信息。这一步能解决三分之二的部署问题,剩下的 502 基本是 PHP-FPM 权限或监听方式不对。
4. 多商户客服的核心流程:会话分配、消息入库和 Redis 消费组
4.1 客服分配:别把“谁在线就选谁”写进调度里
多商户客服系统最核心的体验差异在分配环节。源码里常见的分配逻辑是随机选一个在线客服,或者按添加顺序取第一个。这种写法在演示环境没问题,一旦某个客服挂机不退出但已经看不见消息,就会占住大量会话。可落地的分配策略是“最少活动会话数优先”。
class Assigner { public function assign(int $merchantId): int { $row = db()->query( 'SELECT k.id, k.max_concurrent, COUNT(s.id) AS active_cnt FROM kefu k LEFT JOIN chat_session s ON s.kefu_id = k.id AND s.merchant_id = k.merchant_id AND s.status = 1 WHERE k.merchant_id = ? AND k.online_status = 1 GROUP BY k.id HAVING active_cnt < k.max_concurrent ORDER BY active_cnt ASC, k.id ASC LIMIT 1', [$merchantId] ); return $row ? (int)$row['id'] : 0; } }这里COUNT(s.id)统计的是该客服当前正在进行的会话数,HAVING active_cnt < max_concurrent把已经满载的客服过滤掉,ORDER BY active_cnt ASC保证优先分给最空闲的客服。参数max_concurrent在客服表里默认为 5,你可以把它理解为最大同时接待人数,范围 1 到 20 比较合理,超过 20 后单个客服根本回复不过来。
回到 0 表示当前没有可用客服,这时候访客会话要留在等待队列里。常见误用是把没有可用客服直接当作系统异常,给访客弹“连接失败”,这让商户少收了大量排队线索。正确的做法是保持等待状态,并在客服端提示有 1 个会话排队。
4.2 消息入库必须带 merchant_id,否则分页查“串名单”
聊天消息表通常是这样的结构:session_id、visitor_id、kefu_id、content、msg_type、created_at。问题在于,如果session_id是某个商户的会话主键,而消息表里没有冗余 merchant_id,那么一旦你按消息表单独查询(比如统计、搜索),就必须 join 会话表才能确定商户。生产环境消息量大,join 很贵,所以常见做法是把 merchant_id 冗余进消息表。
CREATE TABLE `chat_message` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `merchant_id` int unsigned NOT NULL, `session_id` int unsigned NOT NULL, `kefu_id` int unsigned NOT NULL DEFAULT '0', `visitor_id` varchar(64) NOT NULL, `content` text NOT NULL, `msg_type` tinyint NOT NULL DEFAULT '1' COMMENT '1文字 2图片 3系统提示', `direction` tinyint NOT NULL COMMENT '1访客发送 2客服回复', `created_at` int NOT NULL, PRIMARY KEY (`id`), KEY `idx_merchant_session_time` (`merchant_id`, `session_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';写入消息时,最容易出错的一点:不要直接从 session 表查 merchant_id 再写入。凡是通过 HTTP 接口创建消息,必须把 merchant_id 放在请求上下文里,比如从客服登录态解析。这样即使 session_id 被篡改,消息也会落到发起者自己的商户里面。idx_merchant_session_time这个索引是给分页查询历史记录用的,没有这个索引,消息量大时查询只能全表扫。
4.3 Redis Streams 消费组:让 PHP 队列不丢消息
多商户客服系统上线后,访客发送的消息先进队列,再由 PHP 进程推送。传统做法是LPUSH+BRPOP,但进程消费完如果崩溃,消息就丢了。比较省心的方案是使用 Redis 5.0 引入的 Streams 消费组,把每笔消息都持久化在 Redis 里,消费后显式ACK,这样至少能记录消费状态。
public function consume(string $stream, string $group, string $consumer): void { $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->xGroup('CREATE', $stream, $group, '0', true); while (true) { $messages = $redis->xReadGroup($group, $consumer, [$stream => '>'], 1, 10000); if (empty($messages)) continue; foreach ($messages as $id => $body) { try { $this->handleMessage($body); $redis->xAck($stream, $group, [$id]); } catch (Throwable $e) { // 记录日志,不 ACK,后续可重新消费 error_log($e->getMessage()); } } } }xGroup('CREATE', ...)在消费组不存在时创建,参数'0'表示从最早未消费的消息开始。xReadGroup中的'>'是特殊 ID,表示只返回未投递给当前消费组的新消息。xAck是关键:只有业务处理成功才确认。Throwable是 PHP 7 之后能捕获所有异常的基类,包括 Error 和 Exception。消息处理失败时不 ACK,进程重启后还能重新读到这批消息,避免客服漏回。
4.4 WebSocket 与 HTTP 长轮询的选择
很多 PHP 客服系统的源码包里既有 WebSocket 网关,也有 HTTP 长轮询回退。二开之前要明白为什么需要两套。WebSocket 适合消息到达后立刻推给浏览器,延迟在毫秒级,但维护长连接需要常驻内存进程,而且受反向代理超时影响。HTTP 长轮询实现简单,容错高,但每个在线客服会持续占用一个 PHP-FPM 进程,商户一多就容易把进程池打满。
以 50 个客服同时在线的规模做对比:
| 方案 | 延迟 | 并发能力 | 服务端成本 | 需要额外进程 |
|---|---|---|---|---|
| WebSocket | 10-100ms | 高 | 高 | GatewayWorker/Swoole |
| HTTP 长轮询 | 1-3s | 中 | 中 | 无 |
| SSE | 100-500ms | 中高 | 低 | 无 |
如果二开目标是减少复杂度,可以先保留长轮询。只有当客服数超过 100 或要发图片语音时才需要上 WebSocket。实测中,长轮询的保活时间要设在 25 秒左右,低于 Nginx 的 proxy_read_timeout,否则轮询请求会被中断,客服端表现为“每隔一会儿就断线重连”。
5. 上线前调优:辰光客服的 5 个参数、隔离验证与排查技巧
5.1 参数清单
| 位置 | 参数 | 推荐值 | 说明 |
|---|---|---|---|
| php.ini | max_execution_time | 30 | 耗时的同步操作走队列 |
| php-fpm | pm.max_children | 20-50 | 按内存计算,每个进程约 40M |
| php-fpm | request_terminate_timeout | 60 | 超时即杀掉,避免卡死 |
| MySQL | innodb_buffer_pool_size | 物理内存 60% | 会话和消息都是 InnoDB 表 |
| Redis | maxmemory-policy | allkeys-lru | 在线列表过期可回收 |
注意max_execution_time只影响普通 HTTP 请求,CLI 队列进程不受它限制,别以为调大它就能让队列跑得更久。PHP-FPM 的pm.max_children建议用pm = dynamic起步,根据压测调。如果服务器只有 2G 内存,不能按推荐开 50,要按 20 起。
5.2 多商户隔离验证
上线前用一个商户的客服账号登录,打开开发者工具,手动把另一个商户的会话 ID 填到请求里。如果接口返回数据里有对方商户的访客昵称,说明隔离没做到位。需要跑通的三条验证路径:客服列表不能越权、历史消息不能越权、关闭会话不能越权。
curl -X POST 'https://kefu.example.com/api/session/close' \ -H 'X-Merchant-Id: 10001' \ -H 'Cookie: kefu_session=...' \ -d 'session_id=888888'当 merchant_id 传的是商户 A,session_id 属于商户 B 时,正确行为是返回 403 或“无权限操作”。返回成功就是越权。这种测试不需要写单元测试框架,先人工跑一遍,确认数据库层已经有 merchant_id 条件后,再补自动化测试。
5.3 一个排查技巧:按消息表定位会话丢失
如果客服反馈某个会话从工作台消失,常见原因不是状态错了,而是last_message_at没有更新导致排序不对。排查时不要只看会话主键,直接查消息表的最近记录,结合商户和时间就能判断是会话状态还是索引出了问题:
SELECT session_id, merchant_id, direction, created_at FROM chat_message WHERE merchant_id = 10001 AND created_at > UNIX_TIMESTAMP() - 86400 ORDER BY created_at DESC LIMIT 20;如果查出来的最近消息只有访客端没有客服端,说明客服回复的消息没有入库,问题在客服发送接口;如果两方向都有消息但会话列表没变化,再看 chat_session 的 last_message_at 是否同步更新。这条 SQL 把 merchant_id 放在第一条件,可以同时排除跨商户干扰。多商户系统的调试,第一步永远是让所有查询带上 merchant_id,然后才谈其他。
本文还有配套的精品资源,点击获取