PAYATHON 2026

如何设计 payment transactions 与 Orders 表

支付老李

结论

不能因为 Payments 表里有一条 Success 记录,就直接把 Orders.Paid 永久设为 1。

更合适的设计是:

  • Orders 保存订单履约状态和支付汇总状态。
  • PaymentTransactions 保存每次支付尝试,以及后续的退款、撤销等交易记录。
  • 用 payment_status 代替简单的 Paid (0, 1)。
  • 只有成功入账的金额达到订单应付金额,且币种一致时,订单才能进入 paid 状态。
  • 交易记录的写入和订单状态的更新必须放在同一个数据库事务中。
  • 支付平台回调必须支持幂等处理,避免重复入账。

如果业务能保证每笔订单都只会一次性全额付款,而且不会退款或拒付,Paid 布尔值还可以使用。一旦涉及支付重试、部分付款、退款或异步回调,它就无法准确表达订单的资金状态。

为什么不能只判断是否存在 Success

支付交易中的“成功”可能对应不同阶段:

  • 已创建支付请求
  • 已授权但尚未扣款
  • 已成功扣款
  • 已结算
  • 已退款
  • 已撤销
  • 后续发生拒付

同一笔订单还可能遇到以下情况:

  • 第一次付款失败,第二次成功
  • 多次失败后成功
  • 两笔部分付款合计付清
  • 重复回调导致同一笔成功交易被记录两次
  • 成功支付后发生全额或部分退款
  • 用户重复付款,实际支付金额超过订单金额

所以,订单是否已支付,应根据“有效支付净额”判断,不能只看某一行的状态文字。

推荐的表结构

订单状态和支付状态应分别保存。order_status 表示订单处理进度,payment_status 表示资金状态。

CREATE TABLE orders (
    order_id           BIGINT PRIMARY KEY,
    member_id          BIGINT NOT NULL,
    currency           CHAR(3) NOT NULL,
    order_total_minor  BIGINT NOT NULL,
    order_status       VARCHAR(32) NOT NULL,
    payment_status     VARCHAR(32) NOT NULL DEFAULT 'unpaid',
    paid_amount_minor  BIGINT NOT NULL DEFAULT 0,
    created_at         TIMESTAMP NOT NULL,
    updated_at         TIMESTAMP NOT NULL,

    CHECK (order_total_minor >= 0),
    CHECK (paid_amount_minor >= 0)
);

金额最好用最小货币单位的整数保存。例如,人民币 10.25 元可存为 1025 分,以免产生浮点数计算误差。也可以使用精度明确的 DECIMAL,但不要用 FLOAT 或 DOUBLE 保存金额。

支付表记录每一次独立的支付尝试:

CREATE TABLE payment_transactions (
    payment_id          BIGINT PRIMARY KEY,
    order_id            BIGINT NOT NULL,
    provider            VARCHAR(32) NOT NULL,
    provider_payment_id VARCHAR(128) NOT NULL,
    transaction_type    VARCHAR(32) NOT NULL,
    status              VARCHAR(32) NOT NULL,
    amount_minor        BIGINT NOT NULL,
    currency            CHAR(3) NOT NULL,
    failure_code        VARCHAR(64),
    failure_message     VARCHAR(255),
    created_at          TIMESTAMP NOT NULL,
    updated_at          TIMESTAMP NOT NULL,

    CONSTRAINT fk_payment_order
        FOREIGN KEY (order_id) REFERENCES orders(order_id),

    CONSTRAINT uq_provider_payment
        UNIQUE (provider, provider_payment_id),

    CHECK (amount_minor > 0)
);

各字段的含义如下:

  • provider:支付渠道,例如 stripe、paypal 或银行网关。
  • provider_payment_id:支付平台提供的唯一交易标识,用于防止重复处理。
  • transaction_type:可以使用 authorization、capture、payment、refund、void、chargeback 等值。
  • status:可以使用 pending、succeeded、failed、cancelled 等值。
  • amount_minor:本次交易涉及的金额,不应直接复制订单总额。
  • failure_code:记录失败原因,便于排查问题和判断能否重试。

具体的状态名称要与支付平台的交易模型对应,不能把平台返回的 authorized 当成已经完成扣款。

订单支付状态如何计算

订单的支付状态可以设计为:

  • unpaid:尚无有效入账
  • pending:支付正在处理,最终结果尚未确定
  • partially_paid:有效支付金额小于订单应付金额
  • paid:有效支付金额等于订单应付金额
  • overpaid:有效支付金额大于订单应付金额
  • partially_refunded:付款后发生部分退款
  • refunded:有效支付净额降为零
  • disputed:发生拒付或争议

净支付金额可以抽象为:

paid_amount =
    succeeded captures/payments
    - succeeded refunds
    - confirmed chargebacks

再根据净额更新订单状态:

paid_amount = 0                 -> unpaid 或 refunded
0 < paid_amount < order_total   -> partially_paid
paid_amount = order_total       -> paid
paid_amount > order_total       -> overpaid

如果系统只支持一次性全额付款,可以减少状态数量,但仍建议保留支付记录和幂等约束。

示例:失败后再次支付成功

假设订单 123 应付 10000 分:

Payment 1: amount=10000, status=failed
Payment 2: amount=10000, status=succeeded

第一条失败记录不计入已付金额,第二条成功记录计入,因此:

paid_amount_minor = 10000
payment_status = paid

这时可以把订单标记为已支付。判断依据不是“存在一条成功记录”,而是成功入账的有效净额已经覆盖订单应付金额。

如果第二次交易只成功支付了 5000 分,则状态应为:

paid_amount_minor = 5000
payment_status = partially_paid

在同一个事务中更新

处理支付成功通知时,应先锁定订单,写入支付结果,再重新计算支付汇总,避免并发回调造成数据不一致。

下面是示意 SQL,聚合条件要按实际使用的交易类型调整:

BEGIN;

SELECT order_id, order_total_minor, currency
FROM orders
WHERE order_id = 123
FOR UPDATE;

INSERT INTO payment_transactions (
    payment_id,
    order_id,
    provider,
    provider_payment_id,
    transaction_type,
    status,
    amount_minor,
    currency,
    created_at,
    updated_at
)
VALUES (
    9001,
    123,
    'example_gateway',
    'txn_abc123',
    'payment',
    'succeeded',
    10000,
    'CNY',
    CURRENT_TIMESTAMP,
    CURRENT_TIMESTAMP
)
ON CONFLICT (provider, provider_payment_id) DO NOTHING;

UPDATE orders
SET
    paid_amount_minor = (
        SELECT COALESCE(SUM(
            CASE
                WHEN transaction_type IN ('payment', 'capture')
                     AND status = 'succeeded'
                    THEN amount_minor
                WHEN transaction_type IN ('refund', 'chargeback')
                     AND status = 'succeeded'
                    THEN -amount_minor
                ELSE 0
            END
        ), 0)
        FROM payment_transactions
        WHERE order_id = 123
          AND currency = orders.currency
    ),
    updated_at = CURRENT_TIMESTAMP
WHERE order_id = 123;

UPDATE orders
SET payment_status =
    CASE
        WHEN paid_amount_minor = 0 THEN 'unpaid'
        WHEN paid_amount_minor < order_total_minor THEN 'partially_paid'
        WHEN paid_amount_minor = order_total_minor THEN 'paid'
        ELSE 'overpaid'
    END,
    updated_at = CURRENT_TIMESTAMP
WHERE order_id = 123;

COMMIT;

ON CONFLICT 是 PostgreSQL 语法。使用 MySQL、SQL Server 或其他数据库时,需要改成相应的幂等写入方式。

是否需要在 Orders 中保存已付金额

有两种方案可选。

实时聚合交易记录

查询订单时,直接从交易表计算净支付金额。

这种方式以支付记录为唯一事实来源,不容易出现汇总字段过期的问题。不过,数据量较大时,查询成本会比较高。

在 Orders 中保存汇总字段

把 paid_amount_minor 和 payment_status 保存在订单表中,并在支付状态变化时同步更新。

这种方式查询更快,适合订单列表和履约流程。相应地,系统必须通过事务、锁或可靠的事件处理机制,保证汇总字段与交易明细一致。

实际系统通常会采用第二种方式,同时定期执行对账任务,用交易明细和支付渠道账单核对汇总结果。

还需要注意的事项

不要覆盖失败记录

失败的支付尝试也有审计价值。重试时不要直接把原来的 failed 记录改成 succeeded,每次新的支付尝试都应创建一条新记录。

如果支付平台会对同一个交易标识发送状态更新,可以更新对应记录,但要保留状态变更历史,或者单独建立事件表。

回调必须幂等

支付平台可能重复发送同一条通知。应为平台交易 ID 或事件 ID 建立唯一约束。重复通知到达时,系统应安全地返回成功,不能再次增加已付金额。

不要信任前端的成功页面

浏览器跳转到“支付成功”页面,并不表示服务端已经确认收款。订单状态只能在服务端验证支付平台回调签名后更新,也可以由服务端主动查询支付平台并确认结果。

订单金额应固定

支付开始后,不应直接修改原订单的金额或币种。如果价格需要调整,通常应取消原支付、创建新订单,或者生成一笔明确的差额交易。

order_status 不等于 payment_status

订单可能已经付款,但仍处于待发货状态。订单也可能已经完成履约,后来又发生退款或拒付。因此,不能用同一个 Status 字段同时表示资金状态和订单处理进度。

成功后的副作用也要防重

发货、增加会员权益、发送兑换码等操作,不能在每次成功回调时直接执行。只有订单首次从非 paid 状态变为 paid 时才能触发,还要为相关任务设置唯一键或幂等标识。

支付交易表用于保存不能丢失的交易事实,订单表则保存便于业务查询的支付汇总状态。这样的结构既能处理“失败后成功”,也能覆盖部分支付、退款、重复回调和拒付等情况。

备注:内容仅供参考。