统一收单线下交易预创建如何实现一次性扫码支付
结论
alipay.trade.precreate 生成的二维码并不是“一次性二维码”。只要对应交易仍处于 WAIT_BUYER_PAY,用户关闭扫码页面后,仍可能再次打开并完成付款。
如果要实现接近“一次性扫码支付”的效果,可以这样处理:
- 每次支付会话都使用唯一的
out_trade_no。 - 创建订单时设置较短的
timeout_express。 - 用户主动取消、页面超时或业务会话失效时,由服务端调用
alipay.trade.close,关闭尚未支付的交易。 - 关单前后查询交易状态,妥善处理付款和关单同时发生的情况。
- 二维码失效后不再复用原订单。用户重新支付时,必须创建新的
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。
如果业务要求严格的一次性效果,可以把目标明确为:
- 二维码只在短时间内展示;
- 过期后由服务端立即发起关单;
- 旧二维码不能恢复使用;
- 再次支付时必须生成新订单;
- 最终支付状态以支付宝服务端结果为准。
这样可以可靠地做到过期后无法继续付款,但无法控制支付宝账单界面的展示方式。
备注:内容仅供参考。
