一、背景:"每商户一个分支"是场灾难
我们的多租户小程序 SaaS,给每个商户交付独立品牌、独立 AppID 的小程序。最早的做法粗暴:主仓库拉分支,改 AppID、配色、店名,再出包提审发布。第 5 家上线时,问题集中爆发:
- 一个 bug 要在 5 个分支各修一遍,漏一个就是"这家比那家慢三天";
- 分支越多越不敢合并,主干新功能回不去,代码开始"分家";
- 发版从半天膨胀到两天;新商户接入还要全量出包,接一家折腾一整天。
真正下死决心的,是分支爬到 30 多个后的某个周三下午:14 点确认支付回调验签失败的 bug,修复只有 11 行,主干 20 分钟改完自测通过;之后四个多小时,是把这 11 行 cherry-pick 到 37 个分支上——平均每三家撞一次冲突,各家为改标题、改首页模块动过同一批文件,解到第 10 个就开始怀疑前面解错了。19 点半推完最后一个,对台账还是漏了 1 家:那家改过包名结构,分支偏到没法 fast-forward,只能手工重放。收工只剩疲惫——一个 bug,我们修了 38 遍。
此后没有争议:N 个商户必须共用同一份代码,差异只能收敛到"配置"层。这正是微信开放平台第三方平台机制的标准玩法:统一模板 + ext_json 注入。
二、方案:统一模板 + 授权链路 + ext_json 注入
链路五步,每步做什么:
- 代码进草稿箱:开发者工具正常上传,代码进平台草稿箱。草稿箱只有一格,永远被最新上传覆盖,只当中转站,别当版本管理用;
- 草稿箱转模板库:调"从草稿箱添加到模板库"接口固化为模板,拿到递增的 template_id 与自填 user_version;模板库可保留多版本,才是版本锚点;
- 商户扫码授权:平台侧生成预授权码拼出授权链接,商户管理员扫码,把代开发、上传、提审、发布等权限托管给我们的第三方平台;回调拿到 authorizer_appid 与 refresh_token,换出约 2 小时有效、按需续期的 authorizer_access_token,此后一切代办操作都凭它;
- commit 注入 ext_json:调代码托管接口为某商户提交代码时,带上 template_id 和一份 ext_json,商户差异全写在这里;
- 启动时读 ext:运行时用 wx.getExtConfigSync() 读出 ext,merchantId 回答"我是哪家店",首页样式、接口参数全由它驱动。
(配图位:授权→草稿箱→模板库→commit→发布 时序图)
一句话概括:代码是共享的,身份是注入的。
三、关键细节:三个不能含糊的地方
1. ext_json 能带什么。 只放非敏感路由参数:merchantId、主题色、首页模块开关。AppSecret、支付密钥、平台 ticket 绝不进小程序端——代码包可被解包,ext 也不是加密存储,密钥只待在服务端配置中心。
2. 启动时建立商户上下文。 时序写死三段:先同步读 ext,再请求后端换会话,最后以后端结论为准;所有请求统一携带 merchantId(请求头 X-Merchant-Id)。getExtConfigSync 在非模板环境(普通 AppID 直接预览)拿不到值,必须给兜底配置,保证任何环境启动不崩。伪代码见下节。
3. 服务端兜底隔离。 ext 对客户端完全可见,merchantId 可能被篡改:代码包可解包、ext 是明文、请求可被代理重放,客户端来的一切都只是用户输入,隔离不能靠前端自觉。登录时服务端用 code2session 换 openid,校验该用户属于哪个商户,租户身份写进服务端会话;此后数据访问由 ORM 层强制拼接租户条件(Java 21 + Spring Boot,拦截器改写 SQL)——与 PostgreSQL 行级安全(RLS)同一思路,每行带租户列、数据库层强制过滤。前端带身份只是导航,后端验身份才是边界,这条边界有专门的越权用例盯着(1241 个测试方法里相当一部分在打这类场景)。
四、脱敏配置示例(结构示意)
commit 时传的 ext_json(占位符,非真实值):
{
"extAppid": "wx_placeholder_appid",
"ext": {
"merchantId": "mch-000123",
"themeColor": "#3A5FCD",
"homeModules": ["groupBuy", "serviceList", "memberCard"]
},
"window": {
"navigationBarTitleText": "占位商户名"
}
}
小程序端启动的三段时序(JavaScript,伪代码):
// app.js onLaunch:建立商户上下文(伪代码)
const FALLBACK = { merchantId: 'demo', themeColor: '#333333' };
// ① 同步读 ext。模板环境已随 commit 固化进代码包;
// 非模板环境(普通 AppID 预览)抛错或为空,必须兜底
let ext = FALLBACK;
try {
const raw = wx.getExtConfigSync();
if (raw && raw.merchantId) ext = raw;
} catch (e) { /* 保持兜底 */ }
this.globalData.merchant = ext; // 主题色、模块开关从这里取
// ② wx.login 换 code 请求后端;客户端 merchantId 只是导航提示
wx.login({
success: ({ code }) => {
request('/session/init', { code, merchantId: ext.merchantId })
// ③ 以后端为准:code2session 换 openid,校验属于哪家店,
// 签发带租户身份的服务端会话;不一致以服务端为准
.then((srv) => { this.globalData.session = srv; })
.catch(() => { /* 降级到演示模式 */ });
}
});
补一句:按商户换页面集还有 extPages 可用;我们用 homeModules 开关控制显隐够用。文中不出现真实 AppID、域名、密钥,生产值只存在服务端。
五、踩坑清单
前两个坑最贵,展开成"现象→定位→修法"。
坑 1:开发工具与真机的 ext 差异。 现象:工具根目录改 ext.json 立即生效;同一套代码传到体验版和真机,改的字段纹丝不动,清缓存、删小程序、重启手机全无效,一个下午没了。定位:工具直接读本地 ext.json;真机的 ext 是 commit 时固化进代码包的,改字段等于改代码。修法:把"改 ext"当一次完整发版(重新 commit+提审+发布),写进发布 checklist 第一条。
坑 2:模板版本升级没有灰度。 现象:新模板一次性 commit 给全部商户,当晚两家会员卡页白屏——新模板接口参数变了,这两家正命中旧参数路径。定位:template_id 对一家商户全量生效,commit 下去就是全部用户;能回滚,但影响已发生。修法:先挑 1–2 家有代表性的商户 commit 新 template_id,观察 24–48 小时再推全量;台账同步更新,10 分钟内查得到哪家在哪个版本。
其余四个一行带过:
| # | 坑 | 现象与修法 |
|---|---|---|
| 3 | getExtConfigSync 无兜底 | 普通 AppID 预览抛错或为空,启动即崩;try/catch + 默认商户配置 |
| 4 | ext.json 误进仓库 | 模拟用 ext.json 带测试 AppID 提进 git;.gitignore 排除,只放 ext.example.json |
| 5 | 指望 ext 动态换域名 | request 合法域名在后台配置,不随 ext 变;域名统一,差异走 merchantId |
| 6 | 忽略商户类目差异 | 模板一把梭提审,类目资质对不上被卡;commit 前核对类目,敏感页按类目显隐 |
这份清单没有一条是文档明说的,全是上线流程里真金白银换来的。切到统一模板后:主干只有一条,"修 38 遍"的下午不会再有——修复一次,随下一次 commit 推给全部商户;新商户接入从"出包一天"变成"填一份 ext_json 加一次授权"。
六、三句话总结
- 商户差异全部收敛进一份 ext_json,代码永远只有一套,维护成本从 O(N) 降到 O(1);
- ext 只放非敏感路由参数,密钥永远只在服务端;商户身份启动时读入、服务端二次校验;
- 开发者工具与真机对 ext 的处理不同,ext 变更按发版流程管理,模板版本要有台账。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
