ThinkPHP在线客服系统开发:多商户租户隔离与三端接入实践指南
2026/9/16 18:18:08 网站建设 项目流程

简介:这是一份基于ThinkPHP内核的多商户版在线客服系统源码,支持PC、WAP、公众号等多端场景,定位类似美洽的轻量云端客服方案。系统采用私有化部署,数据自主可控,适合站长、企业或开发者快速搭建带独立后台的客服平台。资源共2000个文件,以PHP源码、JS脚本、HTML页面、CSS样式为主,并含PNG图标、SQL数据库脚本与安装配置说明,整体压缩包21.63MB,目录结构清晰,便于按模块查看。当前已有293人学习下载。源码不限制客服数量,每个客服账号拥有独立管理后台,支持客户分组、智能分配与转接、双向微信模板消息通知,还可推送商品、设置自动问候语、对客服评价。包内附带完整前后端程序及部署指引,可自定义版权和LOGO,适合直接部署运营或基于ThinkPHP二次开发。

1. ThinkPHP 在线客服系统的多商户内核与三类接入场景

在线客服系统的交付模式已经从单客定制逐步转向多商户 SaaS 化。一套 ThinkPHP 内核的客服源码,典型结构是用 tenant 表承载商户维度,agent 表承载坐席账号,session 与 message 记录每一次咨询会话和聊天记录;前端入口拆成 PC、WAP、公众号,后端只维护一套业务逻辑。对需要快速交付多租户客服产品的团队,与其从零设计权限体系,不如先把数据模型、租户隔离、消息流转这三层理清楚,再决定改哪里、补哪里。本文从数据模型开始,逐层落到鉴权、推送、三端对接与部署排错,适合准备二次开发这套源码的 PHP 工程师,也适合需要评估这套系统可维护性的技术负责人。

2. 多商户在线客服的数据模型与租户隔离设计

2.1 四张核心表:商户、坐席、会话、消息

先做数据建模。多数 ThinkPHP 内核的客服源码会采用以下四张核心表,我在二次开发中也会沿用这个拆分,因为它同时兼顾了检索性能和业务弹性。

-- 商户表:一个商户 = 一个租户 CREATE TABLE `tenant` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '商户名称', `app_key` varchar(32) NOT NULL COMMENT '接口调用凭证', `app_secret` varchar(64) NOT NULL COMMENT '接口调用密钥', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1开启 0停用', `expire_time` int(11) DEFAULT NULL COMMENT '服务到期时间', `create_time` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_appkey` (`app_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='多商户租户表'; -- 坐席表:坐席归属于某一个商户 CREATE TABLE `agent` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `tenant_id` int(11) NOT NULL COMMENT '所属商户', `user_id` int(11) NOT NULL COMMENT '对应后台用户表 ID', `max_sessions` tinyint(4) NOT NULL DEFAULT '5' COMMENT '最大同时接待会话数', `status` tinyint(1) NOT NULL DEFAULT '1', PRIMARY KEY (`id`), KEY `idx_tenant` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='坐席表'; -- 会话表:一次完整的客服接待过程 CREATE TABLE `session` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `session_no` varchar(32) NOT NULL COMMENT '会话编号,展示用', `tenant_id` int(11) NOT NULL, `agent_id` int(11) DEFAULT NULL COMMENT '当前接待坐席', `visitor_id` varchar(64) NOT NULL COMMENT '访客唯一标识', `channel` varchar(10) NOT NULL DEFAULT 'pc' COMMENT '来源渠道 pc/wap/mp', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0排队中 1进行中 2已结束', `create_time` int(11) NOT NULL, `end_time` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_tenant_status` (`tenant_id`,`status`), KEY `idx_agent_status` (`agent_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话表'; -- 消息表:会话下的每一条聊天记录 CREATE TABLE `message` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `session_id` bigint(20) NOT NULL, `sender_type` tinyint(1) NOT NULL COMMENT '1访客 2坐席 3系统', `sender_id` varchar(64) NOT NULL COMMENT '发送者 ID,访客为 visitor_id', `content_type` varchar(10) NOT NULL DEFAULT 'text' COMMENT 'text/image/file', `content` text NOT NULL, `create_time` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_session_time` (`session_id`,`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息表';

写库逻辑有两点需要注意。会话表上不要用visitor_id做唯一索引,因为同一个访客在排队中、进行中、已结束三种状态下的历史记录会重叠,真正需要保证的是同一访客在同一渠道下不能同时存在两条“进行中”会话,这通常靠代码判断或组合索引实现。消息表的sender_id字段故意做成字符串,是为了让访客、坐席、系统三种发送者共用同一个字段,减少联表次数。content_type预留了 image/file 类型,方便后续接入图片消息和文件传输。

提示:如果源码使用的是utf8字符集,建议二次开发时统一迁移到utf8mb4,否则公众号昵称里的 emoji 会在写入消息表时直接报错或变成乱码。

2.2 租户隔离:字段隔离与独立库的取舍

多商户版最核心的设计决策是租户隔离粒度。常见方案有三种:共享表加tenant_id字段、每商户独立表、每商户独立数据库。ThinkPHP 内核的客服源码绝大多数采用第一种,维护成本最低,迁移最简单。

隔离方案开发成本迁移成本推荐场景
共享表字段隔离商户量几百以内,通用 SaaS 场景
每商户独立表单商户数据量极大,需要单独归档
每商户独立库金融、医疗等强合规场景,数据必须物理隔离

共享表的代价是“忘了带tenant_id条件”会成为安全隐患,第 5 章会用模型全局作用域强制补上。独立库方案隔离性最好,但一个商户要升级表结构时,需要对所有库执行迁移脚本,运维成本随商户数量线性增长。

如果源码确实采用独立库方案,则需要在配置中动态切换连接:

// config/database.php 中的连接池配置 $connections = [ 'tenant_0' => [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'kefu_tenant_0', 'username' => 'root', 'password' => '******', 'charset' => 'utf8mb4', ], ]; // 在业务代码中按商户 ID 动态切换 Db::setConfig($connections); Db::connect('tenant_' . $tenantId)->name('session')->select();

动态连接的代价是连接数变多,且无法复用通用查询封装。因此除非有合规要求,否则我仍然推荐共享表方案,后文所有示例都基于共享表加tenant_id的模型。

2.3 会话状态机与消息流转链路

在线客服系统最容易出 bug 的地方是会话状态没有闭环。正常一次会话从访客发起开始,依次经过排队、接入、进行、结束四个阶段,状态机如下:

0 排队中:访客创建会话后,系统按轮询规则分配坐席,此时坐席未知。
1 进行中:坐席点击接入,或系统自动分配坐席后,会话进入接待中。
2 已结束:访客主动关闭、坐席关闭或超时回收后会话归档;归档后不能通过界面继续回复,只能查看记录。

超时回收场景需要后台定时任务兜底。以下是一个 ThinkPHP 6 的定时任务示例,每 5 分钟扫描一次超过 30 分钟未活跃的进行中会话:

// app/command/CloseTimeoutSession.php public function handle() { $timeoutTs = time() - 1800; $list = Db::name('session') ->where('status', 1) ->where('update_time', '<', $timeoutTs) ->field('id, agent_id') ->limit(500) ->select(); $now = time(); foreach ($list as $session) { Db::name('session') ->where('id', $session['id']) ->update(['status' => 2, 'end_time' => $now]); // 坐席的接待计数必须同步回减,否则满员后无法接入新会话 Db::name('agent') ->where('id', $session['agent_id']) ->dec('current_sessions') ->update(); } }

这里有一个容易被新手忽略的问题:仅更新会话状态还不够,坐席的current_sessions计数字段必须同步回减,否则坐席满 5 个会话后再也无法接入新客人。dec('current_sessions')是 ThinkPHP 的原子自减操作,比先查后改的方式更安全,也不会在高并发下出现计数偏差。

3. ThinkPHP 内核里的路由、鉴权与消息推送实现

3.1 租户鉴权中间件:基于 app_key 与签名

多商户接口不能只靠登录态区分商户,因为公众号端和 WAP 端都可能需要无登录态直连接口。实际开发中,我用X-App-KeyX-App-SignX-App-Ts三个请求头完成租户鉴权:

<?php declare(strict_types=1); namespace app\middleware; use think\facade\Db; use think\Response; class TenantAuth { public function handle($request, \Closure $next): Response { $appKey = $request->header('X-App-Key', ''); $sign = $request->header('X-App-Sign', ''); $ts = $request->header('X-App-Ts', ''); if (!$appKey || !$sign || !$ts) { return json(['code' => 40001, 'msg' => '缺少鉴权参数']); } // 防重放:时间戳偏移超过 300 秒直接拒绝 if (abs(intval($ts) - time()) > 300) { return json(['code' => 40002, 'msg' => '请求时间戳已过期']); } $tenant = Db::name('tenant')->where('app_key', $appKey)->find(); if (!$tenant || intval($tenant['status']) !== 1) { return json(['code' => 40003, 'msg' => '商户不存在或已停用']); } // 签名规则:md5(app_key + app_secret + ts) $localSign = md5($appKey . $tenant['app_secret'] . $ts); if (!hash_equals($localSign, $sign)) { return json(['code' => 40004, 'msg' => '签名校验失败']); } $request->tenant = $tenant; return $next($request); } }

签名规则里把ts拼进去,是为了避免重放攻击:攻击者抓包拿到一次有效请求后,不能无限次复用同一个签名。hash_equals做常量时间比较,可以避免通过响应时间差猜签名字符串。中间件挂在路由上之后,所有带X-App-*的接口都会先走这段逻辑,后续控制器通过$request->tenant就能拿到当前商户的完整数据。

3.2 消息推送:数据库写入后与长连接网关的协作

在线客服的实时性取决于消息推送链路。ThinkPHP 作为业务端,最稳妥的协作方式是先把消息写入 MySQL,再通过 GatewayWorker 或 Swoole WebSocket 服务把新消息事件推给对应坐席或访客;长连接服务不直接操作业务表,只做转发。

我在 ThinkPHP 里封装一个推送函数,调用 GatewayWorker 的文本协议端口:

// app/common.php 中的消息推送封装 function push_to_client(string $clientId, array $payload): void { // GatewayWorker 的 Gateway 端口,默认 1238,文本协议以换行结尾 $fp = stream_socket_client('tcp://127.0.0.1:1238', $errno, $errstr, 2); if (!$fp) { // 推送失败不阻塞主流程,记日志稍后补偿 trace("Gateway 连接失败: {$errstr}", 'error'); return; } $data = json_encode([ 'type' => 'message', 'data' => $payload, ], JSON_UNESCAPED_UNICODE) . "\n"; fwrite($fp, $data); fclose($fp); }

这里有两个参数值得注意。第一个是超时时间 2 秒:如果 Gateway 进程挂掉,主流程不能因为推送而卡住,最多等 2 秒就要放弃。第二个是文本协议末尾的换行符:GatewayWorker 的 Gateway 端口默认按文本协议解析,数据必须以\n结尾,否则会被粘包解析成异常数据。消息推送是“尽力而为”的,真正的可靠性依赖客户端下一次拉取历史消息兜底,消息表里的聊天记录才是唯一事实来源。

3.3 公众号 access_token 缓存策略与客服消息下发

三端对接中,公众号端的实现相对繁琐,难点在于access_token管理。微信接口要求 access_token 全局唯一,多个进程并发刷新会导致旧 token 立即失效,表现为频繁出现 42001 错误。

// 获取公众号全局 access_token,带缓存 public function getAccessToken(): string { $key = 'mp_access_token_' . $this->appId; // 提前 200 秒过期,避免边界时间恰好失效 return Cache::remember($key, function () { $resp = Http::get('https://api.weixin.qq.com/cgi-bin/token', [ 'grant_type' => 'client_credential', 'appid' => $this->appId, 'secret' => $this->appSecret, ]); $data = json_decode((string) $resp, true); if (isset($data['access_token'])) { return $data['access_token']; } // 获取失败时写入空字符串,避免缓存击穿 return ''; }, 7000); }

Cache::remember第三个参数是缓存有效期,单位秒,我习惯设置为 7000 秒而不是微信规定的 7200 秒,预留 200 秒缓冲。关键不是把 token 放进缓存,而是让所有 PHP-FPM 进程共用同一个缓存源;如果每个请求都重新从微信获取 token,并发一高必然互相踢下线。

访客在公众号里发消息时,微信服务器会向配置好的回调 URL 推送 XML 消息体。回调处理函数里需要提取FromUserName作为 openid,与visitor_id绑定;坐席回复时,再调用/cgi-bin/message/custom/send推送文本消息。注意公众号客服消息有 48 小时时效限制,超时后只能改用模板消息触达。

4. PC、WAP、公众号三端对接的落地步骤与参数

4.1 PC 端:坐席工作台与后台管理的入口分离

标题里的[PC+WAP+公众号]指的是三种访问入口。PC 端又分成两块:后台管理(配置商户、查看报表)和坐席工作台(接待会话)。常见做法是把二者拆成两个模块,路由上直接分开,避免坐席人员误入管理菜单。

ThinkPHP 6 的路由定义可以这样组织:

// route/admin.php 后台管理路由 Route::group('admin', function () { Route::rule('tenant/list', 'admin/tenant/list'); Route::rule('agent/list', 'admin/agent/list'); Route::rule('report/overview', 'admin/report/overview'); })->middleware([AuthCheck::class, AdminPermission::class]); // route/kefu.php 坐席工作台路由 Route::group('kefu', function () { Route::rule('workbench', 'kefu/workbench/index'); Route::rule('session/detail', 'kefu/session/detail'); Route::rule('session/transfer', 'kefu/session/transfer'); })->middleware([AuthCheck::class, AgentPermission::class]);

分离的核心好处是权限中间件可以按模块加载:AdminPermission检查管理员角色,AgentPermission只检查坐席角色,两个中间件互不干扰。坐席工作台的轮询间隔建议在配置文件中集中定义,前端 JS 读取同一个配置项,避免后续改间隔时动到多处代码。

4.2 WAP 端:H5 访客页创建会话并标记来源

WAP 端是手机浏览器打开的 H5 访客页,它和 PC 端访客页共用同一套 API,只是需要在创建会话时标记channel=wap。来源标记的意义在于报表统计:运营人员需要知道咨询是从 PC 官网来,还是从手机端宣传页来。

入口channel 值访客标识存放位置坐席回复通道
PC 网页pccookie / visitor_id页面 WebSocket 长连接
WAP H5waplocalStorage visitor_id页面 WebSocket 长连接
公众号mpopenid 映射 visitor_id微信客服消息 / 模板消息
// static/js/visitor.js 访客创建会话 async function createSession(tenantConfig) { const ts = Math.floor(Date.now() / 1000); const sign = md5(tenantConfig.app_key + tenantConfig.app_secret + ts); const resp = await fetch(tenantConfig.apiBase + '/api/session/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-App-Key': tenantConfig.app_key, 'X-App-Sign': sign, 'X-App-Ts': String(ts), }, body: JSON.stringify({ visitor_id: getVisitorId(), // 本地存储中取,没有则新生成 channel: 'wap', url: location.href, // 记录首次咨询页面,方便坐席判断上下文 }), }); const result = await resp.json(); if (result.code === 0) { enterChatWindow(result.data.session_no); } else { showError(result.msg); } }

前端代码里最容易错的一步是把app_secret直接暴露在浏览器环境。演示项目可以这么写,生产环境必须通过后端接口转发创建会话,或使用后端签名接口生成临时签名,app_secret只允许存在于 PHP 配置文件中。上面的代码仅用于说明签名流程,部署时要改成“前端请求签名接口 → 后端返回签名 → 前端用签名创建会话”的路径。

4.3 公众号端:网页授权、openid 绑定与消息下发

公众号入口的完整链路是:用户点菜单 → 网页授权拿到 openid → 后端把 openid 映射为visitor_id→ 进入会话页 → 微信把用户消息推送到回调 URL → 坐席在 PC 工作台回复 → 后端调客服消息接口下发。

第一步是构造授权跳转地址:

// 构造公众号网页授权 URL public function buildOAuthUrl(string $redirectPath): string { $appId = $this->config['app_id']; $redirect = urlencode('https://kefu.example.com/' . $redirectPath); return "https://open.weixin.qq.com/connect/oauth2/authorize" . "?appid={$appId}" . "&redirect_uri={$redirect}" . "&response_type=code" . "&scope=snsapi_base" . "&state=chat#wechat_redirect"; }

scope=snsapi_base是静默授权,用户在公众号内点击时不会弹确认框,适合只取 openid 的客服入口;如果需要昵称头像做展示,才需要换成snsapi_userinfo并引导用户授权。回调用codeopenid时,同一 code 只能用一次,拿到后先查visitor表是否已绑定,已绑定则直接进入会话,未绑定则新增记录再进入会话。

被动消息回调的代表性处理逻辑如下:

// 公众号消息回调入口 public function onMessage(Request $request) { $xml = simplexml_load_string($request->getContent(), 'SimpleXMLElement', LIBXML_NOCDATA); $openid = (string) $xml->FromUserName; $content = trim((string) $xml->Content); // 找到或创建访客 $visitor = Db::name('visitor')->where('openid', $openid)->find(); if (!$visitor) { $visitorId = uniqid('mp_', true); Db::name('visitor')->insert([ 'openid' => $openid, 'visitor_id' => $visitorId, 'create_time' => time(), ]); } else { $visitorId = $visitor['visitor_id']; } // 把消息归档到 message 表,会话不存在时自动创建 $this->archiveMessage($visitorId, $content); // 微信要求 5 秒内响应,返回空串避免重复推送 return response(''); }

微信服务器要求 5 秒内返回响应,超时会重推,所以回调里不能做耗时过长的操作。上面代码把实际响应直接返回空字符串,消息归档交给archiveMessage后异步处理;如果确实要同步回复,则只能回复“收到,正在为您转接坐席”这类静态文案,不要把数据库查询和推送逻辑全部塞进回调。

5. 二次开发部署排错与安全加固

5.1 伪静态规则与 ThinkPHP 版本兼容问题

无论源码基于 ThinkPHP 3.2、5.0 还是 6.0,部署到 Nginx 都会遇到伪静态配置。不同版本对 pathinfo 的解析方式不同,但 Nginx 下最常见的一段配置是:

server { listen 80; server_name kefu.example.com; root /var/www/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; } }

rewrite ^(.*)$ /index.php?s=$1 last;是把不存在的文件路径交给 ThinkPHP 前端控制器处理;如果源码使用旧版 pathinfo 模式,可能需要改成fastcgi_split_path_info方式,由 fastcgi 解析s参数。

兼容性方面,把 ThinkPHP 3.2 迁移到 PHP 8 常见有三个坑:mysql_*系列函数已移除,需要改 PDO;mcrypt扩展废弃,加解密要换成openssleach()create_function()等函数被移除,模板引擎也要跟着升级。我接手的源码里最常遇到的是第二个问题,加解密函数集中在common.phpauthcode()中,迁移时先跑一遍静态扫描定位这些函数。

5.2 并发下的会话唯一性与数据库瓶颈

客服系统在营销活动期间容易遇到每小时上千会话的流量,消息表和会话表的写入首先成为瓶颈。先解决会话重复问题:同一访客快速双击“开始咨询”时,代码还没来得及把状态从0改成1,就可能插入两条排队中记录。

解决方式是在代码入口处做检查:

// 创建会话前检查是否已有进行中/排队中的会话 $exists = Db::name('session') ->where('visitor_id', $visitorId) ->where('channel', $channel) ->whereIn('status', [0, 1]) ->find(); if ($exists) { return json([ 'code' => 0, 'data' => ['session_no' => $exists['session_no']] ]); }

这种方式比单纯依赖数据库唯一索引更容易配合业务扩展,也可以加上status组合唯一索引做双保险。消息表的高频写入建议按天分表,例如message_20250101,报表查询时按日期裁剪分表,避免历史超大表拖慢索引。

5.3 安全加固:越权、注入、日志脱敏

多商户系统最严重的安全风险是水平越权:商户 A 的坐席通过修改 URL 中的会话 ID,查到了商户 B 的聊天记录。表设计上所有业务表都有tenant_id,但查询条件一多就容易漏。ThinkPHP 6 的模型支持全局作用域,可以在模型基类统一强制带上租户条件:

// app/model/BaseModel.php protected static function onBaseQuery($query) { $tenantId = request()->tenant['id'] ?? 0; if ($tenantId > 0) { $query->where(self::getTable() . '.tenant_id', $tenantId); } }

其它加固项整理成一张表,按优先级实施:

风险点典型现象处理建议
水平越权改 URL 参数可看其它商户记录模型全局作用域强制加 tenant_id
SQL 注入访客昵称拼进查询条件全部改为参数绑定或查询构造器
日志泄露trace 日志明文输出 app_secret日志写入前对密钥做脱敏处理
反射型 XSS消息内容里的脚本被浏览器执行前端渲染转义,服务端htmlspecialchars
接口重放抓包重复提交关闭会话请求时间戳校验 + 数据幂等键

日志脱敏的具体做法比较简单,封装一层日志方法,输出前执行substr($secret, 0, 6) . '***',保证排错时能对照密钥前几位,又不会泄露完整密钥。

6. 用 Redis 队列把公众号回复的发送延迟降下来的具体调优

6.1 问题表现与队列设计

当坐席在 PC 工作台点击“发送”时,如果代码里同步调用微信客服消息接口,网络往返通常需要 100 到 300 毫秒,而且接口偶发超时重试会导致坐席端按钮卡顿。并发对话一多,PHP-FPM 进程也会被阻塞。我采用的方案是把“写消息表 + 推 Gateway”保留为同步操作,把“调用微信发送”放进 Redis 队列异步消费。

// 坐席发送消息控制器中的关键代码 Db::name('message')->insert($messageData); // 推送给当前坐席的浏览器,实时性要求最高,保持同步 push_to_client($agentClientId, $messageData); // 微信客服消息进 Redis 队列,异步发送 $queueKey = 'mp_send_queue'; $payload = json_encode([ 'openid' => $visitor['openid'], 'content' => $messageData['content'], ], JSON_UNESCAPED_UNICODE); Redis::rpush($queueKey, $payload);

消费端是一个常驻 CLI 进程,循环lpop队列并调用微信接口。这样坐席点击发送时,本地界面立即出现消息,微信的发送在后台排队完成,任何单条消息的网络超时都不会拖慢工作台响应。

实测中这个调整把坐席端操作反馈时间从 300 到 500 毫秒缩短到 30 毫秒以内,主要收益是解决了长时间占用进程资源的问题。需要注意的是,异步化带来的是最终一致,如果 Redis 队列积压,用户可能晚几秒才在公众号里看到回复。因此消费端要加监控:队列长度超过 50 时报警,并且给消息表增加send_status字段(0 待发送 / 1 成功 / 2 失败),消费完成后回写状态。这样即使 Redis 意外崩溃,坐席端也能看到发送失败的消息并手动触发补发,这个兜底链路不能省。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询