微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款

微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款

admin
2026-06-06 / 0 评论 / 266 阅读

微信小程序支付全链路实战: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_yuanamount_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 回调里区分 errMsgcancel 的情况——用户主动取消不用弹错误提示,保持静默或轻提示"已取消支付";其他失败才引导重试。

四、回调:验签 + 解密 + 幂等

这是整个支付链路最容易出事故的环节。

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_notransaction_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)
}

退款三原则

  1. out_refund_no 必须唯一且可重复提交(同号重复提交不会二次退款,天然幂等)
  2. 退款回调 /v3/refund/domestic/refunds 的 notify 同样要验签、解密、幂等
  3. 退款状态有中间态(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

评论 (0)

取消
0:00