微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
支付是小程序商业化的最后一公里,也是出问题最致命的一环——少一个验签步骤就是资损,回调处理不幂等就是对账灾难。这篇以 Node.js 后端为例,把 JSAPI 支付的下单、调起、回调、退款全链路和每个环节的坑讲透。
一、整体链路总览
用户点击"支付"
→ 小程序端: wx.login 换 openid(通常登录时已拿到)
→ 后端: 统一下单 API(携 openid、金额、商户订单号)
→ 微信返回 prepay_id
→ 后端: 用 prepay_id 二次签名,返回支付参数
→ 小程序端: wx.requestPayment 调起支付
→ 用户完成支付
→ 微信异步回调 notify_url(后端验签 + 更新订单)
→ 兜底: 支付结果主动查询(查单)关键认知:支付状态以微信回调(或主动查单)为准,前端 wx.requestPayment 的 success 回调只代表"用户完成了支付操作",不能作为入账依据。
二、后端统一下单
2.1 准备工作
- 商户号 mch_id + API v3 密钥(微信支付商户平台申请)
- 证书私钥(apiclient_key.pem)+ 证书序列号
- 小程序 appid 需与商户号绑定
2.2 下单代码(API v3)
// pay/service.js
const crypto = require('crypto')
const axios = require('axios')
async function createOrder({ orderId, amount, openid, description }) {
const body = {
appid: process.env.WX_APPID,
mchid: process.env.WX_MCHID,
description, // 商品描述
out_trade_no: orderId, // 商户订单号,唯一
notify_url: 'https://api.example.com/pay/notify',
amount: {
total: amount, // 单位:分!不是元!
currency: 'CNY'
},
payer: { openid } // JSAPI 支付必须传 openid
}
const res = await wxpayV3('POST', '/v3/pay/transactions/jsapi', body)
return res.prepay_id
}第一个高频坑:金额单位是分。前端传元后端忘了乘 100,用户 1 分钱买走 99 元商品,事后哭都来不及。建议在 API 层做一层显式转换,并在数据库订单表同时存 amount_yuan 和 amount_fen 方便对账。
2.3 生成小程序支付参数
拿到 prepay_id 后,需要再签一次名才能给前端调起:
function buildPayParams(prepayId) {
const timeStamp = String(Math.floor(Date.now() / 1000))
const nonceStr = crypto.randomBytes(16).toString('hex')
const appId = process.env.WX_APPID
const pkg = `prepay_id=${prepayId}`
// 签名串:appId\ntimeStamp\nnonceStr\npackage\n
const message = `${appId}\n${timeStamp}\n${nonceStr}\n${pkg}\n`
const signature = crypto.createSign('RSA-SHA256')
.update(message)
.sign(fs.readFileSync('./cert/apiclient_key.pem'), 'base64')
return {
timeStamp,
nonceStr,
package: pkg,
signType: 'RSA',
paySign: signature
}
}前端拿到的就是这五个字段,原封不动传给 wx.requestPayment。
三、小程序端调起支付
// 前端
async function pay(orderId) {
// 1. 调后端下单接口拿支付参数
const { timeStamp, nonceStr, package: pkg, signType, paySign } =
await api.createPayOrder(orderId)
// 2. 调起支付
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp, nonceStr,
package: pkg,
signType, paySign,
success: resolve, // 仅代表用户操作完成
fail: reject // ERR_USER_CANCEL 用户取消 / 其他失败
})
})
}用户体验设计:fail 回调里区分 errMsg 含 cancel 的情况——用户主动取消不用弹错误提示,保持静默或轻提示"已取消支付";其他失败才引导重试。
四、回调:验签 + 解密 + 幂等
这是整个支付链路最容易出事故的环节。
4.1 验签
微信回调请求头带 Wechatpay-Signature,必须用微信支付平台证书验签,防止伪造回调:
const { verifySign } = require('./wxpay-verify')
router.post('/pay/notify', express.raw({ type: '*/*' }), async (req, res) => {
const headers = {
timestamp: req.headers['wechatpay-timestamp'],
nonce: req.headers['wechatpay-nonce'],
signature: req.headers['wechatpay-signature'],
serial: req.headers['wechatpay-serial']
}
// 1. 验签(验的是「时间戳\n随机串\n请求体\n」)
const valid = verifySign(headers, req.body.toString())
if (!valid) {
return res.status(401).json({ code: 'FAIL', message: '验签失败' })
}
// ...
})4.2 解密资源对象
回调 body 里的订单信息是 AES-256-GCM 加密的,用 API v3 密钥解密:
function decryptResource(ciphertext, associatedData, nonce) {
const buf = Buffer.from(ciphertext, 'base64')
const authTag = buf.subarray(buf.length - 16)
const data = buf.subbuf(0, buf.length - 16) || buf.subarray(0, buf.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm',
Buffer.from(process.env.WX_V3_KEY),
Buffer.from(nonce))
decipher.setAuthTag(authTag)
decipher.setAAD(Buffer.from(associatedData))
return JSON.parse(
Buffer.concat([decipher.update(data), decipher.final()]).toString()
)
}解密出来的就是订单详情:out_trade_no、transaction_id(微信支付单号)、trade_state(SUCCESS/REFUND/CLOSED...)。
4.3 幂等处理(对账灾难的防火墙)
微信回调会重试多次(网络异常、你返回非 200 都会触发),代码必须幂等:
// 数据库唯一约束 + 状态机
const affected = await db.query(
`UPDATE orders SET
status = 'paid',
transaction_id = ?,
paid_at = NOW()
WHERE order_no = ? AND status = 'unpaid'`, // 关键:只允许 unpaid -> paid
[transaction_id, out_trade_no]
)
if (affected === 0) {
// 已处理过(重复回调)或状态不对,直接返回成功,别让微信重试
return res.json({ code: 'SUCCESS' })
}
// 幂等后再做副作用:发券、发消息、记账……
await fulfillOrder(out_trade_no)返回规范:处理成功返回 HTTP 200 + {"code":"SUCCESS"};失败返回 4xx/5xx,微信会按退避策略重试(15s/15s/30s/3m/10m...最多 24 小时)。
五、查单兜底
回调可能丢失(服务重启、网络抖动)。前端支付成功后主动查一次,未支付订单定时任务轮询:
// 查单接口
async function queryOrder(orderId) {
const res = await wxpayV3('GET',
`/v3/pay/transactions/out-trade-no/${orderId}?mchid=${process.env.WX_MCHID}`)
return res.trade_state // SUCCESS / NOTPAY / CLOSED / REFUND ...
}
// 定时任务:每 5 分钟扫一次 30 分钟前创建还未支付回调的订单
// 连续 N 次 NOTPAY 后主动关单,防止库存被长期锁死关单时机:限时优惠单、库存类订单超时未支付要主动调 /v3/pay/transactions/out-trade-no/{id}/close 关单,否则用户可能 24 小时后还按旧价格支付成功。
六、退款
退款是独立的一套链路,坑比支付还多:
async function refund({ orderId, refundNo, refundAmount, totalAmount, reason }) {
const body = {
out_trade_no: orderId,
out_refund_no: refundNo, // 商户退款单号,唯一!幂等依据
reason,
amount: {
refund: refundAmount, // 分
total: totalAmount, // 原订单总额
currency: 'CNY'
}
}
return wxpayV3('POST', '/v3/refund/domestic/refunds', body)
}退款三原则:
out_refund_no必须唯一且可重复提交(同号重复提交不会二次退款,天然幂等)- 退款回调
/v3/refund/domestic/refunds的 notify 同样要验签、解密、幂等 - 退款状态有中间态(PROCESSING),钱到账有延迟,用户余额退回通常秒级,银行卡 1-3 天——客服话术要提前准备
七、对账
每日下载对账单做三方核对:
// 商户平台每日生成对账单,API 下载
const res = await wxpayV3('GET',
`/v3/bill/fundflowbill?bill_date=20260822&account_type=BASIC`)
// 逐笔核对:本地订单表 vs 微信账单
// 差异场景:本地 paid 但账单没有(掉单)、账单有但本地 unpaid(漏处理回调)对账脚本跑不通的支付系统都是裸奔。建议每日跑一次 + 告警群通知差异单。
八、避坑清单
| 坑 | 后果 | 解法 |
|---|---|---|
| 金额传了元 | 资损 | 下单前统一 ×100,双字段存库 |
| 前端 success 当入账依据 | 掉单假象 | 只信回调/查单 |
| 回调不验签 | 被伪造回调刷单 | 平台证书验签 |
| 回调不幂等 | 重复发货 | 状态机 + affected rows 判断 |
| 回调处理失败只返回 500 | 微信疯狂重试 | 失败入重试队列,先回 200 |
| out_refund_no 随机生成 | 重复退款 | 基于订单号+序号生成 |
| 证书过期没监控 | 全渠道支付瘫痪 | 证书到期前 30 天告警 |
写在最后
支付系统的代码量其实不大,难点全在异常路径:回调丢失、重复通知、用户中途杀进程、退款卡在中间态。设计时把每一步都当成"随时会失败"来写——验签是底线,幂等是纪律,查单是兜底,对账是审计。四层防御做齐,才能安心睡个好觉。
评论 (0)