支付链路的全貌:谁在和谁说话

微信支付的接入之所以让人觉得复杂,是因为它不是一次接口调用,而是一条跨越三方的链路:小程序(用户操作)、你的服务端(下单与验签)、微信支付(资金与状态)。小程序端永远不参与签名,服务端永远不直接接触用户的支付密码,两者通过 prepay_id 和支付回调串起来。

支付的本质是一条订单状态机:下单 → 用户支付 → 异步回调 → 对账 → (可能)退款。每一步都必须能用同一个商户订单号对得上账,任何一步只靠“前端告诉我成功了”都是不可靠的。
微信支付接入配图:支付流程路径、卡片与安全盾牌锁
支付是一条订单状态机:下单、支付、回调、对账、退款,每一步都要能用商户订单号对得上账

因此判断“支付是否成功”的唯一依据是服务端收到并验签通过的回调,或者是服务端主动调用查单接口得到的明确结果。前端页面的 success 回调只代表“用户完成了授权动作”,它可能因为网络问题永远到不了你的服务端。

接入前的准备:资质、密钥与证书

先完成这些前置动作,否则后面每一步都会卡在权限上。

  • 商户号与 AppID 绑定:小程序需要和商户号在同一主体下完成绑定,并进行关联确认。未绑定时下单接口会直接报 APPID_MCHID_NOT_MATCH。
  • APIv3 密钥:在商户平台自行设置的一串 32 位密钥,用于解密回调通知中的敏感数据,设置后不可查看,只能重置。
  • 商户 API 证书:包含商户私钥(apiclient_key.pem)和证书序列号,用于给请求签名。私钥只能本地保存,绝不能写进小程序代码或前端仓库。
  • 平台证书:微信支付侧的证书,用于验证回调与接口应答的真实性。它会被微信不定期轮换,因此必须通过 /v3/certificates 拉取并缓存,而不是一次性拷贝进代码。
密钥放置原则
  • 私钥只在服务端小程序端只负责调起收银台,它拿不到也不该拿到任何签名密钥。
  • APIv3 密钥只用于解密它不参与签名,很多“回调解密失败”的根源是把它和商户私钥搞混了。
  • 环境隔离测试建议使用沙箱或小额真实支付,密钥分环境存放,避免开发误触发真实资金流转。

下单:服务端签名与 JSAPI 下单

JSAPI 下单接口要求把请求方法、URL、时间戳、随机串、请求体拼成签名串,用商户私钥做 SHA256-RSA 签名,再把商户号、序列号、签名等信息放入 Authorization 请求头。

// server/pay.js —— Node.js(需安装 Node 自带的 crypto,无需第三方 SDK)
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");

const MCHID = "1900000001";
const SERIAL = "4E2A9C1D0F7B3A5E6C8D2B4A1F0E9D7C6B5A4938";  // 商户证书序列号
const PRIVATE_KEY = fs.readFileSync("./cert/apiclient_key.pem", "utf8");

function sign(message) {
  return crypto.createSign("RSA-SHA256").update(message).sign(PRIVATE_KEY, "base64");
}

async function jsapiOrder({ outTradeNo, totalFee, openid, description }) {
  const urlPath = "/v3/pay/transactions/jsapi";
  const body = JSON.stringify({
    appid: "wx1234567890abcdef",
    mchid: MCHID,
    description,
    out_trade_no: outTradeNo,        // 商户订单号,必须全局唯一
    notify_url: "https://api.example.com/pay/notify",
    amount: { total: totalFee, currency: "CNY" },  // 单位:分
    payer: { openid }
  });
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString("hex");
  const signature = sign(`POST\n${urlPath}\n${timestamp}\n${nonce}\n${body}\n`);

  const auth = `WECHATPAY2-SHA256-RSA2048 mchid="${MCHID}",nonce_str="${nonce}",` +
               `signature="${signature}",timestamp="${timestamp}",serial_no="${SERIAL}"`;
  return request("POST", urlPath, auth, body);   // 返回 { prepay_id: "..." }
}

签名串的拼接顺序与换行是最容易出错的地方:五行依次是请求方法、URL(带 query 的完整路径)、时间戳、随机串、请求体,最后还有一个换行符。少一个 \n、或者对 body 做了美化序列化,都会得到 SIGN_ERROR。

小程序端调起支付

拿到 prepay_id 后,服务端再用商户私钥对“小程序调起支付的签名串”做一次签名,把结果返回给小程序。小程序端只做一件事:带着这几个参数调用 wx.requestPayment。

// 小程序端:先向后端要支付参数,再拉起收银台
async function pay(orderId) {
  const { data } = await wx.request({
    url: "https://api.example.com/pay/prepay",
    method: "POST",
    data: { orderId }
  });

  wx.requestPayment({
    timeStamp: data.timeStamp,     // 字符串形式的秒级时间戳
    nonceStr:  data.nonceStr,
    package:   data.package,       // 形如 prepay_id=wx201410272009395522657a690389285100
    signType:  "RSA",
    paySign:   data.paySign,
    success: () => {
      // 仅仅是“用户完成了支付动作”,不要在这里发货
      wx.showToast({ title: "支付完成,正在确认", icon: "none" });
      pollOrderStatus(orderId);    // 轮询自己的订单状态,等回调落库
    },
    fail: (err) => {
      // errMsg 为 requestPayment:fail cancel 表示用户主动取消
      console.warn("支付未完成", err.errMsg);
    }
  });
}

服务端构造 paySign 时的签名串是固定四行:小程序 AppID、时间戳、随机串、package 值(prepay_id 前缀不能漏)。还有一个实用细节:timeStamp 用字符串而不是数字,能避免某些环境下被当成科学计数法处理。

回调通知:验签、解密与幂等

支付结果通过异步通知推送到你配置的 notify_url。这个请求必须当成“不可信输入”来处理:先验签确认来源,再解密资源内容。

// server/notify.js
const crypto = require("crypto");

function decryptResource(resource, apiV3Key) {
  const buf = Buffer.from(resource.ciphertext, "base64");
  const authTag = buf.subarray(buf.length - 16);
  const data = buf.subarray(0, buf.length - 16);
  const d = crypto.createDecipheriv("aes-256-gcm", apiV3Key, resource.nonce);
  d.setAuthTag(authTag);
  d.setAAD(Buffer.from(resource.associated_data || "", "utf8"));
  return JSON.parse(Buffer.concat([d.update(data), d.final()]).toString("utf8"));
}

app.post("/pay/notify", (req, res) => {
  // 1) 用平台证书验签:Wechatpay-Timestamp / Nonce / Signature / Serial 四个头
  if (!verifyByPlatformCert(req)) return res.status(401).send("bad sign");

  // 2) 解密
  const msg = decryptResource(req.body.resource, process.env.API_V3_KEY);
  if (msg.trade_state !== "SUCCESS") return res.status(200).json({ code: "SUCCESS" });

  // 3) 幂等:同一次支付可能被重复通知
  if (alreadyProcessed(msg.transaction_id)) return res.status(200).json({ code: "SUCCESS" });

  markPaid(msg.out_trade_no, msg.transaction_id, msg.amount.total);
  res.status(200).json({ code: "SUCCESS" });
});

关于应答:处理成功要返回 200(或 204)且内容为成功标识,否则微信会按照退避策略重复通知多轮。也正因为如此,回调处理必须幂等——用 transaction_id 或 out_trade_no 做一次去重检查,比事后对账要省事得多。