如何设计 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 时才能触发,还要为相关任务设置唯一键或幂等标识。
支付交易表用于保存不能丢失的交易事实,订单表则保存便于业务查询的支付汇总状态。这样的结构既能处理“失败后成功”,也能覆盖部分支付、退款、重复回调和拒付等情况。
备注:内容仅供参考。
