Vue+Spring Boot支付宝PC支付:扫码与跳转双模式实战
1. 写在前面为什么我会把两种支付方式都做一遍去年接了一个管理后台项目技术栈正好是 Vue Spring Boot其中有个需求是支付宝 PC 端支付。一开始我图省事只做了扫码支付想着“反正扫码就完事了”。结果客户提了个需求某些内网环境下用户手机不在手边或者扫码框在页面上渲染不出来必须提供一种“点一下按钮直接跳去支付宝收银台”的兜底方案。那时候才开始认真把支付宝的两种 PC 支付方式一起研究透扫码支付alipay.trade.precreate和跳转支付alipay.trade.page.pay。这两个名字在后端接口里看起来很相近但前端的使用体验、对接难度、回调机制完全不是一回事。这篇文章我把整个接入过程、踩过的坑、以及前后端如何配合一次性说清楚。无论你是刚接触支付宝支付的 Vue 新手还是已经有基础但被“回调验签”折磨过的老手这篇文章都能给你一个可以直接照抄的答案。2. 整体设计思路拆解扫码支付 vs 跳转支付到底该怎么选2.1 两种支付方式的本质区别先看一张对比表我尽量用人话解释清楚免得你看完官方文档还是一头雾水。对比维度扫码支付precreate跳转支付page.pay用户操作页面显示二维码用户用手机支付宝扫码用户点击按钮浏览器跳转到支付宝收银台页面后端接口alipay.trade.precreatealipay.trade.page.pay返回内容返回一个二维码字符串qr_code返回一段自动提交表单的 HTML前端处理把 qr_code 交给二维码库渲染成图片新窗口打开/当前页面提交表单适合场景后台管理、PC 网站收银台需要引导用户完成支付的所有 PC 场景回调机制异步通知notify_url 主动查询同步跳转return_url 异步通知notify_url用户感知需要手机配合不需要手机电脑上就能完成我对这个表格的总结是扫码支付适合“人不一定在电脑前但手机一定在手上”的场景跳转支付适合“用户正在电脑前操作”的场景。绝大多数项目里这两者不是二选一而是做一个“扫码为主跳转为兜底”的支付方式切换。2.2 为什么选择“前后端分离 后端统一封装”的方案很多 Vue 新手最容易犯的错误是把支付宝的 SDK 和密钥直接放在前端代码里。这个做法我强烈不建议第一支付宝的应用私钥一旦泄露等于把你的支付权限拱手让人别人可以伪造订单、篡改金额。放在前端代码里就等于公开招标。第二支付宝的签名算法涉及 RSA2虽然前端可以做但会把签名逻辑、验签逻辑、回调处理逻辑全部散落在各端维护成本极高。第三前后端分离的项目支付订单状态需要写进自己的业务数据库这部分逻辑只能放在后端。所以我采用的方案是前端Vue只负责获取支付参数、渲染二维码、发起跳转、处理返回结果。后端Spring Boot 或任意后端语言只负责生成订单、调用支付宝接口、签名、验签、处理异步通知、更新订单状态。这样做的好处是你以后如果要把 Vue 换成 React或者把 Web 端换成 App 端、小程序端后端支付模块完全不用动。2.3 技术选型的几个细节考量二维码渲染我用的是qrcode这个 npm 包它支持 canvas 和 data URL 两种模式体积小无依赖。对比过vue-qrcode组件库qrcode包更可控因为支付场景里经常需要手动处理二维码刷新、失效、遮罩这些 UI 态。请求库项目里已经有 axios所以继续沿用。但支付宝支付接口的响应格式需要统一封装我习惯在后端返回一个标准结构{ code, message, data }前端只管读取 data 里的 payUrl 或 qrCode 字段。弹窗方式跳转支付我用的是window.open()打开新窗口方案而不是当前页跳转。原因很简单——支付宝收银台支付完成后要回到 return_url如果当前页跳走了用户的购物车、页面历史全部丢失。新窗口支付完成关闭窗口回到原页面体验好太多。3. 后端接口准备这些参数前端工程师也必须懂你可能会疑惑这是 Vue 项目的内容为什么我要花一整节讲后端因为支付联调时 80% 的问题出在参数理解不一致上。前端要清楚地知道后端返回什么、自己需要传给后端什么才能把责任边界划清楚。3.1 后端调用扫码支付的核心参数后端调用alipay.trade.precreate核心参数大致如下参数说明备注out_trade_no商户订单号唯一前端无需关心后端生成total_amount订单总金额单位是元两位小数subject订单标题用户扫码后支付宝里看到的标题store_id门店编号可选timeout_express交易超时时间建议传5m表示 5 分钟未支付自动关闭notify_url异步通知地址支付宝服务器回调你的后端接口这里有两个地方容易踩坑一是total_amount 必须是字符串不能传数字。别问为什么问就是支付宝官方 SDK 序列化时数字类型会出现精度问题比如 9.9 变成 9.899999。二是out_trade_no 不能重复。如果同一笔订单号在一天内重复提交支付宝会直接返回错误码ACQ.TRADE_HAS_SUCCESS这个坑让我当时排查了半天。3.2 后端调用跳转支付的核心参数跳转支付alipay.trade.page.pay的参数跟扫码支付基本一致但多了一个return_url参数说明备注return_url同步跳转地址支付完成后浏览器回跳地址前端能感知notify_url异步通知地址后端确认支付结果的唯一可信来源需要特别注意的是return_url 只是“通知浏览器跳转”它不可信。用户在支付宝收银台点完“已完成支付”后浏览器会立刻跳回 return_url但这个跳转并不能保证支付一定成功了。真正的结果要以 notify_url 收到的异步通知为准。这个逻辑对你前端展示“支付成功/失败”有直接影响后面我会专门讲怎么处理。3.3 后端返回给前端的字段设计我后端给前端的响应设计得尽量简单直接扫码支付返回{ code: 200, data: { payType: qr, qrCode: https://qr.alipay.com/... } }跳转支付返回{ code: 200, data: { payType: jump, payUrl: https://openapi.alipay.com/gateway.do?... } }前端拿到这两个字段后分别走渲染二维码和打开新窗口的逻辑。有人可能会问跳转支付的 payUrl 不是返回的是一段自动提交表单的 HTML 吗这里有个技巧如果你在后端没用官方 SDK而是自己拼 form 表单你可以生成一个包含action和input的 HTML 页面也可以把支付宝网关地址和参数拼接成一个可以直接 GET 访问的 URL。推荐后者因为前端处理起来更简单——直接window.open(url)就行。4. Vue 前端实战扫码支付的完整实现4.1 安装二维码依赖项目根目录执行npm install qrcode最好也装一下类型提示npm install --save-dev types/qrcode项目用的是 Vue 3 组合式 API所以下面代码都以script setup语法展示。如果是 Vue 2 项目把ref换成data()里的字段逻辑同样适用。4.2 扫码支付核心代码我先给一个完整的组件代码再做逐段解读template div classpay-container div v-ifqrCodeUrl classqr-wrapper canvas refqrCanvas/canvas p classtips请使用支付宝扫码支付/p p classorder-info订单号{{ orderNo }}/p p classamount金额¥ {{ amount }}/p el-button typetext clickrefreshQrCode二维码失效点击刷新/el-button /div div v-else classqr-loading p正在生成支付二维码.../p /div /div /template script setup import { ref, onMounted, nextTick } from vue import QRCode from qrcode import { createQrPayOrder } from /api/pay const qrCanvas ref(null) const qrCodeUrl ref() const orderNo ref() const amount ref() const timer ref(null) // 生成二维码 async function generateQrCode() { try { const { data } await createQrPayOrder({ orderNo: orderNo.value, amount: amount.value }) qrCodeUrl.value data.qrCode await nextTick() // 渲染二维码到 canvas QRCode.toCanvas(qrCanvas.value, data.qrCode, { width: 220, margin: 2, errorCorrectionLevel: M }) // 启动轮询查询支付结果 startPolling() } catch (error) { console.error(生成二维码失败, error) } } // 轮询支付结果 function startPolling() { stopPolling() timer.value setInterval(async () { const res await checkOrderStatus(orderNo.value) if (res.data.status PAID) { stopPolling() // 支付成功跳转或提示 window.location.href /pay-success } }, 3000) } function stopPolling() { if (timer.value) { clearInterval(timer.value) timer.value null } } function refreshQrCode() { generateQrCode() } onMounted(() { generateQrCode() }) onBeforeUnmount(() { stopPolling() }) /script4.3 几个值得强调的细节为什么用 canvas 而不是 img我自己测试过用QRCode.toDataURL()生成 base64 图片再塞进 img 标签在二维码内容较长时支付宝的 qr_code 有时挺长生成速度明显变慢内存占用也高。而toCanvas是直接在 canvas 上绘制性能好很多。如果你需要把二维码保存或发给用户再考虑 toDataURL 生成图片。轮询时间间隔怎么定默认我写的是 3 秒。这个值不是随便定的因为支付宝异步通知本身有延迟通常 1~3 秒内能到达。轮询太频繁比如 1 秒会白白给后端增加压力轮询太慢比如 10 秒用户体验会差。3 秒算是一个折中。后端对应查询订单状态的接口建议直接查数据库不要再去调支付宝的查询接口否则每 3 秒一次的频率很容易触发支付宝接口频率限制。二维码失效问题。我在代码里加了“二维码失效点击刷新”按钮。这是因为timeout_express我建议后端设为 5 分钟5 分钟后这个二维码扫码会提示“订单已关闭”。与其让用户一个劲儿扫一个永远支付不了的码不如直接提供刷新入口。这里有两个方案定时 5 分钟自动刷新一次或者用户点击后重新请求后端生成新订单。实操中我两个都做了自动刷新逻辑会因为页面停留超时导致后端订单关闭所以还是优先保留手动刷新。5. Vue 前端实战跳转支付的完整实现5.1 从按钮到支付收银台跳转支付的代码比扫码支付简单得多核心就是拿到 payUrl 后打开新窗口template div classpay-buttons el-button typeprimary :loadingsubmitting clickhandleJumpPay 支付宝支付 /el-button el-button v-ifshowQrSwitch clickswitchToQrPay 切换为扫码支付 /el-button /div /template script setup import { ref } from vue import { createPagePayOrder } from /api/pay const submitting ref(false) async function handleJumpPay() { submitting.value true try { const { data } await createPagePayOrder({ orderNo: 订单号, amount: 订单金额 }) if (data.payUrl) { // 打开新窗口跳转支付宝收银台 const newWindow window.open(data.payUrl, _blank, noopener,noreferrer,width1024,height600) if (!newWindow) { // 浏览器弹窗被拦截这里做兜底 window.location.href data.payUrl } } } catch (error) { console.error(创建跳转支付失败, error) } finally { submitting.value false } } /script5.2 处理弹窗被拦截的体验问题上面代码里我专门判断了newWindow是否为空这是因为浏览器对非用户直接触发的window.open会拦截。点击按钮的回调里调用是允许的但如果你的创建订单接口用了 async/await在网络等待期间浏览器会失去“用户手势”的上下文部分浏览器会判定这不是用户主动触发的弹窗导致拦截。一种比较稳妥的处理方案是先在点击时立刻打开一个空白窗口拿到 payUrl 后把这个窗口的地址替换掉let payWindow null function handleJumpPay() { // 先打开空白窗口 payWindow window.open(about:blank, _blank, width1024,height600) // 再请求接口 const { data } await createPagePayOrder({...}) if (payWindow) { payWindow.location.href data.payUrl } else { // 兜底 window.location.href data.payUrl } }这种方式虽然看起来有点绕但能彻底解决弹窗拦截问题。我在实际项目里遇到过一个极端情况用户浏览器装了很多安全插件把about:blank也拦了。最后我在点击事件里改成window.open(, _blank)空字符串打开当前页面自身地址反而能绕过去。这里可以根据你的用户群情况做兼容处理。5.3 支付完回跳后前端怎么处理跳转支付的 return_url 可以由后端指定也可以在前端创建订单时传给后端。我建议回跳地址直接指向 Vue 路由的一个支付结果页比如/pay/result?orderNoxxxresultsuccess。回到这个页面时前端要做两件事从 URL 上拿到订单号和相关参数展示“正在确认支付结果”。调用后端查询接口真正从数据库里查出支付状态再显示最终结果。不要一看到 return_url 里有resultsuccess就告诉用户支付成功了。因为 return_url 是可以被伪造的。我见过有人直接把支付宝回跳里的 sign、timestamp 等参数忽略了只拿out_trade_no去查后端订单状态。这才是正确的姿势。下面是一个回跳页的判断逻辑script setup import { ref, onMounted } from vue import { useRoute } from vue-router import { queryOrderStatus } from /api/pay const route useRoute() const orderStatus ref(CONFIRMING) onMounted(async () { const orderNo route.query.orderNo // 查询后端订单真实状态 const { data } await queryOrderStatus(orderNo) orderStatus.value data.status // PAID | UNPAID | CLOSED }) /script6. 扫码和跳转的切换逻辑一个组件搞定两种模式很多项目不会只放一种支付方式而是“支付方式切换”。我用一个pay-mode变量来控制script setup import { ref } from vue const payMode ref(qr) // qr | jump function switchToJumpPay() { payMode.value jump } function switchToQrPay() { payMode.value qr } /script切换时有个细节从扫码切到跳转或者反过来都需要重新创建一笔对应类型的支付单因为支付宝两种接口的订单号如果一样可能会报ORDER_NOT_EXIST或ORDER_NOT_EXIST之类的错误。我踩过这个坑——同一笔订单先用扫码接口创建用户没扫切到跳转支付后端传了同一个 out_trade_no结果支付宝返回了一个错误支付表单提交不了。后来我的解决方式是在后端创建支付单时给每个支付方式生成独立的out_trade_no格式如订单号 支付方式标识不会冲突。7. 支付结果确认前端轮询 后端回调双保险7.1 前端轮询的设计扫码支付没有 return_url所以必须依赖轮询或者 WebSocket 来获取支付结果。跳转支付虽然有回跳但回跳不可信所以也需要轮询或异步通知来兜底。我团队里的主力方案是前端轮询 后端异步通知双通道后端收到支付宝的异步通知验签成功后更新订单状态。前端不管有没有收到回跳都会在支付中页面定时调用“查询订单状态”接口一旦查到已支付立刻跳转成功页。为什么不用 WebSocket对于一个普通管理后台引入 WebSocket 的成本偏高而且支付完成的即时性要求并没有那么高——晚个两三秒用户是感知不到的。轮询简单可靠够用就好。7.2 回调验签为什么必须放在后端关于支付宝异步通知我再啰嗦一遍支付宝服务器会向你的 notify_url 发送 POST 请求携带一堆参数和 sign。后端必须用支付宝公钥验签确认参数没被篡改。验签通过后后端要检查trade_status是否为TRADE_SUCCESS或TRADE_FINISHED。处理完业务逻辑后后端必须返回字符串success给支付宝否则支付宝会认为通知失败继续重试。前端是不需要也不能参与验签的。但如果你的前端想验证“这个页面是不是真的从支付宝回跳的”你可以把 notify_url 或 return_url 的参数原样传给后端的验签接口让后端帮你验证。这也是很多系统里“确认订单”按钮的实现逻辑。8. 常见问题与排查技巧实录8.1 二维码生成了但扫不出来排查步骤先确认qrCode字段是不是以https://qr.alipay.com/开头。如果后端返回的不是这个域名说明返回内容不对。检查 canvas 渲染尺寸有些环境 canvas 被 CSS 缩放导致模糊扫不出。把 canvas 的 CSS 加上display: block防止 inline 元素底部空隙干扰。临时用QRCode.toString(qrCode, { type: terminal })在控制台打印出二维码字符画手动用手机支付宝扫一下试试。如果字符画能扫出来但 canvas 的扫不出来那就是渲染问题。8.2 跳转支付页面显示“该笔交易不存在”这个错误几乎都是out_trade_no或者trade_no传错了。排查方式看看后端日志里传给支付宝的out_trade_no和前端页面上展示的订单号是否一致。确认是不是跨环境调用了测试环境订单号拿到生产环境去支付。8.3 支付宝异步通知一直失败如果后端日志显示通知收到但支付宝还在重试最常见的原因就是后端没有返回success。记住支付宝要求返回的 body 就是纯文本success不要返回 JSON不要返回 HTML就四个字母。另外检查 notify_url 是否公网可以访问。本地联调时你可以用一些内网穿透工具把本地服务暴露出去但要注意支付宝在通知时会有超时时间限制穿透工具的稳定性会影响通知成功率。我在本地联调时一般用内网穿透工具临时接收回调真正做压测和生产联调时都是部署到服务器环境。8.4 支付成功后订单状态没更新这是老生常谈的问题。排查思路先看支付宝后台的“订单记录”确认这笔交易是不是真的成功了。再看后端异步通知是否收到如果收到验签能不能通过。再看后端更新订单的逻辑是否正常比如更新时用的订单号是否正确。有一个细节容易被忽略out_trade_no在同一个支付宝账号下是全局唯一的。如果你测试环境多次用同一个订单号第二次以后的通知会被支付宝忽略导致你的 never 状态不更新。解决方法是每次生成一个带时间戳的订单号。8.5 常见问题速查表问题现象可能原因解决方案扫码支付二维码不出来后端返回非 qr.alipay.com 链接检查后端是否有异常返回扫码支付二维码扫不出canvas 渲染问题或链接被截断打印字符画测试检查渲染层跳转支付新窗口被拦截弹窗上下文丢失先开空白窗口再替换地址支付成功但页面一直转圈轮询接口未更新订单状态检查后端异步通知是否成功异步通知无限重试后端未返回 success 文本返回纯文本success测试环境订单号冲突out_trade_no 全局唯一加时间戳或流水号9. 几个值得记住的实操心得把两种支付方式完整过一遍后我最深的体会是支付功能真正的难点不在“调通接口”而在“把边界情况想清楚”。比如用户生成了二维码但不扫了怎么办、支付成功后网络断了怎么办、通知延迟了用户反复点支付按钮怎么办——这些才是决定一个支付功能好不好用的关键。我后来养成了一个习惯任何支付订单后端都维护一个完整的订单状态机。订单创建 - 等待支付 - 支付成功 / 支付关闭 / 支付失败。前端不直接改订单状态只负责把用户的操作告诉后端所有的状态流转都发生在后端。这样即使前端逻辑写得再乱后端依然能保证数据最终一致。适配到你的项目里还有一个小技巧值得分享把支付相关的接口单独拆成一个模块比如pay.js里面统一放创建扫码订单、创建跳转订单、查询订单状态、确认回调等函数。这样后续如果对接支付宝手机网站支付、App 支付、小程序支付只需要增加新模块不用动已有的 Vue 组件。如果你正在做类似的管理后台建议先在支付宝开放平台的“沙箱环境”里把所有流程跑通一遍。沙箱环境和正式环境接口一致只是需要单独下载沙箱版支付宝 App再用沙箱账号登录唯一要注意的是沙箱环境的密钥和正式环境不能混用。初始联调就用沙箱能省下大量真钱测试成本。最后再留个扩展空间如果你们项目用的不是 Spring Boot而是 Node.js、Python 或其他后端语言原理完全相同——前端代码几乎不用改只需要后端生成对应的支付参数和验签逻辑即可。这也是我把前后端职责划分得这么清楚的原因——支付能力一旦抽象成“创建订单 查询状态”两个接口前端就能彻底摆脱对具体支付服务商的依赖。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →