PAYATHON 2026

DB design:如何保存不同付款方式的详细信息?

支付小周

结论

建议采用“主表 + 类型明细表”的超类型/子类型结构:

  • 主表保存所有付款方式的共有信息,如所属会员、付款类型、状态和创建时间。
  • 信用卡、支票和银行账户分别使用独立的明细表,只保存各自需要的字段。
  • 每个明细表与主表建立一对一关系。

这种结构不会产生大量无意义的 NULL 字段。与把所有明细放进 JSON 相比,它也更便于设置约束、索引和外键。

还需要区分两个概念:

  • payment_method:会员保存的付款方式。
  • payment:实际发生的支付交易。

一个会员通常可以保存多个付款方式,也可以产生多笔支付记录。

推荐的数据结构

以下示例使用 PostgreSQL 语法。其他关系型数据库也可以采用相同思路,但枚举、约束和触发器的写法可能不同。

付款方式主表

CREATE TYPE payment_method_type AS ENUM (
    'credit_card',
    'check',
    'bank_account'
);

CREATE TABLE payment_method (
    payment_method_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    member_id         BIGINT NOT NULL REFERENCES member(member_id),
    method_type       payment_method_type NOT NULL,
    display_name      VARCHAR(100),
    is_default        BOOLEAN NOT NULL DEFAULT FALSE,
    status            VARCHAR(20) NOT NULL DEFAULT 'active',
    created_at        TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

该表只保存三种付款方式共有的字段,method_type 用于标识对应的明细表。

信用卡明细表

CREATE TABLE credit_card_detail (
    payment_method_id BIGINT PRIMARY KEY
        REFERENCES payment_method(payment_method_id) ON DELETE CASCADE,

    provider_token    VARCHAR(255) NOT NULL,
    card_brand        VARCHAR(30),
    last_four         CHAR(4) NOT NULL,
    expiry_month      SMALLINT NOT NULL CHECK (expiry_month BETWEEN 1 AND 12),
    expiry_year       SMALLINT NOT NULL
);

不要直接保存完整卡号、磁道数据或 CVV。实际系统通常只保存支付服务商返回的 provider_token,另行保留卡品牌、末四位和有效期,方便用户识别。

支票明细表

CREATE TABLE check_detail (
    payment_method_id BIGINT PRIMARY KEY
        REFERENCES payment_method(payment_method_id) ON DELETE CASCADE,

    account_holder    VARCHAR(150) NOT NULL,
    bank_name         VARCHAR(150),
    check_number      VARCHAR(50)
);

具体保存哪些支票字段,要看业务流程。如果支票只在实际付款时产生,check_number 更适合放在支付交易的附属表中,不必长期保存在付款方式表里。

银行账户明细表

CREATE TABLE bank_account_detail (
    payment_method_id BIGINT PRIMARY KEY
        REFERENCES payment_method(payment_method_id) ON DELETE CASCADE,

    provider_token    VARCHAR(255) NOT NULL,
    account_holder    VARCHAR(150) NOT NULL,
    bank_name         VARCHAR(150),
    account_last_four CHAR(4),
    account_type      VARCHAR(20),
    country_code      CHAR(2)
);

银行账号和路由信息也属于敏感数据。如果支付机构支持令牌化,应保存令牌和脱敏后的展示信息,不要保存原始账户凭据。

记录实际支付

付款方式描述“用什么付”,支付表记录“何时支付了多少,以及结果如何”。

CREATE TABLE payment (
    payment_id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    member_id         BIGINT NOT NULL REFERENCES member(member_id),
    payment_method_id BIGINT REFERENCES payment_method(payment_method_id),
    amount             NUMERIC(19, 4) NOT NULL CHECK (amount > 0),
    currency           CHAR(3) NOT NULL,
    status             VARCHAR(20) NOT NULL,
    provider_reference VARCHAR(255),
    paid_at             TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

会员日后删除或更换付款方式时,历史支付记录不应随之丢失。常见做法有两种:

  • 对已经使用过的付款方式执行逻辑删除,只修改 status。
  • 在 payment 中保存交易发生时的必要快照,如付款类型、卡末四位和银行名称。

不要只依赖付款方式的当前明细解释历史交易,因为这些信息可能会发生变化。

写入示例

创建信用卡付款方式时,应在同一事务中写入主表和明细表:

BEGIN;

WITH new_method AS (
    INSERT INTO payment_method (
        member_id,
        method_type,
        display_name
    )
    VALUES (
        1001,
        'credit_card',
        'Personal Visa'
    )
    RETURNING payment_method_id
)
INSERT INTO credit_card_detail (
    payment_method_id,
    provider_token,
    card_brand,
    last_four,
    expiry_month,
    expiry_year
)
SELECT
    payment_method_id,
    'tok_example_123',
    'Visa',
    '4242',
    12,
    2028
FROM new_method;

COMMIT;

查询会员保存的信用卡付款方式时,可以直接连接对应的明细表:

SELECT
    pm.payment_method_id,
    pm.display_name,
    pm.status,
    cc.card_brand,
    cc.last_four,
    cc.expiry_month,
    cc.expiry_year
FROM payment_method AS pm
JOIN credit_card_detail AS cc
  ON cc.payment_method_id = pm.payment_method_id
WHERE pm.member_id = 1001
  AND pm.method_type = 'credit_card';

如何保证类型一致

只使用上述外键,数据库仍可能出现以下错误数据:

  • 主表中的 method_type 是 credit_card。
  • 实际插入的明细却是 bank_account_detail。

普通 CHECK 约束通常不能直接查询另一张表。可以根据数据库能力选择以下方案:

  1. 由写入服务统一控制,始终在同一事务中创建主记录和对应的明细记录。
  2. 使用触发器检查 method_type 是否与明细表一致。
  3. 在明细表中加入类型字段,并通过复合外键强化约束。

采用第三种方式时,可以这样修改主表:

ALTER TABLE payment_method
ADD CONSTRAINT uq_payment_method_id_type
UNIQUE (payment_method_id, method_type);

然后在信用卡明细表中固定类型:

CREATE TABLE credit_card_detail (
    payment_method_id BIGINT PRIMARY KEY,
    method_type       payment_method_type NOT NULL
        DEFAULT 'credit_card'
        CHECK (method_type = 'credit_card'),

    provider_token    VARCHAR(255) NOT NULL,
    card_brand        VARCHAR(30),
    last_four         CHAR(4) NOT NULL,
    expiry_month      SMALLINT NOT NULL CHECK (expiry_month BETWEEN 1 AND 12),
    expiry_year       SMALLINT NOT NULL,

    FOREIGN KEY (payment_method_id, method_type)
        REFERENCES payment_method(payment_method_id, method_type)
        ON DELETE CASCADE
);

这样,数据库就能阻止信用卡明细关联到其他类型的主记录。不过,要保证“每条主记录必须且只能存在一条对应明细”,通常还需要使用延迟触发器、存储过程或应用事务。

为什么不建议使用一张宽表

把所有字段放进一张表,结构看起来很简单:

payment_method
├── card_number
├── expiry_month
├── check_number
├── bank_account_number
└── ...

随着付款方式增加,可空列会越来越多,还需要编写复杂的约束,判断不同类型下哪些字段必须有值。这种设计只适合字段很少、类型固定且业务非常简单的场景。

JSON 是否可用

不同付款类型的属性也可以保存在 details JSONB 中:

CREATE TABLE payment_method (
    payment_method_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    member_id         BIGINT NOT NULL REFERENCES member(member_id),
    method_type       VARCHAR(30) NOT NULL,
    details           JSONB NOT NULL
);

这种方式便于快速增加付款类型,但会增加字段约束、外键、数据迁移和查询的复杂度。如果这些字段需要频繁筛选、关联或严格校验,使用独立明细表更合适。JSON 更适合保存变化频繁且很少参与查询的非核心扩展属性。

注意事项

  • member 与 payment_method 通常是一对多关系,不宜只在 member 表中保存一个 payment_type。
  • 金额应使用 NUMERIC 或数据库提供的定点金额类型,不要使用 FLOAT。
  • 币种建议保存 ISO 4217 三字母代码,如 CNY、USD。
  • 不要保存 CVV。完整卡号和银行账户信息应优先交给合规支付服务商进行令牌化。
  • 敏感字段需要加密,并配置访问控制、审计和日志脱敏。具体要求取决于所在地区、支付渠道和适用的合规标准。
  • 删除付款方式时,优先考虑逻辑删除,以免破坏支付历史和审计记录。
  • 如果一笔支付可以拆分到多种付款方式,应增加 payment_allocation 一类的关联表,不要只在 payment 中保存一个 payment_method_id。

备注:内容仅供参考。