3.2 模板引擎:租户自定义皮肤

场景:一套视图,一百种门面

签约那天客户提了两个要求:页面要我们的蓝,登录页要放公司 logo。运营满口答应,回来就找我开会——全系统一百多户,难道每户 fork 一套模板?显然不行。但完全不让改也不行,SaaS 卖的不只是功能,还有归属感,客户老板看到自己公司 logo 挂在登录页上,续费谈判时脾气都好三分。

模板个性化的需求分层得很清楚:八成客户只想换 logo、主色、登录页背景图这三样;一成五想调布局,比如首页banner 换成轮播、菜单收进侧边;剩下的半成是魔改狂人,恨不得把按钮都重画。按这个分布,我们把个性化做成三级能力:基础变量级、配置块级、模板继承级。前两级自助完成,第三级需要我们评审,不开放的口子比开放的口子更难守。

真正的技术挑战不在渲染本身,而在两处:一是性能,每户一套模板就意味着缓存键不能共用,模板编译结果必须按租户隔离;二是安全,租户自定义的内容一旦能写进模板,就有 XSS 和逻辑注入的风险,我们不允许任何租户写模板代码,只允许选模板、填变量。

模板方案对比过三轮,最后落在「变量加配置块」这个组合上:

方案 灵活度 安全性 落地难度
静态变量替换 低 高 一周
配置块组装 中 高 三周
模板继承覆盖 高 中 两个月
在线模板编辑器 最高 低 不考虑

配置:主题变量与渲染管线

主题变量存在配置中心(见 2.3),渲染时注入模板上下文, 这样运行时切换主题不用重启:

class ThemeRenderer
{
    public function render(string $tpl, array $data): string
    {
        $ctx  = app(TenantContext::class);
        $vars = $this->themeService->vars($ctx->id());

        // 按 租户+模板+主题 缓存编译结果,键里带租户号
        $key = sprintf('tpl:%d:%s:%s', $ctx->id(), $tpl, $vars['theme']);

        return $this->cache->remember($key, 600, function () use ($tpl, $data, $vars) {
            return view($tpl, array_merge($data, $vars))->render();
        });
    }
}

两个细节值得多看一眼。第一,缓存键里的租户号是这条管线的命脉,少了它所有租户共享同一份编译缓存,A 户的红色按钮会出现在 B 户的页面上——这不是假设,是我们付费买来的教训。第二,变量合并的优先级:框架默认值、平台级主题、租户主题、租户临时活动皮肤,四层从低到高覆盖,活动皮肤用于大促时期的限时换装,到期自动回落。

模板目录结构上,我们维护三套内置主题(经典、简约、商务),租户只能选不能改源码。想自定义的部分走配置块:每个页面预设若干槽位(banner、侧栏、页脚文案),租户在后台勾选启用哪些、填什么内容。这个设计把九成个性化需求挡在了模板源码之外,安全团队和我都松了口气。

再说说配置块的具体形态。以首页为例,我们预设了五个槽位:顶部公告、主 banner、快捷入口区、侧栏推荐、页脚合作链接。每个槽位有类型约束:公告只能填文本且限两百字,banner 只能传图片且自动生成三档裁剪,快捷入口最多八个并校验链接必须为本站 URL。类型约束听着繁琐,但它把租户能闯的祸限制在一个可枚举的集合里,审批与回滚都有了明确对象。

主题版本的管理也值得一提。每套主题有版本号,租户切换主题时记录变更日志:谁在何时从经典切到了商务、回滚又是谁操作的。这份日志在两次客户内部纠纷里起到了关键作用——新来的运营以为是系统出 bug 把页面改了,查日志发现是前任管理员主动切的,纠纷当场化解。审计日志平时是成本,出事的时候是证据。

量化:模板个性化的账单

个性化能力上线半年后,我们盘过一次账,数据让运营很兴奋:

指标 数值 备注
使用主题变量的租户 百分之八十七 logo 与主色为主
切换过布局配置块的租户 百分之三十一 banner 与侧栏居多
申请模板继承的租户 百分之四 走企业版定制
因个性化续约提及率 四成 客户回访抽样

最后一行是这套能力真正的回报。技术上看它只是几个变量和缓存键,商业上它是客户每次登录时被反复强化的品牌存在感。投入三周开发,换来回访时四成客户主动提及的元素,这是我们在功能优先级排序时没预料到的。

顺带记录一个运营层面的发现:主题切换有明显的季节性。每年十一月初,大促客户的换皮肤申请会集中爆发, 运营提前半个月发问卷收集需求,技术侧预留裁剪与预览的人力。这种可预测的峰谷,比临时打仗从容得多,也让技术排期第一次有了季节属性。

演进:皮肤系统的三代形态

回头看,我们的模板个性化也走了三代,每一代都是被具体事故推着走的:

代际 形态 触发升级的事故
第一代 改配置文件,重启生效 客户改一次颜色要提工单
第二代 后台表单,实时生效 缓存串号与半换色混搭
第三代 预览态加发布与回滚 未改完的皮肤被自己上线

第三代最关键的一步是把主题变更当成发布来管理:有预览、有发布人、有回滚按钮、有操作审计。一旦把变更流程化,前两代那些手忙脚乱的事故就自然消失了。这套思路后来也被 4.1 的灰度发布复用,本质是同一件事:任何会影响线上呈现的改动,都不应该一键直达,中间必须有人按确认、有记录可查、有退路可走。

踩坑:皮肤背后的四个雷

第一个坑是缓存串号。上线初期模板缓存键没带租户号,某天 A 户把自己的主题切回默认,十分钟后 B 户客服找上来问为什么页面变蓝了。排查发现两户共用一个主题名,缓存键只按主题名拼,先写谁的就认谁。修复办法很笨:所有模板缓存键强制带租户号,并且写了一条单测断言——渲染同一段模板两次传入不同租户上下文,结果必须不同。这条断言后来救过两次。

第二个坑是 CSS 覆盖范围。租户自定义主色时,如果只替换按钮的颜色变量,引用了旧色的静态图片(渐变背景图、icon)不会变,页面会呈现一种拧巴的混搭。我们的解法是主题色必须成体系:主色、辅色、强调色、底色四组一起换,静态切图统一做成透明底、着色靠 CSS,从一开始就断了半换色的可能。

第三个坑是预览与生效的边界。租户在后台调主题时,应当所见即所得,但直接写进正式缓存会让没改完的皮肤先上线。我们做了预览态:改动暂存到独立的预览键,预览页渲染读预览键,点发布才写正式键,且支持一键回滚到上一版。没有预览态的主题系统,最后都会变成事故_generator。

第四个坑是移动端适配。租户上传的 banner 往往是按 PC 宽度做的,手机上不是变形就是把按钮挤出屏幕。强制裁剪三种尺寸上传太反人类,我们的妥协是上传后自动生成三档裁剪,渲染时按视口取用,并在预览页提供手机视窗检查。这事不解决,客服工单里每个月都有它。

第五个坑藏得最深:字体授权。租户上传自家品牌的定制字体做 logo 文字渲染,听上去合理,但字体文件普遍有商用授权范围,平台替租户渲染等于共同侵权。我们的处理是渲染层只服务器端出图、不提供字体文件下载与网页嵌入,并在后台上传字体时弹出授权确认。法务问题没有技术解,只能靠流程把责任边界画清楚。

第三代的预览态也好,灰度发布也好,说到底都是同一个哲学:把不可逆的改动变成可逆的操作。下一节看文件存储:租户上传的每一张图,怎么在 COS 上找到自己家的院子。

小结

模板个性化的核心不是模板引擎多强大,而是把差异收敛进数据:变量进配置中心,缓存键带租户号,自定义只开放到配置块级别。三周做出来的三级能力(变量、配置块、受评审的模板继承),撑起了八成多客户的需求,还意外成了续费的抓手。缓存键带租户号这条铁律,适用于后面所有跨租户共享的资源——3.4 缓存隔离会把它再讲一遍,那边的故障比这边更隐蔽,因为缓存串号不会改变页面外观,只会让数据悄悄对不上,等客户截图来问的时候往往已经串了好几天。模板个性化还有一条隐藏收益值得单说:它把「让客户满意」这件事从开发流水线上彻底剥离——销售在后台改配色、运营调配置块、客户自己换文案,三方各自操作且没有一个工单落到开发头上。季度统计显示,个性化需求工单从每月二十多个降到两三个,省下的开发精力全部投进了新功能。个性化一旦不再消耗开发资源,它就从成本中心变成了留存抓手——这个反转是当初做方案时没有想到的。

想扩展看渲染管线与令牌配合的,可以读 PHP API 实战;上下文与配置中心的联动,参见 2.3 配置中心一节;主题文件本身的静态资源分发,与 3.3 文件存储共用同一套 COS 前缀体系,建议两节连读。

延伸阅读