做Vue项目接入支付宝PC支付,说难不难,说简单也不简单。坑基本都藏在细节里:同步回调、异步回调、签名验证、二维码过期、轮询状态、History模式下路由回跳……每一项都能把你卡住半天。这篇我直接基于实际项目经验,把扫码支付和跳转支付这两种方式从方案选型到落地代码完整拆开讲清楚,全程以前后端分离的Vue + Spring Boot为例,代码可以直接照着改。
1. 整体设计与方案选型思路
先说清楚这两种支付方式到底是什么、适合什么场景,不然你很容易一开始就走偏。
1.1 扫码支付和跳转支付的业务逻辑差异
扫码支付在支付宝官方文档里的名称是“统一收单线下交易预创建”,对应接口是alipay.trade.precreate。后端调用这个接口后,支付宝会返回一个qr_code字符串(本质是一串URL链接),前端拿到后用插件生成二维码图片展示在页面上,用户打开支付宝App扫描二维码完成付款。
跳转支付对应的是“电脑网站支付”,接口是alipay.trade.page.pay。后端调用后,支付宝会返回一段自动提交的HTML表单,前端把这个表单渲染到页面上并自动触发跳转,用户会从你的页面被带到支付宝官方收银台,登录支付宝账号后完成付款,再跳转回你的网站。
两者的核心区别可以从三个维度看:用户操作路径、前端实现复杂度、支付状态获取方式。
| 对比维度 | 扫码支付 | 跳转支付 |
|---|---|---|
| 用户操作 | 手机支付宝扫码 | 跳转支付宝页面登录付款 |
| 前端工作 | 渲染二维码+轮询状态 | 渲染隐藏表单+自动提交 |
| 支付状态 | 需要主动轮询订单 | 依赖支付宝同步/异步通知 |
| 适用场景 | PC端网站、收银台、线下大屏 | PC端商城、需要账号体系联动 |
| 对前端要求 | 需要处理二维码过期和刷新 | 需要兼容History路由回跳 |
从技术角度看,两者都需要后端去和支付宝打交道——落单、签名、验签、回调处理全都在后端完成,前端只负责“把这个链接变成用户能操作的东西”和“把支付结果正确展示出来”。这一点想清楚了,整个项目的分工就明确了:前端不要直接引入任何支付宝SDK,也不要自己去调支付宝接口,所有和钱相关的操作都在后端做。
1.2 为什么前端只做“展示层”而不是直接对接
我接触过一些刚入门的同学,一看支付宝开放平台文档就想着“前端能不能直接用JS调起支付”,这个思路在PC端是走不通的。原因有三个:
第一,安全红线。支付宝的接口签名需要用到应用私钥,私钥一旦暴露在前端代码里,等于把资金操作权限拱手送给用户。前端代码打包后是公开的,任何人打开浏览器控制台都能看到你的网络请求、源码逻辑,所以涉及签名的操作绝对不能出现在前端。
第二,数据一致性。订单金额、商品信息、订单号必须由后端统一管控。如果前端能自己拼支付请求,那用户完全可以篡改金额再发起支付,这属于非常严重的逻辑漏洞。支付宝虽然会对部分参数做二次校验,但你的业务系统不能依赖支付宝替你防守。
第三,技术架构的天然边界。典型的Vue项目都走前后端分离,前端跑在Nginx或Node服务上,后端跑在Java服务里。支付宝的异步通知notify_url要求必须是公网可访问的地址,且通知目标必须是后端接口,前端根本没有能力接收这种服务端到服务端的请求。
所以正确的做法就是:后端负责生成支付参数并调用支付宝接口,把结果交给前端展示;前端负责把支付能力包装成友好的用户交互;支付结果以后端收到的异步通知为最终准绳。这个架构在扫码支付和跳转支付里是统一的。
2. 核心细节解析与实操要点
接入支付宝前,有几个关键概念必须吃透,否则你连参数都填不明白。我刚开始搞的时候就是没搞懂这些,来回折腾了两天才理顺。
2.1 密钥体系:应用私钥、应用公钥、支付宝公钥
支付宝开放平台采用的是RSA2非对称签名机制,整套体系涉及三把钥匙:
- 应用私钥:你自己生成的,放在后端服务里,用来给请求参数签名。相当于你的“私章”,绝对不能外泄。
- 应用公钥:和应用私钥配对,上传到支付宝开放平台,支付宝用它来验证你的请求确实是你的服务发出的。
- 支付宝公钥:支付宝提供给开发者的,放在后端服务里,用来验证支付宝回调通知的签名。注意区分“支付宝公钥”和“支付宝开放平台密钥工具生成的公钥”——支付宝公钥是平台给你的一长串字符串,不是你自己生成的那把。
生成密钥对推荐使用支付宝官方提供的“支付宝开放平台密钥工具”,或者用OpenSSL手动生成。工具会生成两对:一对应用密钥(上传公钥到平台),支付宝后台会显示对应的支付宝公钥,你复制下来配置到后端即可。
签名算法一定要选RSA2,对应的是SHA256withRSA,比旧的RSA(SHA1withRSA)更安全,从2025年起支付宝已经逐步强制RSA2了。
2.2 同步通知return_url与异步通知notify_url
这两个URL是支付宝PC支付最容易搞混的地方。
return_url(同步通知):用户支付完成后,支付宝会把用户的浏览器重定向回这个地址。它只是“告诉用户支付完成了”,因为用户可能关闭浏览器、断网、或被中途拦截,所以不能作为订单状态更新的依据。
notify_url(异步通知):支付宝服务器在你支付成功后,会主动向这个地址发一个POST请求,携带完整的订单信息和签名。这才是订单状态更新的唯一可靠依据。后端在收到异步通知并验签成功后,应该将订单状态更新为“已支付”,并返回字符串“success”给支付宝,否则支付宝会按一定策略重复通知(4次、10分钟、1小时……最多24小时)。
这里有一个很常见的需求:扫码支付模式下,用户扫码付款后并不会自动跳转到return_url,因为用户的操作停留在手机支付宝里,PC浏览器并没有发生跳转。所以扫码支付必须靠前端轮询订单状态来刷新页面;而跳转支付因为有浏览器的页面跳转行为,可以依赖return_url做页面层面的“跳转成功提示”,但业务状态仍要等notify_url。
2.3 金额单位:永远是“元”还是“分”
支付宝接口中金额单位是元,精确到小数点后两位。很多从微信支付转过来的同学容易踩坑,因为微信支付里金额单位是“分”。这个细节你要是没注意,就会出现“支付0.01元变成支付1元”这种体验事故。
后端落单时一定要对金额做严格格式化,比如用BigDecimal保留两位小数,再转成字符串传给支付宝。另外强烈建议后端每次下单前,从数据库重新读一遍订单金额,而不是直接把前端传来的金额拼进参数。前端传来的金额理论上只作为展示,不可直接信任。
2.4 沙箱环境与模拟器使用
开发阶段不要直接上正式环境。支付宝开放平台提供沙箱环境(sandbox),对应独立的APPID、网关地址和密钥对。你在“沙箱应用”页面可以配置沙箱的支付宝公钥,还有一个官方的“沙箱版支付宝”App,用来扫码测试。同时开发时还可以用支付宝的模拟器工具,帮你模拟1:1的扫码结果和回调流程,省得每次都要拿手机扫码调试。
沙箱环境有几点和线上不同:网关是openapi.alipaydev.com;买家账号是系统分配的沙箱账号;部分金额限制比线上宽松。调试阶段建议后端把网关地址做成可配置项,环境切换时只改配置不改代码。
3. 实操过程与核心环节实现
理论部分讲完了,下面进入代码实现环节。我会按后端核心接口、前端扫码支付、前端跳转支付、回调处理四块来拆,保证你拿到就能用。
3.1 后端环境与依赖准备
后端以Spring Boot为例,引入支付宝官方SDK:
<dependency> <groupId>com.alipay.sdk</groupId> <artifactId>alipay-sdk-java</artifactId> <version>4.39.89.ALL</version> </dependency>版本号建议以Maven仓库最新稳定版为准,不同大版本API略有差异,但核心调用方式一致。
配置文件application.yml中集中管理支付宝相关参数:
alipay: app-id: 2021000123456789 private-key: 你的应用私钥(不要提交到Git) alipay-public-key: 支付宝公钥 gateway: https://openapi.alipaydev.com/gateway.do notify-url: https://yourdomain.com/api/pay/alipay/notify return-url: https://yourdomain.com/pay/result sign-type: RSA2 charset: utf-8 format: json注意notify-url和return-url都要使用线上可访问的HTTPS地址。本地联调阶段可以用内网穿透工具把本机端口映射成一个公网临时域名,不然支付宝的异步通知根本无法触达你的本地服务。
3.2 后端接口:生成扫码支付二维码
扫码支付的核心逻辑:后端接收商户订单号 → 从数据库查询订单与金额 → 组装支付请求对象 → 调用支付宝预创建接口 → 返回qr_code字符串和订单信息给前端。
@PostMapping("/api/pay/alipay/precreate") public Result precreate(@RequestBody PrecreateRequest request) { // 1. 从数据库读取订单,校验订单状态是否为待支付 Order order = orderMapper.selectByOrderNo(request.getOrderNo()); if (order == null) { return Result.fail("订单不存在"); } if (order.getStatus() != OrderStatus.WAIT_PAY) { return Result.fail("订单状态异常"); } // 2. 组装支付宝请求参数 AlipayTradePrecreateRequest alipayRequest = new AlipayTradePrecreateRequest(); alipayRequest.setNotifyUrl(alipayConfig.getNotifyUrl()); alipayRequest.setBizContent("{" + "\"out_trade_no\":\"" + order.getOrderNo() + "\"," + "\"total_amount\":\"" + order.getAmount().setScale(2, RoundingMode.HALF_UP) + "\"," + "\"subject\":\"" + order.getSubject() + "\"," + "\"timeout_express\":\"15m\"," + "\"qr_code_timeout_express\":\"15m\"" + "}"); // 3. 调用SDK执行请求 AlipayTradePrecreateResponse response = alipayClient.execute(alipayRequest); if (response.isSuccess()) { return Result.ok(response.getQrCode()); } // 失败要记录日志并排查原因 log.error("预创建订单失败: code={}, msg={}, subCode={}, subMsg={}", response.getCode(), response.getMsg(), response.getSubCode(), response.getSubMsg()); return Result.fail("二维码生成失败"); }这里有几个参数我得单独解释:
timeout_express:订单整体的超时时间,格式是15m、1h这种,超过时间支付宝会关闭该笔交易。建议根据业务场景设置30分钟或15分钟。qr_code_timeout_express:二维码本身的“过期时间”,只有在qr_code模式下才有意义。如果二维码过期但订单还没关,用户刷新页面后你可以重新调一次接口获取新的二维码。notifyUrl:异步通知地址,必须公网可访问。
3.3 前端Vue实现:扫码支付组件
前端收到后端返回的qrCode字符串后,把它展示成二维码图片。推荐使用qrcode这个npm包,体积小、无依赖,用法也非常简单。
先安装依赖:
npm install qrcode然后写一个扫码支付组件ScanPay.vue:
<template> <div class="scan-pay"> <div class="qr-wrapper"> <canvas ref="qrCanvas"></canvas> <div v-if="payStatus === 'expired'" class="mask"> <p>二维码已过期</p> <button @click="refreshQrCode">刷新二维码</button> </div> </div> <p class="tip">请使用支付宝App扫码完成支付</p> <p class="order-no">订单号:{{ orderNo }}</p> </div> </template> <script> import QRCode from 'qrcode' import { getQrCode, getPayStatus } from '@/api/pay' export default { name: 'ScanPay', data() { return { orderNo: '', qrCodeUrl: '', payStatus: 'waiting', // waiting | paid | expired | closed pollTimer: null, } }, async mounted() { this.orderNo = this.$route.query.orderNo await this.loadQrCode() this.startPolling() }, beforeDestroy() { clearInterval(this.pollTimer) }, methods: { async loadQrCode() { const { data } = await getQrCode({ orderNo: this.orderNo }) this.qrCodeUrl = data.qrCode await QRCode.toCanvas(this.$refs.qrCanvas, this.qrCodeUrl, { width: 200, margin: 2, }) // 重新开始轮询 this.startPolling() }, startPolling() { clearInterval(this.pollTimer) this.pollTimer = setInterval(this.checkStatus, 2000) }, async checkStatus() { const { data } = await getPayStatus({ orderNo: this.orderNo }) if (data.status === 'paid') { clearInterval(this.pollTimer) this.payStatus = 'paid' // 跳转到支付成功页 this.$router.replace({ path: '/pay/result', query: { orderNo: this.orderNo } }) } if (data.status === 'expired') { clearInterval(this.pollTimer) this.payStatus = 'expired' } }, refreshQrCode() { this.payStatus = 'waiting' this.loadQrCode() }, }, } </script>轮询间隔我一般设2秒或3秒,不要设太短,否则会给后端和无谓的压力。爬虫式1秒一次完全没必要,用户扫码到完成支付至少有5秒以上的时间差。
后端要配套一个查单接口/api/pay/status,内部逻辑是:查数据库订单状态,如果能查到支付成功就直接返回;如果还没支付,就去调用支付宝的alipay.trade.query查询交易状态,更新本地状态后返回。
3.4 后端接口:生成跳转支付表单
跳转支付的核心逻辑是:后端调用alipay.trade.page.pay接口,支付宝会返回一段海量的HTML表单文本,后端把它原样返回给前端。前端拿到表单后,把它插入页面并调用form.submit()自动提交,用户就跳转到了支付宝收银台。
@PostMapping("/api/pay/alipay/pagePay") public Result pagePay(@RequestBody PagePayRequest request) { Order order = orderMapper.selectByOrderNo(request.getOrderNo()); if (order == null) { return Result.fail("订单不存在"); } AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest(); alipayRequest.setReturnUrl(alipayConfig.getReturnUrl()); alipayRequest.setNotifyUrl(alipayConfig.getNotifyUrl()); alipayRequest.setBizContent("{" + "\"out_trade_no\":\"" + order.getOrderNo() + "\"," + "\"total_amount\":\"" + order.getAmount().setScale(2, RoundingMode.HALF_UP) + "\"," + "\"subject\":\"" + order.getSubject() + "\"," + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"" + "}"); AlipayTradePagePayResponse response = alipayClient.pageExecute(alipayRequest); // pageExecute 返回的response.getBody() 是一段完整的HTML表单 return Result.ok(response.getBody()); }这里有一个非常关键的细节:pageExecute和execute不一样。如果这里错误地用了execute,返回的结果会让你一头雾水。跳转支付必须用pageExecute,并且建议传入第二个参数POST:alipayClient.pageExecute(alipayRequest, "POST"),让表单以POST方式提交。POST方式相比GET更安全,也不会因为URL过长导致参数丢失。默认方式是GET,签名都拼在URL上,用户能看到一长串参数,体验很丑。
3.5 前端Vue实现:跳转支付逻辑
前端把后端返回的HTML表单接住,然后渲染并提交。实现方式有两种:
方式一:动态创建form容器并提交(推荐)
<template> <div class="page-pay-wrap"> <div ref="formContainer" v-html="formHtml"></div> </div> </template> <script> import { pagePay } from '@/api/pay' export default { name: 'PagePay', data() { return { formHtml: '', orderNo: '', } }, async mounted() { this.orderNo = this.$route.query.orderNo const { data } = await pagePay({ orderNo: this.orderNo }) this.formHtml = data // 等DOM渲染完成后自动提交表单 this.$nextTick(() => { const form = this.$refs.formContainer.querySelector('form') if (form) form.submit() }) }, } </script> <style scoped> /* 容器不需要显示样式,视觉上点击按钮后瞬间跳转即可 */ .page-pay-wrap { display: flex; justify-content: center; align-items: center; height: 300px; } </style>方式二:v-html直接插入到body下
在index.html或App根组件下固定挂载一个隐藏容器,把后端返回的HTML设置进去后自动提交。这种方式更接近“整页跳转”,不会受到Vue Router的干扰。
跳转支付的核心问题是:用户在支付宝页面支付完成后,浏览器会重定向回return_url指定的地址。如果你用的是Vue Router的History模式,这个地址不能再是后端接口地址,而应该是前端的某个路由,比如/pay/result。然后前端在这个结果页里读取URL上的参数(out_trade_no、trade_no、total_amount、sign等),向后端确认一次订单最终状态,再决定展示“支付成功”还是“支付处理中”。
这里推荐一个很实用的细节:return_url上支付宝会带上out_trade_no、trade_no等参数,前端可以直接用this.$route.query拿到。但因为同步通知的sign是支付宝签名后的参数,你可以选择不信任它、也不做前端验签,而是把它带上一起发给后端,由后端再做一次查询和确认。这样最稳妥。
3.6 后端回调处理:notify_url 才是核心
无论是扫码支付还是跳转支付,后端都必须实现notify_url的接收接口。它是支付宝主动发起的POST请求,body里是application/x-www-form-urlencoded格式的参数,需要通过request.getParameterMap()读取。
@PostMapping("/api/pay/alipay/notify") public String notify(HttpServletRequest request) { Map<String, String> params = new HashMap<>(); Map<String, String[]> requestParams = request.getParameterMap(); for (Map.Entry<String, String[]> entry : requestParams.entrySet()) { String name = entry.getKey(); String[] values = entry.getValue(); String valueStr = ""; for (int i = 0; i < values.length; i++) { valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ","; } params.put(name, valueStr); } // 1. 验签 boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayConfig.getAlipayPublicKey(), alipayConfig.getCharset(), alipayConfig.getSignType()); if (!signVerified) { log.error("支付宝异步通知验签失败: {}", params); return "failure"; } // 2. 业务处理 String tradeStatus = params.get("trade_status"); String outTradeNo = params.get("out_trade_no"); String tradeNo = params.get("trade_no"); String totalAmount = params.get("total_amount"); if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) { // 这里要做幂等处理:判断订单是否已被处理,避免重复发货 boolean handled = orderService.handlePaidOrder(outTradeNo, tradeNo, totalAmount); if (handled) { return "success"; } } return "failure"; }回调处理的几个关键点:
验签必须放在第一步。如果验签不过直接返回“success”,等于让任何人都能伪造通知来把订单标记为已支付,后果不用我说。
TRADE_SUCCESS和TRADE_FINISHED都算支付成功。TRADE_FINISHED表示交易完成且全额退款或不可退款,TRADE_SUCCESS表示支付成功,具体区别取决于产品类型。PC网站支付基本只关注这两个状态。
幂等处理极其重要。支付宝的异步通知会重复发送多次,直到你返回“success”。如果你的handlePaidOrder没有幂等保护,就可能出现重复入账、重复加会员、重复发货的问题。常见做法是:查询订单当前状态,如果已经是“已支付”就直接返回success不再处理,或者在订单表加一个唯一索引字段trade_no做约束。
返回字符串必须是“success”(小写),不能是“SUCCESS”或“true”。支付宝只有收到“success”才会停止通知,其他任何内容都会被视为通知失败,继续重发。
4. 常见问题与排查技巧实录
这里把我实际开发中遇到的、以及社区里高频出现的典型问题集中整理出来,这些问题有的是环境问题,有的是代码逻辑问题,但每个都真实发生过。
4.1 金额单位写错引发的“天价订单”
有个朋友刚开始接支付宝,直接照着微信支付的代码把金额转成了“分”传给支付宝,结果用户下单显示1元,实际支付变成了100元。排查了很久才发现是单位问题。支付宝的所有金额字段都是元,必须是字符串形式的“1.00”。后端用BigDecimal,前端展示、传参、后端落库,每一层都要明确单位,建议在订单实体类上加注释标明“金额单位:元,精确到两位小数”。
4.2 本机联调时收不到notify回调
支付宝的异步通知会从支付宝服务器发到你的notify_url。如果你是本地开发环境,notify_url配置成了localhost:8080,那支付宝永远访问不到。解决方案是使用内网穿透工具,把本地服务的8080端口映射到公网域名,然后把notify_url配置成这个公网域名。
一个很容易忽略的坑:支付宝浏览器端跳转(return_url)用的是浏览器访问,所以localhost其实可以访问;但notify是服务器端请求,localhost不行。因此你可能会发现“同步跳转成功了,但状态一直不变”,其实就是异步通知没收到。
4.3 前端History模式下路由回跳404
Vue Router默认是Hash模式,URL形如https://xxx.com/#/pay/result。但跳转支付的return_url如果配置了Hash形式的地址,支付宝在重定向时会把Hash参数也处理掉一部分,导致回跳后拿不到查询参数。如果你配置的是History模式,return_url形如https://xxx.com/pay/result,Nginx需要配置try_files $uri $uri/ /index.html;,否则刷新或回跳时会直接404。
实测下来,return_url配成History模式的纯前端路由地址最干净。结果页从this.$route.query里读取参数。
4.4 同步通知参数和异步通知参数不一致
有时候用户明明支付成功了,但return_url上带的total_amount和真实支付金额差几分钱,或者out_trade_no对不上。这类问题不要试图在前端找原因,因为return_url只是支付宝回显参数,本来就不保证和异步通知完全一致。一律以后端查询订单状态或异步通知为准,前端只把同步通知当作一个“回跳信号”,不做任何业务判断。
4.5 二维码出现但扫不出来
二维码能渲染出来但扫不出来,先排除这几种情况:
qrCode字符串被截断或格式不对。可以尝试手动在浏览器打开这个链接,如果打不开就是后端拼接参数出了错。- QRCode插件的宽度设置太小人眼看不清楚。建议设置200px以上,并给二维码周围留白边框。
- 屏幕过亮或反光导致扫码识别不了。设置一个灰度背景或者白色卡片承托二维码。
另外强烈建议在二维码下方放一个“订单号和支付金额”的展示区,这是很好的用户体验补充,用户扫码前会核对金额,减少支付后才发现金额不对的纠纷。
4.6 沙箱环境支付成功但回调一直不到
沙箱环境有两个常见坑:
一是你必须在沙箱应用的“开发设置”里配置接口加签方式和回调地址,有些同学只改了代码里的配置,没动开放平台后台,自然通知不来。
二是沙箱版支付宝App的账号密码以沙箱控制台提供的为准,不要拿真实支付宝账号去扫沙箱环境的二维码,那样必失败。
4.7 金额精度导致验签失败
total_amount在回调里是字符串,转成BigDecimal时不要用new BigDecimal(float),要用new BigDecimal(String)。一个最简单的例子:new BigDecimal(0.99f)会得到0.9899993这样一串数字,金额比较就永远对不上。另外验签用的是原始参数Map,不要在验签前修改参数值,比如转成别的类型后再验签,这样也会导致失败。
4.8 后端代码里测试环境切正式环境时的钥匙混淆
你会在沙箱环境调通整个流程后,再换正式环境的app-id、网关、密钥。这个环节最容易犯的错误是:把正式环境的支付宝公钥和沙箱环境的支付宝公钥搞混。平台上的两个应用各自有独立的密钥对,你必须为每个环境保存一套配置。建议在后端配置中心做成独立环境变量,部署时按环境注入。
4.9 异步重复通知导致业务重复处理
前面提到的幂等处理,我再演示一个简单的实现思路:
@Transactional public boolean handlePaidOrder(String outTradeNo, String tradeNo, String totalAmount) { Order order = orderMapper.selectByOrderNoForUpdate(outTradeNo); if (order == null) { return false; } if (order.getStatus() == OrderStatus.PAID) { // 已经处理过,直接返回成功,告诉支付宝别发了 return true; } // 校验金额是否一致 if (order.getAmount().compareTo(new BigDecimal(totalAmount)) != 0) { log.error("订单金额不一致:outTradeNo={}, 本地={}, 回调={}", outTradeNo, order.getAmount(), totalAmount); return false; } // 更新订单状态 order.setStatus(OrderStatus.PAID); order.setTradeNo(tradeNo); orderMapper.updateById(order); return true; }注意我用了selectByOrderNoForUpdate,也就是SELECT ... FOR UPDATE,这会锁住这行记录,避免并发通知同时进来导致重复处理。这是比较稳妥的幂等策略。
5. 两种支付方式的联调流程与验证清单
开发完代码后,我建议按以下清单做一次完整的联调,避免上线前才暴露问题。
5.1 扫码支付联调清单
- 用户在商城下单,生成订单号,订单状态为待支付。
- 进入扫码支付页,前端请求
/precreate接口,后端返回qrCode。 - 前端渲染二维码,页面上显示订单号和金额。
- 手机支付宝扫一扫沙箱版二维码,进入支付页面。
- 确认金额无误后完成支付。
- 前端轮询接口检测到订单状态变为已支付,自动跳转结果页。
- 后端收到异步通知,验签通过后更新订单状态。
- 二次进入订单列表,确认状态已更新。
5.2 跳转支付联调清单
- 用户下单,点击“支付宝支付”按钮。
- 前端请求
/pagePay接口,后端返回HTML表单。 - 前端自动提交表单,跳转到支付宝收银台。
- 登录沙箱买家账号,支付成功。
- 浏览器自动跳转回
/pay/result?out_trade_no=xxx&trade_no=xxx&sign=xxx。 - 结果页展示“支付成功”,并调用后端查单接口确认最终状态。
- 后端收到异步通知,更新订单状态。
- 如果中途关闭浏览器,订单状态以后端异步通知为准。
清单一跑下来,你就能明显感觉到:扫码支付前端要多做一个轮询才能知道结果,跳转支付则多了一个页面回跳的环节。两者的本质相同,只是用户操作路径不同。
5.3 环境切换注意事项
正式上线时,不要把沙箱的配置带到生产环境。我建议在后端维护一个AlipayProperties配置类,通过@ConfigurationProperties读取不同环境的配置。同时在配置文件中增加一个alipay.env字段,日志里输出当前环境,方便排查问题。
alipay: env: prod # sandbox | prod app-id: ${ALIPAY_APP_ID} private-key: ${ALIPAY_PRIVATE_KEY} alipay-public-key: ${ALIPAY_PUBLIC_KEY} gateway: ${ALIPAY_GATEWAY}把密钥放在环境变量或密钥管理服务里,绝对不要直接写在代码或提交到代码仓库。
6. 我的几个额外心得
还有一个容易被忽略的点:下单到支付的过程中,订单金额和商品信息一定要保持只读。如果用户在支付页停留太久,管理员后台把订单改了价,你再来支付时就会出现前端展示金额和实际支付金额不一致的情况。遇到这种问题,后端下单前一定要校验一下当前订单的最新金额,避免脏数据。
二维码扫码支付中,qr_code_timeout_express建议比timeout_express短一点,比如订单30分钟有效,二维码10分钟或者15分钟过期。用户刷新页面后重新获取新码即可,订单在有效期内可以反复生成二维码,不需要重新下单。这个小设计在收银台场景中特别实用。
再补一个小技巧:结果页要做成“支付确认中”和“支付成功”双状态。用户从支付宝回跳到/pay/result时,如果后端异步通知还没来得及更新订单状态,前端可以先把页面展示为“支付确认中”,然后定时器每2秒查一次订单状态,直到查到已支付再切换为成功页。这样既不会让用户以为支付失败,也给后端留出了处理异步通知的时间窗口。我自己项目里是设置最多轮询30秒,超时就提示“支付结果确认中,请稍后在订单列表查看”,并且把订单列表页做成权威展示。
最后再强调一句,无论你用哪种支付方式,前端的展示永远不可靠,后端的异步通知才是订单状态的唯一真相。理解这一点,你的支付模块就成功了一大半。