微信支付是电商、知识付费、线下到店等小程序商业化的核心环节。与一般 API 不同,支付涉及金额、签名、回调、退款、对账与合规,任何一步出错都意味着真金白银的损失。本文将以「微信支付 API v3」为主,完整拆解 JSAPI 支付的统一下单、拉起支付、回调验签与解密、退款对账、订单状态机,并给出云函数集成与合规注意事项。
一、JSAPI 支付整体流程
JSAPI 支付是小程序内最常见的支付场景,用户在小程序内完成下单与支付:
前端 wx.login 拿到 openid
→ 后端「统一下单」拿到 prepay_id
→ 后端组签名参数返回前端
→ 前端 wx.requestPayment 拉起微信支付
→ 用户输入密码/指纹完成支付
→ 微信异步通知后端「支付成功」回调
→ 后端验签解密、更新订单状态、发货/开卡
关键原则:前端永远不碰商户密钥,所有涉及签名、金额的环节都必须在服务端完成。
二、统一下单(服务端)
2.1 API v3 下单接口
服务端调用 POST /v3/pay/transactions/jsapi(旧版 v2 为「统一下单」unifiedorder):
// 服务端 Node.js:统一下单
const axios = require('axios');
const { wxPaySign } = require('./sign'); // v3 请求签名工具
async function createJsapiOrder({ openid, outTradeNo, totalFee, description }) {
const url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi';
const body = {
appid: APPID, // 小程序 AppID
mchid: MCHID, // 商户号
description, // 商品描述
out_trade_no: outTradeNo, // 商户订单号,唯一
notify_url: NOTIFY_URL, // 支付结果回调地址
amount: {
total: totalFee, // 金额,单位:分
currency: 'CNY'
},
payer: { openid } // 下单用户的 openid
};
const resp = await axios.post(url, body, {
headers: wxPaySign('POST', url, body) // 注入 v3 签名头
});
return resp.data.prepay_id; // 用于拉起支付
}
2.2 组前端支付参数
拿到 prepay_id 后,服务端生成前端拉起支付所需的签名参数(签名串按 appId\n timeStamp\n nonceStr\n package\n \n 拼接,使用商户私钥做 SHA256-RSA 签名):
// 服务端:生成 wx.requestPayment 参数
function buildPayParams(prepayId) {
const timeStamp = String(Math.floor(Date.now() / 1000));
const nonceStr = crypto.randomBytes(16).toString('hex');
const package_ = `prepay_id=${prepayId}`;
const signStr = `${APPID}\n${timeStamp}\n${nonceStr}\n${package_}\n`;
const paySign = crypto
.createSign('RSA-SHA256')
.update(signStr)
.sign(process.env.MCH_PRIVATE_KEY, 'base64');
return { appId: APPID, timeStamp, nonceStr, package: package_, signType: 'RSA', paySign };
}
三、前端拉起支付:wx.requestPayment
前端拿到后端返回的支付参数后调用 wx.requestPayment:
// 小程序端
async function payOrder(orderId) {
// 1. 请求后端统一下单,拿到支付参数
const payParams = await request({ url: '/api/pay/create', data: { orderId } });
// 2. 拉起微信支付面板
wx.requestPayment({
timeStamp: payParams.timeStamp, // 秒级时间戳字符串
nonceStr: payParams.nonceStr,
package: payParams.package, // prepay_id=xxx
signType: 'RSA', // v3 为 RSA,v2 为 MD5
paySign: payParams.paySign,
success() {
// 注意:success 不代表支付成功,只代表用户完成了支付动作
// 真正以回调为准
wx.showToast({ title: '支付完成', icon: 'success' });
},
fail(err) {
if (err.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消支付', icon: 'none' });
} else {
wx.showToast({ title: '支付失败', icon: 'none' });
}
}
});
}
| 参数 | 类型 | 说明 |
|---|---|---|
| timeStamp | string | 秒级时间戳 |
| nonceStr | string | 随机字符串 |
| package | string | prepay_id=${prepay_id} |
| signType | string | v3: RSA;v2: MD5 |
| paySign | string | 支付参数签名 |
一句话:
wx.requestPayment的success只表示用户完成了支付交互,唯一可信的支付结果来源是服务端回调,前端绝不能据此直接发货。
四、支付回调:验签与解密
4.1 回调数据结构
微信支付以 POST 通知 notify_url,v3 通知的 body 是 AES-256-GCM 加密的密文,且需用微信平台证书验签:
// 服务端:处理 v3 支付回调
async function handlePayNotify(req, res) {
const headers = req.headers;
// 验签:Wechatpay-Signature 用平台证书公钥验签
const valid = verifySignature(
headers['wechatpay-timestamp'],
headers['wechatpay-nonce'],
headers['wechatpay-signature'],
headers['wechatpay-serial']
);
if (!valid) return res.status(401).end();
// 解密:AES-256-GCM,key 为 APIv3 密钥
const plain = decryptNotify(req.rawBody); // 得到明文 JSON
const { out_trade_no, transaction_id, trade_state, amount } = plain;
if (trade_state === 'SUCCESS') {
// 幂等处理:先查本地订单状态,避免重复回调
const order = await orderService.getByOutTradeNo(out_trade_no);
if (order && order.status === 'pending_pay' && order.totalFee === amount.total) {
await orderService.markPaid(order, transaction_id);
}
}
res.json({ code: 'SUCCESS', message: '成功' }); // 必须返回 SUCCESS
}
4.2 回调安全要点
| 安全项 | 措施 |
|---|---|
| 验签 | 用平台证书公钥验证签名,防伪造回调 |
| 解密 | 用 APIv3 密钥解密通知体 |
| 幂等 | 按 out_trade_no 防重复处理 |
| 金额校验 | 回调金额必须与本地订单一致 |
| 响应要求 | 必须返回 SUCCESS,否则微信会重试 |
一句话:回调处理的三道关卡是「验签、解密、幂等」,缺一不可;金额校验是防止中间人篡改的最后防线。
4.2 主动查单兜底
回调存在延迟或丢失风险,尤其用户支付成功后立即杀掉小程序时,回调可能晚于用户看到结果。业务层应提供「主动查单」兜底:前端在 wx.requestPayment 成功或进入订单详情时触发服务端查单,服务端通过微信查单 API 与本地订单比对:
// 服务端:按商户订单号主动查单
async function queryOrder(outTradeNo) {
const url = `https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`;
const resp = await axios.get(url, { headers: wxPaySign('GET', url, '') });
const { trade_state, transaction_id } = resp.data;
if (trade_state === 'SUCCESS') {
await orderService.markPaidByIdempotent(outTradeNo, transaction_id);
}
return trade_state; // SUCCESS / NOTPAY / CLOSED / REFUND 等
}
前端在「支付完成」进入确认页时,可先展示「支付确认中」状态,再通过查单结果刷新,避免用户看到与实际支付结果不一致的页面。
五、退款与对账
5.1 退款
退款走 POST /v3/refund/domestic/refunds,同样需要签名:
// 服务端:发起退款
async function createRefund({ outTradeNo, refundNo, refundFee, totalFee }) {
const url = 'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds';
const body = {
out_trade_no: outTradeNo, // 原支付订单号
out_refund_no: refundNo, // 商户退款单号
notify_url: REFUND_NOTIFY_URL, // 退款结果回调(可配置)
amount: {
refund: refundFee, // 退款金额(分)
total: totalFee, // 原订单金额(分)
currency: 'CNY'
}
};
const resp = await axios.post(url, body, { headers: wxPaySign('POST', url, body) });
return resp.data; // { refund_id, status: 'PROCESSING'|'SUCCESS' }
}
5.2 对账
- 每日对账:用「下载交易账单」接口(
/v3/bill/tradebill)拉取日账单,与本地订单流水比对,发现不一致即告警。 - 订单金额核对:微信账单金额与本地数据库
totalFee逐一匹配。 - 异常处理:对不上的单子进人工核查队列,避免长期挂账。
| 动作 | 接口/渠道 | 频率 |
|---|---|---|
| 查询订单 | GET /v3/pay/transactions/out-trade-no/{no} | 实时兜底 |
| 下载账单 | GET /v3/bill/tradebill | 每日 |
| 退款查询 | GET /v3/refund/domestic/refunds/{no} | 按需 |
六、订单状态机设计
支付相关订单建议设计严谨的状态机,杜绝「支付了没发货」「退款了还显示待支付」等状态错乱:
创建订单(pending_pay)
│ wx.requestPayment 成功
▼
待支付确认(pending_confirm) ← 等待回调
│ 回调成功
▼
已支付(paid) ── 发起退款 → 退款中(refunding)
│ │ 退款成功
▼ ▼
已完成(completed) 已退款(refunded)
│
└─ 超时未支付 → 已取消(cancelled)
// 状态机校验示例
const ALLOWED_TRANSITIONS = {
'pending_pay': ['pending_confirm', 'cancelled'],
'pending_confirm': ['paid', 'cancelled'],
'paid': ['refunding', 'completed'],
'refunding': ['refunded'],
'cancelled': [],
'refunded': [],
'completed': []
};
function transitionOrder(order, nextStatus) {
if (!ALLOWED_TRANSITIONS[order.status].includes(nextStatus)) {
throw new Error(`非法状态流转:${order.status} → ${nextStatus}`);
}
return { ...order, status: nextStatus, updatedAt: new Date() };
}
一句话:订单状态机的核心是「每个状态只有有限个合法出口」,把非法流转挡在代码层,比事后对账补救便宜得多。
6.1 超时未支付自动关闭
订单在 pending_pay 停留过久会造成库存占压,应配置定时任务自动关闭超时订单:
// 服务端:定时扫描超时未支付订单
const TIMEOUT_MS = 30 * 60 * 1000; // 30 分钟未支付自动关闭
async function closeExpiredOrders() {
const expired = await db.orders.find({
status: 'pending_pay',
createdAt: { $lt: new Date(Date.now() - TIMEOUT_MS) }
});
for (const order of expired) {
await transitionOrder(order, 'cancelled');
}
console.log(`已关闭 ${expired.length} 笔超时订单`);
}
注意:关闭订单后若微信回调仍到达(极端竞态),需在回调处理中判断订单已为 cancelled 并跳过发货流程,保证幂等。
七、与云函数/服务端集成
7.1 自建服务端
商户私钥、APIv3 密钥等敏感配置放服务端环境变量,统一封装支付 SDK 模块,对外提供 createPayment / handleNotify / refund 三个方法,业务层只调用不碰签名细节。
7.2 云开发集成
云开发提供了云支付能力,wx-server-sdk 内置 cloud.cloudPay:
// cloudfunctions/pay/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
exports.main = async (event) => {
const { orderId, totalFee } = event;
const wxContext = cloud.getWXContext();
const res = await cloud.cloudPay.unifiedOrder({
body: '商品购买',
outTradeNo: orderId,
spbillCreateIp: '127.0.0.1',
subMchId: '商户号',
totalFee, // 单位:分
envId: '你的云环境 ID',
functionName: 'pay-callback' // 支付回调云函数
});
return res.payment; // { timeStamp, nonceStr, package, signType, paySign }
};
前端拿到 payment 后同样调用 wx.requestPayment 拉起支付。
八、支付合规注意事项
- 主体资质:支付能力与小程序主体绑定,个体工商户/企业需完成相应认证。
- 经营类目:类目与交易场景须一致,虚拟商品、金融、医疗等类目有额外资质要求。
- 费率与结算:标准费率通常为 0.6%,部分行业/服务有差异,结算周期 T+1 或更长,需做好资金规划。
- 支付凭证留存:保留订单、回调、退款流水,满足对账与审计要求。
- 隐私合规:支付相关个人信息(订单、金额)按隐私政策收集与保护。
九、总结
| 环节 | 关键接口/机制 | 核心要点 |
|---|---|---|
| 统一下单 | POST /v3/pay/transactions/jsapi | 服务端持密钥,产出 prepay_id |
| 拉起支付 | wx.requestPayment | 前端只做「拉起」,success 不等于成功 |
| 回调处理 | 验签 + AES-GCM 解密 | 幂等 + 金额校验,回 SUCCESS |
| 退款 | POST /v3/refund/domestic/refunds | 独立退款单,异步结果 |
| 对账 | 下载账单 API | 每日比对,异常告警 |
| 订单状态机 | 白名单流转 | 杜绝非法状态迁移 |
| 云开发 | cloud.cloudPay | 免自建签名服务 |
微信支付是「签名、回调、状态、资金」四重逻辑的交汇点。守住三条底线——签名永远在服务端、支付结果以回调为准、金额校验与幂等贯穿始终——再配合严谨的订单状态机与每日对账,交易闭环才能在真实流量下稳定运行。将支付集成到完整业务中,可参考本专题的电商全栈项目实战。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。