想象一个场景:销售谈成了一家客户,卖的是“基础版”——只用资产管理和库存,不要折旧、不要报表导出。可你的系统是全量功能,客户登录进去,所有菜单都在。怎么办?最差的答案是“再编译一个阉割版给他部署”——版本分叉的故事在《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:注解 + 拦截器(把判断也收走)
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) {
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())) {
response.setStatus(403);
return false;
}
return true;
}
}新功能上线时,开发者只需要想一件事:“这个功能属于哪些套餐”——在注解里写清楚,矩阵里登记好,完事。
二、两层把关:前端裁剪 + 后端校验
第一层在前端:登录后按套餐拉取功能清单,没有的功能——菜单不出现、按钮置灰。这层的价值是体验:用户根本看不到用不了的东西,不会反复点一个按钮然后吃 403。
第二层在后端:每个功能入口都过矩阵校验。这层的价值是安全——是唯一的防线。
两者的关系必须想清楚:前端裁剪只是体验优化,安全完全依赖后端校验。抓包绕过前端直接调接口是最基础的攻击姿势,前端隐藏了导出按钮、后端没拦,等于付费功能对所有人免费。反过来说,后端校验做了,前端裁剪也不能省——否则付费用户天天看到一堆灰按钮,观感极差。
三、矩阵放哪:表还是配置
- 套餐-功能矩阵表(
plan_feature:plan_code + feature_code):标准做法,管理后台可以改,改完发缓存失效通知。 - 租户级 JSON 配置:个别大客户“买了个性化组合”(比如专业版 + 单独开通 SSO),在租户维度存一份功能清单覆盖矩阵。思路和租户级的 JSON 配置存储一脉相承——配置跟着租户走,而不是跟着代码走。
两个都做时判定顺序:先查租户覆盖配置,没有再落到套餐矩阵。读多写少,套一层缓存(缓存 key 记得带租户前缀,不然又踩多租户缓存串号的坑,见《多租户数据隔离》)。
四、实跑验证判定逻辑
矩阵判定和试用到期判断这些逻辑可以实跑验证(JDK 21,以下为真实输出):
// FREE 套餐请求导出 → deny
// PRO 套餐请求导出 → allow
// PRO 套餐请求 SSO → deny(SSO 属于旗舰版)
// 到期日当天 → 仍可用(含尾判定,见生命周期一文)程序里 10 项断言全部 PASS,矩阵的 getOrDefault(plan, Set.of()) 兜底保证了未知套餐默认全拒绝——宁可误拦,不可漏放。
五、常见误区
① 只做前端隐藏。 再说一遍:安全落在后端,前端只是体验。这是功能开关错误里唯一可能变成事故的。
② 判定粒度混乱。 功能开关管“能不能用”(租户/套餐维度),数据权限管“能看哪些行”(用户/部门维度)。两个正交的体系,别搅在一起——“专业版才能看全部报表”这种需求,是功能开关(报表入口)+ 数据权限(报表范围)配合,不是一个开关能表达的。
③ 新功能忘了登记矩阵。 功能上线时矩阵没登记,默认行为必须是全拒绝(未知功能 = 不放行),这样忘了登记最多是“客户看不到新功能”,而不是“旗舰版卖点瞬间免费”。
④ 矩阵变更不看生效时机。 客户正在用的功能被矩阵下线,当期账单怎么算?一般是“已付费周期内不中断,下个周期生效”——和《套餐与计费设计》里降级的思路一致。
系列导航
- 上一篇:《租户生命周期管理》——冻结和降级是两条正交的线
- 《SaaS 架构演进》——为什么“一套代码”是混合架构的生命线
- 《SaaS 多租户数据隔离》——租户 ID 和缓存隔离的底层机制
- 《动态租户实现原理》——“当前租户”这个值的存储与清理
- 《SaaS 套餐与计费设计》——功能矩阵背后的商业模型
