Database design:如何为不同支付系统设计 FK
结论
不要让 orders.payment_record_id 根据另一个字段指向不同的表。MySQL 的外键必须在建表时指定目标表,不支持这种“多态外键”,因此数据库无法通过该方案保证引用完整性。
可以采用以下设计:
payment_method保存支付方式的名称、说明和稳定代码。- 统一使用
payment表记录每次支付尝试,并关联订单和支付方式。 - 各支付系统的专有字段分别保存在对应的明细表中。
- 明细表以
payment.id作为主键,同时将其作为指向payment的外键,形成共享主键的一对一关系。 - 订单与支付记录采用一对多关系,因为一个订单可能经历支付重试、失败后更换渠道、退款前重新支付等情况。
这样,每条需要被引用的支付记录都有统一身份,数据库不必判断 payment_record_id 究竟属于哪张表。
推荐的数据结构
CREATE TABLE payment_method (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id),
UNIQUE KEY uq_payment_method_code (code)
) ENGINE=InnoDB;
code 应使用稳定的业务标识,例如 system_a、system_b、cheque。显示名称和说明可以修改,但表名或 PHP 类名不应作为业务数据保存。
统一的支付主表如下:
CREATE TABLE payment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
payment_method_id BIGINT UNSIGNED NOT NULL,
canonical_status VARCHAR(30) NOT NULL,
provider_status VARCHAR(100) NULL,
amount DECIMAL(12, 2) NOT NULL,
currency CHAR(3) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_payment_order (order_id),
KEY idx_payment_method (payment_method_id),
CONSTRAINT fk_payment_order
FOREIGN KEY (order_id) REFERENCES orders (id),
CONSTRAINT fk_payment_method
FOREIGN KEY (payment_method_id) REFERENCES payment_method (id)
) ENGINE=InnoDB;
各支付系统的专有数据使用共享主键:
CREATE TABLE payment_system_a (
payment_id BIGINT UNSIGNED NOT NULL,
reference_id VARCHAR(255) NULL,
transaction_id VARCHAR(255) NULL,
xml_response MEDIUMTEXT NULL,
PRIMARY KEY (payment_id),
UNIQUE KEY uq_system_a_transaction (transaction_id),
CONSTRAINT fk_system_a_payment
FOREIGN KEY (payment_id) REFERENCES payment (id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE payment_system_b (
payment_id BIGINT UNSIGNED NOT NULL,
transaction_id VARCHAR(255) NOT NULL,
PRIMARY KEY (payment_id),
UNIQUE KEY uq_system_b_transaction (transaction_id),
CONSTRAINT fk_system_b_payment
FOREIGN KEY (payment_id) REFERENCES payment (id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE payment_cheque (
payment_id BIGINT UNSIGNED NOT NULL,
payment_date DATE NULL,
PRIMARY KEY (payment_id),
CONSTRAINT fk_cheque_payment
FOREIGN KEY (payment_id) REFERENCES payment (id)
ON DELETE CASCADE
) ENGINE=InnoDB;
payment_system_a.payment_id 既是主键,也是外键。因此,每条系统 A 明细都必须属于一条有效的支付记录,而一条支付记录最多只能对应一条系统 A 明细。
不要把支付记录直接放进 orders
如果只在 orders 中保存一个 payment_record_id,往往等于假定每个订单只有一次支付。但实际业务中可能出现:
- 第一次支付失败后重试;
- 从 system A 改用 system B;
- 支付请求已经创建,但尚未完成;
- 重复回调;
- 线下支票后来才确认到账;
- 同一订单拆分支付。
因此,应由 payment.order_id 指向 orders.id:
orders 1 ─── N payment
payment 1 ─── 0..1 payment_system_a
payment 1 ─── 0..1 payment_system_b
payment 1 ─── 0..1 payment_cheque
如果还需要记录客户当前选择的支付方式,可以给订单增加一个字段:
ALTER TABLE orders
ADD COLUMN selected_payment_method_id BIGINT UNSIGNED NULL,
ADD CONSTRAINT fk_order_selected_payment_method
FOREIGN KEY (selected_payment_method_id)
REFERENCES payment_method (id);
该字段表示“选择的支付方式”,并不代表具体的支付结果。
如何处理不同系统的 status
建议同时保存两类状态:
canonical_status:系统内部使用的统一状态,例如pending、processing、succeeded、failed、cancelled。provider_status:支付渠道返回的原始状态,即 system A 或 system B 自己定义的值。
订单业务只依赖统一状态,渠道适配器负责将外部状态映射为内部状态。
如果渠道状态需要通过配置管理,并接受外键约束,可以增加状态映射表:
CREATE TABLE payment_method_status (
payment_method_id BIGINT UNSIGNED NOT NULL,
provider_status VARCHAR(100) NOT NULL,
canonical_status VARCHAR(30) NOT NULL,
PRIMARY KEY (payment_method_id, provider_status),
CONSTRAINT fk_status_payment_method
FOREIGN KEY (payment_method_id)
REFERENCES payment_method (id)
ON DELETE CASCADE
) ENGINE=InnoDB;
有些支付系统会返回大量状态,甚至临时状态,这种情况下不适合强制使用外键。可以直接保存原始状态,再由应用层完成映射。
数据完整性的边界
上述外键可以保证:
- 支付记录必须属于有效订单;
- 支付记录必须使用有效的支付方式;
- 专有明细不能脱离支付主记录单独存在;
- 删除支付主记录时,可以同步删除对应的专有明细。
但仅靠这些外键,数据库仍然无法保证 payment_method.code = 'system_a' 的记录只出现在 payment_system_a 中,也无法保证每条支付记录恰好有一种专有明细。
可以通过以下方式补足这部分约束:
- 在同一个数据库事务中创建支付记录和专有明细。
- 由应用服务根据
payment_method.code创建相应的明细。 - 提交前检查明细类型是否与支付方式一致。
- 如果必须完全由数据库强制执行,可以增加触发器,但这会提高维护和测试成本。
较新的 MySQL 可以使用 CHECK 约束辅助限制类型,但旧版本 MySQL 曾经只解析 CHECK,并不真正执行。项目仅说明使用 PHP 5,无法据此判断 MySQL 版本,因此不能假定该约束有效。
Doctrine 中的建模方式
如果使用 Doctrine 2,可以考虑 Joined Table Inheritance。不过对于支付渠道,使用组合关系通常更容易理解:
Payment关联Order和PaymentMethod;SystemAPaymentDetail与Payment建立一对一关系;SystemBPaymentDetail与Payment建立一对一关系;ChequePaymentDetail与Payment建立一对一关系。
具体使用哪种注解或 XML 映射语法,取决于实际的 Doctrine 主版本。仅凭 PHP 5 无法判断项目使用的是 Doctrine 1 还是 Doctrine 2。
业务代码可以通过策略对象避免出现大量 if:
interface PaymentHandler
{
public function createPayment($order, array $parameters);
public function processNotification(array $payload);
}
$handlers = array(
'system_a' => $systemAHandler,
'system_b' => $systemBHandler,
'cheque' => $chequeHandler,
);
$methodCode = $payment->getPaymentMethod()->getCode();
if (!isset($handlers[$methodCode])) {
throw new RuntimeException('Unsupported payment method');
}
$handlers[$methodCode]->processNotification($payload);
这组映射应交给应用配置或依赖注入容器管理。数据库只保存稳定的 code,不要保存表名、Doctrine entity 名或 PHP class name,以免数据库结构与程序实现产生不必要的耦合。
其他注意事项
处理支付回调和交易号时,通常还需要注意以下问题:
- 为渠道交易号设置合适的唯一索引,防止重复回调生成重复支付记录;
- 保存原始回调内容,以便审计和排错;
- 金额使用
DECIMAL,不要使用FLOAT; - 同时保存币种;
- 状态更新需要考虑幂等性;
- 已完成的支付记录通常不应物理删除;
- XML 响应可能包含敏感信息,存储前需要评估脱敏、加密和访问权限。
核心原则是用统一的 payment 表为所有支付记录提供可被外键引用的身份,再用一对一的专有表保存不同渠道的数据,而不是使用无法建立真实外键的“表名 + 记录 ID”组合。
备注:内容仅供参考。
