1. 写在前面:为什么我会把两种支付方式都做一遍
去年接了一个管理后台项目,技术栈正好是 Vue + Spring Boot,其中有个需求是支付宝 PC 端支付。一开始我图省事,只做了扫码支付,想着“反正扫码就完事了”。结果客户提了个需求:某些内网环境下,用户手机不在手边,或者扫码框在页面上渲染不出来,必须提供一种“点一下按钮直接跳去支付宝收银台”的兜底方案。
那时候才开始认真把支付宝的两种 PC 支付方式一起研究透:扫码支付(alipay.trade.precreate)和跳转支付(alipay.trade.page.pay)。
这两个名字在后端接口里看起来很相近,但前端的使用体验、对接难度、回调机制完全不是一回事。这篇文章我把整个接入过程、踩过的坑、以及前后端如何配合一次性说清楚。无论你是刚接触支付宝支付的 Vue 新手,还是已经有基础但被“回调验签”折磨过的老手,这篇文章都能给你一个可以直接照抄的答案。
2. 整体设计思路拆解:扫码支付 vs 跳转支付,到底该怎么选
2.1 两种支付方式的本质区别
先看一张对比表,我尽量用人话解释清楚,免得你看完官方文档还是一头雾水。
| 对比维度 | 扫码支付(precreate) | 跳转支付(page.pay) |
|---|---|---|
| 用户操作 | 页面显示二维码,用户用手机支付宝扫码 | 用户点击按钮,浏览器跳转到支付宝收银台页面 |
| 后端接口 | alipay.trade.precreate | alipay.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 class="pay-container"> <div v-if="qrCodeUrl" class="qr-wrapper"> <canvas ref="qrCanvas"></canvas> <p class="tips">请使用支付宝扫码支付</p> <p class="order-info">订单号:{{ orderNo }}</p> <p class="amount">金额:¥ {{ amount }}</p> <el-button type="text" @click="refreshQrCode">二维码失效?点击刷新</el-button> </div> <div v-else class="qr-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() }) </script>4.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 class="pay-buttons"> <el-button type="primary" :loading="submitting" @click="handleJumpPay"> 支付宝支付 </el-button> <el-button v-if="showQrSwitch" @click="switchToQrPay"> 切换为扫码支付 </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,width=1024,height=600') if (!newWindow) { // 浏览器弹窗被拦截,这里做兜底 window.location.href = data.payUrl } } } catch (error) { console.error('创建跳转支付失败', error) } finally { submitting.value = false } } </script>5.2 处理弹窗被拦截的体验问题
上面代码里我专门判断了newWindow是否为空,这是因为浏览器对非用户直接触发的window.open会拦截。点击按钮的回调里调用是允许的,但如果你的创建订单接口用了 async/await,在网络等待期间浏览器会失去“用户手势”的上下文,部分浏览器会判定这不是用户主动触发的弹窗,导致拦截。
一种比较稳妥的处理方案是:先在点击时立刻打开一个空白窗口,拿到 payUrl 后把这个窗口的地址替换掉:
let payWindow = null function handleJumpPay() { // 先打开空白窗口 payWindow = window.open('about:blank', '_blank', 'width=1024,height=600') // 再请求接口 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?orderNo=xxx&result=success。
回到这个页面时,前端要做两件事:
- 从 URL 上拿到订单号和相关参数,展示“正在确认支付结果”。
- 调用后端查询接口,真正从数据库里查出支付状态,再显示最终结果。
不要一看到 return_url 里有result=success就告诉用户支付成功了。因为 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' }) </script>6. 扫码和跳转的切换逻辑:一个组件搞定两种模式
很多项目不会只放一种支付方式,而是“支付方式切换”。我用一个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 或其他后端语言,原理完全相同——前端代码几乎不用改,只需要后端生成对应的支付参数和验签逻辑即可。这也是我把前后端职责划分得这么清楚的原因——支付能力一旦抽象成“创建订单 + 查询状态”两个接口,前端就能彻底摆脱对具体支付服务商的依赖。