PAYATHON 2026

支付失败后是否应保留或重用订单号?

支付阿杰

明确结论

支付失败后,应保留原订单号,不要删除,也不要分配给其他订单。

同一笔业务订单再次付款时,可以沿用原订单号,但每次发起支付都要创建独立的支付尝试记录,并生成新的 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 则可以采用另一套格式。两者都要保持唯一,但不必相同。

自增主键不需要连续。事务回滚、并发插入和预分配机制都可能造成编号空档,这并不表示数据有问题。

限制支付重试条件

并非所有订单都适合无限重试。商品库存、价格有效期、汇率或优惠券可能已经发生变化。重试前应检查:

  • 订单是否仍在有效期内
  • 商品是否仍有库存
  • 金额和币种是否与原订单一致
  • 是否已经存在成功的支付记录
  • 是否存在状态不明、仍待确认的支付尝试
  • 重试次数是否已经触发风控限制

如果订单内容或金额需要调整,更稳妥的处理方式是取消原订单并创建新订单,不要修改已经发送给支付网关的历史订单。

最终建议

订单号应当作为永久标识。它生成后只属于这一笔订单,即使付款失败、订单取消或编号中出现空档,也不能回收重用。

同一订单允许再次付款时,应保留原订单号,并为每次付款创建新的支付尝试编号。配合幂等键、状态机、唯一约束和完整日志,可以减少重复扣款、错误回调和对账混乱。

备注:内容仅供参考。