PAYATHON 2026

统一收单线下交易预创建如何实现一次性扫码支付

支付阿杰

结论

alipay.trade.precreate 生成的二维码并不是“一次性二维码”。只要对应交易仍处于 WAIT_BUYER_PAY,用户关闭扫码页面后,仍可能再次打开并完成付款。

如果要实现接近“一次性扫码支付”的效果,可以这样处理:

  1. 每次支付会话都使用唯一的 out_trade_no。
  2. 创建订单时设置较短的 timeout_express。
  3. 用户主动取消、页面超时或业务会话失效时,由服务端调用 alipay.trade.close,关闭尚未支付的交易。
  4. 关单前后查询交易状态,妥善处理付款和关单同时发生的情况。
  5. 二维码失效后不再复用原订单。用户重新支付时,必须创建新的 out_trade_no。

不过,商户端通常无法保证在用户放弃付款时立即收到通知,也无法保证这笔订单完全不出现在支付宝账单中。扫码后的支付宝页面不受商户网页控制,用户直接退出时,商户一般收不到可靠通知。账单或交易记录的展示方式也由支付宝客户端决定。

商户能够保证的是:交易成功关闭后,用户不能再继续支付。

为什么会出现等待支付的订单

调用 alipay.trade.precreate 后,支付宝侧已经创建交易并返回二维码内容。付款前,订单通常处于以下状态:

WAIT_BUYER_PAY

用户扫码后没有付款,并不代表交易已经取消。只要订单尚未超时,也没有被商户关闭,它就可能一直处于待支付状态。

所以,仅让二维码显示一次并不能实现一次性支付。真正需要控制的是二维码对应的交易状态。

推荐实现流程

1. 每次进入支付页面都创建新的业务订单

不要重复使用历史 out_trade_no。每次支付会话都可以生成一个全新的订单号,例如:

member_20260910_8f43d91c2a

业务数据库建议至少记录以下字段:

out_trade_no
payment_session_id
amount
status
expire_at
alipay_trade_no
created_at

业务状态可以设计为:

CREATED
PAYING
PAID
CLOSING
CLOSED

2. 预创建时设置订单有效期

下面以 Java 和支付宝 SDK 的常见调用方式为例。具体的 SDK 类名和初始化方式,应以项目实际使用的 SDK 版本为准。

AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest();
request.setNotifyUrl("https://example.com/payment/alipay/notify");

JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", outTradeNo);
bizContent.put("total_amount", "19.90");
bizContent.put("subject", "会员开通");
bizContent.put("timeout_express", "5m");

request.setBizContent(bizContent.toJSONString());

AlipayTradePrecreateResponse response = alipayClient.execute(request);

if (!response.isSuccess()) {
    throw new IllegalStateException(
        "预创建失败:" + response.getCode() + " / " + response.getSubCode()
    );
}

String qrCode = response.getQrCode();

timeout_express 支持的格式、最小值和实际生效规则,可能受到接口产品及签约能力的限制,应以当前支付宝开放平台文档和接口实际响应为准。

前端展示二维码时,应使用相同的有效期进行倒计时。倒计时结束后立即隐藏二维码,同时触发服务端关单,不能只在前端隐藏。

3. 用户主动取消时请求服务端关单

例如,用户点击“取消支付”时:

async function cancelPayment(outTradeNo) {
  const response = await fetch("/api/payment/alipay/cancel", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      outTradeNo
    })
  });

  if (!response.ok) {
    throw new Error("取消支付失败");
  }
}

服务端收到请求后,不要直接将本地订单改为 CLOSED。应先确认订单归属、金额和当前状态,再调用支付宝关单接口。

4. 使用 alipay.trade.close 关闭交易

public void closeAlipayTrade(String outTradeNo) throws AlipayApiException {
    AlipayTradeCloseRequest request = new AlipayTradeCloseRequest();

    JSONObject bizContent = new JSONObject();
    bizContent.put("out_trade_no", outTradeNo);
    request.setBizContent(bizContent.toJSONString());

    AlipayTradeCloseResponse response = alipayClient.execute(request);

    if (response.isSuccess()) {
        paymentRepository.markClosed(outTradeNo);
        return;
    }

    // 不能简单认定为关单失败,需要查询最终交易状态。
    String code = response.getCode();
    String subCode = response.getSubCode();

    throw new IllegalStateException(
        "支付宝关单结果不确定:" + code + " / " + subCode
    );
}

关单成功后,即使旧二维码仍保存在截图中,也不应再完成付款。用户重新发起支付时,需要创建新订单并生成新的二维码。

不要只依赖浏览器关闭事件

可以监听 pagehide、visibilitychange 或 beforeunload,但这些事件只能作为辅助信号,不能承担可靠关单的职责:

window.addEventListener("pagehide", () => {
  navigator.sendBeacon(
    "/api/payment/alipay/cancel",
    JSON.stringify({ outTradeNo })
  );
});

遇到断网、浏览器进程退出、系统休眠或移动端限制时,这些事件可能无法成功发送。因此,还需要设置服务端定时任务作为兜底:

@Scheduled(fixedDelay = 30000)
public void closeExpiredOrders() {
    List<PaymentOrder> orders =
        paymentRepository.findExpiredUnpaidOrders(100);

    for (PaymentOrder order : orders) {
        try {
            closeAfterStatusCheck(order.getOutTradeNo());
        } catch (Exception e) {
            // 记录日志并进入重试队列,避免单笔失败中断整个批次
        }
    }
}

完整的处理方式包括:

用户主动取消关单
        +
支付页面倒计时关单
        +
服务端扫描过期订单兜底

必须处理支付与关单的并发

用户可能正好在超时瞬间完成付款。此时,支付成功通知和关单请求可能同时到达,不能按照请求发出的先后顺序判断最终结果。

如果关单失败或返回结果不明确,应调用 alipay.trade.query 查询交易状态:

public String queryTradeStatus(String outTradeNo)
        throws AlipayApiException {

    AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();

    JSONObject bizContent = new JSONObject();
    bizContent.put("out_trade_no", outTradeNo);
    request.setBizContent(bizContent.toJSONString());

    AlipayTradeQueryResponse response = alipayClient.execute(request);

    if (!response.isSuccess()) {
        throw new IllegalStateException(
            "交易查询失败:" + response.getCode()
        );
    }

    return response.getTradeStatus();
}

再根据最终状态处理:

String tradeStatus = queryTradeStatus(outTradeNo);

switch (tradeStatus) {
    case "TRADE_SUCCESS":
    case "TRADE_FINISHED":
        paymentRepository.markPaid(outTradeNo);
        break;

    case "TRADE_CLOSED":
        paymentRepository.markClosed(outTradeNo);
        break;

    case "WAIT_BUYER_PAY":
        // 仍未支付,可继续尝试关单或稍后重试。
        break;

    default:
        // 保留原状态并记录异常,等待后续查询。
        break;
}

判断支付成功时,必须以验签通过的支付宝异步通知和主动查询结果为依据。不能因为本地倒计时结束,就直接认定订单没有支付。

关于“不出现在用户账单中”

商户接口无法对此作出绝对保证。

商户可以调用 alipay.trade.close,阻止未支付订单继续付款。但支付宝客户端是否展示待支付、已关闭或历史交易记录,属于支付宝侧的产品行为。即使及时关单,也不能承诺用户账单中完全没有相关记录。

某些平台看起来具有类似的一次性扫码体验,但这可能涉及特定支付产品、客户端交互或平台级能力。不能只根据页面表现,推断其使用的是普通 alipay.trade.precreate。

如果业务要求严格的一次性效果,可以把目标明确为:

  • 二维码只在短时间内展示;
  • 过期后由服务端立即发起关单;
  • 旧二维码不能恢复使用;
  • 再次支付时必须生成新订单;
  • 最终支付状态以支付宝服务端结果为准。

这样可以可靠地做到过期后无法继续付款,但无法控制支付宝账单界面的展示方式。

备注:内容仅供参考。