PAYATHON 2026

Database design:如何为不同支付系统设计 FK

支付小周

结论

不要让 orders.payment_record_id 根据另一个字段指向不同的表。MySQL 的外键必须在建表时指定目标表,不支持这种“多态外键”,因此数据库无法通过该方案保证引用完整性。

可以采用以下设计:

  1. payment_method 保存支付方式的名称、说明和稳定代码。
  2. 统一使用 payment 表记录每次支付尝试,并关联订单和支付方式。
  3. 各支付系统的专有字段分别保存在对应的明细表中。
  4. 明细表以 payment.id 作为主键,同时将其作为指向 payment 的外键,形成共享主键的一对一关系。
  5. 订单与支付记录采用一对多关系,因为一个订单可能经历支付重试、失败后更换渠道、退款前重新支付等情况。

这样,每条需要被引用的支付记录都有统一身份,数据库不必判断 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 中,也无法保证每条支付记录恰好有一种专有明细。

可以通过以下方式补足这部分约束:

  1. 在同一个数据库事务中创建支付记录和专有明细。
  2. 由应用服务根据 payment_method.code 创建相应的明细。
  3. 提交前检查明细类型是否与支付方式一致。
  4. 如果必须完全由数据库强制执行,可以增加触发器,但这会提高维护和测试成本。

较新的 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”组合。

备注:内容仅供参考。