前面我们已经聊过认证、头像、角色管理等功能。这篇我们来聊一下订阅系统的数据库设计
我们可以先把这个模块拆成 3 个概念来看:用户、套餐、订阅。
用户解决的是谁在使用系统。套餐解决的是我们的系统提供了什么权益组合。订阅解决的是某个用户当前拥有哪个套餐。
我们可以在管理后台,单独修改、新增某个套餐,也可以单独给某一个用户分配某一个套餐
因此,在表结构的设计上,我们就应该把用户、套餐、订阅这三个概念分开来设计,而不是把它们揉在一起。然后通过中间表来建立它们之间的关系。
用户表可以先保持克制,只保存用户主体相关的信息,比如用户 ID、展示名称、状态、创建时间、最近登录时间。
邮箱、密码、角色、订阅都会变化。它们围绕用户存在,但不适合全部压到用户主体里。
举个常见场景。一个人今天用邮箱登录,后面可能绑定 GitHub;今天只有一个邮箱,后面可能增加备用邮箱;今天只是普通用户,后面可能被分配后台角色。这些变化都和用户有关,但它们的生命周期并不完全一样。
用户表越像一份主体档案,后面扩展起来越自然。用户相关的关系越多,越应该通过独立表表达。
角色管理那篇里已经出现过类似思路:
1users2└── user_role_bindings3└── roles
用户没有把角色字段写死在自己身上,而是通过绑定表和角色连接起来。订阅也可以顺着这个思路设计:
1users2└── user_subscription_bindings3└── subscription_plans
这样一来,用户表只需要回答这个用户是谁。至于他现在拥有什么套餐,交给订阅关系来表达。
套餐可以理解成一份权益模板。它定义的是系统准备提供什么能力组合。
比如免费版、专业版、团队版,本质上都是一组权益配置。某个套餐可能会限制 Agent 数量,可能会决定能不能使用群聊,能不能使用多 Agent 联动,能不能进入发现广场,也可能会包含价格、计费周期、当前状态这些信息。
这些都属于套餐本身。它们回答的是这个套餐是什么。
这里要避免把 user_id 放进套餐表。套餐是统一的权益定义,不应该跟某一个用户绑死。一个套餐可以被很多用户拥有,也可以被后台统一调整、禁用、下架。
比较清楚的关系是这样:
1subscription_plans2只定义套餐本身34user_subscription_bindings5记录哪个用户拥有哪个套餐
套餐还需要一个稳定的业务编码,比如 free、pro_monthly、team_yearly。数据库主键适合内部引用,业务编码更适合人阅读,也更适合后台配置和排查问题。
比如日志里出现 pro_monthly,运营和开发都能大概判断它代表什么。如果只出现一串 ID,通常还要再查一次数据库。
套餐还要有状态。一个套餐正在售卖时可以是 active,暂时不再分配时可以是 disabled,从普通列表里移除时可以是 deleted。
套餐一般不适合直接物理删除。因为历史订阅、订单、客服排查、审计记录都可能还要解释当时的权益来源。套餐一旦直接消失,历史记录就少了一块重要上下文。
订阅表连接用户和套餐,但它承担的事情比普通中间表更多。
普通中间表只说明两个对象有关联。订阅关系还要说明这条关系从什么时候开始,现在是否仍然生效,什么时候失效,是谁做的分配。
所以订阅表里通常会出现用户 ID、套餐 ID、状态、分配时间、分配人、撤销时间这些字段。它们合在一起,表达的是一段有生命周期的业务关系。
这里的状态非常关键。一个用户当前可能只有一个生效套餐,但他过去可能换过很多次套餐。
如果每次换套餐都直接更新同一条记录里的 plan_id,历史就被覆盖了。系统只能知道用户现在是什么套餐,却看不到他之前是什么套餐,也看不到变化发生的时间。
更稳的方式是保留变化过程:
1旧订阅:active -> revoked2新订阅:新增一条 active
这样看起来多了一条记录,但业务含义清楚很多。用户从免费版换到专业版,数据库里能看到变化轨迹。用户从专业版降回免费版,也能看到具体发生在什么时候。
后面做账单、客服排查、运营统计时,这些历史记录会很有价值。不需要靠猜,也不需要从日志里临时拼线索。
历史订阅可以有很多条,但当前生效订阅通常只能有一条。
我们可以想一下,如果一个用户同时有两个 active 订阅,权限判断会变得很尴尬。一个套餐允许 3 个 Agent,另一个套餐允许 20 个 Agent,系统到底应该按哪个算?
所以数据库里最好直接表达这条规则:同一个用户只能有一个当前 active 订阅。
这里约束的是当前订阅,不是所有订阅。如果对 user_id 做普通唯一约束,用户永远只能有一条订阅记录,历史就保存不下来了。
更合适的理解方式是:
1同一个 user_id2可以有多条历史 revoked 订阅3只能有一条当前 active 订阅
这条约束很重要。业务代码当然也应该判断,但数据库需要作为最后一道保护。后台重复点击、接口重试、脚本误跑,都可能把异常数据写进来。
还有一个细节也要提前想清楚:重复分配同一个套餐时,不应该制造新记录。
如果用户已经是专业版,管理员又点了一次分配专业版,这次操作并没有改变订阅状态。系统可以成功返回,但不应该撤销旧订阅再新增一条一模一样的订阅。
这种处理叫幂等。简单理解就是:同一件事做一次和做多次,结果保持一致。
数据库外键看起来像技术细节,实际上也在表达业务边界。
订阅关系里通常会引用 3 个对象:用户、套餐、操作人。这 3 个对象的删除策略不应该完全一样。
用户是订阅的归属主体。如果用户被物理删除,订阅关系通常也没有独立存在的意义。当然在真实业务里,用户更常见的是软删除,这样历史还能保留下来。
套餐要更谨慎。套餐一旦被订阅引用,就不应该随便物理删除。否则历史订阅会失去解释来源。更稳的做法是限制物理删除,用 disabled 或 deleted 这样的状态控制它是否还能继续使用。
操作人只是审计信息。比如某个管理员给用户分配了套餐。这个管理员账号后来如果被删除,订阅本身仍然应该存在。操作人字段可以置空,但不能因为操作人没了就把订阅删掉。
这里可以简单记成下面这张表:
| 引用对象 | 更合适的处理 | 原因 |
|---|---|---|
| 用户 | 跟随用户生命周期 | 订阅归属于用户 |
| 套餐 | 限制物理删除 | 历史订阅需要套餐解释 |
| 操作人 | 允许置空 | 只是审计信息 |
理解了这层关系之后,再看外键删除策略就不会机械照抄。每一种处理方式背后,都应该有清楚的业务语义。
早期做套餐,不需要一上来就把模型拆得特别复杂。
如果权益只有几个字段,比如 Agent 数量、是否支持群聊、是否支持多 Agent 联动,直接放在套餐表里就足够清楚。后台展示时方便,权限判断时也直接。
过早拆成 capabilities、subscription_plan_capabilities 这类通用模型,表面上很灵活,实际会让理解和开发成本都上去。等权益真的变多,或者开始出现额度、单位、模块分组、版本管理这些需求,再演进也来得及。
那时可以调整成这样的模型:
1subscription_plans2└── subscription_plan_capabilities3└── capabilities
价格也可以按阶段处理。早期先把价格和计费周期分开表达,让系统知道 99 元是月付、年付,还是一次性付费。等真正接入支付,再把金额拆得更严格,比如货币、分单位金额、优惠规则、订单记录。
订单和订阅也需要分清楚。订单表达的是用户买了什么、付了多少钱、支付状态如何。订阅表达的是用户现在享有什么权益。
支付成功、管理员赠送、试用开通、企业合同,都可能带来订阅。所以订阅不应该只依赖一种支付路径。它应该作为用户权益的最终状态来管理。
这套设计不用一开始就做得很重,但方向要放对:用户是主体,套餐是模板,订阅是关系。当前状态要能查,历史变化也要能追。
订阅与套餐的数据库设计,核心就是把 3 件事分开。
用户表保存稳定主体,不承载所有业务关系。套餐表保存权益模板,不直接绑定用户。订阅表保存用户和套餐之间的关系,并记录状态和生命周期。
这样设计之后,后台页面读取数据时可能会多一些关联,但模型会清楚很多。用户换套餐不会覆盖历史,套餐下架不会破坏旧记录,后面接支付、续费、试用、审计,也都有继续扩展的位置。
数据库不要只按页面字段来设计。页面关心展示,数据库要先把业务关系放对。