PAYATHON 2026

Payments 和 PaymentMethods 表的设计方案

支付小周

结论

建议将 Payments 作为所有支付方式共享的主表,再把银行卡、银行转账等支付方式的专有数据拆分到各自的明细表中。

不要将 CardDetails 直接放进 Payments,也不要随着支付方式增加而不断添加可空字段。更合理的结构是“支付主表 + 支付方式表 + 支付方式明细表”。

推荐结构

PaymentMethods
       |
       v
   Payments
     /    \
    v      v
CardPaymentDetails
WireTransferDetails

各表的用途如下:

  • Payments 保存金额、币种、状态和创建时间等公共信息。
  • PaymentMethods 定义支付方式,例如 CARD、WIRE_TRANSFER。
  • CardPaymentDetails 只保存银行卡支付需要的信息。
  • WireTransferDetails 只保存银行转账需要的信息。
  • 每张明细表都与 Payments 建立一对一关系。

表结构示例

下面使用常见的 SQL 写法,数据类型可以根据具体数据库调整:

CREATE TABLE PaymentMethods (
    Id          INTEGER PRIMARY KEY,
    Type        VARCHAR(50) NOT NULL UNIQUE
);

CREATE TABLE Payments (
    Id              BIGINT PRIMARY KEY,
    PaymentMethodId INTEGER NOT NULL,
    Amount          DECIMAL(18, 2) NOT NULL,
    Currency        CHAR(3) NOT NULL,
    Status          VARCHAR(30) NOT NULL,
    CreatedAt       TIMESTAMP NOT NULL,
    OtherDetails    VARCHAR(500),

    CONSTRAINT FK_Payments_PaymentMethods
        FOREIGN KEY (PaymentMethodId)
        REFERENCES PaymentMethods(Id),

    CONSTRAINT CK_Payments_Amount
        CHECK (Amount >= 0)
);

CREATE TABLE CardPaymentDetails (
    PaymentId           BIGINT PRIMARY KEY,
    CardToken           VARCHAR(255) NOT NULL,
    CardBrand           VARCHAR(30),
    LastFourDigits      CHAR(4),
    AuthorizationCode   VARCHAR(100),

    CONSTRAINT FK_CardPaymentDetails_Payments
        FOREIGN KEY (PaymentId)
        REFERENCES Payments(Id)
        ON DELETE CASCADE
);

CREATE TABLE WireTransferDetails (
    PaymentId           BIGINT PRIMARY KEY,
    BankReference       VARCHAR(100),
    SenderBankCode      VARCHAR(50),
    SenderAccountName   VARCHAR(200),
    TransferDate        TIMESTAMP,

    CONSTRAINT FK_WireTransferDetails_Payments
        FOREIGN KEY (PaymentId)
        REFERENCES Payments(Id)
        ON DELETE CASCADE
);

明细表中的 PaymentId 既是主键,也是指向 Payments 的外键,所以同一笔支付在一张明细表中最多只能有一条记录。

可以预先写入支付方式:

INSERT INTO PaymentMethods (Id, Type)
VALUES
    (1, 'CARD'),
    (2, 'WIRE_TRANSFER');

保存一笔银行卡支付时,可以这样写:

BEGIN;

INSERT INTO Payments (
    Id,
    PaymentMethodId,
    Amount,
    Currency,
    Status,
    CreatedAt
)
VALUES (
    10001,
    1,
    299.00,
    'CNY',
    'AUTHORIZED',
    CURRENT_TIMESTAMP
);

INSERT INTO CardPaymentDetails (
    PaymentId,
    CardToken,
    CardBrand,
    LastFourDigits,
    AuthorizationCode
)
VALUES (
    10001,
    'provider_token_xxx',
    'VISA',
    '1234',
    'AUTH-001'
);

COMMIT;

银行转账的数据写入 WireTransferDetails,银行卡字段中不需要保留一批 NULL:

BEGIN;

INSERT INTO Payments (
    Id,
    PaymentMethodId,
    Amount,
    Currency,
    Status,
    CreatedAt
)
VALUES (
    10002,
    2,
    5000.00,
    'CNY',
    'PENDING',
    CURRENT_TIMESTAMP
);

INSERT INTO WireTransferDetails (
    PaymentId,
    BankReference,
    SenderBankCode,
    SenderAccountName,
    TransferDate
)
VALUES (
    10002,
    'WIRE-20260829-001',
    'BANK-CODE',
    'Example Company',
    CURRENT_TIMESTAMP
);

COMMIT;

为什么这样设计

这种结构有几个实际好处:

  • 公共字段只保存一份,不会重复出现在不同的支付表中。
  • 每种支付方式都可以定义自己的必填字段和约束。
  • 增加支付方式时,只需添加对应的明细表。
  • 查询公共支付记录时,无须关心具体的支付方式。
  • 一张表中不会堆积大量仅适用于少数记录的可空列。

银行卡支付可能需要 CardToken 和卡号后四位,银行转账则可能需要银行流水号和付款方信息。这些字段的含义不同,分表保存更便于维护和校验。

如何保证支付方式与明细表一致

只使用上述外键时,数据库通常无法自动阻止这类错误:

  • Payments.PaymentMethodId 表示 WIRE_TRANSFER
  • 实际插入的却是 CardPaymentDetails

通常有三种处理方法:

  1. 由应用服务校验,并在同一个事务中写入主表和明细表。
  2. 使用数据库触发器,检查支付方式与明细表是否匹配。
  3. 调整数据模型,让支付方式由明细记录是否存在来决定。不过,这种模型在查询和扩展时通常更复杂。

多数业务系统会同时使用应用层校验和事务。如果脚本、后台任务或多个服务也能直接写入这些表,建议再增加数据库触发器或其他约束,作为最后一道保护。

读取数据时,可以根据支付方式选择对应的明细表:

SELECT
    p.Id,
    p.Amount,
    p.Currency,
    p.Status,
    pm.Type,
    c.CardBrand,
    c.LastFourDigits,
    w.BankReference,
    w.SenderAccountName
FROM Payments AS p
JOIN PaymentMethods AS pm
    ON pm.Id = p.PaymentMethodId
LEFT JOIN CardPaymentDetails AS c
    ON c.PaymentId = p.Id
LEFT JOIN WireTransferDetails AS w
    ON w.PaymentId = p.Id
WHERE p.Id = 10001;

如果支付方式很多,就不适合每次查询都连接所有明细表。可以先读取 Payments 和 PaymentMethods,确认支付类型后,再查询对应的明细表。

其他可选方案

如果支付方式很少,而且不同方式之间只相差一两个非关键字段,也可以直接将这些字段放入 Payments,并允许它们为 NULL。不过,随着支付方式增多,这种结构很容易变成难以理解的“大宽表”。

另一种做法是使用 JSON 字段保存各支付方式的扩展数据:

CREATE TABLE Payments (
    Id              BIGINT PRIMARY KEY,
    PaymentMethodId INTEGER NOT NULL,
    Amount          DECIMAL(18, 2) NOT NULL,
    MethodDetails   JSON NOT NULL
);

这种结构适合字段经常变化、数据主要按整体读取,而且很少根据明细字段查询的场景。相应的代价包括:

  • 数据库更难约束必填字段和数据类型。
  • 索引和关联查询会更复杂。
  • 数据结构变更需要额外的版本管理。
  • 各支付方式的数据可能逐渐失去统一规范。

因此,核心支付数据通常更适合使用独立明细表。不重要且经常变化的附加信息,可以考虑放入 JSON。

安全注意事项

不要在业务数据库中直接保存完整卡号、CVV/CVC 或磁道数据等敏感认证信息。这些数据通常应交给符合相应安全要求的支付服务商处理,本地只保存支付令牌、卡品牌、卡号后四位和服务商交易编号。

具体可以保存哪些字段,取决于业务所在地区、支付服务商协议,以及 PCI DSS 等适用的合规要求。即使数据经过加密,也不能默认所有银行卡敏感信息都可以长期保存。

备注:内容仅供参考。