PAYATHON 2026

当面付扫码支付为何触发 WAIT_BUYER_PAY 异步回调

支付老李

结论

对于已经通过验签,并确认属于本商户订单的 WAIT_BUYER_PAY 通知,可以直接返回纯文本 success,但不能执行付款成功后的业务。这个响应只代表当前通知已经收到,不会影响订单之后变为 TRADE_SUCCESS 时触发新的异步通知。

异步通知也不能作为确认订单状态的唯一方式。即使 WAIT_BUYER_PAY 已正确处理,后续的成功通知仍可能因网络、配置或程序异常而延迟或遗漏。系统还需要通过主动查询和定时补偿来确认最终状态。

如果相关功能默认关闭,却仍收到 WAIT_BUYER_PAY,应检查商户配置、实际调用身份、通知来源和订单归属。仅凭通知中的状态,不能直接认定支付宝出现异常。

返回 success 为什么不影响后续成功通知

支付宝异步通知确认的是当前这一次通知,并不表示整个订单以后不再发送通知。

订单状态可能发生以下变化:

WAIT_BUYER_PAY -> TRADE_SUCCESS

服务端对 WAIT_BUYER_PAY 返回 success,只表示:

这次 WAIT_BUYER_PAY 通知已收到,无须重试

订单之后支付成功时,TRADE_SUCCESS 属于新的状态变化。正常情况下,支付宝仍会发送相应的异步通知。

如果服务端收到 WAIT_BUYER_PAY 后直接抛出异常,通常就无法返回支付宝要求的 success。支付宝可能认为通知处理失败并继续重试,进而造成:

  • 同一状态被重复推送;
  • 日志和异常告警持续增加;
  • 无效重试占用服务端资源;
  • 大量异常日志干扰真正的支付成功通知。

因此,合法但暂时不需要处理的业务状态,不应被当成系统异常。

推荐的处理方式

异步通知入口应先完成安全校验,再根据状态分别处理。

1. 验证通知签名

不能在验签前返回 success。验签失败的请求不是可信的支付结果,也不能用来更新订单状态。

使用 EasySDK 时,可以先把通知参数整理为 Map<String, String>,再调用通知验签接口。具体方法签名以项目实际使用的 EasySDK 版本为准。

2. 确认通知属于当前应用和订单

验签通过后,至少需要检查:

  • app_id 是否为当前应用;
  • out_trade_no 是否存在;
  • 订单是否属于当前商户;
  • 通知金额是否与本地订单金额一致;
  • seller_id 等商户身份信息是否符合预期;
  • 使用服务商模式时,是否与实际被授权商户匹配。

金额校验应使用精确的十进制类型,例如 Java 的 BigDecimal,不要用 double 直接比较。

3. 根据 trade_status 分别处理

建议至少区分以下状态:

  • WAIT_BUYER_PAY:记录或忽略,不发货、不记账,返回 success;
  • TRADE_SUCCESS:以幂等方式完成支付成功处理;
  • TRADE_FINISHED:按照业务规则更新最终状态;
  • TRADE_CLOSED:更新订单关闭状态;
  • 未识别状态:记录完整上下文并告警,不能直接执行付款成功逻辑。

4. 支付成功处理必须幂等

支付宝可能重复发送同一通知,因此,收到回调后不能无条件重复执行业务。

可以通过订单状态的条件更新实现幂等:

UPDATE payment_order
SET payment_status = 'PAID',
    alipay_trade_no = ?,
    paid_at = ?
WHERE out_trade_no = ?
  AND payment_status = 'PENDING';

只有更新行数为 1 时,才继续执行首次支付成功后的业务。更新行数为 0 通常说明订单已经处理过,此时直接返回 success 即可。

Java 处理示例

下面的代码展示了基本处理结构。参数获取方式和 EasySDK 初始化方式,需要根据实际使用的 Web 框架和 SDK 版本调整。

@PostMapping(
    value = "/alipay/notify",
    produces = "text/plain;charset=UTF-8"
)
public String handleAlipayNotify(HttpServletRequest request) {
    Map<String, String> parameters = new HashMap<>();

    request.getParameterMap().forEach((key, values) -> {
        if (values != null && values.length > 0) {
            parameters.put(key, String.join(",", values));
        }
    });

    boolean verified;
    try {
        verified = Factory.Payment.Common().verifyNotify(parameters);
    } catch (Exception e) {
        log.error("Alipay notify signature verification failed", e);
        return "failure";
    }

    if (!verified) {
        log.warn("Rejected Alipay notify because signature is invalid");
        return "failure";
    }

    String appId = parameters.get("app_id");
    String outTradeNo = parameters.get("out_trade_no");
    String tradeNo = parameters.get("trade_no");
    String tradeStatus = parameters.get("trade_status");
    String totalAmount = parameters.get("total_amount");

    PaymentOrder order = paymentOrderService.findByOutTradeNo(outTradeNo);
    if (order == null) {
        log.error("Alipay notify references an unknown order: {}", outTradeNo);
        return "failure";
    }

    if (!expectedAppId.equals(appId)) {
        log.error("Unexpected app_id for order {}: {}", outTradeNo, appId);
        return "failure";
    }

    BigDecimal notifiedAmount = new BigDecimal(totalAmount);
    if (order.getAmount().compareTo(notifiedAmount) != 0) {
        log.error(
            "Amount mismatch for order {}, expected {}, actual {}",
            outTradeNo,
            order.getAmount(),
            notifiedAmount
        );
        return "failure";
    }

    switch (tradeStatus) {
        case "WAIT_BUYER_PAY":
            log.info(
                "Order is waiting for payment, outTradeNo={}, tradeNo={}",
                outTradeNo,
                tradeNo
            );
            return "success";

        case "TRADE_SUCCESS":
            paymentOrderService.markPaidIdempotently(
                outTradeNo,
                tradeNo,
                parameters
            );
            return "success";

        case "TRADE_FINISHED":
            paymentOrderService.markFinishedIdempotently(
                outTradeNo,
                tradeNo,
                parameters
            );
            return "success";

        case "TRADE_CLOSED":
            paymentOrderService.markClosedIdempotently(
                outTradeNo,
                tradeNo,
                parameters
            );
            return "success";

        default:
            log.warn(
                "Unhandled Alipay trade status, outTradeNo={}, status={}",
                outTradeNo,
                tradeStatus
            );
            return "success";
    }
}

响应内容应是支付宝接口要求的纯文本,例如:

success

不要返回 JSON:

{"result":"success"}

响应中也不要包含 HTML 标签、额外空格或其他业务说明。

未知状态是否返回 success,取决于系统的告警和补偿能力。通常,当验签、应用、订单和金额都已确认无误时,可以保存原始通知并返回 success,避免无意义的重试,再通过主动查询核实订单状态。

默认关闭时为什么仍可能收到 WAIT_BUYER_PAY

商户或应用实际开启了通知能力

“默认关闭”并不等于当前商户一定处于关闭状态。商户、服务商、支付宝运营人员或历史接入流程都可能开启过相关能力。

需要确认配置是否对应实际发起交易的:

  • app_id;
  • 商户 PID;
  • 服务商代调用关系;
  • 应用授权令牌所指向的被授权商户。

如果使用服务商模式,服务商自己的配置不能代表实际收款商户的配置。

生产环境使用了另一套应用或商户身份

代码中的应用配置、证书、授权令牌,可能与运营人员在开放平台查看的应用不属于同一套配置。应从预创建请求和回调中直接核对 app_id、seller_id、out_trade_no 等字段,不能只根据部署环境名称判断。

多个应用或支付产品共用通知地址

同一个通知地址如果同时接收多个应用、多个商户或不同支付产品的通知,其他业务产生的 WAIT_BUYER_PAY 可能会被误认为当前当面付订单的回调。

建议在日志中记录以下经过脱敏的信息:

app_id
seller_id
out_trade_no
trade_no
trade_status
notify_id
notify_time

再通过 out_trade_no 关联预创建请求日志,确认二者是否属于同一笔交易。

请求并非可信的支付宝通知

公网通知地址可能收到扫描请求、伪造请求或内部测试请求。只有签名验证通过,并且应用、订单、金额和商户身份全部匹配,才能将请求认定为支付宝通知。

文档范围或能力配置有差异

文档描述的默认行为,可能因产品能力、商户配置、服务商模式或接入时间而不同。如果验签已经通过,也确认通知属于同一应用和同一订单,但后台仍找不到相关开关,可以携带以下信息联系支付宝技术支持核查:

  • app_id;
  • 商户 PID;
  • out_trade_no;
  • trade_no;
  • notify_id;
  • 通知发生时间;
  • 已脱敏的预创建请求和异步通知参数。

不要提交应用私钥、完整证书私钥或其他敏感凭据。

增加主动查询补偿

扫码页面可以轮询 alipay.trade.query。服务端也应定时查询长时间停留在待支付状态的订单。查询到支付成功后,仍需复用异步通知使用的幂等支付处理逻辑。

建议按以下流程确认状态:

异步通知优先处理
        ↓
未收到或处理异常
        ↓
主动调用 alipay.trade.query
        ↓
幂等更新本地订单

主动查询用于弥补单次网络请求可能出现的问题,并不替代异步通知。

注意事项

  • WAIT_BUYER_PAY 只表示交易正在等待买家付款,不能据此发货、充值或记账。
  • 即使已经收到 WAIT_BUYER_PAY,也不能拒绝同一订单后续发送的 TRADE_SUCCESS。
  • 对合法但暂时不关心的状态,不要抛出异常,以免触发重复通知。
  • 返回 success 前,必须完成验签和订单关键字段校验。
  • 支付成功应以支付宝的可信状态为依据,同时校验金额、商户身份和本地订单。
  • 异步通知和主动查询应共用一套幂等状态机,防止重复处理。
  • 建议保留原始通知,但个人信息和敏感字段需要脱敏。

备注:内容仅供参考。