subscriptions、单次购买与variable services的数据模型设计
结论
不建议为 subscription、单次购买和 variable service 分别建立三套 orders 表。
更合适的做法是:
- 用统一的
orders和order_items表记录交易; - 用
products表定义可重复销售的产品或服务; - 在
order_items中保存成交时的名称、数量和价格快照; - 用
subscriptions表保存订阅的生命周期数据; - 对临时服务或按实际情况计费的服务,允许订单项不关联固定产品,也可以关联一个通用服务产品;
- 如果 variable service 还要记录工时、用量或计费区间等明细,再增加专门的
service_usages或service_charges表。
建模时,应把“发生了一笔交易”和“交易内容有哪些业务属性”分开处理,不要按商品类型复制整套订单结构。
推荐的数据关系
可以按下面的关系组织:
customers
└── domains
├── subscriptions
└── order_items
orders
└── order_items
├── products(可选)
├── subscriptions(仅订阅类)
└── service_charges(仅可变服务类)
各表的职责如下:
orders表示一次下单或商业交易;order_items表示订单购买了什么;products是产品目录,不负责保存历史成交价格;subscriptions表示订阅实例,而不是订阅产品本身;service_charges保存某次服务的实际计费依据;domains表示服务归属的业务对象。
按照这种结构,一张订单可以同时包含订阅、固定价格商品和临时服务。
为什么不适合拆成三套订单表
三类业务的计费方式虽然不同,但通常会共用很多交易属性,例如:
- 客户;
- 订单编号;
- 下单时间;
- 币种;
- 订单状态;
- 税额;
- 折扣;
- 支付状态;
- 发票关联;
- 审计信息。
如果分别建立 subscription_orders、product_orders 和 service_orders,这些字段以及支付、退款、报表逻辑都可能重复。查询某个客户的全部消费记录时,也要对多张表执行 UNION。
订阅使用的 start_at 和 expires_at 不适用于普通商品,但这不代表订单表也必须拆开。这些字段描述的是订阅实例,应该放在 subscriptions 表,而不是订单主表。
一个可落地的表结构
下面使用 PostgreSQL 风格的 SQL 演示。具体字段还要根据实际的税务、支付和履约规则调整。
客户与域名
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(200) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE domains (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
domain_name VARCHAR(253) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (customer_id, domain_name)
);
这里假设一个 domain 只属于一个 customer。如果 domain 与 customer 实际是多对多关系,应改用中间表:
CREATE TABLE customer_domains (
customer_id BIGINT NOT NULL REFERENCES customers(id),
domain_id BIGINT NOT NULL REFERENCES domains(id),
PRIMARY KEY (customer_id, domain_id)
);
产品目录
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
product_type VARCHAR(30) NOT NULL
CHECK (product_type IN (
'subscription',
'one_time',
'variable_service'
)),
default_unit_price NUMERIC(19, 4),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
default_unit_price 只是报价或默认价格,不能当作历史订单的最终价格。产品价格可能调整,但已完成订单的成交价格不能随之变化。
对于临时发生且以后不会复用的服务,不必为每次收费都创建新产品。可以选择以下任一种方式:
- 允许
order_items.product_id为NULL; - 创建一个通用的
VARIABLE_SERVICE产品,再在订单项中填写本次服务的描述和实际价格。
如果系统要求所有收入都归入财务科目,第二种方式通常更方便,具体仍取决于会计和报表要求。
统一订单与订单项
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_number VARCHAR(50) NOT NULL UNIQUE,
customer_id BIGINT NOT NULL REFERENCES customers(id),
status VARCHAR(30) NOT NULL
CHECK (status IN (
'draft',
'confirmed',
'cancelled',
'completed'
)),
currency_code CHAR(3) NOT NULL,
ordered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
domain_id BIGINT REFERENCES domains(id),
product_id BIGINT REFERENCES products(id),
item_type VARCHAR(30) NOT NULL
CHECK (item_type IN (
'subscription',
'one_time',
'variable_service'
)),
description VARCHAR(500) NOT NULL,
quantity NUMERIC(19, 4) NOT NULL DEFAULT 1,
unit_price NUMERIC(19, 4) NOT NULL,
tax_amount NUMERIC(19, 4) NOT NULL DEFAULT 0,
discount_amount NUMERIC(19, 4) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (quantity > 0),
CHECK (unit_price >= 0),
CHECK (tax_amount >= 0),
CHECK (discount_amount >= 0)
);
订单项金额可以在查询时计算:
quantity * unit_price - discount_amount + tax_amount
也可以把计算结果保存为单独的金额字段,但必须保证它与数量、单价、折扣和税额一致。涉及财务审计时,通常会保存计算后的金额快照,并由应用服务统一计算。
即使 description 和 unit_price 已经存在于 products 表,也必须保存到 order_items 中。这样才能保留成交时的商品名称和价格。
订阅数据单独保存
订阅不是一种订单,而是由订单产生的持续性业务实例。
CREATE TABLE subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
source_order_item_id BIGINT NOT NULL REFERENCES order_items(id),
customer_id BIGINT NOT NULL REFERENCES customers(id),
domain_id BIGINT REFERENCES domains(id),
product_id BIGINT NOT NULL REFERENCES products(id),
status VARCHAR(30) NOT NULL
CHECK (status IN (
'pending',
'active',
'paused',
'cancelled',
'expired'
)),
start_at TIMESTAMPTZ NOT NULL,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (expires_at IS NULL OR expires_at >= start_at),
UNIQUE (source_order_item_id)
);
下面几个概念容易混淆,需要分开处理:
start_at:订阅首次生效的时间;current_period_start、current_period_end:当前计费周期;expires_at:订阅最终到期的时间;cancelled_at:执行取消操作的时间。
取消订阅不一定会让订阅立即失效,所以 cancelled_at 和 expires_at 不应合并成一个字段。
续费时通常会创建新的订单和订单项,用来记录新一笔收费;原来的 subscriptions 记录继续表示同一个订阅实例。还可以增加订阅与续费订单项的关联表:
CREATE TABLE subscription_charges (
subscription_id BIGINT NOT NULL REFERENCES subscriptions(id),
order_item_id BIGINT NOT NULL REFERENCES order_items(id),
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
PRIMARY KEY (subscription_id, order_item_id),
CHECK (period_end > period_start)
);
可变服务的建模方式
如果 variable service 只是偶尔手工录入一笔价格,使用 order_items 就够了:
INSERT INTO order_items (
order_id,
domain_id,
product_id,
item_type,
description,
quantity,
unit_price
) VALUES (
1001,
25,
NULL,
'variable_service',
'数据库故障排查与恢复支持',
3.5,
800.00
);
这条记录表示 3.5 个计费单位,每单位 800 元。计费单位可能是小时、GB、请求次数或其他单位。建议增加 unit_code,以免只看数字无法判断单位:
ALTER TABLE order_items
ADD COLUMN unit_code VARCHAR(30);
如果服务费用由多条用量记录汇总而来,应把原始用量和最终收费分开保存:
CREATE TABLE service_usages (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
domain_id BIGINT REFERENCES domains(id),
product_id BIGINT REFERENCES products(id),
usage_start TIMESTAMPTZ NOT NULL,
usage_end TIMESTAMPTZ NOT NULL,
quantity NUMERIC(19, 4) NOT NULL,
unit_code VARCHAR(30) NOT NULL,
description VARCHAR(500),
billed_order_item_id BIGINT REFERENCES order_items(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (usage_end >= usage_start),
CHECK (quantity >= 0)
);
系统可以先记录工时或用量,结算时再生成订单项。多条 service_usages 可以关联到同一个 order_item,方便合并计费。
domain 应该关联在哪里
如果每个订单项只服务于一个 domain,最简单的做法是在 order_items 中直接保存 domain_id。
如果一项购买可以分配给多个 domain,就不应在订单项中保存单个 domain_id,而应使用关联表:
CREATE TABLE order_item_domains (
order_item_id BIGINT NOT NULL REFERENCES order_items(id),
domain_id BIGINT NOT NULL REFERENCES domains(id),
PRIMARY KEY (order_item_id, domain_id)
);
订阅也可以采用同样的方式。是否需要关联表,应根据实际业务基数决定,不必只因为“以后可能用到”就提前增加复杂度。
应用层或数据库约束还要保证订单所属的 customer 与 domain 所属的 customer 一致。仅设置两个外键无法自动保证这一点。
不要混淆订单、发票和支付
如果系统除了记录购买行为,还要负责实际收费,建议把以下概念分开:
orders:客户买了什么;invoices:客户应付多少钱;payments:实际收到多少钱;refunds:退回了多少钱;subscriptions:客户当前拥有哪些持续权益;service_usages:实际发生了多少可计费服务。
一张订单可能分多次开票,一张发票也可能通过多次支付结清。出现订阅续费失败、补缴或部分退款等情况后,如果把这些概念全部塞进 orders,维护会变得很困难。
需要特别注意的事项
保存价格快照
不要只通过 product_id 查询产品当前价格。订单项至少应保存:
- 成交名称或描述;
- 数量;
- 单位;
- 单价;
- 币种;
- 折扣;
- 税额。
否则产品改名或调价后,历史订单的数据会失真。
金额使用定点数
金额应使用 NUMERIC 或数据库提供的精确定点类型,不要使用 FLOAT 或 DOUBLE。币种和舍入规则也要明确。
类型约束不能只靠 item_type
item_type 方便查询,但它与扩展表之间仍可能不一致。例如,普通订单项可能意外关联到订阅记录。
可以通过事务、触发器或应用服务保证:
- 只有
subscription类型的订单项才能创建subscriptions; - 只有
variable_service类型的订单项才能绑定服务用量; - 产品类型必须与订单项类型一致。
这些规则是否全部下沉到数据库,应根据团队对数据库约束和应用层校验的职责划分决定。
不要删除历史产品
产品停售后,通常应设置 active = FALSE,而不是物理删除,因为历史订单仍要引用该产品。订单、发票和支付记录通常也应通过状态变更或冲销记录处理,不要直接覆盖历史数据。
最终建议
采用“一套交易表加按业务类型扩展”的模型:
orders
order_items
products
subscriptions
subscription_charges
service_usages
固定价格商品只需要 orders、order_items 和可选的 products。订阅使用 subscriptions 扩展;可变服务则通过订单项中的价格快照和可选的 service_usages 扩展。
只有当三类业务的交易流程、权限、生命周期和结算规则几乎完全无关,而且不需要统一支付、开票或统计时,才有必要考虑独立的订单系统。对于题述场景,直接拆成三张订单表通常只会增加重复逻辑和查询成本,不应作为首选方案。
备注:内容仅供参考。
