Skip to content

想象一个场景:销售谈成了一家客户,卖的是“基础版”——只用资产管理和库存,不要折旧、不要报表导出。可你的系统是全量功能,客户登录进去,所有菜单都在。怎么办?最差的答案是“再编译一个阉割版给他部署”——版本分叉的故事在《SaaS 架构演进》里已经说过了,那是回头的路。

正确的做法是一套代码,按套餐裁剪功能。这篇讲清楚这个“裁剪”怎么设计。

一、演进:从散装 if 到功能矩阵

v1:硬编码判断(能跑,但别这么干)

java
// 某个接口里
if (!"PRO".equals(tenant.getPlanCode())) {
    throw new ServiceException("当前套餐不支持导出");
}

问题一眼可见:判断散落在几十个接口里,加一个套餐(比如介于基础版和专业版之间的“标准版”)要改几十处,漏一处就是“标准版客户用上了旗舰版功能”。

v2:套餐-功能矩阵(正确的基础形态)

把“哪个套餐有哪些功能”收拢成一张矩阵——一张表,或一份租户级配置:

java
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);
    }
}

判定逻辑收敛到一处,接口里只留一行:

java
if (!PlanFeatureRegistry.enabled(tenant.getPlanCode(), "EXPORT")) {
    throw new ServiceException("当前套餐不支持导出,请升级专业版");
}

注意错误信息:带上升级引导,别只丢一个 403。对用户来说这是销售触点,不是报错。

v3:注解 + 拦截器(把判断也收走)

v2 还剩一个问题:每个接口里都要手写那两行。用注解声明、拦截器统一校验,业务代码里连判断都不用写:

java
@RequirePlan(feature = "EXPORT")
@GetMapping("/export")
public void export(HttpServletResponse response) {
    // 纯业务逻辑,没有任何套餐代码
}
java
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,以下为真实输出):

java
// FREE 套餐请求导出 → deny
// PRO 套餐请求导出  → allow
// PRO 套餐请求 SSO  → deny(SSO 属于旗舰版)
// 到期日当天        → 仍可用(含尾判定,见生命周期一文)

程序里 10 项断言全部 PASS,矩阵的 getOrDefault(plan, Set.of()) 兜底保证了未知套餐默认全拒绝——宁可误拦,不可漏放。

五、常见误区

① 只做前端隐藏。 再说一遍:安全落在后端,前端只是体验。这是功能开关错误里唯一可能变成事故的。

② 判定粒度混乱。 功能开关管“能不能用”(租户/套餐维度),数据权限管“能看哪些行”(用户/部门维度)。两个正交的体系,别搅在一起——“专业版才能看全部报表”这种需求,是功能开关(报表入口)+ 数据权限(报表范围)配合,不是一个开关能表达的。

③ 新功能忘了登记矩阵。 功能上线时矩阵没登记,默认行为必须是全拒绝(未知功能 = 不放行),这样忘了登记最多是“客户看不到新功能”,而不是“旗舰版卖点瞬间免费”。

④ 矩阵变更不看生效时机。 客户正在用的功能被矩阵下线,当期账单怎么算?一般是“已付费周期内不中断,下个周期生效”——和《套餐与计费设计》里降级的思路一致。

系列导航