
说实话接到“PC端接入支付宝支付”这个需求时我一开始是有点轻敌的。想的是不就是调个接口、给个表单、用户扫码或者跳转过去付款吗等真打开支付宝开放平台文档面对alipay.trade.page.pay、alipay.trade.precreate这两个接口和一堆名词时才发现事情没那么简单。尤其是 Vue 前端既要把跳转支付的表单老老实实提交出去又要在扫码支付场景里把后端返回的字符串变成二维码图片还要处理轮询、回调、订单状态同步这一堆事。这篇文章我就把在 Vue 项目里同时落地扫码支付和跳转支付这套完整流程捋一遍包括前后端接口契约怎么定最省心、两种支付方式各自的实现思路、以及我在真实项目里踩过的坑。不管你是刚接手这类需求还是已经在做但被各种边界问题卡住这篇应该都能帮你省下不少排查时间。1. 先分清两个接口page.pay 和 precreate 各自解决什么问题很多人在第一步就搞混了以为 PC 端支付就一个接口搞定。实际上支付宝把 PC 端的支付能力拆成了两个不同场景的接口前端落地的形态天然不同。1.1 跳转支付背后是 alipay.trade.page.pay这个接口叫“电脑网站支付”它的特点就是最终要把用户带到支付宝的收银台页面去完成付款。后端调用这个接口后返回的东西通常是一个表单 HTML 字符串表单里包含所有请求参数和签名action 指向支付宝网关前端拿到后把表单渲染出来并自动 submit浏览器就会跳转到支付宝页面。用户在那个页面里可以扫码付也可以登录支付宝账号付支付完成后支付宝再跳回你指定的 return_url。它适合什么场景呢我个人体会是订单流程比较重、用户有明确的“下完单去付款”这个独立动作的场景。比如电商订单确认页、提交订单后的收银台页面。这时候跳转支付体验最顺畅用户也能在支付宝页面看到详细的订单信息信任感更强。1.2 扫码支付背后是 alipay.trade.precreate这个接口叫“统一收单线下交易预创建”本质是后端先向支付宝发起预下单支付宝返回一个二维码字符串qr_code前端把这个字符串生成二维码图片展示在页面上用户用支付宝 App 扫码完成付款。注意这个接口返回的不是支付链接也不是表单而是纯粹的字符串。你也无法通过它让用户“跳转”到支付宝页面因为它的产品设计目标就是“线下面对面扫码”或“PC 网页展示二维码扫码”。它适合的场景很典型用户在你的页面/终端上看到了一个订单码用手机扫码完成支付支付完成后仍留在当前页面。像网吧充值、自助机点单、网银支付跳转会被用户抵触的场景、以及需要在收银台页面上保持“支付状态实时刷新”的场景扫码支付明显更合适。1.3 业务选型建议我在实际项目里一般这么判断判断维度跳转支付page.pay扫码支付precreate用户操作路径跳转到支付宝页面付完再跳回来全程留在我的页面手机扫码付前端工作量需要处理表单自动提交和跳转需要生成二维码和轮询订单状态支付状态感知依赖支付宝回跳和异步通知依赖轮询和异步通知适合场景独立收银台、订单确认后支付页面内嵌收银台、终端机、充值页弱网/弹窗屏蔽耐受相对较低跳转可能被拦截较高页面还在这俩不是替代关系是互补关系。如果产品只允许选一个我通常更建议扫码支付因为用户不离开你的页面前端可控性更强也不容易被浏览器弹窗拦截策略干扰。但如果你的产品有“订单提交后独立收银台”这个页面那跳转支付的转化路径会更主流。2. 前后端契约设计接口返回什么前端才能少改版这步没想清楚后面前端代码会被迫改好几次。我见过好几种“后端把活全干了”和“后端啥也不干”的极端设计最后前端都很难受。这里说说我觉得最合理的一套接口约定。2.1 两个后端接口职责不能混跳转支付场景后端需要提供一个“创建支付单”接口比如POST /api/pay/page最终返回给前端的就是一个form 表单需要的参数集合而不是所谓的“支付链接”更不是直接返回跳转 URL 让前端 window.location 去跳。因为 page.pay 要求 POST 表单提交如果你拿到的是一个 URL 想用 GET 跳多数情况下是接不通的。扫码支付场景后端提供一个“预下单”接口比如POST /api/pay/qrcode返回的应该是一个qr_code 字符串再加上这个二维码的过期时间、商户订单号等信息。我推荐后端直接返回前端能直接消费的结构不要做模棱两可的封装例如跳转支付统一返回{ code: 200, data: { orderId: PAY20240101001, payParams: { app_id: 2021000123456789, biz_content: {out_trade_no:20240101001,total_amount:100.00,subject:测试商品}, charset: utf-8, method: alipay.trade.page.pay, sign_type: RSA2, timestamp: 2024-01-01 12:00:00, version: 1.0, sign: xxxxx } } }支付参数让后端拼好前端不要自己去构建这些参数。关键点在于签名必须在后端做密钥绝对不能暴露在前端代码里。前端拿到payParams后自己创建表单、填入参数、提交即可。扫码支付接口返回{ code: 200, data: { orderId: PAY20240101001, qrCode: https://qr.alipay.com/xxxxx, expireTime: 1800 } }这样前端的逻辑就非常清晰跳转支付接受参数并触发提交扫码支付接受字符串并渲染二维码。2.2 密钥、网关、签名这些基础概念速成如果你第一次接触支付宝支付有几个名词得先过一遍应用网关与接口网关接口网关是后端调支付宝 OpenAPI 时固定的请求地址不在前端涉及范围内前端不需要关心。应用私钥与支付宝公钥商户用自己的应用私钥对请求参数签名支付宝用支付宝公钥验签。反之支付宝的通知和响应用支付宝私钥签名商户用支付宝公钥验证。这俩都是后端管前端完全不碰。签名机制本质是把所有业务参数按字典序拼接再用 RSA2 算法做签名防止参数被篡改。前端如果擅自改 total_amount 这类参数签名验不过支付宝拒收。沙箱环境支付宝官方提供的测试环境网关地址是openapi-sandbox.alipaydev.com/gateway.do有配套的沙箱应用 AppID、沙箱公私钥以及沙箱买家账号。调通之前别碰正式环境。说到这里得多提一句网上那些“支付宝模拟器 1:1”“测试工具”什么的我建议别用。且不说安全性这类模拟器既不走官方验签流程也模拟不了支付宝复杂的异步通知和状态机测完爽了上生产必出问题。老老实实用支付宝官方的沙箱环境对接流程和正式环境完全一致只是换个网关、换套密钥。2.3 订单号与金额的约定必须提前锁定这是前后端契约里最容易扯皮的部分。商户订单号out_trade_no最好是后端根据业务规则生成的唯一单号前端不要自己拼。金额这里有个大坑支付宝的 total_amount 单位是元支持两位小数但很多后端库喜欢用分存储一旦转换不仔细就会出现 1000 传成 10.00 还是 100.00 的问题。我踩过一次后端用分存储转了字符串之后忘记除以 100用户下单价 10 元的商品跳过去付款详情里显示 1000 元吓得我当场把支付关闭排查。这个事必须后端自己做类型转换和校验前端只负责展示订单号和金额不要参与任何计算。3. 跳转支付的 Vue 实现动态渲染表单并自动提交跳转支付在前端的核心动作就一个用后端返回的参数创建一个表单然后自动提交。但就这么个动作也有好几种写法踩过的坑也不少。3.1 最原生的做法拼接表单并 submit后端返回了payParams我们就在 Vue 组件里动态创建一个form把参数一个个塞成 hidden input然后触发 submit。function submitPayForm(payParams) { const form document.createElement(form) form.style.display none form.method POST form.action https://openapi.alipay.com/gateway.do // 如果后端给的 payParams 里已经有 sign直接循环塞进去就行 Object.keys(payParams).forEach((key) { const input document.createElement(input) input.type hidden input.name key input.value payParams[key] form.appendChild(input) }) document.body.appendChild(form) form.submit() document.body.removeChild(form) }注意几个细节网关地址不要硬编码。开发环境走沙箱网关生产环境走正式网关要支持从后端配置读取否则联调完你很可能忘记切回正式环境。不建议直接用 window.location.href 跳转因为支付宝网关只接受 POST 表单提交的请求用 GET 方式访问大概率报错或验签失败。form 要 append 到 body 再提交否则某些浏览器会忽略没有挂载到文档流的表单提交。提交后不要立刻 remove部分浏览器在 submit 过程中访问不到表单会导致失败。稳妥做法是setTimeout(() form.remove(), 1000)或者干脆不清理页面马上就跳走了。3.2 用 iframe 包裹避免当前页面整个跳走有的产品要求“支付时停留在原页面”这时候可以把表单的target指向一个隐藏 iframe让支付宝收银台在 iframe 里打开。function submitPayFormInIframe(payParams) { const iframe document.createElement(iframe) iframe.id alipayIframe iframe.name alipayIframe // 样式隐藏但不能用 display:none有些浏览器对 display:none 的 iframe 不做渲染 iframe.style.position fixed iframe.style.right 0 iframe.style.bottom 0 iframe.style.width 760px iframe.style.height 600px iframe.style.border 0 document.body.appendChild(iframe) const form document.createElement(form) form.style.display none form.method POST form.action gatewayUrl form.target alipayIframe // ... 同样循环塞 payParams document.body.appendChild(form) form.submit() }这种写法适合那种“订单信息保持在自己的系统里”“支付区域只是局部弹层”的页面。但这里要提醒PC 扫码支付在 iframe 里打开支付宝收银台是没问题的但千万不要试图让 alipay.trade.page.pay 的页面在你站内 iframe 里再套一层你们自己的登录态支付宝页面有自己的安全和会话逻辑你控制不了。3.3 用户取消支付、关闭页面时怎么处理跳转支付一个很容易被忽略的问题是用户去了支付宝页面可能付了一半就关掉了也可能压根没付款就关了支付宝页面。这时支付宝不会产生异步通知订单状态一直卡在“待支付”。所以跳转支付必须配合一个“主动查单”的兜底逻辑。我在项目里的做法是用户从支付宝 return_url 回到本站后前端自动轮询一次订单状态同时页面提供“我已完成支付”的按钮点击后也去查一次单如果还没支付就提示用户稍后在订单列表里继续支付。注意return_url的作用只是通知浏览器“用户回来了”它并不可靠这一点后面第 5 节会详细说。4. 扫码支付的 Vue 实现二维码生成与订单轮询扫码支付前端的两个核心任务把 qr_code 字符串变成能扫的二维码图片在支付完成前持续查单。4.1 二维码组件选型Vue 生态里生成二维码的库不少我实际用下来最顺手的是qrcode这个 npm 包。npm install qrcode它既支持浏览器环境也支持 Node 环境API 稳定生成速度也快。另一个选择是qrcode.vue它是个封装好的 Vue 组件直接传值渲染适合完全不想碰 Canvas 的场景。我比较倾向用qrcode自己写因为可控性强比如可以自定义尺寸、边距、错误修正级别。script setup import { ref, onMounted } from vue import QRCode from qrcode const qrCodeUrl ref() function renderQrCode(text) { QRCode.toDataURL(text, { width: 220, margin: 1, errorCorrectionLevel: M, color: { dark: #000000, light: #ffffff } }).then((url) { qrCodeUrl.value url }) } /script template div classqr-box img :srcqrCodeUrl alt支付宝扫码支付 / /div /template这里为什么要用toDataURL而不是直接在 Canvas 上画因为很多场景下你需要把二维码图片展示在打印的小票上、或者嵌入到页面上传送到其他组件里DataURL 更容易到处复用。如果只是临时展示你也可以保留 Canvas 节点效果一样。4.2 二维码过期与刷新逻辑二维码不是永远有效的支付宝的 qr_code 默认有效期大概是 2 小时左右但产品上通常不会让用户对着一个二维码等那么久。我建议在后端预下单时就设置timeout_express参数比如30m同时把过期时间返回给前端。前端拿到过期时间后开启一个倒计时倒计时归零时自动调后端“重新创建支付单”接口获取新的 qr_code 并重新渲染。这样用户不用手动刷新页面就能获得一个全新的二维码。const expireSeconds ref(1800) const timer setInterval(() { expireSeconds.value-- if (expireSeconds.value 0) { clearInterval(timer) reloadQrCode() // 重新调预下单接口 } }, 1000)有个细节刷新二维码时最好把商户订单号一并更新或者让它保持同一个订单号取决于业务逻辑。如果同一个订单号重复调用 precreate支付宝会直接返回上一次的 qr_code那这个更新就没意义了。正常情况下应该让后端重新生成一个新的订单号旧的订单号标记为已关闭避免用户扫了旧码付了款结果系统不知道往哪个订单上挂。4.3 轮询订单状态的节奏与接口设计扫码支付因为用户不离开页面前端必须自己获取支付结果。轮询是最常见的方案。const pollTimer ref(null) const isPaying ref(false) function startPolling(orderId) { isPaying.value true pollTimer.value setInterval(async () { try { const res await checkOrderStatus(orderId) if (res.data.status PAID) { clearInterval(pollTimer.value) handlePaySuccess(res.data) } else if (res.data.status CLOSED || res.data.status FAILED) { clearInterval(pollTimer.value) handlePayFail(res.data) } } catch (e) { // 单次查询失败不要立刻终止轮询可能是网络抖动 console.error(poll error, e) } }, 3000) }轮询频率我建议3 秒一次就好太频繁没有意义反而会给后端和支付宝带来不必要的压力。也不要完全依赖轮询第 5 节会讲到异步通知才是最终对账的依据轮询只是让用户在前端页面尽快看到结果的一种手段。后端接口一般返回几个状态WAITING_PAYMENT等待支付、PAID已支付、CLOSED已关闭/超时。前端只消费这些状态不要自己判断。轮询还有一个细节用户离开页面时要清掉 pollTimer不然组件卸载了定时器还在跑会不停发请求。建议在onUnmounted里清理。onUnmounted(() { if (pollTimer.value) { clearInterval(pollTimer.value) } })5. 支付结果确认回调、return_url 和轮询三者的配合这一节对经历过线上事故的同事来说应该感触最深支付结果到底以什么为准答案听过很多遍但踩坑的时候还是容易忘。5.1 谁说了算异步通知 全链路轮询 return_url支付宝支付结果的确认有三个渠道异步通知notify_url支付宝服务器在用户支付成功后主动向商户后端 notifica 地址发一个 POST 请求携带支付结果和签名签名验过后可以确认订单已支付。这是唯一能作为改单依据的渠道。return_url用户支付完自动跳回商户页面时携带的参数只用于展示给用户看“支付完成”不能改订单状态。因为支付宝不一定保证 return_url 一定被访问用户主动关掉支付结果页就不跳了而且这个跳转是通过浏览器进行的参数可以被伪造。轮询/主动查单前端轮询后端后端调用支付宝查询接口去确认订单状态。这个可以作为用户前端体验的补充但严格来说也要以支付宝查询接口返回为准不能以自己数据库的状态为准。在项目里明确的规则是前端任何支付成功提示都只是“乐观提示”订单最终入账必须靠后端异步通知。所以扫码支付场景里前端轮询到 PAID 后接下来弹窗提示用户支付成功同时给后端发一个确认请求后端可以再查一次支付宝订单状态做兜底确认防止极端场景下前端拿到错误状态。5.2 前端收到支付成功后的展示策略我实操中的常见做法是轮询到 PAID 或从 return_url 回到页面时只弹一个“支付处理中”的过渡提示再主动查一次后端接口确认后端已经更新了订单状态后再跳转订单详情页。如果后端还没更新异步通知延迟前端不要直接跳转成功页不然会出现用户到了成功页订单实际没入账的问题客诉就是这么来的。更稳妥的交互是支付成功提示 “我已完成支付”按钮 “刷新订单状态”按钮。把主动权交给用户同时也让系统有充分的处理时间。5.3 后端处理异步通知时的几个关键校验点虽然这是后端的事但前端如果有一点概念排查问题会快很多。后端收到通知后至少要校验这四样验签用支付宝公钥验证通知的签名确保消息确实来自支付宝。app_id 校验通知里的 app_id 应该等于自己的应用 AppID。金额校验通知里的 total_amount 要与订单表中的应付金额一致。商户订单号校验out_trade_no 必须是自己系统里真实存在的订单号。我见过一次事故后端同事只做了验签没校验金额结果用户把订单金额改了虽然改了签名会失败但那个场景发生在支付之前前端被改了属性后把订单金额写错了通知回来按错误的金额入账了查单查了半天。后来我把这四步校验写成了后端支付的改单前置检查谁都不许省。6. 这套方案在真实项目里的踩坑记录最后这部分必须单独写都是真金白银换来的经验。如果你按前文的步骤做大部分问题碰不到但这里如果再踩到至少不用再去翻半天的日志了。6.1 金额精度元转分的正确姿势支付宝用元数据库经常用分。转换别用浮点数直接乘除会有精度问题。正确做法是用字符串或 Decimal 处理# Python 后端示例伪代码 def yuan_to_fen(amount): return int(round(Decimal(amount) * 100))更关键的是前端不要在 url 参数里用 unicode 传出金额。我见过一个同事把金额直接拼在 iframe 的 src 里他用了中文逗号支付宝直接报参数格式错误问题定位了一下午。金额永远是数字字符串别用科学计数法别带货币符号。6.2 时间格式时区问题比想象中多支付宝要求 timestamp 格式是yyyy-MM-dd HH:mm:ss而且用的是东八区时间。如果后端服务器时区配的是 UTC你拼出来时间戳直接是错的签名会一直失败。解决思路是后端生成参数时显式指定时区TimeZone.setDefault(TimeZone.getTimeZone(GMT8)); // 或者用带时区的 DateTimeFormatter前端不用处理时间格式但不能对时间做本地化偏移。有些同事喜欢在前端把时间 new Date() 后直接转字符串结果跳转支付表单里的 timestamp 被改了个格式验签直接挂。6.3 重复通知的幂等处理支付宝的异步通知会重试频率是递增的4m、10m、10m、1h、2h、6h、15h。如果后端收到通知后不做幂等处理同一笔订单会被更新两次可能造成重复入账想象一下每次通知你都往余额里加钱。这个虽然是后端责任但前端如果做模拟支付测试会更容易触发这种情况。我建议前端在测试环境模拟成功回调时也顺便测一下接口的幂等性这也算前后端联调的一部分。6.4 表单提交后的加载状态跳转支付如果是通过表单 submit 实现的提交之后页面会进入“白屏等跳转”状态。用户如果网络慢会以为没点成功然后狂点按钮结果同一个订单生成多个支付请求。前端解决办法是提交一次后立刻把按钮置灰文案改成“正在跳转支付宝收银台...”。同时用 sessionStorage 记录提交状态刷新页面后还能恢复置灰防止重复提交。6.5 沙箱环境与模拟器再提一次模拟器这个事。不少人会在本地用一个“支付宝模拟器”来假装支付成功方便前端调试。我不建议这么干原因上面说过它绕过了支付宝的通知验签和状态机你在模拟器上看到的成功和真实支付成功完全不是一回事。正确做法是后端联调时用支付宝沙箱前端用沙箱买家账号支付支付宝也会真实给沙箱网关发异步通知整个链路和线上完全一致。等真要切线上只需要把后端网关、密钥换成正式环境就行前端代码一行都不用改。6.6 安全控件或电脑环境的影响PC 端支付宝收银台有时会检测用户电脑上是否安装了安全控件个别电脑环境会弹窗提示。这个前端没法控制但产品上可以在引导页给个文案“如无法弹出收银台请检查浏览器是否拦截了弹窗”。从开发角度说iframe 方式比整页跳转更少受弹窗拦截影响如果你的用户群用的是顽固的企业浏览器优先用 iframe 方案。写在最后的小建议如果你在看这篇文章的时候刚准备动手我建议你把节奏放成三步第一先对照后端把两个接口的返回字段定死别急着写页面第二用沙箱环境把跳转支付和扫码支付各跑通一遍重点观察支付宝异步通知有没有成功打到后端第三再开始补前端轮询、二维码过期、按钮防重复这些体验层的东西。我现在做这类需求已经完全形成固定流程了后端先出契约前端用沙箱环境打通主链路然后补状态机和异常分支。这套流程让我少踩了很多坑希望这篇也能帮你少走点弯路。