PAYATHON 2026

如何设计支持不同客户类型的 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”代替数据库能够验证的关系。

备注:内容仅供参考。