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 约束通常不能直接查询另一张表。可以根据数据库能力选择以下方案:
- 由写入服务统一控制,始终在同一事务中创建主记录和对应的明细记录。
- 使用触发器检查
method_type是否与明细表一致。 - 在明细表中加入类型字段,并通过复合外键强化约束。
采用第三种方式时,可以这样修改主表:
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。
备注:内容仅供参考。
