Node.js+Nuxt3+Vue如何获取支付宝异步回调通知
结论
支付宝的异步通知不能由 Vue 浏览器页面接收,必须交给 Nuxt 3 服务端接口处理。创建订单时,需要把一个可从公网访问的服务端地址作为 notify_url 传给支付宝。支付结果产生后,支付宝会通过 POST 表单请求通知该地址。
服务端收到通知后,应完成以下处理:
- 解析支付宝提交的全部表单参数。
- 使用
alipay-sdk验证签名。 - 核对订单号、金额、商户身份和交易状态。
- 业务处理成功后返回纯文本
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。否则支付宝可能停止重试,而本地订单仍停留在未支付状态。
沙箱环境排查清单
无法收到异步通知时,可以按以下顺序检查:
- 下单请求是否确实传入了
notify_url。 notify_url是否是支付宝服务器能够访问的公网地址。- 路由是否允许外部请求在未登录的情况下发起
POST。 - 代理服务器是否已将
/api/alipay/notify转发到 Nuxt 服务。 - 接口是否被 CSRF、WAF、防火墙或请求体大小限制拦截。
- 沙箱应用 ID、应用私钥和支付宝公钥是否来自同一套沙箱配置。
- 支付账号是否为沙箱买家账号。
- 服务端是否记录了请求时间、
out_trade_no、trade_no和处理结果。 - 接口是否在规定时间内返回了纯文本
success。 - 是否误把同步跳转地址
return_url当作异步通知地址。
调试时可以记录通知字段,但不要把应用私钥、完整的用户隐私数据或其他敏感信息写入日志。最终支付状态应以验签通过、业务字段核对无误的异步通知为准,也可以通过服务端主动查询交易结果来确认。
备注:内容仅供参考。
