自有 APP 嵌入 H5 下单时取消订单与支付状态冲突
结论
支付宝返回“查询订单不存在”,并不代表本地订单已经安全取消。
H5 支付表单是一份已经签名的支付请求。只要它仍在有效期内,就可能被继续提交或使用。即使支付宝当时还没有创建可查询的交易,用户之后仍可能通过已经打开的支付页面唤起收银台并完成付款。
处理这类情况时,应当:
- 为支付请求设置与本地订单一致的支付截止时间。
- 取消本地订单时,先撤销支付资格,禁止旧支付入口继续使用。
- 如果支付宝交易已经存在且仍在等待付款,调用
alipay.trade.close关闭交易。 - 无论本地订单是否取消,都要接收并处理支付宝异步通知。
- 如果订单取消后仍收到支付成功结果,应按业务规则将订单恢复为已支付,或者发起退款,不能忽略这笔支付。
取消前只查询一次支付宝订单,无法解决并发冲突。
冲突产生的原因
这个场景涉及两套独立的状态:
- 商户系统中的订单状态;
- 支付宝侧的交易状态。
两套状态不属于同一个原子事务,也不会同步变化。
服务端生成 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 的官方文档为准。
- 即使设置了交易过期时间并主动关闭交易,也要保留晚到支付后的恢复或退款流程。这是保证账实一致的最后一道保障。
备注:内容仅供参考。
