电脑网站支付未扫码时如何使支付宝旧价格支付页失效?
结论
在支付宝电脑网站支付中,alipay.trade.page.pay 生成支付页面并返回浏览器后,页面里已经包含签名的 out_trade_no、total_amount 等参数。即使用户还没有扫码,支付宝侧也尚未创建交易,商户仍然无法主动撤销这份已经签名的旧支付页面。
所以,调用 alipay.trade.close 并不能让金额为 50 元的页面立即失效。建议这样处理:
- 每次改价都创建新的支付版本,并使用新的
out_trade_no。 - 在商户系统中将旧支付版本标记为失效,不再生成或展示对应的旧支付页面。
- 处理异步通知时,严格校验支付版本、金额和订单状态。
- 如果用户仍通过已经打开的旧页面支付了 50 元,不得按 60 元订单交付,应自动退款或转人工处理。
- 旧支付尝试一旦在支付宝侧生成交易,再调用
alipay.trade.close将其关闭。
商户可以阻止旧支付记录继续履约,但无法保证已经发送到用户浏览器的支付宝签名页面绝对不能付款。
为什么无法直接关闭旧页面
alipay.trade.close 关闭的是支付宝侧已经存在的交易,不是浏览器中的 HTML 页面、跳转表单或二维码。
用户尚未扫码时,系统中可能只有商户调用 alipay.trade.page.pay 后生成的签名请求,支付宝侧还没有可以关闭的交易。此时:
- 支付参数已经签名,商户无法事后修改其中的
total_amount; - 浏览器中的旧页面已经不受商户服务器控制;
- 支付宝侧没有交易记录,
alipay.trade.close也就没有明确的关闭对象; - 用户之后仍可能提交旧表单,或者扫描旧页面中的二维码。
删除商户页面、清除缓存,或者把业务订单价格改成 60 元,都不会让用户已经打开的支付宝页面自动失效。
推荐的订单设计
不要让一个 out_trade_no 对应多个价格版本。业务订单和支付尝试应分别保存,例如:
| 字段 | 示例 | 说明 |
|---|---|---|
business_order_no | A | 商户业务订单号 |
price_version | 2 | 当前价格版本 |
pay_out_trade_no | A-P2-202609090001 | 本次支付尝试对应的支付宝商户订单号 |
expected_amount | 60.00 | 该支付版本应付金额 |
status | PENDING | 支付尝试状态 |
订单从 50 元调整到 60 元时,应执行以下操作:
- 将原支付尝试标记为
INVALID或CANCELLED。 - 提升订单的价格版本。
- 使用新的
out_trade_no和total_amount=60.00创建支付请求。 - 查询旧
out_trade_no是否已经在支付宝侧形成交易。 - 如果交易已经形成且仍可关闭,调用
alipay.trade.close。 - 如果旧交易已经支付成功,进入退款流程,不再继续履约。
这种设计可以避免同一个 out_trade_no 对应不同金额,也更容易处理支付和改价同时发生的竞态。
支付页面生成前必须再次校验
商户自己的“去支付”接口不能直接信任前端提交的金额。服务端应读取业务订单的当前有效价格,并校验支付版本。
下面是简化的 Java 伪代码:
@PostMapping("/orders/{orderNo}/pay")
public String createPayment(@PathVariable String orderNo) throws Exception {
BusinessOrder order = orderService.lockByOrderNo(orderNo);
if (!order.isPayable()) {
throw new IllegalStateException("订单当前不可支付");
}
PaymentAttempt attempt = paymentAttemptService.create(
order.getOrderNo(),
order.getPriceVersion(),
order.getAmount()
);
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setNotifyUrl("https://merchant.example.com/alipay/notify");
request.setReturnUrl("https://merchant.example.com/pay/result");
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(attempt.getOutTradeNo());
model.setTotalAmount(order.getAmount().setScale(2).toPlainString());
model.setSubject("订单 " + order.getOrderNo());
model.setProductCode("FAST_INSTANT_TRADE_PAY");
request.setBizModel(model);
return alipayClient.pageExecute(request).getBody();
}
total_amount 必须由服务端根据当前订单数据生成,不能直接采用浏览器传入的值。
改价时使商户侧旧支付尝试失效
改价操作应放在事务中,同时更新订单价格和支付版本:
@Transactional
public void changePrice(String orderNo, BigDecimal newAmount) {
BusinessOrder order = orderRepository.lockByOrderNo(orderNo);
if (order.isPaid()) {
throw new IllegalStateException("订单已支付,不能直接改价");
}
paymentAttemptRepository.invalidatePendingAttempts(orderNo);
order.setAmount(newAmount.setScale(2));
order.setPriceVersion(order.getPriceVersion() + 1);
orderRepository.save(order);
}
这一步只能让旧支付尝试在商户系统中失效,无法撤回已经到达浏览器的支付宝支付请求。因此,支付通知处理仍需承担最后一道校验。
异步通知必须核对金额和支付版本
收到支付宝异步通知后,除了验签,还要核对以下内容:
app_id是否属于当前商户应用;seller_id是否为预期收款账号;out_trade_no是否存在;trade_status是否属于业务接受的状态;total_amount是否与该支付尝试的预期金额一致;- 支付尝试是否仍对应业务订单的当前价格版本;
- 业务订单是否已被其他支付尝试完成;
- 该通知是否已经被幂等处理。
示例逻辑:
@PostMapping("/alipay/notify")
public String handleNotify(HttpServletRequest request) {
Map<String, String> params = extractAlipayParams(request);
if (!AlipaySignature.rsaCheckV1(
params,
alipayPublicKey,
StandardCharsets.UTF_8.name(),
"RSA2")) {
return "failure";
}
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"));
PaymentAttempt attempt =
paymentAttemptService.lockByOutTradeNo(outTradeNo);
if (attempt == null) {
riskService.recordUnknownPayment(outTradeNo, tradeNo, paidAmount);
return "success";
}
if (!paidAmount.equals(attempt.getExpectedAmount())) {
refundService.scheduleRefund(tradeNo, paidAmount, "支付金额不匹配");
return "success";
}
BusinessOrder order =
orderService.lockByOrderNo(attempt.getBusinessOrderNo());
boolean currentVersion =
attempt.getPriceVersion() == order.getPriceVersion();
if (!currentVersion || !attempt.isPending() || order.isPaid()) {
refundService.scheduleRefund(tradeNo, paidAmount, "支付版本已失效");
return "success";
}
if ("TRADE_SUCCESS".equals(tradeStatus)
|| "TRADE_FINISHED".equals(tradeStatus)) {
orderService.markPaid(order, attempt, tradeNo, paidAmount);
}
return "success";
}
这段代码只展示业务结构。退款通常应交给独立、可重试且有幂等控制的流程执行,不能只记录一条日志。
返回 failure 只表示通知处理失败,支付宝可能再次发送通知。这并不意味着交易会自动撤销或退款。旧价格交易已经成功时,应明确调用退款接口处理。
发现支付宝交易后及时关闭
价格修改后,可以通过旧支付尝试的 out_trade_no 查询支付宝交易状态。如果交易已经创建但尚未支付,可以尝试调用:
alipay.trade.close
典型处理逻辑如下:
void closeOldPaymentAttempt(String oldOutTradeNo) {
AlipayTradeQueryModel queryModel = new AlipayTradeQueryModel();
queryModel.setOutTradeNo(oldOutTradeNo);
// 查询到支付宝交易后,根据实际交易状态决定是否调用关闭接口。
// 如果已经支付成功,应进入退款或异常支付处理流程。
}
查询和关闭之间存在时间窗口,用户可能正好在这段时间完成支付。因此,无论关闭接口正在调用还是已经调用成功,异步通知处理都必须继续校验支付版本,不能只依赖关闭接口。
可以增加支付有效期,但不能替代改价控制
创建支付请求时,可以根据当前接口实际支持的业务参数设置支付有效期,例如 timeout_express 或 time_expire。具体可用参数、格式和限制,应以正在使用的支付宝接口文档及 SDK 模型为准。
有效期只能缩短旧页面的可支付时间,不能在改价时动态修改已经签名的支付请求。如果业务要求“改价后立即禁止旧金额成交”,仅设置超时时间仍然不够。
更稳妥的交互方式
为了缩短旧页面仍可使用的时间,可以采用以下设计:
- 用户点击“确认支付”后,服务端再创建支付宝支付请求;
- 尽量缩短支付请求的有效期;
- 改价后强制刷新订单确认页;
- 每次支付都使用一次性的支付尝试编号;
- 页面轮询订单版本,发现改价后立即停止商户侧跳转;
- 不提前长期保存或重复展示已经生成的支付表单。
这些措施可以降低用户继续使用旧支付页面的概率,但无法彻底消除这种情况。签名后的支付宝页面一旦交给用户,就要按“旧金额仍可能被支付”来设计后续流程,通过拦截履约、关闭交易和自动退款兜底。
注意事项
-
不要用同一个
out_trade_no反复提交不同的total_amount。支付宝交易一旦形成,关键交易信息通常不能由新的支付请求随意覆盖。 -
比较金额时应使用
BigDecimal,不要使用double:
BigDecimal expected = new BigDecimal("60.00");
BigDecimal actual = new BigDecimal(params.get("total_amount"));
if (expected.compareTo(actual) != 0) {
// 金额不一致
}
-
不能根据同步跳转页面判断支付成功。支付结果应以验签后的异步通知为主,必要时再调用交易查询接口确认。
-
改价、支付通知、交易关闭和退款可能并发发生。数据库操作应使用事务、条件更新或行锁,通知、关闭和退款操作也要有幂等控制。
-
如果用户按旧价格支付成功,不应简单补差价后自动发货,除非业务规则明确允许,而且用户已经确认。默认做法应是拦截履约并退款。
这个问题无法通过“撤销浏览器中的旧支付宝页面”解决。系统需要隔离不同的支付版本,在服务端校验状态,并结合交易关闭和退款机制,确保订单不会按错误价格履约。
备注:内容仅供参考。
