PAYATHON 2026

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 扩展。

只有当三类业务的交易流程、权限、生命周期和结算规则几乎完全无关,而且不需要统一支付、开票或统计时,才有必要考虑独立的订单系统。对于题述场景,直接拆成三张订单表通常只会增加重复逻辑和查询成本,不应作为首选方案。

备注:内容仅供参考。