PAYATHON 2026

自有 APP 嵌入 H5 下单时取消订单与支付状态冲突

支付阿杰

结论

支付宝返回“查询订单不存在”,并不代表本地订单已经安全取消。

H5 支付表单是一份已经签名的支付请求。只要它仍在有效期内,就可能被继续提交或使用。即使支付宝当时还没有创建可查询的交易,用户之后仍可能通过已经打开的支付页面唤起收银台并完成付款。

处理这类情况时,应当:

  1. 为支付请求设置与本地订单一致的支付截止时间。
  2. 取消本地订单时,先撤销支付资格,禁止旧支付入口继续使用。
  3. 如果支付宝交易已经存在且仍在等待付款,调用 alipay.trade.close 关闭交易。
  4. 无论本地订单是否取消,都要接收并处理支付宝异步通知。
  5. 如果订单取消后仍收到支付成功结果,应按业务规则将订单恢复为已支付,或者发起退款,不能忽略这笔支付。

取消前只查询一次支付宝订单,无法解决并发冲突。

冲突产生的原因

这个场景涉及两套独立的状态:

  • 商户系统中的订单状态;
  • 支付宝侧的交易状态。

两套状态不属于同一个原子事务,也不会同步变化。

服务端生成 H5 支付表单并交给前端后,这份请求可能一直留在页面、WebView 或返回栈中。支付宝在查询时返回“交易不存在”,通常只能说明此时没有查到对应交易,不能证明之前生成的支付请求已经失效。

例如,可能出现以下时序:

T1  服务端生成支付表单
T2  用户进入支付页面,但支付宝交易尚不可查询
T3  商户系统将订单取消
T4  商户查询支付宝,得到“交易不存在”
T5  用户继续操作旧页面并唤起收银台
T6  支付成功
T7  支付宝发送异步通知

所以,“交易不存在”与“这笔订单以后不可能支付”是两个不同的结论。

推荐的订单状态设计

订单状态不应只有“未支付、已支付、已取消”三种。至少需要区分:

WAIT_PAY       待支付
CANCELLING     取消处理中,禁止创建或获取支付请求
CANCELLED      已取消
PAID           已支付
REFUND_PENDING 待退款
REFUNDED       已退款

还应单独记录支付单,例如:

merchant_order_no
payment_no
alipay_out_trade_no
payment_status
payment_expire_time
payment_version

用户每次重新支付时,可以创建新的 payment_no 和 out_trade_no,但同一个商户订单最终只能接受一笔有效支付。支付宝对 out_trade_no 的复用限制以及交易参数一致性要求,应以当前接入产品的官方接口文档为准。

下单时设置支付截止时间

调用 alipay.trade.wap.pay 时,应在支付请求中设置支付宝支持的超时或绝对过期时间,并让它与本地订单的支付截止时间一致。

不同 SDK、接口版本和产品配置支持的字段可能不同,常见字段包括 timeout_express 和 time_expire。使用前需要核对当前接口文档及 SDK 模型,不能只靠前端倒计时控制支付期限。

示意请求如下:

{
  "out_trade_no": "PAY202609090001",
  "total_amount": "99.00",
  "subject": "商品订单",
  "product_code": "QUICK_WAP_WAY",
  "timeout_express": "15m"
}

如果当前接口支持绝对过期时间,应尽量让支付宝的截止时间与本地 payment_expire_time 对齐。考虑到服务器时间误差和接口处理延迟,可以在本地截止时间之前留出少量安全余量。

前端倒计时只能用于展示,不能保证支付请求失效。

“去支付”必须重新向服务端申请

APP 不应缓存并反复使用旧的支付表单。用户每次点击“去支付”时,都应向服务端申请当前有效的支付凭证。

服务端需要检查:

  • 订单是否仍为 WAIT_PAY;
  • 是否已超过支付截止时间;
  • 是否正在取消;
  • 是否已经支付;
  • 当前支付单是否仍然有效。

示意接口逻辑:

@Transactional
public PayResponse createPayment(String orderNo, Long userId) {
    Order order = orderRepository.lockByOrderNo(orderNo);

    if (!order.getUserId().equals(userId)) {
        throw new ForbiddenException();
    }

    if (order.getStatus() != OrderStatus.WAIT_PAY) {
        throw new IllegalStateException("当前订单不可支付");
    }

    if (!Instant.now().isBefore(order.getPaymentExpireTime())) {
        throw new IllegalStateException("订单已超过支付截止时间");
    }

    Payment payment = paymentRepository.findActivePayment(orderNo)
        .orElseGet(() -> createNewPayment(order));

    String form = alipayClient.pageExecute(buildWapPayRequest(order, payment))
        .getBody();

    return new PayResponse(payment.getPaymentNo(), form);
}

如果业务上必须暂存支付表单,也要把它绑定到服务端维护的短期支付会话。订单取消后应立即撤销该会话,并在前端打开支付页之前再次向服务端校验,不能直接使用本地缓存的表单。

用户一旦进入支付宝控制的页面,商户仅修改自己页面上的按钮,并不能彻底阻止用户继续付款。因此,系统仍要依靠支付宝侧的交易过期、主动关闭交易以及支付后的补偿机制。

取消订单的正确流程

取消订单与创建支付请求必须互斥,可以通过数据库行锁、乐观锁或条件更新实现。

建议按以下顺序处理:

1. 将订单从 WAIT_PAY 原子更新为 CANCELLING
2. 撤销支付会话,禁止再次获取支付表单
3. 查询支付宝交易状态
4. 根据支付宝状态执行关闭、确认支付或重试
5. 最终更新本地订单状态

核心逻辑示例:

@Transactional
public void beginCancel(String orderNo) {
    Order order = orderRepository.lockByOrderNo(orderNo);

    if (order.getStatus() == OrderStatus.PAID) {
        throw new IllegalStateException("订单已支付,不能直接取消");
    }

    if (order.getStatus() != OrderStatus.WAIT_PAY) {
        return;
    }

    order.setStatus(OrderStatus.CANCELLING);
    paymentSessionRepository.revokeByOrderNo(orderNo);
    orderRepository.save(order);
}

订单进入 CANCELLING 后,再处理支付宝侧的状态:

public void finishCancel(String orderNo) {
    Payment payment = paymentRepository.findLatestByOrderNo(orderNo);
    AlipayTradeStatus status = queryTrade(payment.getOutTradeNo());

    switch (status) {
        case WAIT_BUYER_PAY:
            closeTrade(payment.getOutTradeNo());
            markCancelledAfterVerification(orderNo);
            break;

        case TRADE_SUCCESS:
        case TRADE_FINISHED:
            markPaidOrScheduleRefund(orderNo, payment);
            break;

        case NOT_FOUND:
            /*
             * NOT_FOUND 不等于旧支付请求已经失效。
             * 只有确认支付请求已超过支付宝有效期,或经过延迟复查及
             * 业务设定的安全窗口后,才适合最终结束取消流程。
             */
            scheduleCancelRecheck(orderNo);
            break;

        default:
            scheduleCancelRecheck(orderNo);
    }
}

如果交易已经存在,且状态为 WAIT_BUYER_PAY,应调用 alipay.trade.close。关闭成功后,建议再次查询确认,同时继续保留处理异步通知的能力。

如果关闭接口返回交易不存在,也不能立即将其视为最终状态。订单可以继续保持在 CANCELLING,等待支付请求过期,并安排延迟查询。

支付通知必须覆盖已取消订单

支付宝异步通知是同步支付结果的重要依据。即使本地订单已经处于 CANCELLED,也不能直接返回成功并忽略通知。

通知处理至少要完成以下工作:

  • 验证支付宝签名;
  • 校验 app_id、商户身份等归属信息;
  • 校验 out_trade_no;
  • 使用精确金额类型校验 total_amount;
  • 校验交易状态;
  • 做幂等处理;
  • 记录支付宝交易号 trade_no;
  • 必要时主动调用查询接口复核。

示意代码:

@Transactional
public void handleAlipayNotify(Map<String, String> params) {
    verifySignature(params);
    verifyMerchantIdentity(params);

    String outTradeNo = params.get("out_trade_no");
    String tradeNo = params.get("trade_no");
    String tradeStatus = params.get("trade_status");
    BigDecimal paidAmount = new BigDecimal(params.get("total_amount"));

    if (!"TRADE_SUCCESS".equals(tradeStatus)
            && !"TRADE_FINISHED".equals(tradeStatus)) {
        return;
    }

    Payment payment = paymentRepository.lockByOutTradeNo(outTradeNo);
    Order order = orderRepository.lockByOrderNo(payment.getOrderNo());

    verifyAmount(payment.getExpectedAmount(), paidAmount);

    if (payment.isPaid()) {
        return;
    }

    payment.markPaid(tradeNo, paidAmount);

    if (order.getStatus() == OrderStatus.WAIT_PAY
            || order.getStatus() == OrderStatus.CANCELLING) {
        order.markPaid();
    } else if (order.getStatus() == OrderStatus.CANCELLED) {
        /*
         * 根据业务规则二选一:
         * 1. 恢复订单并履约;
         * 2. 标记为待退款,异步发起退款。
         */
        order.markRefundPending();
    }

    paymentRepository.save(payment);
    orderRepository.save(order);
}

只有在通知经过可靠、幂等的落库处理后,才能向支付宝返回成功响应。如果本地处理失败,应由支付宝按照其通知机制继续重试。

取消后支付成功的处理策略

仅靠本地取消操作,无法从技术上保证晚到支付绝对不会发生。系统必须事先确定一种兜底策略。

恢复订单

这种方式适用于库存、价格和服务仍然有效的情况:

CANCELLED / CANCELLING → PAID

恢复订单时,需要重新检查库存、优惠和履约能力等条件,防止收款后无法交付。

自动退款

这种方式适用于订单取消后无法恢复履约的情况:

CANCELLED → REFUND_PENDING → REFUNDED

退款任务必须支持幂等处理,并记录退款单号、金额、原因和处理结果。不能只根据退款接口的受理结果判断退款成功,还应通过退款查询或相应的通知机制确认最终状态。

无论选择哪种策略,都不能让订单一直显示“已取消”,而对应资金已经支付却没有后续处理。

增加定时对账与异常扫描

异步通知可能因为网络问题、应用故障或验签处理异常而延迟,所以系统还需要定时扫描:

  • 长时间处于 CANCELLING 的订单;
  • 本地为 CANCELLED,但支付宝已经支付成功的订单;
  • 本地为 WAIT_PAY,但支付宝已经关闭或支付成功的订单;
  • 已经发起退款,但长时间没有最终结果的订单。

对账时,应通过 out_trade_no 查询支付宝交易,再由幂等状态机修正本地状态。

注意事项

  • 不要把“查询不到交易”当成“支付请求已经作废”。
  • 不要依赖关闭 H5 页面、APP 返回操作或前端倒计时阻止支付。
  • 订单进入 CANCELLING 或 CANCELLED 后,不要再返回旧的支付表单。
  • 支付创建、订单取消和通知处理之间的并发覆盖问题,必须通过锁或条件更新解决。
  • 金额应使用 BigDecimal 或同类十进制定点类型,避免浮点误差。
  • out_trade_no、通知处理和退款操作都必须保证幂等。
  • 支付宝的交易关闭能力、过期字段和通知状态含义,应以当前接入产品及 SDK 的官方文档为准。
  • 即使设置了交易过期时间并主动关闭交易,也要保留晚到支付后的恢复或退款流程。这是保证账实一致的最后一道保障。

备注:内容仅供参考。