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
通常有三种处理方法:
- 由应用服务校验,并在同一个事务中写入主表和明细表。
- 使用数据库触发器,检查支付方式与明细表是否匹配。
- 调整数据模型,让支付方式由明细记录是否存在来决定。不过,这种模型在查询和扩展时通常更复杂。
多数业务系统会同时使用应用层校验和事务。如果脚本、后台任务或多个服务也能直接写入这些表,建议再增加数据库触发器或其他约束,作为最后一道保护。
读取数据时,可以根据支付方式选择对应的明细表:
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 等适用的合规要求。即使数据经过加密,也不能默认所有银行卡敏感信息都可以长期保存。
备注:内容仅供参考。
