资讯动态

SaaS套餐怎么设计?能力项、资源配额、版本与租户订阅的建模方法

发布时间:2026/10/17 2:41:18 来源:尧图企业网站定制
一个 SaaS 系统刚上线时常见做法是在租户表上放plan_type再用if (plan_type PRO)判断能不能使用批量报表。只要套餐永远不变这样似乎够用。但当平台把专业版每月报表任务上限从 20 次调整为 30 次时问题就来了甲公司已经订阅的旧版本是否立即变成 30 次客服如何解释昨天和今天看到的权益不同乙公司没有报表能力却有同名菜单权限又该以谁为准这篇讨论的不是定价策略而是套餐定义如何变成可解释的租户权益。示例使用虚构数据环境为 Java 17、Spring Boot 风格服务层、MySQL 8.0.16 及以上不涉及实际产品的套餐、价格或客户配置。目录先确定一次权益争议的输入和预期套餐模板、订阅、能力和配额分别负责什么数据模型让版本和来源可以追溯如何解析某一时刻的有效权益用测试和 SQL 证明结果没有漂移边界、上线验收与延伸阅读一、先确定一次权益争议的输入和预期沿用前文的虚构租户甲公司t_a、乙公司t_b。平台在 2026-10-01 发布专业版第 3 版PRO v3其中batch_report已开通每自然月最多创建 20 个报表任务存储上限为 100 GiB。甲公司订阅sub_101指向这个具体版本有效区间为[2026-10-01 00:00, 2026-11-01 00:00)。乙公司订阅基础版没有batch_report。10 月 16 日平台发布PRO v4把示例报表上限改为 30。此时查询甲公司预期仍是PRO v3 / 20 次只有一条明确的订阅变更记录把它指向v4它的权益才会改变。这里的数字只用于说明版本绑定并非真实产品配额。查询对象与时间套餐来源报表能力报表上限应如何解释t_a10 月 15 日sub_101 → PRO v3开通每月 20 次当时仅有 v3t_a10 月 16 日sub_101 → PRO v3开通每月 20 次发布 v4 不改旧订阅t_b10 月 16 日sub_201 → BASIC v1未开通无菜单权限不能补出套餐能力t_a11 月 1 日 00:00无覆盖的有效订阅不可判定为开通无区间右端不包含在内最后一行故意没有假设自动续订。系统若有续订应产生可追溯的新订阅或新有效区间不能在权益解析失败时回退成“默认专业版”。图1发布新套餐版本与变更租户实际订阅是两件事查询结果必须带来源版本。二、套餐模板、订阅、能力和配额分别负责什么最容易混淆的是“产品能提供什么”“租户买到了什么”“某个用户能做什么”三个问题。套餐模板描述候选产品组合租户订阅说明一个租户在什么时间采用哪个不可变版本能力项回答功能是否可用配额给出允许的资源边界。成员角色仍决定个人动作权限不能被套餐替代。对象保存什么不保存什么套餐版本套餐编码、版本号、发布状态某租户已经用了多少资源能力项batch_report等稳定标识、是否开通个人的按钮权限配额项指标键、上限、单位、统计周期实际用量和剩余额度租户订阅租户、套餐版本、有效时间、订阅状态一份可随模板编辑而变化的模糊引用用量事实某周期已使用或已预留的量套餐原始定义把能力和配额拆开有实际价值。例如“可以上传文件”是能力“总存储不超过 100 GiB”是配额“可运行批量报表”是能力“每自然月最多创建 20 个任务”是配额。没有开通能力时上限即使残留也不能授权执行上限没有配置时也不能擅自理解为无限。是否允许无限额应使用显式策略而不是以NULL猜测。版本号也不能只是页面展示字段。PRO v3发布后能力和配额明细要保持不可变调配额应创建v4。可以把“停止新售”和“旧订阅失效”分成两种动作前者禁止新订阅引用旧版本后者涉及存量客户权益不能通过修改模板状态偷偷完成。本文采用应用层发布校验与变更权限控制来保证不可变数据库外键只保证引用关系不能自动阻止管理员直接改动已发布的明细。图2配额上限来自套餐用量来自运行事实套餐能力与个人权限也不是一回事。三、数据模型让版本和来源可以追溯下面是一个刻意收窄的模型只表达套餐与有效订阅不把计费、优惠、续费或资源扣减混进来。时间统一存储为 UTC接口按业务时区展示[starts_at, ends_at)避免相邻区间在边界时刻同时生效。真实系统应有租户主表这里只保留用于锁定订阅变更的最小租户行。CREATETABLEdemo_tenant(tenant_idVARCHAR(32)PRIMARYKEY)ENGINEInnoDB;CREATETABLEdemo_plan_version(plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,sale_stateVARCHAR(16)NOTNULL,published_atDATETIME(6)NOTNULL,PRIMARYKEY(plan_code,version_no),CONSTRAINTck_plan_versionCHECK(version_no0),CONSTRAINTck_plan_saleCHECK(sale_stateIN(ON_SALE,CLOSED)))ENGINEInnoDB;CREATETABLEdemo_plan_feature(plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,feature_keyVARCHAR(64)NOTNULL,enabledBOOLEANNOTNULL,PRIMARYKEY(plan_code,version_no,feature_key),CONSTRAINTfk_feature_planFOREIGNKEY(plan_code,version_no)REFERENCESdemo_plan_version(plan_code,version_no))ENGINEInnoDB;CREATETABLEdemo_plan_quota(plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,quota_keyVARCHAR(64)NOTNULL,limit_valueBIGINTNOTNULL,unit_codeVARCHAR(24)NOTNULL,period_codeVARCHAR(24)NOTNULL,PRIMARYKEY(plan_code,version_no,quota_key),CONSTRAINTck_quota_limitCHECK(limit_value0),CONSTRAINTfk_quota_planFOREIGNKEY(plan_code,version_no)REFERENCESdemo_plan_version(plan_code,version_no))ENGINEInnoDB;CREATETABLEdemo_subscription(subscription_idVARCHAR(32)PRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,starts_atDATETIME(6)NOTNULL,ends_atDATETIME(6)NOTNULL,sub_stateVARCHAR(16)NOTNULL,revisionBIGINTNOTNULL,CONSTRAINTuq_tenant_startUNIQUE(tenant_id,starts_at),INDEXix_sub_cover(tenant_id,sub_state,starts_at,ends_at),CONSTRAINTck_sub_intervalCHECK(starts_atends_at),CONSTRAINTck_sub_stateCHECK(sub_stateIN(ACTIVE,CANCELLED)),CONSTRAINTfk_sub_tenantFOREIGNKEY(tenant_id)REFERENCESdemo_tenant(tenant_id),CONSTRAINTfk_sub_planFOREIGNKEY(plan_code,version_no)REFERENCESdemo_plan_version(plan_code,version_no))ENGINEInnoDB;为复现第一节的查询结果可以在空的演示库中插入下面这组数据。PRO v4在 10 月 16 日作为新增版本发布但甲公司的订阅仍指向 v3INSERTINTOdemo_tenantVALUES(t_a),(t_b);INSERTINTOdemo_plan_versionVALUES(PRO,3,CLOSED,2026-10-01 00:00:00),(PRO,4,ON_SALE,2026-10-16 00:00:00),(BASIC,1,ON_SALE,2026-10-01 00:00:00);INSERTINTOdemo_plan_featureVALUES(PRO,3,batch_report,1),(PRO,4,batch_report,1),(BASIC,1,batch_report,0);INSERTINTOdemo_plan_quotaVALUES(PRO,3,report_jobs,20,COUNT,CALENDAR_MONTH),(PRO,3,storage_bytes,107374182400,BYTE,CURRENT),(PRO,4,report_jobs,30,COUNT,CALENDAR_MONTH),(PRO,4,storage_bytes,107374182400,BYTE,CURRENT),(BASIC,1,storage_bytes,10737418240,BYTE,CURRENT);INSERTINTOdemo_subscriptionVALUES(sub_101,t_a,PRO,3,2026-09-30 16:00:00,2026-10-31 16:00:00,ACTIVE,1),(sub_201,t_b,BASIC,1,2026-09-30 16:00:00,2026-10-31 16:00:00,ACTIVE,1);订阅区间按北京时间 10 月 1 日至 11 月 1 日换算后以 UTC 入库套餐的published_at也是 UTC仅用于说明发布时间不参与订阅有效性判断。示例将 v3 标为停止新售正好验证旧订阅不因该状态而失效。实际发布流程应先发布 v3、创建订阅后发布 v4 并关闭 v3 销售演示插入一次给出最终状态不代表生产可跳过流程校验。unit_code与period_code要有约定report_jobs使用COUNT / CALENDAR_MONTHstorage_bytes使用BYTE / CURRENT。不能把 100 GiB 写成数字100后让不同服务自行解释。生产环境最好由指标目录约束合法的键、单位和周期避免同一quota_key在不同套餐里含义漂移。这里的 100 GiB 在存储时换算为107374182400字节。uq_tenant_start只阻止相同起点的重复记录不能阻止两个不同起点的时间区间重叠。创建或变更订阅时应在事务里先锁定该租户行再查重叠区间new_start old_end AND new_end old_start。所有订阅写入都必须经过这一路径如果允许绕过服务直接写库还需额外的数据库侧保护或严格写权限。查询时仍要对“命中 0 条 / 多条”做显式处理不能靠LIMIT 1掩盖脏数据。图3复合外键固定订阅指向的版本区间重叠需要事务串行化校验。四、如何解析某一时刻的有效权益权益解析器输入应是服务端确认的tenantId与查询时刻输出要同时包含结果和证据订阅 ID、套餐版本、订阅修订号、能力集合、配额集合、查询时间。只返回hasFeaturetrue排查时就无从解释它来自哪里。recordSubscription(Stringid,StringtenantId,StringplanCode,intversion,InstantstartsAt,InstantendsAt,longrevision){}recordQuota(longlimit,Stringunit,Stringperiod){}recordEntitlement(StringsubscriptionId,StringplanCode,intversion,longrevision,InstantasOf,SetStringfeatures,MapString,Quotaquotas){booleanhas(StringfeatureKey){returnfeatures.contains(featureKey);}}interfaceEntitlementRepository{ListSubscriptionfindActiveCovering(StringtenantId,Instantat);SetStringenabledFeatures(StringplanCode,intversion);MapString,Quotaquotas(StringplanCode,intversion);}finalclassEntitlementResolver{privatefinalEntitlementRepositoryrepo;EntitlementResolver(EntitlementRepositoryrepo){this.reporepo;}Entitlementresolve(StringtenantId,Instantat){ListSubscriptionmatchesrepo.findActiveCovering(tenantId,at);if(matches.size()!1){thrownewIllegalStateException(matches.isEmpty()?NO_ACTIVE_SUBSCRIPTION:OVERLAPPING_SUBSCRIPTIONS);}Subscriptionsubmatches.get(0);returnnewEntitlement(sub.id(),sub.planCode(),sub.version(),sub.revision(),at,Set.copyOf(repo.enabledFeatures(sub.planCode(),sub.version())),Map.copyOf(repo.quotas(sub.planCode(),sub.version())));}}findActiveCovering的筛选语义必须是sub_stateACTIVE AND starts_at :at AND ends_at :at。不能再额外要求旧套餐sale_stateON_SALE否则“停止销售 v3”会无意中终止甲公司仍有效的订阅。服务还应确认租户当前状态租户被冻结时即使订阅记录仍在业务执行也要被拒绝这是租户状态校验不应悄悄篡改权益事实。以 10 月 16 日 10:30 查询为例预期解析结果为tenantt_a, subscriptionsub_101, planPRO/v3, revision1 features[batch_report] quotas{report_jobs: 20 COUNT/CALENDAR_MONTH, storage_bytes: 107374182400 BYTE/CURRENT} tenantt_b, subscriptionsub_201, planBASIC/v1, revision1 features[] quotas{storage_bytes: 10737418240 BYTE/CURRENT}这里的20是上限不是“还剩 20”。如果已经创建 7 个报表任务还剩多少要看用量事实、失败是否退还、时间窗口以及并发预留策略这属于后续配额控制专题。把“套餐上限”直接当“剩余额度”显示会在并发和失败补偿时误导用户。缓存也要保留来源。可以按tenantId subscription revision缓存权益快照但订阅变更后必须提高修订号并让旧缓存失效对立即撤销要求高的执行路径不能只信长 TTL 缓存。具体执行鉴权还要叠加成员动作权限与资源归属下一篇单独讨论。图4解析只读取被订阅绑定的版本无订阅或多订阅命中都应显式失败。五、用测试和 SQL 证明结果没有漂移关键测试不是“能查到套餐”而是“新版本发布后老订阅保持原结果”和“异常订阅不被静默选中”。以下 JUnit 5 示例使用内存仓储夹具repo其方法与上文接口一致测试数据按第一节建立TestvoidpublishingV4DoesNotUpgradeExistingSubscription(){InstantatInstant.parse(2026-10-16T02:30:00Z);EntitlementbeforenewEntitlementResolver(repo).resolve(t_a,at);repo.publishVersion(PRO,4,30);// 只新增模板版本不改 sub_101EntitlementafternewEntitlementResolver(repo).resolve(t_a,at);assertEquals(sub_101,after.subscriptionId());assertEquals(3,after.version());assertEquals(20,after.quotas().get(report_jobs).limit());assertEquals(before,after);}TestvoidrightBoundaryAndOverlapMustNotBeGuessed(){EntitlementResolverresolvernewEntitlementResolver(repo);assertEquals(NO_ACTIVE_SUBSCRIPTION,assertThrows(IllegalStateException.class,()-resolver.resolve(t_a,Instant.parse(2026-10-31T16:00:00Z))).getMessage());repo.forceInsertOverlappingSubscriptionForTest(t_a,sub_bad);assertEquals(OVERLAPPING_SUBSCRIPTIONS,assertThrows(IllegalStateException.class,()-resolver.resolve(t_a,Instant.parse(2026-10-16T02:30:00Z))).getMessage());}这段展示的是服务层契约测试publishVersion和forceInsertOverlappingSubscriptionForTest是测试夹具方法不是生产管理接口后者特意绕过写入校验证明读取端面对脏数据会拒绝。数据库集成测试还要验证外键、CHECK、并发订阅写入是否符合预期。CHECK的强制执行需要 MySQL 8.0.16 及以上。上线核对时先查同一时刻实际命中的订阅再追到套餐明细不要只看管理后台显示的“专业版”文字。SETat2026-10-16 02:30:00.000000;-- UTC即北京时间 10:30SELECTtenant_id,COUNT(*)ASactive_countFROMdemo_subscriptionWHEREsub_stateACTIVEANDstarts_atatANDends_atatANDtenant_idIN(t_a,t_b)GROUPBYtenant_id;-- 预期t_a1t_b1不是 1 的租户需要人工核查。SELECTs.tenant_id,s.subscription_id,s.plan_code,s.version_no,f.feature_key,f.enabled,q.quota_key,q.limit_value,q.unit_code,q.period_codeFROMdemo_subscription sLEFTJOINdemo_plan_feature fONf.plan_codes.plan_codeANDf.version_nos.version_noLEFTJOINdemo_plan_quota qONq.plan_codes.plan_codeANDq.version_nos.version_noWHEREs.tenant_idt_aANDs.sub_stateACTIVEANDs.starts_atatANDs.ends_atat;-- 此 JOIN 会把能力行与配额行交叉展开核对 t_a 的版本应为 3-- report_jobs 的 limit_value 应为 20不可用结果行数当能力或配额数量。SELECTa.tenant_id,a.subscription_idASfirst_id,b.subscription_idASsecond_idFROMdemo_subscription aJOINdemo_subscription bONa.tenant_idb.tenant_idANDa.subscription_idb.subscription_idANDa.starts_atb.ends_atANDa.ends_atb.starts_atWHEREa.sub_stateACTIVEANDb.sub_stateACTIVE;-- 预期 0 行出现结果表示有效区间重叠不能让读取端随便选一个。第二个查询为了人工追溯把两张明细一起关联容易产生笛卡尔式的行数放大正式统计应分别聚合能力和配额或者先分别查询再在服务层装配。这个细节很重要上线检查脚本自身也可能制造“重复权益”的假象。图5验收既核对正常租户的版本和上限也核对无订阅、区间重叠等拒绝路径。六、边界、上线验收与延伸阅读上线前至少准备三组事实发布前后的套餐版本快照、每个租户的订阅区间、同一时刻的权益解析结果。对甲公司发布PRO v4后仍应解释为sub_101 → PRO v3 → 每月 20 次对乙公司即使成员被误授report:run动作租户也没有batch_report能力。订阅到期、数据保留与升级生效时间要另订规则不能由本文示例的“无有效订阅就拒绝”推断所有产品都必须立即删除数据。本文还没有解决“20 次如何在并发下准确扣减”。权益解析只给出政策上限运行时需要配额用量、预留、幂等和失败补偿。也没有把套餐当个人授权能力开通只能说明这个租户可以使用某功能真正执行仍需要当前成员的动作权限与目标资源归属。把这几层分开平台才能回答“为什么这个租户现在能用、什么时候开始、依据的是哪个版本”也才能在争议时拿出证据。参考资料AWS SaaS LensTenant Tiers用于理解租户分层不只是价格标签还会影响服务体验和运维边界。MySQL 8.0Foreign Key Constraints用于核对版本引用的复合外键约束。MySQL 8.0TABLE_CONSTRAINTS用于核对CHECK约束在 8.0.16 起的执行边界。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑