
简介这是一套基于ThinkPHP内核开发的多商户在线客服系统源码兼容PC、WAP与公众号等对接场景适合需要私有化部署客服系统的站长、企业或开发者。它采用类似美洽的接入方式可通过一行代码快速集成到网页、小程序、App中支持无限客服与坐席、独立后台、客户分组、商品推送、评价及微信模板消息通知等功能能有效解决多商户场景下客服管理与数据可控问题。资源包共2000个文件压缩后仅21.63MB包含801个png界面素材、312个js前端交互脚本、236个html页面模板、125个php业务逻辑文件、125个md文档说明、104个css样式、96个json配置数据和3个sql数据库脚本等目录结构清晰便于二次开发与部署调试。目前已有293人学习下载。借助这套源码可快速搭建完整的多商户客服平台免去从零开发成本附带的数据库脚本、部署脚本与文档能辅助完成环境安装、权限配置和功能定制适合具备PHP基础的开发者研究ThinkPHP架构与客服系统交互流程。1. ThinkPHP 内核在线客服系统源码多商户版部署边界比想象中宽拿到一份“ThinkPHP 内核在线客服系统源码多商户版”压缩包先别急着解压、配站、跑起来看界面。这套源码真正值钱的地方不是聊天框长什么样而是“多商户”三个字背后的租户隔离设计以及“PCWAP公众号”三端入口在同一个坐席工作台里的会话归并。常见做法是把它部署成一套 SaaS 客服平台平台方掌握总后台各企业租户有独立后台和访客入口访客从电脑网页、手机浏览器、微信公众号任意一端发起咨询坐席都能在同一套后台里接待和回复。适合接手的是能用 ThinkPHP 做二次开发的工程师。动手之前先把三件事对齐PHP 版本和 ThinkPHP 分支、商户与坐席的数据关系、微信回调路由的位置。这三件事的顺序不能反先立数据模型再通渠道最后才谈界面。2. 多商户在线客服的数据模型tenant_id 贯穿表结构先过三关2.1 商户、坐席、会话三张表主键类型先统一多商户版在线客服系统里“商户”需要独立开通、独立到期、独立统计不能塞进 user 表加一个类型字段糊弄。常见的落地做法是单独建 merchant 作为租户主表把 name、status、expire_at 这类运营字段放进去坐席表 merchant_seat 用 tenant_id 指向 merchant.id会话表 conversation 同时持有 tenant_id、visitor_id、seat_id。这样商户后台查报表、坐席端拉历史会话都只需要一条组合索引过滤不用额外来一次子查询。建表时有三个容易错的地方。第一主键统一用 bigint unsigned别把 int、char、varchar 混着用多商户系统后面大概率要接报表或按租户分库主键类型不一致最先炸在 JOIN 上。第二tenant_id 必须出现在所有业务表上包括聊天消息表不能只放在会话表里否则按商户导出聊天记录时要回表补条件。第三状态字段必须带默认值坐席在线状态、会话状态一旦为空分配算法会把所有在线坐席直接跳过。表名关键字段在多商户场景里的作用merchantid、name、status、expire_at租户主体控制开通、到期、停用merchant_seatid、tenant_id、username、status、max_load、current_load坐席归属商户max_load 限制同时接待数conversationid、tenant_id、visitor_id、seat_id、channel、status一次咨询会话channel 区分 pc/wap/mpchat_messageid、tenant_id、conversation_id、sender_type、content消息流水sender_type 区分访客和坐席2.2 坐席分配要原子化避免并发重复派单在线客服系统最典型的并发冲突是两个访客同时发起咨询系统先 SELECT 出同一个“空闲”坐席然后各自 INSERT 会话导致一个坐席被分配给了两段对话。解决办法不是加锁而是把“抢占坐席”和“累计接待数”放进同一条 UPDATE 语句让 InnoDB 行锁帮我们挡掉冲突。常见写法是UPDATE merchant_seat SET current_load current_load 1, last_assign_at NOW() WHERE tenant_id :tenantId AND status 1 AND current_load max_load ORDER BY current_load ASC, last_assign_at ASC LIMIT 1;这条 SQL 是“先占位再建会话”思路的核心。current_load 自增放在 UPDATE 里而不是先 SELECT 后 UPDATE是因为 InnoDB 在 UPDATE 时会锁住命中的行第二个请求到达时要么等锁要么因为 current_load 已经达到 max_load 而匹配不到行返回影响行数为 0于是系统把它放进排队列表。ORDER BY current_load ASC 让接待量最少的坐席优先被选中last_assign_at 做次级排序避免老坐席一直吃流量。在 ThinkPHP 里执行时可以用 query 对象直接拿影响行数use think\facade\Db; $updated Db::name(merchant_seat) -where(tenant_id, $tenantId) -where(status, 1) -whereRaw(current_load max_load) -order(current_load ASC, last_assign_at ASC) -limit(1) -update([ current_load Db::raw(current_load 1), last_assign_at date(Y-m-d H:i:s), ]); if ($updated 0) { $seatId Db::name(merchant_seat) -where(tenant_id, $tenantId) -order(last_assign_at DESC) -value(id); }2.3 聊天消息表与会话模型的最小写法聊天消息表必须冗余 tenant_id这一点再怎么强调都不过分。它看起来破坏了第三范式但换来了两个实际收益按商户批量清理数据时不用 JOIN 会话表坐席端翻聊天记录时只按 conversation_id 过滤消息表本身就能独立分表。CREATE TABLE chat_message ( id bigint unsigned NOT NULL AUTO_INCREMENT, tenant_id bigint unsigned NOT NULL COMMENT 所属商户, conversation_id bigint unsigned NOT NULL COMMENT 会话ID, sender_type tinyint NOT NULL DEFAULT 0 COMMENT 0访客 1坐席 2系统, msg_type varchar(10) NOT NULL DEFAULT text COMMENT text/image/file, content text NOT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_conv_time (conversation_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT聊天消息表;对应 ThinkPHP 模型只需要做两件事指定表名声明会话关联。sender_type和msg_type建议用字符或小整数常量不要在业务代码里散落裸数字否则后面做消息日志审计时到处都要猜含义。namespace app\common\model; use think\Model; class ChatMessage extends Model { protected $name chat_message; protected $autoWriteTimestamp true; public function conversation() { return $this-belongsTo(Conversation::class, conversation_id, id); } }这里有个容易踩的坑即使定义了 belongsTo 关联ThinkPHP 也不会自动补 tenant_id。多商户系统里所有关联查询必须显式带上当前商户 ID否则一个坐席只要知道另一个商户的会话 ID就能跨商户查到别人的消息。3. 跑通 ThinkPHP 在线客服源码环境、迁移、PC/WAP 路由一次对齐3.1 先确认源码包的 ThinkPHP 分支和 PHP 版本解压源码包后第一件事不是配域名而是看目录结构判断它基于哪个 ThinkPHP 分支。根目录有 think 文件、app 目录、config 目录、route 目录是 ThinkPHP 6 的标准结构如果看到 Application、Runtime、ThinkPHP 目录那是 ThinkPHP 3.2 的老结构。老结构在 PHP 8 上跑会撞上“thinkphp 3.2 版本兼容 php8”的经典坑create_function 被禁用、花括号数组下标语法被移除、mysql 扩展换成 mysqli。逐文件改语法不现实比较稳的做法是确认 composer.json 锁定的版本如果是 3.2.x 且线上业务还没上线直接迁移到 ThinkPHP 6.0.x LTS 再接手后续功能。环境依赖对照表按这套标准来配能少踩一半的坑依赖推荐版本说明PHP8.0.2 以上、8.1 优先ThinkPHP 6.0.x LTS 对 8.1 兼容性较好Composer2.5 以上老版本解析依赖可能拉回不兼容包MySQL5.7 或 8.08.0 建库时统一 utf8mb4_0900_ai_ciRedis6.x 以上坐席在线状态、公众号 access_token、会话锁都用它PHP 扩展pdo_mysql、redis、openssl、socketsopenssl 缺失会卡在公众号消息加解密3.2 本地初始化的迁移命令和 .env 配置如果是标准的 ThinkPHP 6 项目结构依赖安装和初始化流程是按这个顺序走的cd /data/wwwroot/kefu php -v composer install --no-dev --prefer-dist cp .env.example .env php think migrate:run php think seed:run php think runcomposer install 是拉取 vendor 依赖--no-dev 可以避免把开发调试工具带进生产目录。migrate:run 执行数据库迁移seed:run 写入初始商户和坐席数据。最后php think run是起 ThinkPHP 内置服务器默认监听 0.0.0.0:8000只在本地联调时用。.env 是最低限度的数据库配置注意 APP_DEBUG 在公网环境必须关掉APP_DEBUG false [DATABASE] TYPE mysql HOSTNAME 127.0.0.1 DATABASE kefu_multi USERNAME kefu PASSWORD changeit PREFIX kf_ HOSTPORT 3306如果这套源码没有提供 .env.example手动创建 .env 也一样。配置里最容易写错的是 PREFIX很多 ThinkPHP 客服系统源码的表名是 kf_merchant、kf_conversation前缀不写对migrate 会直接报表不存在的错。Redis 配置建议独立写一块因为公众号 access_token 和坐席在线状态的读写频率完全不同共用一个库也至少要分 db 序号。3.3 Nginx 伪静态与 PC/WAP 双端入口识别ThinkPHP 6 默认是 pathinfo 模式Nginx 下不能直接把所有请求交给 index.php 处理要做一次伪静态重写。参考配置是这样server { listen 80; server_name kefu.example.com; root /data/wwwroot/kefu/public; index index.php; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~* \.(env|log|ini)$ { deny all; } }rewrite 那一行是把不存在对应静态文件的 URL 转给 index.php在 ThinkPHP 6 里等价于?s$1的路由参数传递。fastcgi_pass 用 unix socket 而不是 127.0.0.1:9000一是减少 TCP 开销二是避免 php-fpm 监听地址被占用时排查半天。最后一条 deny all 是给 .env 和日志文件上保险这类文件一旦被直接访问数据库账号密码等于裸奔。PC 和 WAP 双端的识别在访客入口控制器里做不加额外的 URL 前缀因为公众号场景下用户点开的是同一个域名下的链接等跳转后再按来源区分才能保证会话不割裂public function index(Request $request) { $ua strtolower($request-header(user-agent, )); $isMobile str_contains($ua, mobile) || str_contains($ua, android) || str_contains($ua, iphone) || str_contains($ua, micromessenger); return view($isMobile ? wap/index : pc/index); }检测到 MicroMessenger 时可以把渠道记录成 mp这样坐席端能看到访客是从公众号来的而不是普通的手机浏览器。WAP 适配里最容易被忽略的是meta nameviewport模板缺了这个标签手机端打开页面会按 PC 宽度渲染整个客服会话框挤在屏幕左侧。4. 公众号渠道对接ThinkPHP 回调验签、消息分发与坐席回复边界4.1 在公众号后台配置服务器 URL 之前先在路由里留接口公众号对接的核心是把微信服务器的消息推送到 ThinkPHP 应用里这一步的入口是“服务器配置”里的 URL。一般会把它映射成一个带 appid 参数的路由方便在同一个公众号体系下区分不同商户的公众号。在 route/app.php 里定义Route::rule(wechat/callback/:appid, wechat.Callback/callback, GET|POST);URL 填成https://kefu.example.com/wechat/callback/xxxx。注意这里必须用真实公网可访问的域名不能用 IP。微信公众号平台要求 GET 请求用于验证POST 请求用于接收消息事件所以路由必须同时放行两种请求方法。如果源码包里已经是多商户版appid 这个参数一定要落到路由变量里后续解析商户时要用它查出对应的 Token 和 EncodingAESKey。4.2 GET 验签与 POST 消息处理的最小代码微信服务器第一次接入时会往回调 URL 发 GET 请求带 signature、timestamp、nonce、echostr 四个参数。验签逻辑是把公众号后台填的 Token 拿出来和 timestamp、nonce 一起排序后做 sha1比较结果。这个流程用 ThinkPHP 控制器写很短public function callback($appid) { $token Merchant::where(appid, $appid)-value(wechat_token); $timestamp $this-request-get(timestamp, ); $nonce $this-request-get(nonce, ); $signature $this-request-get(signature, ); $echostr $this-request-get(echostr, ); $tmp [$token, $timestamp, $nonce]; sort($tmp, SORT_STRING); if (sha1(implode($tmp)) ! $signature) { abort(403, signature mismatch); } if ($this-request-isGet()) { return response($echostr); } return $this-dispatchMessage($this-request-getContent()); }排序用 SORT_STRING 而不是默认的 SORT_REGULAR是为了避免纯数字字符串在比较时被当作整数导致排序结果和微信服务端不一致。GET 请求必须原样返回 echostr否则公众号后台会提示“Token 验证失败”。POST 请求的 body 是一段 XML 消息验签通过后再交给消息分发函数处理。注意微信要求 5 秒内响应所以 dispatchMessage 里不能做耗时操作写库和通知坐席都要异步化。消息分发可以按 MsgType 和 Event 走一个简单的分支protected function dispatchMessage($raw) { $data $this-xmlToArray($raw); $openid $data[FromUserName] ?? ; if (($data[MsgType] ?? ) event) { return $this-handleEvent($data, $openid); } if (($data[MsgType] ?? ) text) { $this-createOrUpdateConversation($openid, $data[Content]); return response(success); } return response(success); }这里返回的success文本是微信的标准确认响应。如果返回空串或者超时微信会重试多次导致同一访客的同一句话被重复写入会话。4.3 访客从公众号发起的会话如何落库与坐席回复边界公众号访客发起会话后系统的动作是把 openid 映射成一个 visitor_id检查这个商户下是否已有未结束会话没有再建一条 channelmp 的会话。事件和系统行为对应关系如下公众号消息/事件客服系统行为subscribe 事件创建或激活公众号访客身份CLICK/VIEW 菜单事件跳转到 WAP 客服对话页channel 记为 mptext 消息创建会话并写入 chat_messageimage 消息保存图片 URL 并通知坐席用户取消关注结束进行中的会话标记访客离线坐席回复公众号消息时要分清楚两种接口边界。5 秒内可以走被动回复直接在回调响应里返回 XML 文本超过 5 秒或者需要主动推送时要调微信公众号“客服消息”接口。客服消息接口不是任何时候都能调它有“用户最近互动时间窗口”的约束具体限制以微信公众平台文档为准。正确的处理方式是坐席在后台点了回复按钮系统先落库再推送给坐席工作台同时检查消息通道如果是公众号渠道就用客服接口发出去失败则进入重试队列。5. 多商户版源码改造实操路由分组、租户中间件与越权拦截5.1 商户后台路由按 controller 子目录分区多商户版和单商户版最大的差异在后台路由的组织方式。单商户把控制器平铺在 app\controller 下没有问题多商户版必须有清晰的模块边界否则几十个商户的功能互相干扰。常见做法是在 controller 目录下再建 merchant 和 admin 两个子目录平台管理员走 admin商户走 merchant。Route::group(merchant, function () { Route::post(login, merchant.Login/index); Route::get(conversation/list, merchant.Conversation/lists); Route::post(conversation/reply, merchant.Conversation/reply); Route::get(seat/list, merchant.Seat/lists); })-middleware(\app\middleware\CheckMerchant::class);控制器文件放到 app/controller/merchant/ 下路由地址里用 merchant.Conversation 指向它。所有商户端接口都挂在同一个路由组下组级别统一加中间件不要在每个控制器里重复写鉴权代码。5.2 解析当前商户的 ThinkPHP 中间件商户身份从哪来是这部分要解决的第一个问题。如果是网页后台登录后把商户 ID 写进 session如果是 API建议放到登录接口签发的 token 里请求时通过 header 或参数带回。中间件拿到 ID 后查一次数据库并把商户对象挂到 Request 上后续控制器直接取用namespace app\middleware; use Closure; use think\Request; use app\common\model\Merchant; class CheckMerchant { public function handle(Request $request, Closure $next) { $tenantId $request-param(tenant_id); if (empty($tenantId)) { $tenantId $request-header(X-Tenant-Id); } $merchant Merchant::where(id, $tenantId) -where(status, 1) -find(); if (!$merchant) { abort(403, tenant unavailable); } $request-merchant $merchant; return $next($request); } }这里有两个地方要留意。header 传参的 X-Tenant-Id 只适合内网可信环境如果服务直接暴露在公网必须配合签名机制否则任何人都能伪造商户身份。商户过期状态应该在这里校验而不是放到每个控制器里判断一旦有商户忘记续费平台方只需要改 merchant.status所有入口立刻失效。5.3 数据隔离的兜底全局查询约束和 SQL 监听多商户版源码的越权风险集中在查询环节。一个有经验的做法是给所有业务模型加一个全局查询作用域让 tenant_id 条件永远自动拼接。在 ThinkPHP 6 里可以用模型事件或访问器实现最简单的是在模型基类里统一处理namespace app\common\model; use think\Model; use think\facade\Request; class BaseMerchantModel extends Model { protected function onBeforeQuery($query) { $merchant Request()-merchant ?? null; if ($merchant) { $query-where(tenant_id, $merchant-id); } } }然后让 Conversation、ChatMessage、MerchantSeat 这些模型都继承 BaseMerchantModel 而不是直接继承 Model。这样即便业务代码里忘记补 whereSQL 也会自动带上当前商户的隔离条件把“人肉保证隔离”变成“结构保证隔离”。隔离方案开发成本隔离强度适用规模每商户独立数据库高最高大客户专用实例每商户独立表中中表结构长期稳定的场景共享表 tenant_id低依赖代码约束多商户在线客服最常见做法验证隔离是否彻底可以在本地用 Db::listen 监听 SQL。接口请求时打开日志看是否每条 SELECT 都带上了 tenant_id 条件。凡是漏掉的条件基本就是横向越权的候选点。6. 上线前校准四个参数并发、超时、队列与 access_token 缓存6.1 队列消费者参数和消息超时退化在线客服的消息链路里任意一环阻塞都会表现为“访客发消息没反应”。常见做法是用进程常驻模型接收消息队列例如 think-queue 配合 supervisor 运行消费者。上线前需要确认三个值消费者进程数、单次任务超时时间、失败重试次数。进程数不要超过 CPU 逻辑核心数的两倍超时时间按最大消息处理时长估算一般 30 到 60 秒足够。重试次数要给但不能无限重试否则一条坏消息会卡死整个队列。6.2 公众号 access_token 集中缓存与失效回退公众号所有接口调用都依赖 access_token这个值有效期为 7200 秒官方建议全局缓存并在过期前刷新。最稳的做法是封装一个读取函数业务侧不关心 token 从哪来$key wechat:access_token:{$appid}; $token Cache::get($key); if (!$token) { $res Http::get(https://api.weixin.qq.com/cgi-bin/token, [ grant_type client_credential, appid $appid, secret $secret, ]); $data json_decode($res, true); if (!empty($data[access_token])) { Cache::set($key, $data[access_token], $data[expires_in] - 300); } }缓存过期时间设置成expires_in - 300预留 5 分钟提前刷新避免 token 恰好过期导致坐席回复失败。如果公众号后台重置过密钥旧的 access_token 立即失效这时候排查方向是先清 Redis 缓存再确认密钥是否被运营人员改过。还有三个非功能性参数建议压在配置中心访客端消息轮询间隔、长连接心跳超时、坐席离线判定阈值。轮询间隔太短会打满 PHP-FPM 进程太长访客觉得卡。一般访客端 3 秒轮询、坐席端走 WebSocket 心跳 30 秒一次是比较常见的组合。上线验证时按这个顺序跑清空队列和 Redis重启消费者进程在公众号后台重新保存一次服务器配置观察回调日志里出现 echostr 通过再放一批真实访客进入。本文还有配套的精品资源点击获取