支付失败后是否应保留或重用订单号?
明确结论
支付失败后,应保留原订单号,不要删除,也不要分配给其他订单。
同一笔业务订单再次付款时,可以沿用原订单号,但每次发起支付都要创建独立的支付尝试记录,并生成新的 payment_attempt_id 或支付流水号。即使订单已经取消,原订单号也应永久保留,只更新订单状态即可。
为什么不能重用订单号
订单号不只存在于数据库中,还可能出现在支付网关、日志、客服系统、风控系统和对账文件里。即使支付失败,网关通常也会保留请求记录。
删除并重用订单号会造成很多问题:
- 无法判断网关回调指向旧订单还是新订单。
- 客服和财务查询同一个订单号时,可能看到相互矛盾的信息。
- 支付失败原因和后续重试过程无法审计。
- 延迟到达的回调可能误改后来使用该号码的新订单。
- 重复扣款、拒付和退款问题会很难排查。
- 数据库自增编号本来就不保证连续,强行补齐没有实际意义。
订单号存在空档很正常。唯一性和可追踪性远比连续性重要。
推荐的数据模型
不要把”订单”和”支付请求”存成同一条记录。
一笔订单可以对应多次支付尝试:
Order 10001
├── Payment Attempt A:信用卡号无效,失败
├── Payment Attempt B:余额不足,失败
└── Payment Attempt C:支付成功
订单表负责保存业务状态,支付尝试表则记录每次与支付网关交互的结果。
CREATE TABLE orders (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_number VARCHAR(64) NOT NULL UNIQUE,
customer_id BIGINT NOT NULL,
amount DECIMAL(18, 2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE payment_attempts (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
payment_attempt_id VARCHAR(64) NOT NULL UNIQUE,
order_id BIGINT NOT NULL,
gateway_transaction_id VARCHAR(128),
idempotency_key VARCHAR(128) NOT NULL UNIQUE,
status VARCHAR(32) 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(id)
);
MySQL、PostgreSQL、SQL Server 等数据库的自增语法各不相同。这里主要说明字段之间的关系,并不限定具体的数据库实现。
建议的处理流程
1. 提交支付前创建订单
先生成一个不可重复使用的订单号,将订单保存为待支付状态:
order.status = "PENDING_PAYMENT"
金额、币种和商品明细也应在这个阶段确定,避免它们在支付过程中发生不受控的变化。
2. 每次付款都创建新的支付尝试
每当用户点击支付或重新提交信用卡信息时,都要新建一条 payment_attempts 记录:
payment_attempt.status = "PROCESSING"
发送给支付网关的数据应包含:
order_number:标识业务订单payment_attempt_id:标识本次支付尝试idempotency_key:防止同一次请求被重复执行
各支付网关使用的字段名称和幂等机制不同,具体应以所用网关的 API 文档为准。
3. 根据支付结果更新状态
支付失败时保留订单,只更新状态:
payment_attempt.status = "FAILED"
order.status = "PAYMENT_FAILED"
如果允许重试,下一次付款仍然关联原订单,但要创建一条新的支付尝试记录。
支付成功时:
payment_attempt.status = "SUCCEEDED"
order.status = "PAID"
如果用户放弃付款或订单超时,可以将订单状态改为:
order.status = "CANCELLED"
无论订单最终处于哪种状态,原订单号都不能再次分配。
代码示例
下面是简化后的伪代码。事务语法和网关接口需要根据实际技术栈调整:
PaymentResult payOrder(String orderNumber, CardToken cardToken) {
Order order = orderRepository.findByOrderNumber(orderNumber);
if (order == null) {
throw new OrderNotFoundException(orderNumber);
}
if (order.getStatus() == OrderStatus.PAID) {
return PaymentResult.alreadyPaid();
}
if (order.getStatus() == OrderStatus.CANCELLED) {
throw new IllegalStateException("Cancelled order cannot be paid");
}
PaymentAttempt attempt = new PaymentAttempt();
attempt.setPaymentAttemptId(UUID.randomUUID().toString());
attempt.setOrderId(order.getId());
attempt.setIdempotencyKey(UUID.randomUUID().toString());
attempt.setStatus(PaymentStatus.PROCESSING);
paymentAttemptRepository.save(attempt);
try {
GatewayResult result = paymentGateway.charge(
order.getOrderNumber(),
attempt.getPaymentAttemptId(),
attempt.getIdempotencyKey(),
order.getAmount(),
order.getCurrency(),
cardToken
);
attempt.setGatewayTransactionId(result.getTransactionId());
if (result.isSuccessful()) {
attempt.setStatus(PaymentStatus.SUCCEEDED);
order.setStatus(OrderStatus.PAID);
} else {
attempt.setStatus(PaymentStatus.FAILED);
attempt.setFailureCode(result.getFailureCode());
attempt.setFailureMessage(result.getFailureMessage());
order.setStatus(OrderStatus.PAYMENT_FAILED);
}
paymentAttemptRepository.save(attempt);
orderRepository.save(order);
return PaymentResult.from(result);
} catch (GatewayTimeoutException e) {
attempt.setStatus(PaymentStatus.UNKNOWN);
paymentAttemptRepository.save(attempt);
return PaymentResult.pendingVerification();
}
}
这段代码单独处理了超时情况,因为请求超时不代表支付失败。网关可能已经完成扣款,只是响应没有成功传回网站。此时应将状态设为 UNKNOWN 或 PENDING_CONFIRMATION,再通过查询接口或异步回调确认结果,不能立即重新扣款。
回调处理必须保证幂等
支付网关可能重复发送通知,也可能在订单取消后才发来延迟通知。处理回调时,应使用网关交易号、支付尝试编号或事件编号进行去重。
void handlePaymentCallback(GatewayCallback callback) {
if (callbackRepository.existsByEventId(callback.getEventId())) {
return;
}
PaymentAttempt attempt =
paymentAttemptRepository.findByPaymentAttemptId(
callback.getPaymentAttemptId()
);
verifySignature(callback);
if (callback.isSuccessful()
&& attempt.getStatus() != PaymentStatus.SUCCEEDED) {
attempt.setStatus(PaymentStatus.SUCCEEDED);
Order order = orderRepository.findById(attempt.getOrderId());
order.setStatus(OrderStatus.PAID);
paymentAttemptRepository.save(attempt);
orderRepository.save(order);
}
callbackRepository.save(callback);
}
状态更新最好放在数据库事务中完成,同时使用唯一约束、条件更新或行锁,避免并发请求让同一订单被重复标记或重复履约。
需要特别注意的情况
支付失败不一定意味着订单失败
信用卡号格式错误、发卡行拒绝、余额不足和网络超时代表不同情况。系统应保存标准化的内部状态,同时保留网关返回的原始错误代码,方便排查和对账。
不要将支付网关返回的详细错误信息直接展示给用户。对外提示要清楚、克制,以免泄露风控规则或敏感信息。
不要存储完整的信用卡信息
除非系统具备相应的 PCI DSS 合规能力,否则不要保存完整卡号、CVV 或磁道数据。通常应使用支付网关提供的 tokenization 机制,系统内只保存不敏感的支付令牌和必要的展示信息,例如卡品牌及末四位。
区分订单号和数据库主键
数据库主键可以使用自增整数或 UUID,面向用户和外部系统的 order_number 则可以采用另一套格式。两者都要保持唯一,但不必相同。
自增主键不需要连续。事务回滚、并发插入和预分配机制都可能造成编号空档,这并不表示数据有问题。
限制支付重试条件
并非所有订单都适合无限重试。商品库存、价格有效期、汇率或优惠券可能已经发生变化。重试前应检查:
- 订单是否仍在有效期内
- 商品是否仍有库存
- 金额和币种是否与原订单一致
- 是否已经存在成功的支付记录
- 是否存在状态不明、仍待确认的支付尝试
- 重试次数是否已经触发风控限制
如果订单内容或金额需要调整,更稳妥的处理方式是取消原订单并创建新订单,不要修改已经发送给支付网关的历史订单。
最终建议
订单号应当作为永久标识。它生成后只属于这一笔订单,即使付款失败、订单取消或编号中出现空档,也不能回收重用。
同一订单允许再次付款时,应保留原订单号,并为每次付款创建新的支付尝试编号。配合幂等键、状态机、唯一约束和完整日志,可以减少重复扣款、错误回调和对账混乱。
备注:内容仅供参考。
