PAYATHON 2026

EasySDK 电脑网站支付订单关闭失败后仍可支付如何处理?

支付老李

结论

Factory.Payment.Common().Close() 返回 Business Failed,并不表示支付宝侧订单已经关闭。也不能仅凭本地过期时间,将订单改为“已关闭”。只要支付宝侧交易仍可支付,原支付页面或二维码就可能继续完成付款。

应按以下方式处理:

  1. 检查关单响应中的 code、sub_code 和 sub_msg;
  2. 立即调用交易查询接口,确认支付宝侧的实际状态;
  3. 只有关单接口明确返回成功,或查询结果为 TRADE_CLOSED,才能把本地订单标记为已关闭;
  4. 如果查询到 TRADE_SUCCESS,或之后收到有效的支付通知,应按“超时支付”处理,通常需要自动退款或转人工处理;
  5. 关单和支付可能同时发生,必须通过订单状态机和幂等控制避免误判。

为什么关单失败后仍然可以支付

订单“过期”可能只是业务系统中的状态,并不代表支付宝交易已经关闭。电脑网站支付页面生成后,只要支付宝侧交易仍处于 WAIT_BUYER_PAY,客户就可能继续付款。

Business Failed 是 SDK 对业务失败结果的概括,具体原因要查看原始响应字段。常见情况包括:

  • 关单请求参数不正确;
  • out_trade_no 或 trade_no 不匹配;
  • 支付宝侧尚未创建可关闭的交易;
  • 交易已经支付或正在支付;
  • 交易已经关闭,本次属于重复关单;
  • 关单请求和客户付款同时发生;
  • 网络重试、响应丢失等问题导致调用方无法确认最终结果。

因此,调用了关单接口,不等于订单已经关闭。订单状态应以明确的关单结果或后续查询结果为准。

如果已经确认支付宝侧状态为 TRADE_CLOSED,但同一笔支付宝交易后来又显示支付成功,需要核对两次响应中的 trade_no 和 out_trade_no 是否完全一致。正常情况下,同一笔已经关闭的支付宝交易不应再次支付成功。更常见的情况是本地订单已关闭,但支付宝交易并未关闭,或者客户支付的是重新创建的另一笔交易。

建议的处理流程

1. 保存完整的关单响应

不要只记录 Business Failed,至少要保存:

  • 商户订单号 out_trade_no
  • 支付宝交易号 trade_no
  • code
  • msg
  • sub_code
  • sub_msg
  • 请求时间和响应时间
  • SDK 请求 ID 或支付宝返回的追踪信息(如果响应中存在)

日志中的敏感信息需要脱敏。

2. 关单失败后立即查询交易

关单接口返回失败、超时或结果不明确时,应调用交易查询接口,并根据支付宝侧状态处理:

支付宝交易状态建议处理
TRADE_CLOSED将本地订单更新为已关闭
TRADE_SUCCESS按已支付处理,再执行超时支付策略
TRADE_FINISHED按已支付且交易完成处理
WAIT_BUYER_PAY延迟后重试关单,并继续查询确认
查询不到交易检查订单是否尚未在支付宝侧创建,短暂延迟后复查
查询失败或状态不明确保持处理中,不得直接标记为已关闭

重试要设置次数上限和退避间隔,避免持续高频调用。

3. 使用明确的本地状态机

建议区分以下状态:

WAIT_PAY
CLOSING
CLOSED
PAID
LATE_PAID
REFUNDING
REFUNDED
MANUAL_REVIEW

订单过期时,先将状态从 WAIT_PAY 原子更新为 CLOSING。支付宝明确返回关单成功后,再更新为 CLOSED。

如果关单过程中查询到支付成功,或收到经过验签的支付成功通知,订单应进入 LATE_PAID,不能再按普通关单成功处理。

4. 妥善处理超时支付

业务订单已经过期,但支付宝侧实际支付成功时,应按照预先设定的规则处理:

  • 商品或服务仍可履约:恢复订单并正常履约;
  • 已经无法履约:发起原路退款;
  • 涉及库存、价格变化或高风险交易:转人工审核。

不能忽略支付成功通知,也不能因为本地状态已经是 CLOSED 就直接拒绝更新。资金发生变化后,系统必须留下可追踪的处理结果。

5. 同时停用业务侧支付入口

订单进入 CLOSING 后,应禁止再次生成支付页面,不再展示二维码,也不能继续跳转到旧支付入口。

这样只能降低客户继续支付的概率,无法替代支付宝关单。客户已经打开的页面不完全受本地前端状态控制,最终仍要根据支付宝侧的关单结果判断。

Java 处理示例

下面的示例用于说明处理原则。不同 EasySDK 版本的响应类型和字段访问方式可能不同,应以项目实际依赖中的接口定义为准。

String outTradeNo = order.getOutTradeNo();
String tradeNo = order.getTradeNo();

// 先通过数据库条件更新或分布式锁,将订单从 WAIT_PAY 切换为 CLOSING。
boolean acquired = orderService.markClosingIfWaiting(outTradeNo);
if (!acquired) {
    return;
}

try {
    AlipayTradeCloseResponse closeResponse =
        Factory.Payment.Common().close(tradeNo, outTradeNo);

    log.info(
        "close trade result, outTradeNo={}, tradeNo={}, code={}, msg={}, subCode={}, subMsg={}",
        outTradeNo,
        tradeNo,
        closeResponse.code,
        closeResponse.msg,
        closeResponse.subCode,
        closeResponse.subMsg
    );

    if (closeResponse.isSuccess()) {
        orderService.markClosed(outTradeNo);
        return;
    }

    // 关单业务失败不能直接标记为 CLOSED,继续查询真实交易状态。
    reconcileTradeStatus(outTradeNo, tradeNo);

} catch (Exception e) {
    log.error(
        "close trade exception, outTradeNo={}, tradeNo={}",
        outTradeNo,
        tradeNo,
        e
    );

    // 请求异常时结果未知,同样通过查询进行确认。
    reconcileTradeStatus(outTradeNo, tradeNo);
}

交易状态核对逻辑可以这样组织:

private void reconcileTradeStatus(String outTradeNo, String tradeNo) throws Exception {
    AlipayTradeQueryResponse queryResponse =
        Factory.Payment.Common().query(tradeNo, outTradeNo);

    if (!queryResponse.isSuccess()) {
        orderService.markClosePending(outTradeNo);
        retryService.scheduleCloseReconcile(outTradeNo);
        return;
    }

    String tradeStatus = queryResponse.tradeStatus;

    switch (tradeStatus) {
        case "TRADE_CLOSED":
            orderService.markClosed(outTradeNo);
            break;

        case "TRADE_SUCCESS":
        case "TRADE_FINISHED":
            orderService.markLatePaid(
                outTradeNo,
                queryResponse.tradeNo,
                queryResponse.totalAmount
            );
            latePaymentService.handle(outTradeNo);
            break;

        case "WAIT_BUYER_PAY":
            orderService.markClosePending(outTradeNo);
            retryService.scheduleClose(outTradeNo);
            break;

        default:
            orderService.markManualReview(outTradeNo);
            break;
    }
}

示例中的 markClosingIfWaiting、scheduleCloseReconcile 和 handle 需要由业务系统自行实现,不属于 EasySDK。

如果调用签名要求传入 trade_no 和 out_trade_no,应保证至少有一个有效编号。如果两个编号都提供,它们必须指向同一笔交易。具体参数顺序以当前使用的 EasySDK 版本为准。

支付通知也要做状态兜底

处理异步通知时,必须完成验签并核对以下字段:

  • app_id
  • seller_id
  • out_trade_no
  • trade_no
  • total_amount
  • trade_status

通知处理也要保证幂等:

@Transactional
public void handlePaymentNotify(PaymentNotify notify) {
    Order order = orderRepository.lockByOutTradeNo(notify.getOutTradeNo());

    verifyMerchantAndAmount(order, notify);

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

    if (order.isExpired() || order.isClosing() || order.isClosed()) {
        order.markLatePaid(notify.getTradeNo());
        latePaymentService.handle(order.getOutTradeNo());
        return;
    }

    order.markPaid(notify.getTradeNo());
}

即使本地订单已经进入关闭流程,也不能直接丢弃合法的支付成功通知。否则可能出现支付宝已经收款,本地却没有相应处理记录的资金差错。

已经发生客诉时如何排查

按时间顺序核对以下数据:

  1. 支付页面生成时间;
  2. 本地订单过期时间;
  3. 关单请求和响应时间;
  4. 关单响应的 code、sub_code、sub_msg;
  5. 关单后的交易查询结果;
  6. 支付成功时间;
  7. 异步通知到达和处理时间;
  8. 每一步对应的 out_trade_no 与 trade_no。

如果最终确认支付宝侧已经支付成功,但业务无法履约,应调用退款接口处理并保存退款结果。只修改本地订单状态并不会把资金退还给客户。

注意事项

  • 本地超时不代表支付宝订单会自动关闭。
  • Business Failed 不是最终交易状态,需要查看详细错误并补充查询。
  • 关单成功应以接口成功响应或查询到 TRADE_CLOSED 为准。
  • 关单失败时,不能直接将订单更新为 CLOSED。
  • 关单、查询、支付通知和退款都要保证幂等。
  • out_trade_no 应保持唯一。重新支付时通常应创建新的商户订单号,避免复用旧订单导致状态混乱。
  • 数据库更新应使用条件更新、事务或行锁,防止关单任务和支付通知相互覆盖。
  • 长时间停留在 CLOSING、支付金额不一致或订单号不一致的记录,应触发告警并进入人工核对流程。

备注:内容仅供参考。