想象一个场景:销售谈成了一家客户,卖的是“基础版”——只用资产管理和库存,不要折旧、不要报表导出。可你的系统是全量功能,客户登录进去,所有菜单都在。怎么办?最差的答案是“再编译一个阉割版给他部署”——版本分叉的故事在《SaaS 架构演进》里已经说过了,那是回头的路。
正确的做法是一套代码,按套餐裁剪功能。但“裁剪”不只是拦截请求这么简单——拦住了用不了,还得让用户知道有这个东西、升级就能用,否则基础版用户永远不知道还有导出功能,转化路径就断了。这篇把这两条线一起讲清楚。
一、演进:从散装 if 到功能矩阵
v1:硬编码判断(能跑,但别这么干)
// 某个接口里
if (!"PRO".equals(tenant.getPlanCode())) {
throw new ServiceException("当前套餐不支持导出");
}问题一眼可见:判断散落在几十个接口里,加一个套餐(比如介于基础版和专业版之间的“标准版”)要改几十处,漏一处就是“标准版客户用上了旗舰版功能”。
v2:套餐-功能矩阵(正确的基础形态)
把“哪个套餐有哪些功能”收拢成一张矩阵——一张表,或一份租户级配置:
public class PlanFeatureRegistry {
// 套餐 -> 该套餐开通的功能集合
private static final Map<String, Set<String>> PLAN_FEATURES = Map.of(
"FREE", Set.of("ASSET_CRUD", "INVENTORY"),
"PRO", Set.of("ASSET_CRUD", "INVENTORY", "DEPRECIATION", "EXPORT"),
"FLAGSHIP", Set.of("ASSET_CRUD", "INVENTORY", "DEPRECIATION",
"EXPORT", "SSO", "OPEN_API")
);
public static boolean enabled(String planCode, String feature) {
return PLAN_FEATURES.getOrDefault(planCode, Set.of()).contains(feature);
}
}判定逻辑收敛到一处,接口里只留一行:
if (!PlanFeatureRegistry.enabled(tenant.getPlanCode(), "EXPORT")) {
throw new ServiceException("当前套餐不支持导出,请升级专业版");
}注意错误信息:带上升级引导,别只丢一个 403。对用户来说这是销售触点,不是报错——这个细节 v3 还要再往前走一步。
v3:注解 + 拦截器(把判断也收走)
v2 还剩一个问题:每个接口里都要手写那两行。用注解声明、拦截器统一校验,业务代码里连判断都不用写:
@RequirePlan(feature = "EXPORT")
@GetMapping("/export")
public void export(HttpServletResponse response) {
// 纯业务逻辑,没有任何套餐代码
}public class PlanFeatureInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod method)) return true;
RequirePlan anno = method.getMethodAnnotation(RequirePlan.class);
if (anno == null) return true;
String planCode = TenantHelper.getCurrentTenantPlan(); // 从登录态取
if (!PlanFeatureRegistry.enabled(planCode, anno.feature())) {
// 不返回裸 403:结构化响应让前端能统一弹升级引导
// (示意代码;实际用项目里的 JSON 工具序列化,字段全部动态取值)
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(Map.of(
"code", "PLAN_FORBIDDEN",
"feature", anno.feature(), // 哪个功能被拦
"currentPlan", planCode, // 当前套餐
"requiredPlan", FeatureCatalog.minPlanOf(anno.feature()) // 升到哪个套餐能用(从登记表查)
)));
return false;
}
return true;
}
}新功能上线时,开发者只需要想一件事:“这个功能属于哪些套餐”——在注解里写清楚,矩阵里登记好,完事。
基础版用户绕过前端直接调导出接口,拿到的响应体就是这样的结构化 JSON(而不是干巴巴的 403):
{"code": "PLAN_FORBIDDEN", "feature": "EXPORT",
"currentPlan": "FREE", "requiredPlan": "PRO"}到这里先停一下,回答一个自然会产生的问题:响应体为什么要带 requiredPlan?谁在消费它? 这就要先讲清楚一件矩阵本身没写的事——用户的升级路径从哪来。
二、功能清单:升级引导从哪来
矩阵只回答了「能不能调」——enabled() 是个布尔。但产品视角还有一串矩阵没表达的信息:导出这个功能的展示名叫什么?需要升到哪个套餐?低套餐用户的入口是藏起来还是露出来? 这些才是升级转化的原料,塞不进一个布尔矩阵,得为每个功能做一次完整登记——下面这个 FeatureCatalog 就是干这个的,它不是第二套体系,而是第一节那张矩阵的源头登记表(两者的关系往下看):
// 产品登记每个功能——这是“哪些套餐有什么功能”的源头定义
public record FeatureDefinition(
String code, // 功能编码:EXPORT
String name, // 展示名:报表导出
String minPlan, // 最低可用套餐:PRO
boolean visibleBelow // 低于 minPlan 的套餐,是否露出入口做升级引导
) {}
public class FeatureCatalog {
private static final List<FeatureDefinition> FEATURES = List.of(
new FeatureDefinition("ASSET_CRUD", "资产管理", "FREE", false),
new FeatureDefinition("DEPRECIATION", "折旧报表", "PRO", false),
new FeatureDefinition("EXPORT", "报表导出", "PRO", true),
new FeatureDefinition("SSO", "单点登录", "FLAGSHIP", true),
new FeatureDefinition("OPEN_API", "开放接口", "FLAGSHIP", false)
);
// ... 提供按编码查询、按套餐过滤等方法
}看清它和第一节 PlanFeatureRegistry 的分工——不是两个体系,是一张表的两个视角:
PlanFeatureRegistry(套餐 → 功能集合)回答判定问题:“PRO 能不能调导出?”——只要一个布尔;FeatureCatalog(每功能一条完整登记)回答展示问题:“导出叫什么名?差哪个套餐才能用?低套餐要不要露入口?”——升级引导需要的元数据,布尔矩阵装不下。
而且前者其实能从后者推导出来:功能登记了 minPlan: PRO,那么「PRO 及以上套餐开通该功能」——套餐到功能的集合不用另写一份。第一节那个 Map.of 是把推导结果直接摆出来的示意;真实现时登记表是唯一数据源,判定和清单接口都从它派生(存储落地的展开见第四节),不然同一事实写两处,改套餐归属时要同步两份,漏一处就是判定和展示打架。
留意 visibleBelow 这一列——它对应两种入口策略,选哪种取决于功能的性质:
| 策略 | 低套餐用户看到什么 | 适合什么功能 |
|---|---|---|
| 隐藏 | 菜单、按钮完全不出现,系统里像没这回事 | 平台内部能力(开放 API、审计日志),低套餐用户看了也没意义,露出来只是噪音 |
| 锁定引导 | 入口在,但带“专业版”角标或置灰,点击弹升级提示 / 联系销售 | 商业卖点(导出、SSO、高级报表)——入口本身就是转化入口 |
导出、SSO 这类卖点功能选“锁定引导”是刻意的:用户天天看得到、点一下就知道差什么,升级动机是在日常使用里攒出来的;藏起来,就只剩销售人工介绍了。
然后是一份清单接口,登录后前端拉一次:
@GetMapping("/tenant/features")
public TenantFeaturesVo features() {
String plan = TenantHelper.getCurrentTenantPlan();
List<FeatureVo> enabled = new ArrayList<>();
List<LockedFeatureVo> locked = new ArrayList<>();
for (FeatureDefinition f : FeatureCatalog.all()) {
if (PlanFeatureRegistry.enabled(plan, f.code())) {
enabled.add(FeatureVo.of(f)); // 可用:前端正常渲染
} else if (f.visibleBelow()) {
locked.add(LockedFeatureVo.of(f)); // 锁定:前端渲染升级角标
}
// visibleBelow=false 的:两个清单都不进,前端连“存在”都不知道
}
return new TenantFeaturesVo(enabled, locked);
}基础版用户的完整旅程现在闭环了:登录拉清单 → “报表导出”在 locked 里(带 requiredPlan: PRO)→ 菜单里按钮带角标 → 点击弹“该功能需专业版”引导 → 付费升级 → 矩阵变更 + 缓存失效 → 下次拉清单该功能进 enabled → 按钮激活。全程没有一次 403——升级走的是清单,不走拦截器。
那拦截器上的 403 服务谁?两类请求:绕过前端直接调接口的调用方(抓包、脚本,拦截是安全底线),以及清单与矩阵短暂不一致的边缘场景(缓存延迟、刚升级还没重新拉清单)。对后者,结构化响应(code + requiredPlan)让前端能统一弹“升级后可用”而不是干巴巴的报错。两句话总结分工:清单负责让用户知道并到达,拦截器负责兜住绕路的请求——升级引导和安全拦截是同一条链路的两个环节,不是两套机制。
三、两层把关:前端裁剪 + 后端校验
前端要处理的套餐差异其实分三个粒度,各有各的机制——拿 RuoYi-Vue-Plus 这类后台框架(Vben 前端)当实例:
页面级:路由裁剪,前端无感知。 登录后前端调 getRouters 拉菜单路由——后端按「套餐生成的菜单 ∩ 角色分配」过滤完才返回,前端拿到什么就 addRoute 动态注册什么。前端代码里没有任何套餐概念:它不知道谁是基础版,只认识“返回的菜单长什么样”。套餐升级后菜单多了一项,前端一行不改。这是 RuoYi 系的标准姿势——套餐差异全部收敛在后端裁剪,前端保持一个纯渲染者。
按钮级:权限指令,二值判断。 页面里的操作按钮(导出、审批)不裁路由,靠权限标识控制:登录时后端把 perms 集合(如 asset:export)随用户信息下发,前端用指令声明式判断——
<!-- Vben / RuoYi 前端:没有这个 perms,按钮根本不渲染 -->
<a-button v-hasPermi="['asset:export']">导出</a-button>perms 从哪来?还是后端按套餐裁好下发的——前端依然不判断套餐,只判断“这个标识在不在我的集合里”。
引导级:锁定清单,前端唯一需要“知道套餐”的地方。 裁剪式和指令式都是二值的——有或没有,表达不了第二节的“锁定引导”:入口要在、但要带角标。这靠第二节那份 enabled / locked 清单驱动:locked 存进全局状态(Pinia),写一个全局的升级引导组件,菜单和按钮渲染时查清单——在 locked 里就套上角标,点击角标统一弹“该功能需专业版”。前端这套代码对所有套餐通用,套餐差异依然全在数据里。
三个粒度合起来看分工:路由裁剪决定“有哪些页面”、权限指令决定“页面里有哪些按钮”、锁定清单决定“哪些入口带升级引导”——前两者前端完全无感知套餐,第三者前端也只认清单不认套餐编码。
最后是必须刻在脑子里的原则:前端裁剪只是体验优化,安全完全依赖后端校验。抓包绕过前端直接调接口是最基础的攻击姿势,前端隐藏了导出按钮、后端没拦,等于付费功能对所有人免费。反过来说,后端校验做了,前端也不能省——否则付费用户天天看到一堆灰按钮,观感极差。两层各司其职:前端管“看起来对”,后端管“真的是对的”。
四、矩阵放哪:表还是配置
- 套餐-功能矩阵表(
plan_feature:plan_code + feature_code):标准做法,管理后台可以改,改完发缓存失效通知。 - 租户级 JSON 配置:个别大客户“买了个性化组合”(比如专业版 + 单独开通 SSO),在租户维度存一份功能清单覆盖矩阵。思路和租户级的 JSON 配置存储一脉相承——配置跟着租户走,而不是跟着代码走。
两个都做时判定顺序:先查租户覆盖配置,没有再落到套餐矩阵。读多写少,套一层缓存(缓存 key 记得带租户前缀,不然又踩多租户缓存串号的坑,见《多租户数据隔离》)。
第二节说过登记表是唯一数据源,这里落到表结构上:FeatureCatalog 落库就是功能登记表(编码、展示名、最低套餐、可见性),plan_feature 矩阵表按登记生成——甚至不用建:派生一个判定方法就够了:
public static boolean enabled(String planCode, String feature) {
FeatureDefinition f = FeatureCatalog.find(feature);
if (f == null) return false; // 未登记的功能:默认拒绝
return rank(planCode) >= rank(f.minPlan()); // 套餐等级不低于登记的最低要求
}升级改价、调整功能归属时只改登记表,判定和清单接口自动跟着变——单一数据源,不存在两处打架。
五、实跑验证判定逻辑
矩阵判定和试用到期判断这些逻辑可以实跑验证(JDK 21,以下为真实输出):
// FREE 套餐请求导出 → deny
// PRO 套餐请求导出 → allow
// PRO 套餐请求 SSO → deny(SSO 属于旗舰版)
// 到期日当天 → 仍可用(含尾判定,见生命周期一文)程序里 10 项断言全部 PASS,矩阵的 getOrDefault(plan, Set.of()) 兜底保证了未知套餐默认全拒绝——宁可误拦,不可漏放。
六、它和菜单权限(RBAC)是一回事吗
做过带角色权限的系统(RuoYi 这类后台框架),看上面的矩阵会眼熟:是同一个思想,两套实现形式。都是「授权矩阵 + 统一卡口」:
| 要素 | 菜单权限(RBAC) | 套餐功能开关 |
|---|---|---|
| 矩阵的行(主体) | 角色 / 租户套餐 | 套餐 |
| 矩阵的列(功能) | 菜单、按钮 + 权限标识(perms) | 产品功能编码 |
| 矩阵本体 | 角色菜单关联表(存 DB,运行时可配) | 套餐-功能矩阵(配置表) |
| 统一卡口 | 权限注解 + 拦截器,前端动态路由 | 功能注解 + 拦截器,前端按清单裁剪 |
把矩阵的「功能编码」换成菜单 perms、「矩阵表」换成角色菜单关联,就是 RBAC 的实现——机制完全同构。真正的差异在三处:
① 配置对象不同。 菜单权限的列是「菜单/按钮资源」——系统里有什么就列什么;功能开关的列是「产品功能」——商业包装的卖点,一个功能可能横跨多个菜单。
② 谁来改。 菜单权限是运行时可配置的:租户管理员在界面上勾选、即时生效;套餐矩阵是产品契约:只能平台改,绝不能让租户自己勾——不然人人给自己勾成旗舰版。
③ 开关只管布尔,不管用量。 菜单权限回答「能不能用」,配额回答「能用多少」(几个成员、几块看板)——后者是另一套数值校验,菜单体系里没有对应物。
七、和 RBAC 同时存在:两层矩阵怎么配合
真实的 SaaS 系统里,两套矩阵往往同时存在、互相配合。一个用户最终能用什么,是两个矩阵的交集:
用户可用功能 = 套餐矩阵(这个租户买了什么,平台定)
∩ 角色矩阵(租户内谁能用,管理员定)拿贯穿前文的系统举一个完整例子。租户 A 买的是专业版(PRO),租户内有两个角色:管理员给「财务」角色分了报表中心的全部按钮(含导出),「操作员」角色只分了查看。三个用户的实际情况:
| 用户 | 套餐层(PRO 含导出?) | 角色层(分了导出?) | 实际效果 |
|---|---|---|---|
| 财务小张 | ✅ 有 | ✅ 财务角色分了 | 能用——两层都过 |
| 操作员小李 | ✅ 有 | ❌ 操作员角色没分 | 看不到导出按钮——功能在租户里存在,但没分给他 |
| 任何人对“开放 API” | ❌ PRO 不含(旗舰版才有) | — | 全租户都没有——管理员的角色分配界面上压根没有这项可选 |
第三行值得多看一眼:小李用不了导出,管理员帮他开权限的办法是给他的角色补分配——因为功能在套餐范围内,角色层说了算;而开放 API 不在套餐里,管理员就算想给也开不了——分配选项本身就被套餐收窄了,这正是「套餐是天花板、角色做分配」的分工。RuoYi-Vue-Plus 的租户套餐落地时就是这么做的:建租户按套餐生成初始菜单,租户内再分配不能超出这个范围。
这个交集设计还有个附赠的好处,看降级的场景:专业版降到基础版时,管理员之前给财务分的导出权限还在角色矩阵里,一行都不用动——但生效清单等于两层取交集,套餐一收缩,交集立刻变空,导出按钮自动从所有人的界面上消失。不需要任何人手动去撤销几十个角色的分配,到期、降级的批量处理因此只剩改一处:租户的套餐字段。
落地建议:复用同一套卡口引擎,别造两套。两层矩阵是两份数据,判定机制(注解 + 拦截器 + 默认拒绝)应该是同一套——登录时把「套餐功能清单 ∩ 角色权限清单」合并成一个生效清单,后端一个卡口校验,前端一份渲染依据(上面的例子里,小张的生效清单 = PRO 含导出 ∩ 财务角色分了导出,合并后再判,两层数据只算一次)。
最后对齐三个概念的边界——三条正交的线,别搅在一起:
| 体系 | 问的问题 | 主体 |
|---|---|---|
| 套餐功能开关 | 这个租户能不能用 | 租户(平台定) |
| RBAC 菜单权限 | 租户内谁能用 | 用户(管理员定) |
| 数据权限 | 能看到哪些行 | 用户(按部门/角色范围) |
「专业版才能看全部报表」这种需求,是三线配合:功能开关给报表入口、RBAC 给角色分配、数据权限收报表范围——任何一个单打独斗都表达不了。
八、常见误区
① 只做前端隐藏。 再说一遍:安全落在后端,前端只是体验。这是功能开关错误里唯一可能变成事故的。
② 把拦截器当功能的终点。 403 只解决了「不能调」,没解决「怎么让用户升级」。正常用户的升级路径在前端清单上(第二节),拦截器只是绕路请求的兜底——裸 403 一刀切的系统,功能拦得住,转化链路是断的。
③ 判定粒度混乱。 功能开关管「能不能用」(租户/套餐维度),数据权限管「能看哪些行」(用户/部门维度),RBAC 管「租户内谁能用」——三条正交的线,边界见第七节的三线表。「专业版才能看全部报表」是三线配合的需求,不是一个开关能表达的。
④ 新功能忘了登记矩阵。 功能上线时矩阵没登记,默认行为必须是全拒绝(未知功能 = 不放行),这样忘了登记最多是“客户看不到新功能”,而不是“旗舰版卖点瞬间免费”。
⑤ 矩阵变更不看生效时机。 客户正在用的功能被矩阵下线,当期账单怎么算?一般是“已付费周期内不中断,下个周期生效”——和《套餐与计费设计》里降级的思路一致。
系列导航
综合案例:《多租户 SaaS 平台架构分析与 Spring Boot 实现》——本文机制串成一条完整链路后的系统全貌
上一篇:《租户生命周期管理》——冻结和降级是两条正交的线
《SaaS 架构演进》——为什么“一套代码”是混合架构的生命线
《SaaS 多租户数据隔离》——租户 ID 和缓存隔离的底层机制
《动态租户实现原理》——“当前租户”这个值的存储与清理
《SaaS 套餐与计费设计》——功能矩阵背后的商业模型
