尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ThinkPHP6多租户客服系统:租户隔离架构实战

ThinkPHP6多租户客服系统:租户隔离架构实战 简介这是一套基于ThinkPHP6深度定制的SaaS化多商户客服系统源码面向中小企业技术负责人、PHP开发者及需要私有化部署客服能力的网站运营者解决多品牌独立管理、数据自主可控与跨平台快速集成等核心痛点。资源包共5223个文件主体为2985个PHP业务逻辑与控制器文件、364个JS前端交互脚本、353个GIF/PNG/JPG等UI资源以及143个CSS样式与84个Markdown文档完整覆盖后端服务、WebSocket实时通信、LayUI管理后台与多商户权限体系压缩包达311.96MB。已有436人学习下载适合中高级PHP工程师进行二次开发实践。购买后可获得全部开源源码含SSL加密隧道实现、无坐席/商家数量限制的弹性架构设计、支持WordPress/DedeCMS等任意CMS的轻量级HTML代码嵌入式集成方案以及清晰的config配置、functions工具函数与readme说明体系便于快速部署与功能定制。1. ThinkPHP6 多商户客服系统不是「插件式」而是「租户隔离式」的 SaaS 架构落地很多团队在选型客服系统时第一反应是找现成 SaaS 服务——但一旦涉及客户数据合规、定制化话术、与自有 CMS 深度嵌入比如 WordPress 页面级会话绑定、DedeCMS 文章页悬浮入口第三方服务就卡在「权限不可控」和「样式不可改」两个硬伤上。这套基于 ThinkPHP6 Swoole PHP8 实现的多商户客服系统本质不是把单体客服功能「包装成多租户」而是从数据库设计、路由分发、会话上下文到静态资源加载全部按「租户维度」隔离。每个注册商家拥有独立的merchant_id上下文其坐席账号、聊天记录、知识库、访客标签全部通过tenant_id字段物理隔离Swoole Worker 进程内通过Swoole\Coroutine\Channel实现跨商户消息路由避免传统 HTTP 轮询导致的连接抖动。它适合需要自主掌控数据主权、已有 PHP 技术栈、且需支持 WordPress/DedeCMS/PHPCMS 等任意 CMS 前端嵌入的中小技术团队——你不需要重构网站只需在 HTMLhead插入一段 JS 初始化代码就能让访客对话数据自动归属对应商户。2. 多租户架构核心ThinkPHP6 多应用模式下的 URL 路由与数据库隔离策略2.1 为什么必须用 ThinkPHP6 的多应用模式而非单应用多模块ThinkPHP6 的多应用模式app/multi_app天然支持app_name作为 URL 第一层路径如/admin,/api,/merchant而本系统将每个商户映射为一个独立子应用app/merchant_{id}这是实现真正租户隔离的关键。单应用多模块如app/controller/Merchant/Index.php虽可共用模型但路由前缀、中间件、配置文件无法按商户动态加载容易在缓存、日志、异常处理环节出现跨租户污染。多应用模式下每个merchant_{id}目录自带完整config/,route/,middleware/且可通过think app:make merchant_123命令批量生成避免手动复制粘贴导致的配置漂移。提示不要试图用Route::domain()做域名级隔离如shop1.example.com本系统采用路径级隔离/m/123/chat因多数客户 CMS 无法配置泛域名解析且路径路由更利于 Nginx 反向代理统一收敛。2.2 商户路由注册与动态域名适配系统在app/merchant/common/route.php中定义统一入口// app/merchant/common/route.php use think\facade\Route; // 所有商户共享的公共路由不带商户ID Route::get(login, Login/index)-name(merchant.login); Route::post(login, Login/check)-name(merchant.login.check); // 动态商户路由/m/{merchant_id}/xxx Route::group(m/:merchant_id, function () { // 坐席后台 Route::get(dashboard, Dashboard/index)-name(merchant.dashboard); Route::get(chat/list, Chat/list)-name(merchant.chat.list); // 访客前端 Route::get(widget.js, Widget/js)-name(merchant.widget.js); Route::post(message/send, Message/send)-name(merchant.message.send); })-middleware(check_merchant);关键在于check_merchant中间件的实现// app/merchant/middleware/CheckMerchant.php ?php declare(strict_types1); namespace app\merchant\middleware; use think\Request; use think\Response; use app\common\model\Merchant; class CheckMerchant { public function handle(Request $request, \Closure $next) { $merchantId $request-param(merchant_id, 0, intval); if (!$merchantId) { return Response::create(Invalid merchant ID, html, 400); } // 从缓存获取商户配置避免每次查DB $cacheKey merchant_config_ . $merchantId; $config cache($cacheKey); if (!$config) { $merchant Merchant::where(id, $merchantId)-find(); if (!$merchant || !$merchant-status) { return Response::create(Merchant not found or disabled, html, 404); } // 缓存1小时含数据库连接参数 $config [ db_config [ hostname $merchant-db_host ?: config(database.hostname), database $merchant-db_name ?: config(database.database), username $merchant-db_user ?: config(database.username), password $merchant-db_pass ?: config(database.password), ], domain $merchant-domain, theme $merchant-theme_color ?: #007bff, ]; cache($cacheKey, $config, 3600); } // 动态切换数据库连接 \think\facade\Db::connect($config[db_config])-setConfig($config[db_config]); // 将商户信息注入 Request 对象供后续控制器使用 $request-withAttr(merchant, $merchant); $request-withAttr(merchant_config, $config); return $next($request); } }2.2.1 参数说明与安全边界merchant_id必须为整型且非零防止 SQL 注入和路径遍历如../etc/passwdcache($cacheKey)使用 Redis 或 File 缓存避免高并发下 DB 查询风暴Db::connect()动态创建连接而非复用默认连接确保事务、查询日志、连接池完全隔离$request-withAttr()是 ThinkPHP6 新增的请求属性注入机制比全局变量更安全可控。2.3 数据库租户隔离物理分库 vs 逻辑分表的取舍系统默认采用「逻辑分表 tenant_id 字段」方案所有商户共用同一套表结构如chat_message,chat_session,merchant_staff但每张表强制添加tenant_id INT UNSIGNED NOT NULL DEFAULT 0字段并在模型基类中自动注入查询条件// app/common/model/BaseModel.php ?php namespace app\common\model; use think\Model; abstract class BaseModel extends Model { protected function initialize() { parent::initialize(); // 自动添加 tenant_id 条件仅当 request 中存在 merchant 属性 if (request()-hasAttr(merchant)) { $this-where(tenant_id, request()-attr(merchant.id)); } } }但生产环境强烈建议升级为「物理分库」理由如下维度逻辑分表物理分库数据隔离强度依赖 WHERE 条件误操作可能跨租户删数据底层 MySQL 实例隔离彻底杜绝越权查询性能单表数据量大时索引失效风险高如chat_message表超千万行每库数据量可控索引效率稳定备份恢复全库备份耗时长单商户恢复需从全量中提取可按商户粒度单独备份/恢复运维复杂度低无需修改连接池配置需配合sharding-jdbc或自研分库路由中间件注意若启用物理分库CheckMerchant中间件中的Db::connect()必须传入预定义的连接名如db_merchant_123并在config/database.php中预先声明所有商户连接配置或通过Db::setConnection()动态注册。3. Swoole 实时通信层WebSocket 连接池与跨商户消息路由实现3.1 Swoole WebSocket Server 启动与进程模型系统使用swoole_http_server承载 WebSocket 服务非swoole_websocket_server因其支持 HTTP 升级协议且可复用 ThinkPHP6 的路由中间件# 启动命令需在项目根目录执行 php think swoole:server --host0.0.0.0 --port9502 --daemon对应命令类app/command/SwooleServer.php// app/command/SwooleServer.php ?php declare(strict_types1); namespace app\command; use think\console\Command; use think\console\Input; use think\console\Output; use Swoole\Http\Server; use Swoole\Http\Request; use Swoole\Http\Response; use Swoole\WebSocket\Frame; use Swoole\WebSocket\Server as WebSocketServer; class SwooleServer extends Command { protected function configure() { $this-setName(swoole:server)-setDescription(Start Swoole WebSocket server); } protected function execute(Input $input, Output $output) { $server new Server(0.0.0.0, 9502); $server-set([ worker_num 4, task_worker_num 2, max_request 5000, daemonize $input-getOption(daemon) ? 1 : 0, log_file runtime_path() . swoole.log, ]); // HTTP 请求处理用于 /m/{id}/widget.js 等静态资源 $server-on(request, function (Request $request, Response $response) { // 复用 ThinkPHP6 的 HTTP 路由 $app app(); $app-http-run($request, $response); }); // WebSocket 连接建立 $server-on(open, function (WebSocketServer $server, $request) { // 解析 URL 中的 merchant_id 和 session_id $query parse_url($request-server[request_uri], PHP_URL_QUERY); parse_str($query, $params); $merchantId $params[m] ?? 0; $sessionId $params[s] ?? uniqid(sess_); // 将连接存入 Redis 连接池key: merchant:{m}:session:{s} $redis \think\facade\Cache::store(redis)-handler(); $redis-hSet(merchant:{$merchantId}:connections, $sessionId, $request-fd); $redis-expire(merchant:{$merchantId}:connections, 86400); // 24小时过期 // 发送欢迎消息 $server-push($request-fd, json_encode([type welcome, msg Connected])); }); // 消息接收 $server-on(message, function (WebSocketServer $server, Frame $frame) { $data json_decode($frame-data, true); if (!$data || !isset($data[type])) return; switch ($data[type]) { case chat_msg: $this-handleChatMessage($server, $frame, $data); break; case typing: $this-broadcastTyping($server, $frame, $data); break; } }); // 连接关闭 $server-on(close, function (WebSocketServer $server, $fd) { // 从 Redis 清理连接 $redis \think\facade\Cache::store(redis)-handler(); $redis-hDel(merchant:*:connections, $fd); // 通配符需业务层遍历清理 }); $server-start(); } private function handleChatMessage($server, $frame, $data) { // 从 fd 反查 merchant_id实际应从 Redis 存储的 fd→merchant 映射中获取 $redis \think\facade\Cache::store(redis)-handler(); $merchantId $redis-get(fd_to_merchant_{$frame-fd}) ?: 0; if (!$merchantId) return; // 写入数据库异步投递到 task worker $server-task([type save_message, data $data, merchant_id $merchantId]); // 广播给坐席根据坐席在线状态筛选 $staffList $redis-smembers(merchant:{$merchantId}:online_staff); foreach ($staffList as $staffFd) { if ($server-isEstablished($staffFd)) { $server-push($staffFd, json_encode([ type new_message, data $data, from visitor ])); } } } }3.1.1 关键参数与部署约束worker_num4每个 Worker 进程处理一个 CPU 核心避免线程竞争task_worker_num2专用于耗时 DB 写入防止阻塞 WebSocket 主循环max_request5000Worker 进程处理 5000 个请求后自动重启防止内存泄漏daemonize1生产环境必须守护进程化否则终端关闭即退出log_file必须写入可写目录否则 Swoole 启动失败无提示。3.2 跨商户消息路由Redis Hash 结构实现连接映射Swoole 的fd文件描述符是进程内唯一标识但不同 Worker 进程间不可互通。因此必须借助外部存储Redis做连接元数据同步Redis Key类型用途过期时间fd_to_merchant_{fd}Stringfd → merchant_id映射24hmerchant:{id}:connectionsHashsession_id → fd映射用于主动推送24hmerchant:{id}:online_staffSet在线坐席 fd 列表用于广播30m心跳续期坐席登录时触发// app/merchant/controller/Staff.php public function login() { $staff StaffModel::where(account, input(account))-find(); if (!$staff) return json([code 400, msg Account not found]); // 将坐席 fd 存入 Redis Set $redis \think\facade\Cache::store(redis)-handler(); $redis-sAdd(merchant:{$staff-tenant_id}:online_staff, $this-request-fd); $redis-expire(merchant:{$staff-tenant_id}:online_staff, 1800); // 30分钟 // 绑定 fd 与 merchant_id $redis-set(fd_to_merchant_{$this-request-fd}, $staff-tenant_id); $redis-expire(fd_to_merchant_{$this-request-fd}, 86400); }提示sAdd和expire必须原子执行否则出现坐席在线但 Redis 未设置过期时间导致长期占用内存。建议封装为 Lua 脚本调用。4. 前端嵌入与 CMS 适配LayUI 组件化与无侵入式集成方案4.1 LayUI 客服 Widget 的模块化封装系统提供widget.js作为前端 SDK通过document.currentScript自动识别加载位置无需手动传参// /m/{id}/widget.js 返回内容动态生成 !function(){ var script document.currentScript; var url script.src; var match url.match(/\/m\/(\d)\/widget\.js/); var merchantId match ? match[1] : 0; // 创建 iframe 避免样式冲突 var iframe document.createElement(iframe); iframe.src /m/ merchantId /widget.html?r Math.random(); iframe.style.cssText position:fixed;bottom:20px;right:20px;width:360px;height:500px;border:none;z-index:9999;; iframe.setAttribute(allowtransparency, true); document.body.appendChild(iframe); // 监听 iframe 消息访客提交表单后关闭 window.addEventListener(message, function(e) { if (e.source iframe.contentWindow e.data close_widget) { iframe.remove(); } }); }();widget.html内部使用 LayUI 的layer和form模块所有 CSS 加载均加layui-merchant-前缀!-- widget.html -- !DOCTYPE html html head link relstylesheet href/static/layui/css/layui.css style .layui-merchant-chat { position: relative; width: 100%; height: 100%; } .layui-merchant-input { border-top: 1px solid #eee; } /style /head body div classlayui-merchant-chat div idchat-list classlayui-merchant-chat-list/div div classlayui-merchant-input input typetext idmsg-input placeholder输入消息... classlayui-input button classlayui-btn layui-btn-primary onclicksendMsg()发送/button /div /div script src/static/layui/layui.js/script script layui.use([jquery, layer], function(){ var $ layui.jquery; var layer layui.layer; // 通过 postMessage 获取父页面 URL 参数如 utm_source window.parent.postMessage({type: get_params}, *); }); /script /body /html4.2 WordPress/DedeCMS/PHPCMS 三类 CMS 的零代码嵌入方式CMS 类型嵌入位置操作步骤注意事项WordPress主题footer.php或使用wp_add_inline_script()在/body前插入script srchttps://your-domain.com/m/123/widget.js/script避免放在wp_head()防止阻塞渲染DedeCMS模板footer.htm在/body前插入相同 script 标签若模板启用 gzip需确认.js文件 MIME 类型为application/javascriptPHPCMSphpcms/templates/default/footer.tpl.php同样插入 script 标签PHPCMS v9 默认禁用外部 JS需在后台「站点设置」→「安全设置」中白名单添加域名注意所有 CMS 嵌入均不需修改 PHP 后端代码仅前端 HTML 注入。访客访问时widget.js自动解析当前页面 URL 中的?m123参数如有否则 fallback 到 script 标签路径中的m/123。5. 生产部署与 SSL 加密隧道配置Nginx 反向代理与 PHP8 兼容性调优5.1 Nginx 配置要点WebSocket 升级与静态资源分离upstream php_backend { server 127.0.0.1:9000; # PHP-FPM } upstream swoole_backend { server 127.0.0.1:9502; # Swoole WebSocket } server { listen 443 ssl http2; server_name your-domain.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; # 静态资源直接由 Nginx 服务提升性能 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { root /var/www/your-project/public; expires 1y; add_header Cache-Control public, immutable; } # ThinkPHP6 路由入口 location / { try_files $uri $uri/ /index.php?$query_string; } # Swoole WebSocket 代理关键Upgrade 头透传 location /ws/ { proxy_pass http://swoole_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; # 长连接超时设为24小时 } # PHP-FPM 处理 location ~ \.php$ { fastcgi_pass php_backend; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # PHP8 特有参数 fastcgi_param PHP_VALUE opcache.enable1; } }5.1.1 SSL 加密隧道验证方法部署后必须验证 TLS 1.2 和 WebSocket 升级是否生效# 1. 检查 TLS 版本应返回 TLSv1.2 或 TLSv1.3 openssl s_client -connect your-domain.com:443 -tls1_2 # 2. 检查 WebSocket 升级头应包含 Upgrade: websocket curl -i -N -H Connection: Upgrade -H Upgrade: websocket \ -H Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw \ https://your-domain.com/ws/ # 3. 测试 Swoole 进程存活 ps aux | grep swoole:server | grep -v grep5.2 PHP8 兼容性关键修复点ThinkPHP6 官方已支持 PHP8但本系统源码中存在两处需手动修复app/common/model/Merchant.php中的json_last_error_msg()调用PHP8.1 废弃该函数需替换为json_last_error() JSON_ERROR_NONE ? : json_last_error_msg()Swoole 扩展版本要求必须使用 Swoolev4.8.13PHP8.1 兼容或v5.0.0PHP8.2 兼容安装命令pecl install swoole-4.8.13 # 或编译安装推荐 git clone https://github.com/swoole/swoole-src.git cd swoole-src git checkout v4.8.13 phpize ./configure --enable-openssl --enable-http2 make sudo make install提示phpinfo()中检查swoole模块是否显示SWOOLE_VERSION若为undefined说明扩展未正确加载需检查extensionswoole.so是否写入php.ini正确路径。5.3 高稳定性压测验证使用 wrk 模拟千人并发聊天部署完成后用wrk验证 Swoole WebSocket 的抗压能力# 安装 wrkUbuntu sudo apt install build-essential libssl-dev git -y git clone https://github.com/wg/wrk.git cd wrk make sudo cp wrk /usr/local/bin # 模拟 1000 个访客连接并发送消息持续30秒 wrk -t12 -c1000 -d30s \ --scriptchat.lua \ --latency \ https://your-domain.com/m/123/widget.js # chat.lua 内容需自行编写 WebSocket 连接逻辑 -- 此处省略 Lua 脚本核心是建立 ws:// 连接并发送 10 条消息后断开预期指标连接成功率 ≥ 99.5%失败率主要来自瞬时网络抖动P99 延迟 ≤ 200ms消息从发送到坐席收到Swoole Worker CPU 使用率 ≤ 70%无 OOM Killer 日志。若 P99 延迟超标优先检查 Redis 连接池是否打满redis-cli info | grep connected_clients其次检查 MySQL 连接数show status like Threads_connected。本文还有配套的精品资源点击获取
返回列表