PAYATHON 2026

Vue H5 页面如何实现支付宝扫码跳转及支付前用户判断?

支付阿杰

明确结论

这套流程可以拆成四个环节:

  1. 二维码携带业务场景参数,并打开 H5 页面。
  2. H5 页面打开后,通过支付宝 OAuth 或已有登录态识别用户。
  3. 用户资格、订单状态和支付金额等判断都由服务端完成。
  4. 服务端创建支付宝交易,Vue 页面调用支付宝支付能力。最终支付结果以服务端收到的异步通知为准。

不要把支付链接直接放进二维码,也不要在 Vue 前端保存支付宝私钥、计算签名或决定支付金额。前端传来的用户信息和金额都不能直接采信。

推荐流程

假设二维码内容是:

https://m.example.com/pay-entry?scene=STORE_1001

scene 用于标识门店、设备、活动或其他业务场景。实际项目中,最好改用随机且可校验的短期令牌,避免在二维码中直接暴露内部编号和敏感信息。

完整流程如下:

支付宝扫码
  → 打开 Vue H5 页面
  → 服务端校验二维码场景
  → 判断是否已有可信用户身份
  → 必要时进行支付宝 OAuth 授权
  → 服务端判断用户是否允许支付
  → 服务端生成业务订单
  → 创建支付宝交易
  → H5 调起支付宝支付
  → 服务端接收异步支付通知
  → H5 查询订单最终状态

第一步:进入页面后获取业务场景

Vue 页面只读取二维码携带的场景参数,再将参数交给服务端校验。

import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'

export default {
  setup() {
    const route = useRoute()
    const loading = ref(true)
    const message = ref('')

    onMounted(async () => {
      const scene = String(route.query.scene || '')

      if (!scene) {
        message.value = '二维码参数无效'
        loading.value = false
        return
      }

      await initialize(scene)
    })

    async function initialize(scene) {
      // 后续调用服务端初始化接口
    }

    return {
      loading,
      message
    }
  }
}

服务端收到 scene 后,需要检查:

  • 场景是否存在并已启用;
  • 二维码是否过期;
  • 当前场景是否允许创建订单;
  • 参数是否被伪造或重复使用;
  • 是否需要识别支付宝用户。

如果二维码有使用次数或有效期限制,可以把二维码内容设计成由服务端签发的随机令牌,例如:

https://m.example.com/pay-entry?token=随机不可预测的短期令牌

服务端通过令牌查询真实的业务场景,不要让前端自行解析或信任其中的业务数据。

第二步:识别支付宝用户

如果业务需要确认当前支付宝用户的身份,通常要通过支付宝 OAuth 获取用户标识。仅凭 User-Agent 判断用户是否来自支付宝并不可靠,因为 User-Agent 可以伪造,也无法代表具体的用户身份。

常见的授权入口形式如下:

https://openauth.alipay.com/oauth2/publicAppAuthorize.htm
  ?app_id=你的应用ID
  &scope=auth_user
  &redirect_uri=URL编码后的回调地址
  &state=一次性随机值

具体可用的授权范围、应用类型和配置要求,需要以项目接入时的支付宝开放平台文档及应用权限为准。

授权地址建议由服务端生成:

// Node.js / Express 示例,省略数据库和异常处理中间件
import crypto from 'node:crypto'
import express from 'express'

const app = express()

app.get('/api/alipay/authorize-url', async (req, res) => {
  const scene = String(req.query.scene || '')

  if (!scene) {
    return res.status(400).json({ message: '缺少 scene' })
  }

  const state = crypto.randomBytes(24).toString('hex')

  // 应将 state、scene、过期时间绑定保存到 Session、Redis 或数据库
  await saveOAuthState({
    state,
    scene,
    expiresAt: Date.now() + 5 * 60 * 1000
  })

  const callbackUrl =
    `https://m.example.com/api/alipay/oauth/callback`

  const authorizeUrl =
    'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm' +
    `?app_id=${encodeURIComponent(process.env.ALIPAY_APP_ID)}` +
    '&scope=auth_user' +
    `&redirect_uri=${encodeURIComponent(callbackUrl)}` +
    `&state=${encodeURIComponent(state)}`

  res.json({ authorizeUrl })
})

Vue 页面收到授权地址后跳转:

async function startAuthorization(scene) {
  const response = await fetch(
    `/api/alipay/authorize-url?scene=${encodeURIComponent(scene)}`,
    {
      credentials: 'include'
    }
  )

  if (!response.ok) {
    throw new Error('获取授权地址失败')
  }

  const data = await response.json()
  window.location.replace(data.authorizeUrl)
}

支付宝回调服务端时,一般会携带 auth_code 和 state。服务端先验证 state,再用 auth_code 调用 OAuth 换取接口,取得支付宝用户标识。

app.get('/api/alipay/oauth/callback', async (req, res) => {
  const authCode = String(req.query.auth_code || '')
  const state = String(req.query.state || '')

  const stateRecord = await consumeOAuthState(state)

  if (!stateRecord || stateRecord.expiresAt < Date.now()) {
    return res.status(400).send('授权状态无效或已过期')
  }

  if (!authCode) {
    return res.status(400).send('未取得授权码')
  }

  // 使用支付宝服务端 SDK 调用 alipay.system.oauth.token
  const tokenResult = await exchangeAlipayAuthCode(authCode)

  // 将支付宝 user_id 与站内用户或临时会话建立绑定
  await saveUserSession({
    alipayUserId: tokenResult.user_id
  })

  const target =
    `https://m.example.com/pay-entry?scene=${encodeURIComponent(stateRecord.scene)}`

  res.redirect(target)
})

auth_code 必须由服务端兑换,不要通过浏览器调用支付宝开放接口。应用私钥也只能存放在服务端。

第三步:在服务端完成用户判断

取得可信的用户身份后,Vue 页面调用初始化接口:

async function initialize(scene) {
  const response = await fetch('/api/payment/context', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    credentials: 'include',
    body: JSON.stringify({ scene })
  })

  const result = await response.json()

  if (result.action === 'AUTHORIZE') {
    await startAuthorization(scene)
    return
  }

  if (result.action === 'REJECT') {
    message.value = result.message || '当前用户不能参与'
    loading.value = false
    return
  }

  if (result.action === 'PAY') {
    loading.value = false
    // 展示由服务端返回的商品和金额,等待用户确认
  }
}

服务端可以在这个接口中判断:

  • 用户是否已经注册或绑定;
  • 用户是否在允许参与的范围内;
  • 用户是否已经购买或支付;
  • 用户是否达到限购次数;
  • 订单是否已经存在;
  • 活动是否已经开始或结束;
  • 场景、商品和库存是否有效;
  • 用户是否受到风控限制。

这些判断必须由服务端完成。前端隐藏支付按钮只能改善页面交互,不能作为安全措施。

第四步:创建业务订单和支付宝交易

用户确认付款后,前端只提交场景或商品标识:

async function createPayment(scene) {
  const response = await fetch('/api/payment/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    credentials: 'include',
    body: JSON.stringify({ scene })
  })

  const result = await response.json()

  if (!response.ok) {
    throw new Error(result.message || '创建订单失败')
  }

  return result
}

服务端需要重新校验用户资格,并按照数据库中的商品配置计算金额:

app.post('/api/payment/orders', async (req, res) => {
  const user = await requireCurrentUser(req)
  const scene = String(req.body.scene || '')

  const context = await loadPaymentContext(scene)

  if (!context || !context.enabled) {
    return res.status(400).json({ message: '支付场景无效' })
  }

  const eligibility = await checkUserEligibility(user, context)

  if (!eligibility.allowed) {
    return res.status(403).json({ message: eligibility.reason })
  }

  // 金额必须来自服务端配置,不能采用前端传入的金额
  const amount = context.price

  // outTradeNo 必须全局唯一;重复请求应返回同一未支付订单
  const order = await findOrCreatePendingOrder({
    userId: user.id,
    scene,
    amount
  })

  // 通过支付宝服务端 SDK 调用 alipay.trade.create
  const alipayTrade = await createAlipayTrade({
    outTradeNo: order.outTradeNo,
    totalAmount: amount,
    subject: context.subject,
    buyerId: user.alipayUserId
  })

  await saveAlipayTradeNo(order.id, alipayTrade.trade_no)

  res.json({
    orderNo: order.outTradeNo,
    tradeNo: alipayTrade.trade_no
  })
})

订单幂等性需要特别处理。即使用户连续点击按钮、刷新页面或因网络问题重试,服务端也不能生成多笔有效订单。

第五步:在支付宝客户端内调起支付

如果使用支付宝客户端内的 JSAPI 支付,可以等支付宝 JSBridge 准备完成后调用 tradePay。其中的 tradeNO 必须来自服务端创建交易后的返回结果。

function readyAlipayBridge() {
  return new Promise((resolve, reject) => {
    if (window.AlipayJSBridge) {
      resolve(window.AlipayJSBridge)
      return
    }

    const timer = window.setTimeout(() => {
      reject(new Error('支付宝支付组件加载超时'))
    }, 10000)

    document.addEventListener(
      'AlipayJSBridgeReady',
      () => {
        window.clearTimeout(timer)
        resolve(window.AlipayJSBridge)
      },
      { once: true }
    )
  })
}

async function pay(scene) {
  const payment = await createPayment(scene)
  const bridge = await readyAlipayBridge()

  bridge.call(
    'tradePay',
    {
      tradeNO: payment.tradeNo
    },
    async result => {
      // resultStatus 只适合决定页面提示,不能作为最终入账依据
      if (result.resultCode === '9000' || result.resultStatus === '9000') {
        await pollOrderStatus(payment.orderNo)
      } else {
        message.value = '支付未完成,可稍后重新查询订单'
      }
    }
  )
}

不同的支付宝接入方案可能返回不同字段。实际使用哪些字段,要以当前采用的 JSAPI、SDK 和开放平台文档为准,不能假定 resultCode 与 resultStatus 一定同时存在。

如果不使用支付宝客户端内的 JSAPI,也可以由服务端调用 alipay.trade.wap.pay,再把生成的支付表单或跳转内容返回给浏览器。两种方案选择一种即可:

  • 已在支付宝客户端内并取得用户标识时,通常更适合使用交易创建接口配合 tradePay。
  • 如果普通移动浏览器也需要付款,可以考虑手机网站支付 alipay.trade.wap.pay。

最终能使用哪种能力,取决于应用签约的产品、应用类型和已经开通的权限。

支付结果必须由服务端确认

支付控件返回成功或同步跳回成功页,都不等于付款已经成功。最终状态必须以支付宝异步通知或服务端主动查询到的结果为准。

处理异步通知时,至少需要完成以下工作:

  1. 使用支付宝公钥验签;
  2. 校验通知中的应用 ID;
  3. 校验商户订单号;
  4. 校验订单金额和币种;
  5. 校验收款方身份;
  6. 判断交易状态;
  7. 以幂等方式更新订单;
  8. 按支付宝要求返回通知响应。

前端可以轮询自己的订单查询接口:

async function pollOrderStatus(orderNo) {
  for (let count = 0; count < 10; count += 1) {
    const response = await fetch(
      `/api/payment/orders/${encodeURIComponent(orderNo)}`,
      {
        credentials: 'include'
      }
    )

    const order = await response.json()

    if (order.status === 'PAID') {
      window.location.replace(`/payment/success?orderNo=${encodeURIComponent(orderNo)}`)
      return
    }

    if (order.status === 'CLOSED' || order.status === 'FAILED') {
      message.value = '订单未支付成功'
      return
    }

    await new Promise(resolve => window.setTimeout(resolve, 1500))
  }

  message.value = '支付结果确认中,请稍后查看订单'
}

订单查询接口还必须校验订单归属,不能让任何人仅凭订单号就查询订单。

容易忽略的事项

OAuth 回跳循环

页面不能每次加载都直接跳转到授权页。应先向服务端查询当前会话是否已经绑定支付宝用户,只有缺少用户身份时才进入 OAuth,否则很容易出现无限重定向。

Vue Router 模式

OAuth 回调地址通常要指向服务端的真实 URL。服务端建立会话后,再重定向到 Vue 页面。如果使用 History 模式,Web 服务器还要配置 SPA fallback,否则用户刷新页面时可能遇到 404。

二维码参数安全

二维码中的参数可以被复制和修改。服务端必须校验有效期、状态、签名或随机令牌,不能只根据 scene=STORE_1001 创建订单。

会话与跨域

如果 H5 和 API 使用不同域名,需要正确配置 Cookie 的 SameSite、Secure、CORS 和 credentials。让 H5 与 API 位于同一站点下,配置会更简单。

重复支付

创建订单接口、支付宝异步通知和业务发货逻辑都需要实现幂等。支付请求期间也应禁用支付按钮,但前端防止重复点击不能替代服务端幂等。

金额与资格二次校验

页面初始化时判断通过,不代表用户创建订单时仍然符合条件。用户点击支付后,服务端必须再次检查资格、库存、活动时间和订单状态。

整个流程中,Vue 只负责页面交互和调起支付。用户身份、业务资格、订单金额、签名、交易创建以及支付结果确认都应放在服务端。这样可以实现扫码进入后先判断用户再付款,也能避免前端参数被篡改后造成越权或金额错误。

备注:内容仅供参考。