支付链路的全貌:谁在和谁说话
微信支付的接入之所以让人觉得复杂,是因为它不是一次接口调用,而是一条跨越三方的链路:小程序(用户操作)、你的服务端(下单与验签)、微信支付(资金与状态)。小程序端永远不参与签名,服务端永远不直接接触用户的支付密码,两者通过 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 做一次去重检查,比事后对账要省事得多。
退款与对账:把账目闭环
退款是另一个独立的接口,金额单位同样是分,且必须携带原订单号或原支付单号。注意退款金额不能超过原订单的实际支付金额,部分退款可以多次调用,但累计不能超额。
// 申请退款(服务端调用,签名方式与下单一致)
POST /v3/refund/domestic/refunds
{
"out_trade_no": "JJY202610130001",
"out_refund_no": "JJY202610130001-R1", // 商户退款单号,唯一
"reason": "用户申请退款",
"amount": { "refund": 1990, "total": 1990, "currency": "CNY" }
}
// 对账单下载(按天,返回下载链接后再取文件)
GET /v3/bill/tradebill?bill_date=2026-10-12&bill_type=ALL
每天拉一次对账单,把自己的订单库和对账单做一次核对,是发现“回调丢失”“金额不一致”的唯一可靠手段。建议把对账做成定时任务,差异单单独落表告警,而不是等到用户投诉才发现问题。
排障清单:常见错误与定位顺序
接支付的人几乎都会踩一遍下面这些坑。遇到问题时按这个顺序定位,比盲查文档快得多。
| 错误/现象 | 根因 | 处理 |
|---|---|---|
| SIGN_ERROR | 签名串拼接错误 | 核对五行格式与结尾换行;确认 URL 含 query 且未做二次编码 |
| APPID_MCHID_NOT_MATCH | AppID 与商户号未绑定 | 在商户平台完成关联与确认 |
| OUT_TRADE_NO_USED | 商户订单号重复 | 订单号加业务前缀与时间戳,避免复用 |
| ORDERPAID | 订单已支付仍在重复下单 | 先查单再下单,服务端做状态前置校验 |
| 回调解密失败 | 用错密钥或 nonce | 确认使用的是 APIv3 密钥与通知体里的 nonce |
| 前端提示支付成功但订单未发货 | 把前端回调当成功依据 | 只认服务端验签通过的回调或主动查单结果 |
- 金额一律用「分」作为整数存储与传输,界面上再除以 100 展示,避免浮点误差。
- 订单号(out_trade_no)设计成“业务前缀 + 日期 + 序列号”,既能唯一又能肉眼定位日期。
- 所有接口调用都要落日志:请求参数、应答、耗时,尤其是签名相关的中间值。
- 上线前用微信支付的接口沙箱或 1 分钱真实订单完整跑通“下单—支付—回调—退款”闭环。
把这条链路跑通一次之后,支付就不再神秘:它只是分布在不同进程之间、并且需要用签名保证可信的一段状态流转。