简介:这是一套基于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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里把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?appid=wx123456,验签时用这个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_type=client_credential&appid={$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 rel="stylesheet" href="/static/plugin/layui/css/layui.css"> <link rel="stylesheet" href="/static/css/amazeui.min.css"> <link rel="stylesheet" href="/static/css/wechat.app.css"> <link rel="stylesheet" href="/static/css/main.css"> <link rel="stylesheet" href="/static/css/wechat.diy.css">wechat.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `role_menu` ( `role_id` int NOT NULL DEFAULT '0', `menu_id` int NOT NULL DEFAULT '0', PRIMARY KEY (`role_id`,`menu_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;菜单表用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; charset=utf-8'); echo json_encode([ 'code' => $code, 'msg' => $msg, 'data' => $data, ], JSON_UNESCAPED_UNICODE); exit; }LayUI表格默认读取data.data字段,如果你的系统把列表放在data.list里,前端就要写parseData。我的习惯是后端统一把list、count都塞进data对象,前端表格done回调里再做分页和序号拼接。实际项目里还需要约定code=401时统一跳登录页,这个逻辑不用每个页面单独写,全局在$.ajax错误回调里统一判断。
| code | 含义 | 处理方式 |
|---|---|---|
| 0 | 成功 | 正常渲染 |
| 400 | 参数错误 | 提示msg |
| 401 | 未登录 | 跳转登录 |
| 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。注意如果项目根目录是public,root和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的异常记录。只要这三点都通,后台能刷新菜单、回调能验签,整个系统基本就站稳了。
本文还有配套的精品资源,点击获取