我用 PHP 自建微框架 + 微信小程序原生开发,做了一个民宿预订 + 住房券 + 三级分销的旅居生态平台

68 阅读13分钟

我用 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 TokenHeader
业务员 H5Business TokenHeader
代理后台Agent TokenHeader

路由层根据 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 || '' // 优先用民宿首图
  };
}

几条铁律:

  1. 所有 onShareAppMessage 必须通过 share.js 实现,禁止裸写。
  2. 自定义分享路径时必须手动调用 appendUid()。
  3. 民宿详情页分享用真实首图,其他页面不设 imageUrl,让微信自动截取页面顶部 5:4 区域。
  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 + 首页/城市页/搜索页骨架结构
lazyCodeLoadingrequiredComponents 按需加载组件
图片懒加载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`
如果你也在做类似业务,欢迎评论区交流。