如何设计支持不同客户类型的 subscription 数据库
结论
更合适的方案是采用”客户主表 + 类型扩展表”:
client保存所有订阅客户共有的身份信息。school和student都以client_id为主键,同时将其设为指向client的外键。subscription只关联client。payment根据业务含义关联subscription,更完整的模型则关联账单invoice。
不建议在 client 中用 clientgroupID + clientOriginalID 模拟关联。因为 clientOriginalID 有时指向 school,有时指向 student,普通外键无法验证它是否引用了正确的记录。
也不建议为学校和学生分别复制一套 subscription 和 payment 表,除非两类客户的订阅流程、字段和计费规则确实完全不同。
推荐的数据结构
下面以 PostgreSQL 为例。其他数据库也可以采用相同思路,但约束和自动生成主键的语法可能需要调整。
CREATE TYPE client_type AS ENUM ('school', 'student');
CREATE TABLE client (
client_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
client_type client_type NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE school (
client_id bigint PRIMARY KEY
REFERENCES client(client_id) ON DELETE CASCADE,
school_name varchar(200) NOT NULL,
address text,
contact_email varchar(320)
);
CREATE TABLE student (
client_id bigint PRIMARY KEY
REFERENCES client(client_id) ON DELETE CASCADE,
full_name varchar(200) NOT NULL,
date_of_birth date,
email varchar(320)
);
这是常见的”类表继承”或”父表/子类型表”设计。client 表示可以订阅的客户,school 和 student 分别保存各自特有的数据。
例如,创建学生客户时,应在同一个事务中写入两张表:
BEGIN;
INSERT INTO client (client_type)
VALUES ('student')
RETURNING client_id;
-- 假设返回的 client_id 为 1001
INSERT INTO student (client_id, full_name, email)
VALUES (1001, '张三', 'zhangsan@example.com');
COMMIT;
需要注意,仅靠这些外键,数据库还不能自动保证 client_type = 'student' 的记录只出现在 student 表中。可以使用存储过程或触发器维护这项约束,也可以限制应用只能通过统一的创建接口写入数据。如果类型字段没有查询价值,还可以省略 client_type,根据记录存在于哪个子表来判断客户类型。
Subscription 的设计
订阅应直接引用客户,不要引用某一笔付款:
CREATE TABLE subscription (
subscription_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
client_id bigint NOT NULL
REFERENCES client(client_id),
starts_at timestamptz NOT NULL,
ends_at timestamptz,
status varchar(20) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT subscription_period_valid
CHECK (ends_at IS NULL OR ends_at > starts_at),
CONSTRAINT subscription_status_valid
CHECK (status IN ('pending', 'active', 'cancelled', 'expired'))
);
CREATE INDEX subscription_client_idx
ON subscription(client_id);
不要同时保存 subscriptionIsActive 和 subscriptionHasExpired。这两个布尔值可能产生矛盾的组合,例如同时为 true。用一个 status 字段更容易维护。
如果”过期”完全由 ends_at 决定,可以不保存 expired,而是在查询时动态计算:
SELECT
subscription_id,
client_id,
starts_at,
ends_at,
ends_at IS NOT NULL AND ends_at <= CURRENT_TIMESTAMP AS has_expired
FROM subscription;
判断订阅是否有效通常不能只看时间,还要考虑取消、欠费和暂停等状态。因此,实际系统一般会同时检查 status 和有效期。
Payment 应该如何关联
原方案中,subscription.paymentID 和 payment.clientID 的关系不够清楚。实际业务中通常会遇到以下情况:
- 一份订阅发生多次续费;
- 一笔付款失败后重新支付;
- 一张账单被分成多次支付;
- 退款需要与原付款关联;
- 客户付款后才激活订阅。
因此,不应让 subscription 只保存一个 payment_id。在简单场景中,可以让付款直接引用订阅:
CREATE TABLE payment (
payment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subscription_id bigint NOT NULL
REFERENCES subscription(subscription_id),
paid_at timestamptz,
amount numeric(12, 2) NOT NULL,
currency_code char(3) NOT NULL,
status varchar(20) NOT NULL,
provider_session_id varchar(255),
ip_address inet,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT payment_amount_valid
CHECK (amount >= 0),
CONSTRAINT payment_status_valid
CHECK (status IN ('pending', 'succeeded', 'failed', 'refunded'))
);
CREATE INDEX payment_subscription_idx
ON payment(subscription_id);
CREATE UNIQUE INDEX payment_provider_session_uidx
ON payment(provider_session_id)
WHERE provider_session_id IS NOT NULL;
这里通常不需要再保存 client_id,因为可以沿着 payment → subscription → client 找到付款客户。重复保存同一关系会增加数据不一致的风险。
如果需要记录”付款发生时的付款人”,或者系统允许第三方代付,可以增加 payer_client_id。此时它表达的是独立的业务含义,而不是单纯为了少做一次关联查询。
更完整的计费模型
如果系统需要处理续费、账单、税费、折扣、部分付款或退款,可以在订阅和付款之间加入 invoice:
client
└── subscription
└── invoice
└── payment
基本关系可以设计为:
CREATE TABLE invoice (
invoice_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subscription_id bigint NOT NULL
REFERENCES subscription(subscription_id),
amount_due numeric(12, 2) NOT NULL,
currency_code char(3) NOT NULL,
due_at timestamptz,
status varchar(20) NOT NULL
);
CREATE TABLE payment (
payment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
invoice_id bigint NOT NULL REFERENCES invoice(invoice_id),
amount numeric(12, 2) NOT NULL,
status varchar(20) NOT NULL,
provider_session_id varchar(255),
paid_at timestamptz
);
这样,每次续费可以生成一张账单,一张账单也能对应多次支付尝试,订阅生命周期与资金流水不会混在一起。
两个原方案的优缺点
方案 1:clientgroup + clientOriginalID
这个方案只需要一套 subscription 和 payment 表,新增客户类型时,表面上也比较容易扩展。
但 clientOriginalID 实际上是一个”多态外键”:它可能引用 school,也可能引用 student。标准外键无法同时指向两张表,因此可能出现以下问题:
clientOriginalID指向不存在的记录;- 类型标记为
school,实际 ID 却来自student; - 删除学校或学生后,留下孤立的
client; - 查询时必须根据类型动态选择关联表。
如果 clientgroup 只有固定的两三个类型,也不一定需要单独建表。枚举、CHECK 约束或稳定的类型代码通常已经够用。
方案 2:两套订阅和付款表
这个方案的外键关系明确,学校和学生的数据完全隔离,每类业务也能独立演进。
问题在于结构和逻辑会重复。查询全部订阅、统计总收入或统一修改付款规则时,都需要同时处理两套表。客户类型越多,重复的表和代码也会越多。
如果学校与学生的订阅本质上是两种不同产品,比如状态流程、计费周期、付款主体和审计要求都有明显差异,那么拆成两套模型才可能合理。
一个容易忽略的业务问题
设计前还要确认,“学生”和”学校”究竟是客户类型,还是业务实体的角色。学生可能属于某所学校,但订阅的付款方可能是:
- 学生本人;
- 学生家长;
- 学校统一采购;
- 其他组织。
如果存在这些情况,最好将”订阅账户”与”学生、学校”分开建模。可以用 account 表示订阅和付款主体,再建立 account_student、account_school 等关系。否则,当学校需要为多个学生购买订阅时,当前的一对一类型模型会很难扩展。
设计时应把所有可以订阅的主体抽象为拥有真实主键的公共实体,再通过外键扩展不同类型的数据。不要用”类型字段 + 任意表中的 ID”代替数据库能够验证的关系。
备注:内容仅供参考。
