我用 PHP 自建微框架 + 微信小程序原生开发,做了一个民宿预订 + 住房券 + 三级分销的旅居生态平台
本文记录我从零打造「中游链环住」这套旅居生态平台的完整工程实践:自建 PHP 微框架、5 种住房券生命周期、三级分销四方分润、lazyCodeLoading 兼容方案、7 层安全防护等。代码示例均来自生产环境,希望能给做小程序后端 + 业务系统同学一些参考。
一、为什么不用 Laravel/ThinkPHP,而要自建微框架?
「中游链环住」是一个集民宿预订 + 住房券营销 + 三级分销 + 名片社交 + 智慧门店管理于一体的微信小程序平台,覆盖 5 端:
| 端 | 说明 |
|---|---|
| 微信小程序 | C 端用户主入口(41 个页面,5 个 Tab) |
| 业务员 H5 端 | 业务员管理后台 |
| 租户管理后台 | 民宿商家自助管理 |
| 超管后台 | 平台超级管理员(22 个管理页面) |
| H5 推广页 | 券商城、业务申请、广告等独立推广页面 |
域名 mt.zhongyoulian.cn,76+ API 端点。
很多人第一反应是:这种业务复杂度不上 Laravel/Lumen 是不是疯了?我讲下我的思考:
1. 部署环境约束:生产服务器是宝塔安装的 PHP 7.2 环境,业务方对依赖体积敏感,不想引入 composer 全家桶。
2. 路由控制欲:分销 + 多端认证 + 限流 + 安全过滤,我希望在入口层就完全掌控,而不是被框架"接管"。
3. 性能预期:76+ 接口里有很多高并发场景(券抢购、订单创建、支付回调),框架启动开销越小越好。
于是我把框架做得很薄——单入口 + 路由 + 中间件管道 + 控制器 + 响应,全部代码加起来不到 1000 行。
二、自建 PHP 微框架的骨架
2.1 单入口 + 自动加载
所有 /miniapp/v1/* 请求统一走 backend/index.php,核心入口逻辑:
<?php
// API 统一入口文件
ob_start();
require_once __DIR__ . '/config.php';
// API 入口强制关闭 PHP 错误输出(防止污染 JSON 响应)
ini_set('display_errors', 0);
// 加载 Core 类(PSR-4 风格的手动 require,避免 autoload 开销)
$coreClasses = ['Logger','Database','QueryBuilder','Response','Request','Router',
'JwtAuth','AdminAuth','TenantAuth','BusinessAuth','Upload','WechatPay',
'SubscribeMessage','Security','RateLimiter','ReviewUtils'];
foreach ($coreClasses as $class) {
require_once __DIR__ . '/core/' . $class . '.php';
}
// 加载业务控制器
$modules = ['Auth/AuthController','Banner/BannerController', /* ... 21 个模块 */];
foreach ($modules as $module) {
require_once __DIR__ . '/modules/' . $module . '.php';
}
// 处理 CORS
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
// 请求限流 + 安全过滤
RateLimiter::check($requestUri);
$_GET = Security::filterInput($_GET);
$_POST = Security::filterInput($_POST);
$input = Security::filterInput(json_decode($rawInput, true) ?: []);
// 合并 GET/POST/JSON 参数,统一交给 Request
Request::setParsedData(array_merge($queryParams, $_POST, $input));
// 路由分发
$router = new Router();
require_once __DIR__ . '/router.php';
$result = $router->dispatch($requestMethod, $path);
这里有几个工程上的关键决策:
ob_start()+ 关闭 display_errors:微信支付回调和 JSON 接口对 BOM/警告字符零容忍,输出缓冲是最稳的兜底。- 手动 require 而不是 spl_autoload:76 个类 + 单进程一次请求,require_once 反而比 autoload_register 更快,且部署不依赖 composer。
Request::setParsedData()一次性注入:php://input只能读一次,框架层统一读完后注入 Request,避免控制器二次读取拿到空值——这是个很多新手会踩的坑。
2.2 中间件管道 + 5 种认证
5 端共用一套后端 API,但认证方式完全不同:
| 端 | 认证方式 | 载体 |
|---|---|---|
| 小程序 | JWT (HS256, 7 天有效) | Bearer Token |
| 超管后台 | Session + HMAC 签名 | Cookie + IP 白名单 |
| 租户后台 | Tenant Token | Header |
| 业务员 H5 | Business Token | Header |
| 代理后台 | Agent Token | Header |
路由层根据 path 前缀决定走哪条认证管道,互不污染。
2.3 7 层安全防护
| 层级 | 防护措施 | 实现方式 |
|---|---|---|
| 第 1 层 | SQL 注入防护 | PDO 参数化查询 + QueryBuilder 预处理 |
| 第 2 层 | XSS 防护 | Security::filterInput 输入过滤 + 输出转义 |
| 第 3 层 | CSRF 防护 | 管理后台 Token 校验 |
| 第 4 层 | 接口限流 | Redis 滑动窗口限流 |
| 第 5 层 | IP 白名单 | 管理后台仅允许指定 IP |
| 第 6 层 | CORS 策略 | 严格跨域控制 + 凭证白名单 |
| 第 7 层 | 分布式锁 | 订单/券购买防超卖 + 支付回调幂等性 |
第 7 层是高并发业务里最值钱的一层,下面单独展开。
三、住房券系统:把优惠券做成"社交裂变载体"
3.1 为什么设计 5 种券类型?
传统优惠券只有"满减"一种,但我需要支持的场景远比这复杂:
| 类型 | 说明 | 应用场景 |
|---|---|---|
| 折扣券 | 按百分比折扣,受最大折扣上限 | 旺季促销 |
| 满减券 | 满X元减Y元 | 客单价提升 |
| 抵扣券 | 全额抵扣(赠券领取后转换) | 新人福利/活动赠送 |
| 免房券 | 免 N 晚房费 | VIP 权益 |
| 按次住房券 | 按次使用,最低 2 晚起用,扣次=ceil(晚数/2) | 长期合作客户 |
更关键的是券的适用范围支持 5 级精准投放:全部通用 / 指定商户 / 指定民宿 / 指定房间 / 指定城市。这让平台可以给「某城市的某家民宿的某个房间」精准投放营销券。
3.2 券的完整生命周期
模板创建 → 上架券商城 → 用户购买/领取 → 下单抵扣 → 完成/过期
↓
赠券转赠 → 好友领取
↓
拼团购买 → 强制分享 → 分享后可用
这里有几个有意思的设计点:
1. 赠券转换为「抵扣券」:用户领取赠券后,券类型自动变为「抵扣券」(全额抵扣),让社交转赠的收益感更强。
2. 拼团强制分享:拼团券 force_share=1 购买支付成功后不能直接使用,必须分享给好友,被分享者参与后才能成团使用。这把"优惠券"变成了社交裂变的载体。
3. 裂变券 is_share_boost:用户领取后获得初始抵扣额度,每分享一次好友(5 分钟时间窗口内幂等去重),抵扣额度 += 步长,直到上限。让一张券的抵扣力度跟用户的社交传播力绑定。
3.3 库存防护:三重保障防超卖
住房券购买是最容易超卖的高并发场景,我用了三重保障:
购买请求
↓
Redis 分布式锁 (key: voucher_buy_{template_id}_{user_id})
↓
原子扣库存 SQL: UPDATE mt_voucher_templates SET stock = stock - 1
WHERE id = ? AND stock > 0
↓
支付回调幂等性校验 (payment_records 表 + 订单状态双重校验)
关键点:原子扣库存 SQL 用 WHERE stock > 0 把并发竞争收敛到数据库行锁层,比应用层判断库存更安全。支付回调幂等性靠 payment_records 表的唯一索引(订单号+交易号)兜底,避免微信重复回调导致重复发券。
四、三级分销 + 四方分润的工程实现
4.1 分佣模型
平台默认按四方分润模型:
平台 10% + 推广人 5% + 代理 5% + 商户 80%
但住房券模板可以单独覆盖分佣比例,所以每个订单实际分佣比例要从订单关联的券模板读取。
4.2 向上遍历 parent_id 链分佣
// 伪代码:分佣核心逻辑
public function distributeCommission($order) {
$commissionRates = $this->getCommissionRates($order);
// $commissionRates = ['platform'=>10, 'promoter'=>5, 'agent'=>5, 'tenant'=>80]
// 推广人优先取直推上级,其次订单推荐人
$promoterId = $order->direct_promoter_id ?: $order->recommend_id;
// 向上遍历 parent_id 链
$currentUserId = $promoterId;
$level = 0;
while ($currentUserId && $level < 3) {
$user = User::find($currentUserId);
if (!$user) break;
// 推广人不在分销表则自动创建
$distributor = Distribution::firstOrCreate(['user_id' => $currentUserId]);
// 按等级计算佣金
$commission = $this->calcByLevel($order, $distributor, $commissionRates);
if ($commission > 0) {
Commission::create([
'user_id' => $currentUserId,
'order_id' => $order->id,
'amount' => $commission,
'level' => $level,
'status' => 'pending',
]);
}
$currentUserId = $user->parent_id; // 向上一级
$level++;
}
// 分佣失败不影响主流程(订单已成功)
}
工程细节:
- 推广人优先取直推上级:因为分销链可能很长,但订单的推广人通常是当前用户的直接上线,先取直推上级避免分佣错位。
- 推广人不在分销表自动创建:用户被推荐下单后,如果还没成为分销商,系统自动建档,让佣金挂账等待他后续激活提现。
- 分佣失败不影响主流程:佣金计算异常被 try-catch 兜住,订单依然正常流转——这是支付系统的金科玉律,主流程绝对不能被副作用拖垮。
4.3 自动升级 + 提现审核
分销等级升级靠消费金额 + 团队人数双维度自动判定,用户可以手动触发升级检查。
提现走微信/银行卡双渠道,最多 3 张凭证图片,审核状态有"待审核/已通过/已拒绝"三态,拒绝时自动恢复用户可用余额。这里的设计点是:拒绝理由必须由人工填写,防止系统误判让用户资金被锁死。
五、微信小程序原生开发:lazyCodeLoading 兼容是个大坑
5.1 启用 lazyCodeLoading 的代价
小程序 app.json 里启用 lazyCodeLoading: "requiredComponents" 是官方推荐的性能优化项,但会引入一个隐蔽的 bug:
体验版中 lazyCodeLoading 会导致 tabBar 页先于 App 注册被加载,
此时 getApp() 返回 undefined,访问 .globalData 会直接报错:
"Cannot read properties of undefined (reading 'globalData')"
也就是说,在 tabBar 页的 onLoad 里直接写 getApp().globalData.token 是会爆炸的。这是小程序架构里少见的"页面优先于 App 注册"的反直觉行为。
5.2 我的解法:app-helper.js 兜底包装
我写了 utils/app-helper.js 做全局兜底:
// 同步获取 App 实例(带类型安全检查)
function getAppSafe() {
try {
const app = getApp();
if (!app || typeof app !== 'object' || !app.globalData) {
failCount++;
const msg = '[AppHelper] getApp() 返回异常: ' + typeof app + ' (第' + failCount + '次)';
console.warn(msg);
// 前几次失败上报实时日志
if (failCount <= 5) {
const rl = wx.getRealtimeLogManager ? wx.getRealtimeLogManager() : null;
if (rl) rl.warn(msg);
}
return null;
}
// 恢复成功日志
if (failCount > 0) {
console.log('[AppHelper] ✅ getApp() 恢复成功, 之前失败' + failCount + '次');
failCount = 0;
}
return app;
} catch (e) {
failCount++;
console.error('[AppHelper] getApp() 抛出异常:', e);
return null;
}
}
// 异步等待 App 实例就绪(最多等 3 秒)
function waitForApp(timeout = 3000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const check = () => {
const app = getAppSafe();
if (app) { resolve(app); return; }
if (Date.now() - startTime >= timeout) {
reject(new Error('App initialization timeout'));
return;
}
setTimeout(check, 50);
};
check();
});
}
// 安全获取 globalData 中的指定字段
function getGlobalData(key, defaultValue) {
const app = getAppSafe();
if (!app || !app.globalData) return defaultValue;
return app.globalData[key] !== undefined ? app.globalData[key] : defaultValue;
}
几个工程细节:
- 失败计数器 + 上限上报:前 5 次失败才上报实时日志,避免日志风暴。
- 50ms 轮询 + 3 秒超时:冷启动时 App 注册延迟通常在 100-500ms 内,50ms 间隔足够灵敏。
- 恢复成功时打 log:方便开发期观察"App 何时就绪"。
5.3 请求层接入 waitForApp
utils/request.js 是统一请求工具,所有 API 都走它。它在每次请求前都会等待 App 就绪:
let _appReadyPromise = null;
function _waitForApp() {
if (_appReadyPromise) return _appReadyPromise;
_appReadyPromise = new Promise((resolve) => {
const startTime = Date.now();
const check = () => {
try {
const app = getApp();
if (app && typeof app === 'object' && app.globalData) {
console.log('[request] App 就绪,耗时 ' + (Date.now() - startTime) + 'ms');
resolve(app);
return;
}
} catch (_) {}
if (Date.now() - startTime >= 5000) {
console.warn('[request] 等待 App 就绪超时 5s');
try { resolve(getApp()); } catch (_) { resolve(null); }
return;
}
setTimeout(check, 80);
};
check();
});
return _appReadyPromise;
}
注意 _appReadyPromise 是单例——一次冷启动期间所有请求复用同一个 Promise,避免重复轮询。请求超时设为 5 秒,比 app-helper 的 3 秒略长,给请求层一个"再等等"的缓冲。
5.4 401 防重复跳转锁
小程序里很多业务页面会并发请求多个接口,如果 token 过期,每个 401 都触发一次跳转登录页会引发"路由栈爆炸"。我用排队 + 单跳转锁解决:
let isNavigatingToLogin = false;
let pending401Rejects = [];
let loginLockTimer = null;
const request = (options) => {
return _waitForApp().then((waitResult) => {
return new Promise((resolve, reject) => {
const app = waitResult || getApp();
// ... wx.request ...
success(res) {
if (res.statusCode === 401) {
app.clearLoginInfo();
// 当前已在登录页,不再跳转
if (currentRoute === 'pages/login/login') {
return reject({ code: 401, message: '登录已过期,请重新登录' });
}
// 加入排队,只有第一个 401 触发跳转
pending401Rejects.push(reject);
if (!isNavigatingToLogin) {
isNavigatingToLogin = true;
wx.navigateTo({ url: '/pages/login/login' });
startLoginLockTimer(); // 30 秒超时释放
}
return;
}
// ...
}
});
});
};
// 登录成功/页面返回后释放 401 锁
function releaseLoginLock() {
isNavigatingToLogin = false;
clearLoginLockTimer();
const rejects = pending401Rejects.splice(0);
rejects.forEach(function (reject) {
reject({ code: 401, message: '登录已过期,请重新登录' });
});
}
关键设计:
- 第一个 401 触发跳转,后续 401 全部进
pending401Rejects队列等待。 - 30 秒超时释放:用户如果在 30 秒内没完成登录,自动释放锁避免内存积压。
- 登录页 onLoginSuccess / onUnload 调用
releaseLoginLock():登录成功后所有排队的 reject 一起被触发,前端可以根据错误码统一处理。
六、装修系统:自研递归下降解析器执行算术表达式
装修系统允许民宿商家拖拽搭建自定义页面,其中"动态计数器"组件支持用 {count} 占位符插入计算公式,比如"已有 {count} + 500 位用户入住"。
小程序端不允许用 eval 或 new Function,我手写了一个递归下降解析器:
/**
* 安全公式求值(递归下降解析器,完全不用 eval/new Function,兼容所有环境)
* 支持 + - * / 和括号运算
*/
function calcExpr(expr) {
expr = (expr || '').replace(/\s/g, '');
if (!expr || expr.length === 0) return null;
let pos = 0;
// 解析表达式(处理 + -,最低优先级)
function parseExpr() {
let left = parseTerm();
while (pos < expr.length && (expr[pos] === '+' || expr[pos] === '-')) {
const op = expr[pos++];
const right = parseTerm();
left = op === '+' ? left + right : left - right;
}
return left;
}
// 解析项(处理 * /,较高优先级)
function parseTerm() {
let left = parseFactor();
while (pos < expr.length && (expr[pos] === '*' || expr[pos] === '/')) {
const op = expr[pos++];
const right = parseFactor();
left = op === '*' ? left * right : left / right;
}
return left;
}
// 解析因子(数字 或 括号表达式)
function parseFactor() {
if (expr[pos] === '(') {
pos++;
const val = parseExpr(); // 递归解析括号内的表达式
if (pos < expr.length && expr[pos] === ')') pos++;
return val;
}
const start = pos;
let hasDot = false;
while (pos < expr.length && (/\d/.test(expr[pos]) || expr[pos] === '.')) {
if (expr[pos] === '.') {
if (hasDot) break;
hasDot = true;
}
pos++;
}
const numStr = expr.substring(start, pos);
const val = parseFloat(numStr);
return isNaN(val) ? 0 : val;
}
try {
const result = parseExpr();
if (pos < expr.length) return null; // 没完整解析完,视为非法
return isNaN(result) ? null : result;
} catch (e) {
console.warn('[calc] calcExpr error:', e.message);
return null;
}
}
经典的三层结构:parseExpr(加减)→ parseTerm(乘除)→ parseFactor(数字/括号),递归调用。解析完如果 pos 没到字符串末尾,说明表达式非法(比如带字母),返回 null 让调用方降级。
这是个小但完整的编译器前端实现,不到 60 行代码。这种"不引入依赖、自己写小工具"的思路在小程序环境特别有用。
七、分享自动携带分销推荐 ID(全页面统一)
所有分享必须自动携带 recommend_id(uid),这是分销体系最关键的一环——分享链断了一节,整条分佣链就断了。
我写了 utils/share.js 统一管理:
const { getAppSafe, getGlobalData } = require('./app-helper');
// 分享默认配置(不设 imageUrl,让微信自动截图)
const DEFAULT_SHARE = {
title: '中游链-发现理想民宿',
path: '/pages/home/home'
};
/**
* 给路径拼接 ?uid=recommend_id
* 所有分享路径必须走这个函数
*/
function appendUid(path) {
const uid = getCurrentRecommendId();
if (!uid) return path;
const sep = path.indexOf('?') >= 0 ? '&' : '?';
return path + sep + 'uid=' + uid;
}
// 所有页面的 onShareAppMessage 都通过 share.js 实现
function getHomeShare() {
return {
title: getShareTitle(),
path: appendUid('/pages/home/home')
};
}
function getHomestayShare(homestay) {
return {
title: `${homestay.city}·${homestay.name} ¥${homestay.price}/晚`,
path: appendUid(`/pages/detail/detail?id=${homestay.id}`),
imageUrl: homestay.cover_image || '' // 优先用民宿首图
};
}
几条铁律:
- 所有
onShareAppMessage必须通过 share.js 实现,禁止裸写。 - 自定义分享路径时必须手动调用
appendUid()。 - 民宿详情页分享用真实首图,其他页面不设
imageUrl,让微信自动截取页面顶部 5:4 区域。 - 分享标题优先用后台配置的
share_title,方便运营随时改文案。
八、AI 客服 + 装修可视化:把"花活"做扎实
8.1 AI 客服(DeepSeek 接入)
全局浮动按钮 + 聊天面板,调用 /ai/chat 接口,携带最近 10 轮/20 条历史上下文。作为全局组件挂在所有页面上,用户随时能问。
工程要点:上下文要做"压缩+截断"——超出 20 条时只保留最近 10 轮,避免 token 爆炸。错误降级用预设 FAQ 兜底。
8.2 装修系统 + 动态计数器
动态计数器组件的副标题支持 {count} 占位符:
- 真实数据来源可选:用户总数 / 订单总数
- 每天随机增长:min ~ max 范围内随机增加
- 日期做种子保证同天全站一致:同一天全站所有用户看到的数字是同一个值
// 伪代码:日期种子随机数
function getDailyCount(base, min, max) {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const seed = hashStringToInt(today);
const rand = seededRandom(seed); // 0~1
const dailyAdd = Math.floor(rand * (max - min + 1)) + min;
return base + dailyAdd;
}
这样既保证数据的"真实基数"(用户总数/订单总数),又通过"日期种子随机增量"营造平台活跃氛围——全站一致是关键,不能用户 A 看到 1000、用户 B 看到 950。
九、性能优化清单
| 优化项 | 实现 |
|---|---|
| 平台配置缓存 | 5 分钟内存缓存,减少数据库查询 |
| 城市数据缓存 | 5 分钟内存缓存 |
| 热门搜索缓存 | Redis 缓存 1 小时 |
| 微信数据预拉取 | onBackgroundFetchData 监听 + Storage 落地,首屏秒开 |
| 骨架屏 | 全局 CSS 变量 --skeleton-animation + 首页/城市页/搜索页骨架结构 |
| lazyCodeLoading | requiredComponents 按需加载组件 |
| 图片懒加载 | lazy-load 属性 + 占位图 |
| 旅行路线缓存 | 同起终点对 30 分钟 Redis 缓存 |
| 天气数据缓存 | 城市级 3 小时缓存 |
| 接口分批 | 订阅消息最多 3 个/批,自动拆分 |
微信数据预拉取 是个被低估的能力。小程序启动时微信会并发地拉取预配置的数据,落盘到 Storage 后页面 onLoad 直接读,省掉首屏接口 RTT——配合骨架屏,冷启动到首屏渲染可以从 1.5 秒压到 500ms 内。
十、踩过的坑与反思
10.1 lazyCodeLoading 教训
最初为了省事,所有 tabBar 页直接 getApp().globalData.xxx,体验版一上就被用户反馈"打开就白屏"。最后全局替换成 getAppSafe() / getGlobalData() 才稳定。
教训:任何"看起来无害"的官方优化项,在大业务量下都可能踩坑。上线前必须在体验版 + 真机 Android/iOS 双端验证。
10.2 分佣链断点
最早分佣只看 recommend_id,没考虑用户被推荐后 parent_id 链断裂的情况。后来加了"推广人优先取直推上级"逻辑,分佣覆盖率高了不少。
教训:分销链路必须画时序图 + 单测覆盖"断链/单链/三链/循环引用(防死循环)"四种 case。
10.3 支付回调幂等性
微信支付回调可能重发 N 次,最初 payment_records 表没建唯一索引,导致一次订单被发券 3 次。后来加了 (order_id, transaction_id) 唯一索引 + 订单状态双重校验。
教训:支付系统永远假设回调会重复,数据库唯一索引是最稳的兜底,应用层判断都是辅助。
10.4 评价同步民宿评分
最早写评价时忘了同步 mt_homestays.avg_rating 和 review_count,导致民宿列表评分和实际评价数对不上。后来在 ReviewController 里内置了 updateRatings() 自动重算,每次评价变更都调用。
教训:跨表数据一致性必须由"模型层"自动维护,不能依赖调用方记得调。
十一、数据成果
| 指标 | 数值 |
|---|---|
| 小程序页面数 | 41 个 |
| 管理后台页面 | 22 个 |
| API 端点 | 76+ 个 |
| 支持的住房券类型 | 5 种 |
| 分销等级体系 | 3 级裂变 |
| 端侧覆盖 | 5 端 |
| 设施图标映射 | 60+(中英文双兼容) |
| 订阅消息场景 | 6 种 |
| 安全防护层级 | 7 层 |
| 认证方式 | 5 种 |
十二、写在最后
「中游链环住」不是一个"用了什么新框架"的项目,它真正有价值的地方在于:把传统行业的复杂业务(预订、营销、分销、社交)用工程化的方式落地到一个可控的代码体量里。
- 不上 Laravel 不是因为它不好,是因为我能用 1000 行 PHP 写完所有需要的能力,部署简单、启动快、可控性强。
- 自建递归下降解析器不是因为闲,是因为小程序环境禁用 eval,又不想引第三方库。
- lazyCodeLoading 兼容方案不是因为想炫技,是真的被坑了然后想兜底。
工程不是堆叠技术栈,是围绕业务场景做最合适的取舍。希望这篇实践记录能给同样在做小程序 + 后端 + 业务系统的同学一些参考。
项目线上 微信小程序:中游链
微信小程序 AppID:wx83f5c2400c85d6f1`
如果你也在做类似业务,欢迎评论区交流。