
简介这是一份基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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-Key、X-App-Sign、X-App-Ts三个请求头完成租户鉴权?php declare(strict_types1); 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 端坐席工作台与后台管理的入口分离标题里的[PCWAP公众号]指的是三种访问入口。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只是需要在创建会话时标记channelwap。来源标记的意义在于报表统计运营人员需要知道咨询是从 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_typecode . scopesnsapi_base . statechat#wechat_redirect; }scopesnsapi_base是静默授权用户在公众号内点击时不会弹确认框适合只取 openid 的客服入口如果需要昵称头像做展示才需要换成snsapi_userinfo并引导用户授权。回调用code换openid时同一 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_*系列函数已移除需要改 PDOmcrypt扩展废弃加解密要换成openssleach()、create_function()等函数被移除模板引擎也要跟着升级。我接手的源码里最常遇到的是第二个问题加解密函数集中在common.php的authcode()中迁移时先跑一遍静态扫描定位这些函数。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_idSQL 注入访客昵称拼进查询条件全部改为参数绑定或查询构造器日志泄露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 意外崩溃坐席端也能看到发送失败的消息并手动触发补发这个兜底链路不能省。本文还有配套的精品资源点击获取