PAYATHON 2026

Node.js+Nuxt3+Vue如何获取支付宝异步回调通知

支付阿杰

结论

支付宝的异步通知不能由 Vue 浏览器页面接收,必须交给 Nuxt 3 服务端接口处理。创建订单时,需要把一个可从公网访问的服务端地址作为 notify_url 传给支付宝。支付结果产生后,支付宝会通过 POST 表单请求通知该地址。

服务端收到通知后,应完成以下处理:

  1. 解析支付宝提交的全部表单参数。
  2. 使用 alipay-sdk 验证签名。
  3. 核对订单号、金额、商户身份和交易状态。
  4. 业务处理成功后返回纯文本 success。

收到异步通知,只能说明支付宝推送了一份交易结果。服务端仍须验签并核对订单,不能把前端跳转页面作为支付成功的依据。

为什么前端页面收不到异步通知

支付宝异步通知是支付宝服务器发给商户服务器的 HTTP 请求,不经过用户浏览器。即使用户付款后直接关闭页面,支付宝仍可请求 notify_url。

以下地址不能用作有效的异步通知地址:

  • Vue 路由地址
  • 带 # 的前端页面地址
  • localhost 或局域网地址
  • 只能在登录后访问的接口
  • 会被防火墙、鉴权中间件或 CSRF 校验拦截的接口

在 Nuxt 3 中,可以通过 Nitro Server Route 创建通知接口,例如:

server/api/alipay/notify.post.ts

部署后的地址通常是:

https://example.com/api/alipay/notify

沙箱环境也要求支付宝服务器能够访问该地址。本地开发时,需要通过安全的内网穿透服务将接口开放到公网。

配置支付宝 SDK

密钥和应用配置应保存在服务端环境变量中,不要通过 runtimeConfig.public 暴露给 Vue 客户端。

// server/utils/alipay.ts
import AlipaySdk from 'alipay-sdk'

export const alipaySdk = new AlipaySdk({
  appId: process.env.ALIPAY_APP_ID!,
  privateKey: process.env.ALIPAY_APP_PRIVATE_KEY!,
  alipayPublicKey: process.env.ALIPAY_PUBLIC_KEY!,
  gateway: process.env.ALIPAY_GATEWAY!
})

沙箱网关地址应以支付宝开放平台沙箱控制台当前提供的配置为准,可以将其写入环境变量:

ALIPAY_APP_ID=你的沙箱应用ID
ALIPAY_APP_PRIVATE_KEY="你的应用私钥"
ALIPAY_PUBLIC_KEY="沙箱支付宝公钥"
ALIPAY_GATEWAY=沙箱控制台提供的网关地址
ALIPAY_SELLER_ID=你的沙箱卖家ID

这里的 ALIPAY_PUBLIC_KEY 指支付宝公钥,不是应用公钥,也不是应用私钥对应的公钥。沙箱验签失败时,公钥配置错误是常见原因之一。

通过环境变量保存密钥时,还要确认 PEM 格式和换行符没有损坏。某些部署平台会把换行符保存成 \n,此时可能需要转换:

const privateKey = process.env.ALIPAY_APP_PRIVATE_KEY?.replace(/\\n/g, '\n')
const alipayPublicKey = process.env.ALIPAY_PUBLIC_KEY?.replace(/\\n/g, '\n')

下单时设置 notify_url

调用支付接口创建订单时,需要在服务端传入异步通知地址。不同版本的 alipay-sdk 可能采用不同的参数结构和方法名称,但支付宝请求参数中必须包含 notify_url。

示例:

const result = await alipaySdk.exec('alipay.trade.page.pay', {
  bizContent: {
    out_trade_no: orderNo,
    total_amount: '99.00',
    subject: '测试订单',
    product_code: 'FAST_INSTANT_TRADE_PAY'
  },
  notifyUrl: 'https://example.com/api/alipay/notify',
  returnUrl: 'https://example.com/payment/result'
})

notifyUrl 用于服务端异步通知,returnUrl 用于付款完成后的浏览器跳转,两者用途不同。只配置 returnUrl 无法接收异步通知。

部分 SDK 版本或调用方式可能要求使用 notify_url。具体字段名称应以项目实际安装版本的 alipay-sdk 类型定义和文档为准,不能根据前端参数的命名方式推断。

在 Nuxt 3 中接收异步通知

支付宝通常以 application/x-www-form-urlencoded 格式提交数据。Nuxt 3 服务端接口可以先读取请求体,再调用 SDK 验签。

// server/api/alipay/notify.post.ts
import { readBody, setHeader } from 'h3'
import { alipaySdk } from '~/server/utils/alipay'

export default defineEventHandler(async (event) => {
  setHeader(event, 'content-type', 'text/plain; charset=utf-8')

  try {
    const body = await readBody<Record<string, string>>(event)

    if (!body || Object.keys(body).length === 0) {
      return 'failure'
    }

    const verified = alipaySdk.checkNotifySign(body)

    if (!verified) {
      console.error('支付宝异步通知验签失败')
      return 'failure'
    }

    const {
      app_id,
      seller_id,
      out_trade_no,
      trade_no,
      trade_status,
      total_amount
    } = body

    if (!out_trade_no || !trade_no) {
      return 'failure'
    }

    // 1. 根据 out_trade_no 查询本地订单
    const order = await findOrderByOrderNo(out_trade_no)

    if (!order) {
      console.error('支付宝通知对应的本地订单不存在', out_trade_no)
      return 'failure'
    }

    // 2. 核对通知是否属于当前应用和卖家
    if (app_id !== process.env.ALIPAY_APP_ID) {
      console.error('支付宝通知 app_id 不匹配')
      return 'failure'
    }

    if (
      process.env.ALIPAY_SELLER_ID &&
      seller_id !== process.env.ALIPAY_SELLER_ID
    ) {
      console.error('支付宝通知 seller_id 不匹配')
      return 'failure'
    }

    // 3. 金额应使用十进制定点方式比较,避免浮点误差
    if (toCent(total_amount) !== toCent(order.totalAmount)) {
      console.error('支付宝通知金额与本地订单不一致')
      return 'failure'
    }

    // 4. 只处理明确的成功状态
    if (
      trade_status !== 'TRADE_SUCCESS' &&
      trade_status !== 'TRADE_FINISHED'
    ) {
      return 'success'
    }

    // 5. 保证幂等:同一订单重复通知时不能重复入账
    if (order.status !== 'PAID') {
      await markOrderAsPaid({
        orderNo: out_trade_no,
        alipayTradeNo: trade_no,
        paidAmount: total_amount,
        notifyData: body
      })
    }

    return 'success'
  } catch (error) {
    console.error('处理支付宝异步通知时发生异常', error)
    return 'failure'
  }
})

function toCent(amount: string | number): number {
  const value = String(amount)
  const [integerPart, decimalPart = ''] = value.split('.')
  const decimals = `${decimalPart}00`.slice(0, 2)

  return Number(integerPart) * 100 + Number(decimals)
}

// 以下函数需要替换为项目自己的数据库实现
async function findOrderByOrderNo(orderNo: string) {
  throw new Error('请实现订单查询逻辑')
}

async function markOrderAsPaid(data: {
  orderNo: string
  alipayTradeNo: string
  paidAmount: string
  notifyData: Record<string, string>
}) {
  throw new Error('请实现订单更新逻辑')
}

示例中的数据库函数只是占位内容,必须替换成项目实际使用的订单查询和更新代码。生产环境中,最好把订单状态判断和更新放进同一个数据库事务,并通过唯一约束或条件更新保证幂等。

SDK 返回 Promise 时的处理

不同版本的 alipay-sdk 在方法签名和 TypeScript 类型上可能有所不同。如果项目当前使用的 checkNotifySign() 返回 Promise<boolean>,需要写成:

const verified = await alipaySdk.checkNotifySign(body)

具体写法应以项目安装版本的类型定义为准。不要为了消除 TypeScript 报错而直接使用 as any,否则可能误把尚未等待的 Promise 当作验签成功。

验签时不要修改通知参数

签名校验必须使用支付宝提交的原始字段和值。调用 checkNotifySign() 前,不要进行以下处理:

  • 删除看似无关的字段
  • 修改字段大小写
  • 格式化 total_amount
  • 把空字符串转换为 null
  • 对字段再次执行 decodeURIComponent()
  • 错误转换或重复解码 +
  • 重新拼接一个字段不完整的参数对象

如果当前运行环境中的 readBody() 无法正确解析表单,可以显式读取原始请求体:

import { readRawBody } from 'h3'

export default defineEventHandler(async (event) => {
  const rawBody = await readRawBody(event, 'utf8')

  if (!rawBody) {
    return 'failure'
  }

  const params = Object.fromEntries(new URLSearchParams(rawBody))
  const verified = alipaySdk.checkNotifySign(params)

  if (!verified) {
    return 'failure'
  }

  // 继续核对订单并处理业务
  return 'success'
})

同一个请求体只能可靠地读取一次。不要先调用 readBody(),再调用 readRawBody()。

必须核对的业务字段

验签成功只能证明通知使用了对应支付宝私钥签名,并不意味着服务端可以直接更新任意订单。至少还要核对以下字段:

  • app_id:是否为当前沙箱应用
  • seller_id:是否为当前收款账号
  • out_trade_no:是否存在对应的本地订单
  • total_amount:是否与订单应付金额一致
  • trade_status:是否为可接受的支付成功状态
  • trade_no:是否已经绑定到其他订单

不要使用下面这种浮点数方式比较金额:

Number(total_amount) === Number(order.totalAmount)

可以先把货币金额转换为“分”再比较,也可以使用数据库的 DECIMAL 类型或可靠的十进制计算库。

为什么必须做幂等处理

支付宝可能多次发送同一笔交易的异步通知,常见情况包括:

  • 服务端已经处理成功,但返回的内容不是精确的 success
  • 网络中断,支付宝没有收到响应
  • 接口处理时间过长
  • 支付宝按照通知策略再次发送

因此,回调逻辑不能每收到一次通知就执行“增加余额”“发放权益”或“扣减库存”。处理前应检查订单状态,并使用事务或条件更新,确保同一订单只会完成一次。

常见的条件更新方式如下:

UPDATE orders
SET
  status = 'PAID',
  alipay_trade_no = ?,
  paid_at = CURRENT_TIMESTAMP
WHERE order_no = ?
  AND status = 'PENDING';

执行后,可以根据受影响的行数判断这次请求是否真正改变了订单状态。

返回值要求

业务处理成功后,接口应返回以下纯文本:

success

下面这些响应可能不会被支付宝视为处理成功:

{"success":true}
<p>success</p>
支付成功

如果验签失败、订单不存在、金额不一致或数据库处理异常,应返回:

failure

业务数据尚未写入数据库时,不要提前返回 success。否则支付宝可能停止重试,而本地订单仍停留在未支付状态。

沙箱环境排查清单

无法收到异步通知时,可以按以下顺序检查:

  1. 下单请求是否确实传入了 notify_url。
  2. notify_url 是否是支付宝服务器能够访问的公网地址。
  3. 路由是否允许外部请求在未登录的情况下发起 POST。
  4. 代理服务器是否已将 /api/alipay/notify 转发到 Nuxt 服务。
  5. 接口是否被 CSRF、WAF、防火墙或请求体大小限制拦截。
  6. 沙箱应用 ID、应用私钥和支付宝公钥是否来自同一套沙箱配置。
  7. 支付账号是否为沙箱买家账号。
  8. 服务端是否记录了请求时间、out_trade_no、trade_no 和处理结果。
  9. 接口是否在规定时间内返回了纯文本 success。
  10. 是否误把同步跳转地址 return_url 当作异步通知地址。

调试时可以记录通知字段,但不要把应用私钥、完整的用户隐私数据或其他敏感信息写入日志。最终支付状态应以验签通过、业务字段核对无误的异步通知为准,也可以通过服务端主动查询交易结果来确认。

备注:内容仅供参考。