ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP多租户SaaS系统:微信小程序公众号数据隔离与回调验签实战

PHP多租户SaaS系统:微信小程序公众号数据隔离与回调验签实战 简介这是一套基于PHP构建的微信小程序与公众号SaaS管理系统源码适合具备一定PHP开发经验的开发者、技术团队及需要快速搭建多租户公众号/小程序管理平台的运维人员。系统围绕公众号与小程序账号绑定、模板消息、菜单管理、用户管理等典型场景提供前后端完整工程结构。压缩包共1335个文件大小13.6MB其中683个PHP文件承载核心业务逻辑JS/CSS配合JSON、TPL等文件构成前端页面与交互SQL文件用于初始化数据库图片素材覆盖界面元素和运营配图整体目录划分清晰便于二次开发。已有92人学习浏览适合作为学习SaaS架构、微信生态接口对接的参考案例。通过源码可了解多商户权限设计、公众号配置流程、小程序接口调用及后台管理功能模块的实现思路有助于快速吸收并改造出符合自身业务的系统。1. 微信小程序公众号SaaS系统的PHP多租户切入点接手这套基于PHP的微信小程序公众号SaaS管理系统时压缩包里横着不少CSS资源amazeui.min.css、layui.css、wechat.app.css、wechat.diy.css、main.css挨个躺在目录里第一眼很容易当成一个普通后台模板。实际部署之后会发现SaaS化的难点根本不在界面而在多租户数据隔离、公众号token集中管理和回调验签这三件事。如果你要同时服务几十个公众号或小程序给每家分配独立app_id和权限边界这个项目的结构能帮你省掉不少从零趟雷的时间。它适合两类人一类是手里接管多账号矩阵、需要统一后台和统一模板消息分发的人另一类是还没做过共享表结构下租户鉴权、想拿真实代码看PHP怎么实现SaaS边界的人。多租户不是把数据库字段加个merchant_id就完事真正的麻烦在于所有查询都必须自动带上这个条件所有缓存都要防止串租户。这套系统的价值是把公众号、小程序、用户、模板消息、素材这些模块都挂在同一套多租户框架下后台样式只是外衣数据流向才是骨架。下面从最要紧的数据隔离开始拆。2. 多租户数据表结构与PHP查询改写实践2.1 共享表结构下的租户标识选择小规模SaaS最常用的还是共享库共享表每张业务表加一个租户标识字段。独立库隔离性最强但资源成本高独立schema居中但也需要额外维护连接信息对PHP应用来说意味着动态切换PDO连接字符串部署阶段很容易漏配。而共享库模式只需在写入时固定租户ID查询强制追加条件就能把成本压到最低。这套系统看起来就是共享库的玩法。核心表设计一般会先落一张租户主表再挂在用户表。下面是一个常用结构CREATE TABLE merchant ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(120) NOT NULL DEFAULT COMMENT 商户名称, app_id varchar(64) NOT NULL DEFAULT COMMENT 微信公众号或小程序app_id, app_secret varchar(128) NOT NULL DEFAULT COMMENT app_secret, status tinyint NOT NULL DEFAULT 1, created_at int NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_appid (app_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;merchant表一个id对应一组微信凭据。app_id加索引是为了回调验签时快速定位租户否则signature进来后要先扫全表找token并发一高就会慢。业务表上也要建同样的merchant_id联合索引比如模板消息表CREATE TABLE message_template ( id int unsigned NOT NULL AUTO_INCREMENT, merchant_id int unsigned NOT NULL DEFAULT 0, title varchar(200) DEFAULT , content varchar(1000) DEFAULT , template_no varchar(64) DEFAULT , create_time int NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_merchant (merchant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里把merchant_id作为业务的强制外键约束但不在物理层做FOREIGN KEY因为SaaS后台经常要跨商户迁移数据物理外键会引起锁冲突。只保留索引让代码层保证归属关系。2.2 PHP查询改写的统一入口如果每个业务模型里都写一遍AND merchant_id ?很容易漏而且后续要加软删除条件时又得全部改一遍。我一般在数据层外面包一层查询作用域把租户条件自动压进SQL。class MerchantScope { protected PDO $pdo; public function __construct(PDO $pdo) { $this-pdo $pdo; } public function scope(string $sql, array $params []): PDOStatement { $merchantId $this-resolveMerchantId(); if ($merchantId 0) { throw new DomainException(merchant not initialized); } $where stripos($sql, WHERE) ! false ? AND : WHERE ; $sql . $where . merchant_id :merchant_id_scope; $params[:merchant_id_scope] $merchantId; $stmt $this-pdo-prepare($sql); $stmt-execute($params); return $stmt; } private function resolveMerchantId(): int { $uid $_SESSION[uid] ?? 0; $stmt $this-pdo-prepare(SELECT merchant_id FROM user WHERE id ?); $stmt-execute([$uid]); $row $stmt-fetch(); return $row ? (int) $row[merchant_id] : 0; } }scope()做的事很简单判断原SQL里是否已经有WHERE有就在后面接AND merchant_id :merchant_id_scope没有就新增一个WHERE。要注意这里只适合单表查询或简单查询如果SQL里有子查询、UNION或JOIN拼接会落到错误的位置。遇到复杂查询时应该先写子查询再把租户条件放入子查询或者在构造器层面强制传入租户ID。这种写法的好处是数据入口单一后续改成ORDER BY限流、增加角色节点过滤时都只动一处。坏处是scope()对原SQL有侵入调试时必须打开MySQL general log才能看到最终执行语句。我在生产环境会增加一个调试开关env里把SQL_LOG打开后就把拼好的SQL写到日志文件。2.3 租户隔离方案的实际选择方案隔离级别成本运维复杂度适用场景独立库物理隔离高需要维护多个连接客户有合规要求独立schema中等中动态切换schema中小集群共享库merchant_id代码隔离低最容易低预算、账号量大表中第三种方案最依赖代码执行纪律所以在线请求的入口必须有一道统一鉴权先查出当前用户对应的merchant_id和商户状态再放行到业务控制器。class AuthMiddleware { public function handle($route, array $request): mixed { $uid $request[session][uid] ?? 0; if (!$uid) { return jsonResponse([code 401, msg login required]); } $merchant $this-loadMerchant($uid); if (!$merchant || (int) $merchant[status] ! 1) { return jsonResponse([code 403, msg merchant unavailable]); } $request[attributes][merchant_id] (int) $merchant[id]; return $route-dispatch($request); } }这个中间件把merchant_id挂到请求属性里后续所有业务控制器从请求上下文取不再信任前端传的商户参数。只要前端手动传merchant_id的值一概忽略就能避免水平越权。实际操作里我还遇到过调用方把商户参数藏在数组里导致覆盖的问题所以进入控制器前会用array_filter删掉业务参数中的merchant_id键保证源头干净。3. 微信小程序与公众号回调验签及token缓存的PHP实现3.1 服务器地址验证的签名算法公众号或小程序后台配置服务器地址时微信会带着signature、timestamp、nonce、echostr四个参数打到回调URL。你要做的第一件事不是去处理业务消息而是验签。签名算法是把token、timestamp、nonce按字典序排序拼接成一个字符串后做SHA1再与signature比对。$request $_GET; $signature $request[signature] ?? ; $timestamp $request[timestamp] ?? ; $nonce $request[nonce] ?? ; $echostr $request[echostr] ?? ; $token APP_TOKEN; $tmpArr [$token, $timestamp, $nonce]; sort($tmpArr, SORT_STRING); $tmpStr sha1(implode($tmpArr)); if ($tmpStr $signature !empty($echostr)) { echo $echostr; exit; }注意token的来源。在SaaS场景里每个租户的公众号配置的token都不一样所以验签前必须先用app_id反查出对应的token。常见做法是在回调URL上带上一个固定的appid参数比如/wechat/callback?appidwx123456验签时用这个appid查出merchant表里的token再参与计算。否则微信推过来的请求里没有猫腻但你无从判断是哪个租户的消息后面就全都乱套。3.2 access_token 集中缓存与并发锁微信接口的access_token有效期7200秒同一个公众号最多只能同时持有有效token一旦并发请求就会互相挤下线。SaaS后台往往有多个公众号如果每次调用都实时获取很快会撞到每日额度上限。正确做法是把token集中缓存并加上并发锁。function getAccessToken($appId, $appSecret) { $cacheKey wx:token:{$appId}; $token $redis-get($cacheKey); if ($token) { return $token; } $lockKey $cacheKey . :lock; $lock $redis-set($lockKey, 1, [NX, PX 10000]); if ($lock) { $url https://api.weixin.qq.com/cgi-bin/token . ?grant_typeclient_credentialappid{$appId}secret{$appSecret}; $res json_decode(file_get_contents($url), true); if (!empty($res[access_token])) { $redis-set($cacheKey, $res[access_token], [EX 7000]); $redis-del($lockKey); return $res[access_token]; } internalLog(wechat_token_error, $res); } usleep(200000); return $redis-get($cacheKey); }这里的关键参数有两个NX表示只在键不存在时才能写入确保同一时间只有一个进程去请求微信接口EX设为7000秒而不是7200秒是为了给微信服务端留一点容错时间避免刚好在临界点请求时token失效。锁的等待方式我用的是sleep重试进程会阻塞200毫秒后重新读缓存正常场景下第二次读取都会命中。需要说一下为什么不用文件锁生产环境Nginx往往有多个PHP-FPM工作进程跨进程的文件锁处理起来很麻烦而且一旦进程异常退出锁文件容易残留。Redis锁也要配合PX过期时间防止持有锁的进程崩溃后变成死锁。这里给PX的过期时间比接口请求预计耗时大得多正常网络下几百毫秒就完事所以10秒足够。3.3 模板消息与订阅消息的队列化发送SaaS后台的一大核心功能是给用户的公众号粉丝或小程序用户下发模板消息。很多人直接在HTTP请求里同步调微信接口导致PHP进程被网络IO卡住用户量一大就出现502。function sendSubscribeMessage($openid, $templateId, $data, $page ) { $appId $GLOBALS[current_merchant][app_id]; $appSecret $GLOBALS[current_merchant][app_secret]; $token getAccessToken($appId, $appSecret); $payload [ touser $openid, template_id $templateId, data $data, ]; if ($page) { $payload[page] $page; } $url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token{$token}; $ch curl_init($url); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [Content-Type: application/json]); $response json_decode(curl_exec($ch), true); if (isset($response[errcode]) $response[errcode] ! 0) { logSendFailure($openid, $templateId, $response); } return $response; }$data的结构必须和微信后台模板字段一一对应比如{thing1:{value:名称},time2:{value:2025-03-21 10:00}}。页面提交的字段和微信的模板字段经常对不上所以我一般会在后台模板管理页面做一个字段映射配置保存成JSON后发送时再拼装。这里建议把发送动作丢进Redis队列后台跑一个常驻Worker去消费接口只负责往队列里推任务。消息类型场景有效时间发送限制公众号模板消息订单通知2小时内受模板行业限制小程序订阅消息服务进度一次性订阅点击授权后7天内一次性订阅消息预约提醒不可重复订阅需引导再次授权之所以要集中处理是因为微信对单个用户单类模板消息有次数限制队列化之后可以按openid做去重和频控避免同一分钟给一个用户推三条消息被微信封禁。4. 管理后台CSS资源调度与菜单权限的PHP实现4.1 从样式文件定位后台技术栈资源包里同时出现了amazeui.min.css、layui.css、wechat.app.css、wechat.diy.css、main.css、style.css、style.min.css。这很典型旧版后台常用LayUI做表格弹窗AmazeUI做页面组件后面再叠一层自己的wechat.diy.css覆盖默认皮肤。搭建页面时CSS的加载顺序比选哪个框架更重要处理不好就会遇到按钮样式错位、下拉框和弹窗定位不准。link relstylesheet href/static/plugin/layui/css/layui.css link relstylesheet href/static/css/amazeui.min.css link relstylesheet href/static/css/wechat.app.css link relstylesheet href/static/css/main.css link relstylesheet href/static/css/wechat.diy.csswechat.diy.css放在最后可以覆盖前两个框架默认组件的颜色和圆角同时不会破坏基础布局。实际项目中如果用户反馈样式改了没生效先检查Composer或Webpack打包后的静态资源路径是否带版本号浏览器缓存通常才是元凶。给CSS加版本戳最省事的办法是让PHP入口在输出模板时拼上文件修改时间function asset($path) { $full public_path() . $path; $ver filemtime($full); return $path . ?v . $ver; }这样每次文件改动后?v参数都会变浏览器不会再用旧缓存。代码里所有link标签都走asset()方法上线后处理样式不同步就很清爽。4.2 菜单表与权限点校验SaaS后台不能只做一级菜单每个租户要看哪些菜单需要用角色和菜单表关联。菜单表里要同时存路由标识和排序值方便界面渲染和权限校验共用。CREATE TABLE menu ( id int unsigned NOT NULL AUTO_INCREMENT, parent_id int NOT NULL DEFAULT 0, name varchar(50) NOT NULL DEFAULT , route varchar(150) NOT NULL DEFAULT , sort int NOT NULL DEFAULT 0, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE role_menu ( role_id int NOT NULL DEFAULT 0, menu_id int NOT NULL DEFAULT 0, PRIMARY KEY (role_id,menu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;菜单表用parent_id做树形结构二级菜单的路由是/order/list这种相对路径。角色表和菜单表关联后权限校验逻辑只需要判断当前角色的菜单集合里是否包含请求路由对应的菜单ID。function hasAccess($route, $roleId) { static $cache []; if (!isset($cache[$roleId])) { $stmt $pdo-prepare(SELECT menu_id FROM role_menu WHERE role_id ?); $stmt-execute([$roleId]); $menuIds array_map(intval, $stmt-fetchAll(PDO::FETCH_COLUMN)); $cache[$roleId] $menuIds; } else { $menuIds $cache[$roleId]; } $stmt $pdo-prepare(SELECT id FROM menu WHERE route ? AND status 1 LIMIT 1); $stmt-execute([$route]); $menu $stmt-fetch(PDO::FETCH_ASSOC); return $menu in_array((int) $menu[id], $menuIds, true); }函数里用静态变量做请求级的缓存避免同一个页面里查十次权限时每次都执行SQL。但要注意角色配置变更后静态缓存不会自动失效所以后台保存角色权限时应该调用clearAccessCache()把静态变量清理掉。更稳妥的做法是把菜单集合缓存到Redis用角色ID作为键权限更新后删掉对应键。4.3 接口返回格式与前端表格对接无论LayUI还是AmazeUI表格插件都期望接口返回固定结构。后台提供统一响应函数能拦住一多半前端联调问题。function respond($code 0, $msg ok, $data null) { header(Content-Type: application/json; charsetutf-8); echo json_encode([ code $code, msg $msg, data $data, ], JSON_UNESCAPED_UNICODE); exit; }LayUI表格默认读取data.data字段如果你的系统把列表放在data.list里前端就要写parseData。我的习惯是后端统一把list、count都塞进data对象前端表格done回调里再做分页和序号拼接。实际项目里还需要约定code401时统一跳登录页这个逻辑不用每个页面单独写全局在$.ajax错误回调里统一判断。code含义处理方式0成功正常渲染400参数错误提示msg401未登录跳转登录403无权限隐藏入口500系统异常记录日志5. 部署验证中的Nginx伪静态、定时任务与微信验证文件挂载5.1 Nginx路由重写这类PHP系统通常不是原生PHP路由而是用前端控制器模式。部署时Nginx要先把不存在的文件路径转发到index.php。location / { if (!-e $request_filename) { rewrite ^/(.*)$ /index.php?s$1 last; } }$request_filename指请求的绝对路径如果对应文件存在Nginx直接返回静态文件不存在才交给PHP-FPM。这条规则保证了/wechat/callback这类伪静态路径能进入控制器而不是报404。注意如果项目根目录是publicroot和index都需要单独确认路径配错了最常见的现象是后台能开但接口全404。5.2 微信验证文件挂载公众号后台要求上传微信验证TXT文件时文件需要能被www.xx.com/MP_verify_xxxx.txt直接访问。如果你把文件放在public目录下Nginx的if (!-e $request_filename)会直接命中真实文件不需要额外配置。但有的框架会强制把所有请求交给index.php再处理那就得用精确匹配放行验证文件。location /MP_verify_Bn3mK4.txt { root /home/wwwroot/saas/public; }这里必须用精确匹配避免已经存在的业务路由被覆盖。验证文件本身没有业务逻辑但要注意别把文件名写死在代码里租户后台应该允许每个商户自行上传自己的验证文件上传时要对文件内容做白名单校验只允许纯文本内容。5.3 定时清理与队列消费后台大量依赖异步任务模板消息发送、素材拉取、二维码生成。部署后我一般会在crontab里挂两个任务。*/5 * * * * php /home/wwwroot/saas/artisan queue:work --stop-when-empty */30 * * * * php /home/wwwroot/saas/cron/clear_expired_session.phpqueue:work --stop-when-empty会处理完当前队列所有任务后退出再由cron每5分钟拉起一次避免常驻Worker内存泄漏。clear_expired_session.php用来清理超时的用户会话。两个任务的时间间隔可以根据服务器CPU负载调整如果消息量很大就把队列任务拆成每分钟一次但要注意多个Worker同时消费时access_token缓存锁的过期时间要大于最长请求耗时。验证整套系统是否可用最直接的方式是先用curl模拟一次带echostr的回调再检查返回内容是否原样输出随后调用一个需要登录的接口看是否返回401最后看PHP错误日志里有没有merchant_id为0的异常记录。只要这三点都通后台能刷新菜单、回调能验签整个系统基本就站稳了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进