PAYATHON 2026

如何避免业务订单取消后支付交易仍可完成

支付老李

结论

不能因为用户”未扫码”就认为支付入口无法关闭,也不能只靠支付平台阻止付款。系统需要同时设置三道防线:

  1. 业务订单取消后,立即让收银台和支付凭证失效,并尝试关闭支付交易。
  2. 打开支付页面、查询支付结果和处理异步回调时,都以服务端记录的业务订单状态为准。
  3. 订单关闭后如果仍收到支付成功结果,不再履约,同时记录异常资金,自动退款或转入人工处理。

支付状态和业务状态无法通过一次接口调用始终保持强一致。因此,系统既要设计关单流程,也要处理关单后支付成功的情况。

为什么订单关闭后仍能支付

收银台页面、业务订单和第三方支付交易通常是三个相互独立的状态对象:

  • 业务系统中的订单可能已经是 CANCELLED;
  • 用户浏览器里仍保留着旧收银台页面或二维码;
  • 支付平台中的交易仍处于 NOTPAY、WAIT_BUYER_PAY 等等待支付状态。

如果收银台只在首次打开时检查订单状态,用户返回旧页面后,前端可能继续显示二维码或发起支付。即使前端禁止操作,用户仍可能使用已经保存的二维码,所以支付资格必须由服务端校验。

订单取消和支付完成还可能同时发生。例如:

  1. 取消请求查询到交易尚未支付;
  2. 用户恰好在此时完成付款;
  3. 关闭交易请求失败;
  4. 支付成功回调到达。

单靠”先查询、再关单”无法彻底消除这种竞态。系统必须在处理回调时再次判断业务状态,并准备退款补偿。

建议的状态处理原则

业务订单和支付交易应分别记录状态,不要用同一个字段表示两者。

业务订单状态可以包括:

CREATED
PAYING
PAID
CANCELLED
FULFILLED
REFUNDING
REFUNDED
PAYMENT_EXCEPTION

支付交易状态可以包括:

INIT
PENDING
SUCCESS
CLOSED
REFUNDING
REFUNDED

状态只能按照规定的方向转换。例如:

CREATED/PAYING -> PAID
CREATED/PAYING -> CANCELLED
CANCELLED + 支付成功 -> REFUNDING -> REFUNDED

异步回调不能直接把 CANCELLED 订单改回 PAID,否则可能错误触发库存扣减、发货或权益发放。

解决步骤

1. 缩短支付凭证的有效期

创建支付交易时,应根据支付渠道的能力设置合理的支付有效期。收银台页面也要设置有效期,不能永久复用。

前端倒计时只能改善用户体验,不能作为安全控制。凭证是否有效,必须由服务端判断。

如果支付渠道支持关闭交易,应保存创建交易时使用的商户订单号或支付平台交易标识。关闭支付通常不要求用户已经扫码,而是根据商户订单号关闭尚未支付的交易。实际能力和限制以接入渠道的接口文档为准。

2. 每次进入收银台都重新校验订单

用户刷新、返回或重新打开收银台时,前端都要向服务端重新获取支付资格。只有订单仍可支付,服务端才返回二维码、支付参数或跳转地址。

public CashierResult openCashier(String orderNo, Long userId) {
    Order order = orderRepository.findByOrderNo(orderNo);

    if (order == null || !order.getUserId().equals(userId)) {
        throw new BusinessException("订单不存在");
    }

    if (!order.canPay()) {
        return CashierResult.unavailable("订单已关闭或不可支付");
    }

    if (order.isExpired()) {
        cancelOrder(orderNo, "订单超时");
        return CashierResult.unavailable("订单已过期");
    }

    return paymentService.getOrCreateValidPayment(order);
}

不要在页面中保存长期有效的支付二维码地址。更稳妥的做法是让页面持有短期收银台令牌。服务端校验订单状态后,再返回当前有效的支付参数。

3. 取消订单时同时关闭支付交易

取消订单时不能只更新业务表。可以按以下顺序处理:

  1. 锁定业务订单;
  2. 把订单转换为不可支付状态;
  3. 使内部收银台令牌失效;
  4. 调用支付渠道的交易查询或关闭接口;
  5. 根据接口结果更新支付交易状态;
  6. 如果发现交易已经支付,立即转入支付成功处理或退款流程。

下面是通用业务代码,支付接口名称需要替换为实际渠道提供的 API:

@Transactional
public void cancelOrder(String orderNo, String reason) {
    Order order = orderRepository.findForUpdate(orderNo);

    if (order == null) {
        throw new BusinessException("订单不存在");
    }

    if (order.isPaid() || order.isFulfilled()) {
        throw new BusinessException("订单已支付,不能直接取消");
    }

    if (order.isCancelled()) {
        return;
    }

    order.cancel(reason);
    orderRepository.update(order);

    cashierTokenRepository.disableByOrderNo(orderNo);
    paymentCloseTaskRepository.createIfAbsent(orderNo);
}

不要在持有数据库事务和行锁期间长时间等待第三方支付接口。可以先提交订单取消结果,再通过可靠消息或任务表执行关单。

public void executeClosePayment(String orderNo) {
    PaymentTransaction payment =
        paymentTransactionRepository.findByOrderNo(orderNo);

    if (payment == null || payment.isClosed()) {
        return;
    }

    PaymentStatus status = paymentGateway.query(payment);

    switch (status) {
        case SUCCESS:
            handlePaidAfterCancellation(payment);
            break;
        case PENDING:
            CloseResult result = paymentGateway.close(payment);
            if (result.isSuccess()) {
                paymentTransactionRepository.markClosed(payment.getId());
            } else {
                paymentCloseTaskRepository.scheduleRetry(orderNo);
            }
            break;
        case CLOSED:
            paymentTransactionRepository.markClosed(payment.getId());
            break;
        default:
            paymentCloseTaskRepository.scheduleRetry(orderNo);
    }
}

如果关单接口失败、超时或返回未知状态,不能直接把交易视为已关闭。系统应保留任务并继续重试,同时主动查询最终支付状态。

4. 在支付创建接口再次检查状态

不能只在用户打开页面时校验一次。每次创建预支付交易、刷新二维码或重新获取支付参数时,都要检查:

  • 订单是否属于当前用户;
  • 订单是否仍可支付;
  • 订单是否已经过期;
  • 金额和币种是否与服务端订单一致;
  • 是否已经存在成功交易;
  • 是否有交易正在关闭。

客户端传来的金额、商品信息和订单状态只能用于展示,不能作为支付依据。

5. 支付回调必须幂等,并重新判断业务状态

支付回调不能简单地执行”收到成功通知就把订单改为已支付”。处理回调时至少要完成以下校验:

  • 验证回调签名;
  • 校验商户号、应用标识等渠道身份信息;
  • 校验商户订单号;
  • 校验支付金额和币种;
  • 确认交易确实成功;
  • 防止同一交易被重复处理;
  • 锁定订单并判断当前业务状态。
@Transactional
public CallbackResult handlePaymentCallback(PaymentNotification notification) {
    paymentGateway.verifyNotification(notification);

    PaymentTransaction payment =
        paymentTransactionRepository.findForUpdate(
            notification.getMerchantOrderNo()
        );

    if (payment == null) {
        exceptionRepository.recordUnknownPayment(notification);
        return CallbackResult.accepted();
    }

    validateAmountAndIdentity(payment, notification);

    if (payment.isSuccess()) {
        return CallbackResult.accepted();
    }

    payment.markSuccess(
        notification.getGatewayTransactionId(),
        notification.getPaidAt()
    );
    paymentTransactionRepository.update(payment);

    Order order = orderRepository.findForUpdate(payment.getOrderNo());

    if (order.canConfirmPayment()) {
        order.markPaid();
        orderRepository.update(order);
        outboxRepository.addOrderPaidEvent(order.getOrderNo());
        return CallbackResult.accepted();
    }

    if (order.isCancelled() || order.isExpired()) {
        exceptionRepository.recordPaidClosedOrder(
            order.getOrderNo(),
            payment.getId()
        );
        refundTaskRepository.createIfAbsent(payment.getId());
        return CallbackResult.accepted();
    }

    exceptionRepository.recordUnexpectedOrderState(
        order.getOrderNo(),
        order.getStatus(),
        payment.getId()
    );
    return CallbackResult.accepted();
}

幂等性还应通过数据库唯一约束来保证。例如:

CREATE UNIQUE INDEX uk_payment_gateway_transaction
ON payment_transaction(gateway_transaction_id);

CREATE UNIQUE INDEX uk_refund_payment_reason
ON payment_refund(payment_transaction_id, refund_reason);

履约操作最好由本地消息表或事务消息触发。只有订单成功从可支付状态转换为 PAID,系统才能发送发货、充值或权益发放等事件。

6. 补偿”订单已关闭但支付成功”的情况

付款一旦完成,修改订单状态并不能撤销资金结果。此时系统应:

  1. 将支付交易记录为 SUCCESS;
  2. 保持业务订单为 CANCELLED,不再履约;
  3. 创建唯一的退款任务;
  4. 调用原路退款接口;
  5. 持续查询退款结果;
  6. 退款成功后标记为 REFUNDED;
  7. 多次失败后告警并转入人工处理。
public void handlePaidAfterCancellation(PaymentTransaction payment) {
    refundTaskRepository.createIfAbsent(payment.getId());
    alertService.reportPaidCancelledOrder(
        payment.getOrderNo(),
        payment.getGatewayTransactionId()
    );
}

退款任务也必须幂等,避免重复回调或任务重试造成重复退款。退款金额要从已经确认的支付记录中读取,不能使用客户端提交的数据。

如何处理取消与支付同时发生的竞态

可以在数据库层使用条件更新,保证同一个订单只有一次状态转换能够成功:

UPDATE business_order
SET status = 'PAID',
    paid_at = CURRENT_TIMESTAMP
WHERE order_no = ?
  AND status IN ('CREATED', 'PAYING');

如果更新行数为 0,说明订单已经取消、已经支付或处于其他不可转换的状态。此时不能继续履约,需要重新读取订单状态,再决定忽略重复回调还是创建退款任务。

取消操作也可以使用相同的方法:

UPDATE business_order
SET status = 'CANCELLED',
    cancelled_at = CURRENT_TIMESTAMP
WHERE order_no = ?
  AND status IN ('CREATED', 'PAYING');

条件更新可以防止取消线程和支付回调线程相互覆盖状态。逻辑更复杂时,也可以使用行锁或乐观锁版本号。无论使用哪种方式,都不能省略支付成功后的补偿流程。

对账和异常监控不可缺少

异步通知可能延迟或重复,也可能因网络问题暂时无法送达。因此,系统还需要运行定时对账任务:

  • 查询长时间处于 PENDING 状态的支付交易;
  • 查询已经取消但支付渠道仍显示成功的订单;
  • 查询已经创建退款任务但长期没有退款成功的记录;
  • 比对支付渠道账单和本地支付流水;
  • 对金额不一致、未知订单和重复交易告警。

异步回调和主动查询应共用同一套幂等处理入口,以免两条处理路径重复修改业务状态。

注意事项

  • 不要相信旧收银台页面显示的状态,支付资格必须由服务端实时判断。
  • “关闭业务订单”不等于”关闭支付交易”,两者需要分别处理。
  • 关单接口超时不代表关单成功,系统还要查询交易的最终状态。
  • 订单取消后不能直接丢弃支付成功回调,否则会留下无法追踪的实收资金。
  • 不要在支付成功回调返回业务失败后,只等待支付渠道无限重试。验签和数据校验通过后,可以先接收通知,再通过本地可靠任务处理退款。
  • 是否适合自动退款,要结合商品是否已经交付、退款手续费、优惠分摊和渠道规则判断。无法安全自动退款时,应转入人工审核。
  • 各支付渠道对交易关闭时间、二维码有效期、关单条件和退款接口的规定不同,具体实现以实际接入渠道的文档为准。

整个流程需要首尾衔接:取消订单后立即让支付入口失效,后台尝试关单;收到回调时再次检查订单状态;遇到竞态付款时自动退款;最后通过主动查询和对账处理遗漏与异常。

备注:内容仅供参考。