UniApp 小程序支付流程完整对接——前端零坑版
阅读时长:约 15 分钟 | 代码可直接复制使用 | 覆盖 90% 常见报错
一、支付流程概述
1.1 完整时序图
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌─────────┐│ 用户 │ │ 前端 │ │ 后端 │ │ 微信 │ │ 商户后台││ │ │(UniApp) │ │(Node.js)│ │ 支付 │ │ │└────┬────┘ └────┬────┘ └────┬────┘ └────┬───┘ └────┬────┘│ │ │ │ ││ 1.点击支付 │ │ │ ││───────────────> │ │ ││ │ │ │ ││ │ 2.创建订单 │ │ ││ │───────────────> │ ││ │ │ │ ││ │ │ 3.统一下单 │ ││ │ │──────────────> ││ │ │ │ ││ │ │ 4.返回prepay_id ││ │ │<────────────── ││ │ │ │ ││ │ 5.返回签名参数 │ ││ │<─────────────── │ ││ │ │ │ ││ 6.调起支付 │ │ │ ││<─────────────── │ │ ││ │ │ │ ││ 7.输入密码 │ │ │ ││───────────────> │ │ ││ │ │ │ ││ │ │ 8.异步回调通知 ││ │ │<─────────────── ││ │ │ │ ││ │ │ 9.更新订单状态 ││ │ │─────────────────────────────>││ │ │ │ ││ 10.支付结果页│ │ │ ││<─────────────── │ │ │
1.2 一句话总结
前端只负责调起支付,核心逻辑在后端。 微信支付的订单创建、签名、回调验证全部在后端完成,前端拿到参数后调用
uni.requestPayment即可。
1.3 三种支付方式的区别
| 小程序支付 | uni.requestPayment | |
本文专注小程序支付。
二、前置准备(UniApp 小程序端)
2.1 材料清单
2.2 UniApp 配置
manifest.json 中补充微信小程序配置:
{"mp-weixin": {"appid": "wx1234567890abcdef", // 你的小程序 AppID"requireSubscribe": false,"setting": {"urlCheck": false // 调试时可关闭,生产环境建议开启}}}
2.3 微信开发者工具配置
打开微信开发者工具
导入 UniApp 项目(
dist/build/mp-weixin目录)工具 → 项目详情 → 支付功能 → 勾选开通
确保项目域名在白名单内
2.4 HTTPS 域名配置(必读)
⚠️ 微信小程序要求所有接口必须是 HTTPS⚠️ 域名必须备案(国内服务器)⚠️ 证书链必须完整
三、后端统一下单(Node.js 示例)
3.1 安装依赖
npm install axios crypto xml2js3.2 统一下单接口完整代码
// server/pay.jsconst express = require('express');const axios = require('axios');const crypto = require('crypto');const xml2js = require('xml2js');const router = express.Router();// ==================== 配置区 ====================const WX_CONFIG = {appid: 'wx1234567890abcdef', // 小程序 appidmch_id: '1234567890', // 商户号api_key: 'your_api_key_here', // APIv2 密钥(32位)notify_url: 'https://yourdomain.com/api/pay/notify', // 回调地址};// ==================== 工具函数 ====================/*** 生成随机字符串*/function generateNonceStr(length = 32) {const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';let str = '';for (let i = 0; i < length; i++) {str += chars.charAt(Math.floor(Math.random() * chars.length));}return str;}/*** 生成订单号(可自定义格式)*/function generateOutTradeNo() {return `ORDER${Date.now()}${Math.random().toString(36).substr(2, 9)}`;}/*** 计算 MD5 签名(APIv2)*/function makeSign(obj, key) {// 1. 排序并拼接 key=value 形式const sortedKeys = Object.keys(obj).sort();const signStr = sortedKeys.filter(k => obj[k] !== '' && obj[k] !== undefined && obj[k] !== null).map(k => `${k}=${obj[k]}`).join('&');// 2. 拼接 API 密钥const signStrWithKey = `${signStr}&key=${key}`;// 3. MD5 加密转大写return crypto.createHash('md5').update(signStrWithKey, 'utf8').digest('hex').toUpperCase();}/*** 转换对象为 XML*/function objectToXml(obj) {let xml = '<xml>';for (const [key, value] of Object.entries(obj)) {xml += `<${key}><![CDATA[${value}]]></${key}>`;}xml += '</xml>';return xml;}/*** 解析 XML 为对象*/async function xmlToObject(xml) {return xml2js.parseStringPromise(xml, { explicitArray: false });}// ==================== 统一下单接口 ====================/*** POST /api/pay/create* 创建支付订单*/router.post('/create', async (req, res) => {try {const { orderId, totalFee, body = '小程序支付订单' } = req.body;// 1. 参数校验if (!orderId || !totalFee) {return res.json({ code: 400, msg: '缺少必要参数' });}// 2. 构造统一下单参数const outTradeNo = generateOutTradeNo();const nonceStr = generateNonceStr();const timeStamp = Math.floor(Date.now() / 1000).toString();const unifiedOrderParams = {appid: WX_CONFIG.appid,mch_id: WX_CONFIG.mch_id,nonce_str: nonceStr,body: body, // 商品描述out_trade_no: outTradeNo, // 商户订单号total_fee: Math.round(totalFee * 100), // 金额,单位分(必须整数!)spbill_create_ip: req.ip, // 发起支付 IPnotify_url: WX_CONFIG.notify_url,trade_type: 'JSAPI', // 小程序固定值openid: req.body.openid, // 用户 openid(必须)};// 3. 计算签名const sign = makeSign(unifiedOrderParams, WX_CONFIG.api_key);unifiedOrderParams.sign = sign;// 4. 发送请求到微信支付const xmlData = objectToXml(unifiedOrderParams);const wxResponse = await axios.post('https://api.mch.weixin.qq.com/pay/unifiedorder',xmlData,{headers: { 'Content-Type': 'text/xml' },timeout: 10000,});// 5. 解析微信返回结果const result = await xmlToObject(wxResponse.data);const { return_code, return_msg, result_code, prepay_id, err_code, err_msg } = result.xml;if (return_code === 'FAIL') {return res.json({ code: 500, msg: return_msg });}if (result_code === 'FAIL') {return res.json({ code: 500, msg: err_msg || '统一下单失败' });}// 6. 前端调起支付所需的签名参数(再次签名)const paySignParams = {appId: WX_CONFIG.appid,timeStamp: timeStamp,nonceStr: nonceStr,package: `prepay_id=${prepay_id}`, // ⚠️ 必须包含 prepay_id= 前缀signType: 'MD5',};const paySign = makeSign(paySignParams, WX_CONFIG.api_key);// 7. 返回给前端res.json({code: 200,msg: 'success',data: {orderId: outTradeNo, // 商户订单号timeStamp: timeStamp, // 时间戳nonceStr: nonceStr, // 随机字符串package: paySignParams.package, // prepay_id=xxxsignType: 'MD5', // 签名类型paySign: paySign, // 支付签名// 保存 prepay_id 用于后续查单prepayId: prepay_id,},});} catch (error) {console.error('统一下单失败:', error);res.json({ code: 500, msg: '服务器错误' });}});module.exports = router;
3.3 参数说明表
| 必须是整数 | ||
JSAPI | ||
四、前端调起支付(UniApp 原生写法)
4.1 获取用户 openid
// utils/auth.js/*** 获取微信登录凭证 code*/function getWxCode() {return new Promise((resolve, reject) => {// #ifdef MP-WEIXINuni.login({provider: 'weixin',success: (res) => {if (res.code) {resolve(res.code);} else {reject(new Error('获取 code 失败'));}},fail: reject,});// #endif// #ifndef MP-WEIXINreject(new Error('仅支持微信小程序'));// #endif});}/*** 通过 code 获取 openid*/async function getOpenid(code) {const res = await uni.request({url: 'https://yourdomain.com/api/auth/openid',method: 'POST',data: { code },});return res.data.data.openid;}
4.2 支付页面完整代码
<!-- pages/pay/pay.vue --><template><view class="container"><view class="order-info"><text class="title">订单金额</text><text class="price">¥{{ orderInfo.price }}</text></view><buttonclass="pay-btn":disabled="isPaying"@click="handlePay">{{ isPaying ? '支付中...' : '微信支付' }}</button></view></template><script>// #ifdef MP-WEIXINexport default {data() {return {orderId: '',orderInfo: {price: 0.01,goods: '测试商品',},isPaying: false, // 防止重复点击payLock: false, // 支付锁};},onLoad(options) {this.orderId = options.orderId || '';// 实际项目中这里应该调接口获取订单详情},methods: {/*** 支付按钮点击事件*/async handlePay() {// 🔒 防重处理:避免用户多次点击if (this.payLock) {uni.showToast({ title: '支付进行中,请稍候', icon: 'none' });return;}this.payLock = true;this.isPaying = true;try {// ========== 步骤1:获取 openid ==========const openid = await this.getUserOpenid();// ========== 步骤2:调用后端创建订单 ==========const orderRes = await uni.request({url: 'https://yourdomain.com/api/order/create',method: 'POST',data: {goodsId: this.orderId,totalFee: this.orderInfo.price,openid: openid,},});if (orderRes.data.code !== 200) {throw new Error(orderRes.data.msg || '创建订单失败');}const payData = orderRes.data.data;// ========== 步骤3:调起微信支付 ==========await this.requestPayment(payData);// ========== 步骤4:支付成功后的处理 ==========uni.showToast({ title: '支付成功', icon: 'success' });// 跳转到订单页或支付成功页setTimeout(() => {uni.navigateTo({url: '/pages/order/success?orderId=' + payData.orderId,});}, 1500);} catch (err) {this.handlePayError(err);} finally {// 释放锁this.payLock = false;this.isPaying = false;}},/*** 获取用户 openid*/getUserOpenid() {return new Promise((resolve, reject) => {// 方式1:前端直接获取 code 传给后端uni.login({provider: 'weixin',success: (res) => {if (!res.code) {reject(new Error('获取 code 失败'));return;}// 调用后端接口获取 openiduni.request({url: 'https://yourdomain.com/api/auth/openid',method: 'POST',data: { code: res.code },success: (r) => {if (r.data.code === 200) {resolve(r.data.data.openid);} else {reject(new Error('获取 openid 失败'));}},fail: reject,});},fail: reject,});});},/*** 调起微信支付*/requestPayment(payData) {return new Promise((resolve, reject) => {uni.requestPayment({provider: 'wxpay', // 微信支付timeStamp: payData.timeStamp, // 时间戳(字符串)nonceStr: payData.nonceStr, // 随机字符串package: payData.package, // ⚠️ 格式:prepay_id=xxxsignType: payData.signType, // 签名类型:MD5 / HMAC-SHA256paySign: payData.paySign, // 签名success: (res) => {console.log('✅ 支付成功', res);resolve(res);},fail: (err) => {console.log('❌ 支付失败', err);reject(err);},});});},/*** 处理支付错误*/handlePayError(err) {console.error('支付异常:', err);const errMsg = err.errMsg || '';// 用户取消支付if (errMsg.includes('cancel') || errMsg.includes('取消')) {uni.showToast({ title: '用户取消支付', icon: 'none' });return;}// 常见错误提示优化const errorMap = {'2': '支付失败','-1': '支付失败','SYSTEM_ERROR': '系统错误,请重试','BANKERROR': '银行错误,请联系客服','USERPAYING': '支付中,请稍后查看',};const msg = errorMap[err.errCode] || err.message || '支付失败';uni.showToast({ title: msg, icon: 'none' });// 💡 建议:这里可以做一次查单,确保订单状态this.queryOrderStatus();},/*** 查询订单状态(防止掉单)*/async queryOrderStatus() {if (!this.orderId) return;try {const res = await uni.request({url: 'https://yourdomain.com/api/order/query',method: 'POST',data: { orderId: this.orderId },});if (res.data.data.status === 'paid') {uni.showToast({ title: '订单已支付', icon: 'success' });}} catch (e) {console.error('查单失败', e);}},},};// #endif</script><style scoped>.container {padding: 40rpx;}.order-info {background: #fff;padding: 40rpx;border-radius: 16rpx;text-align: center;margin-bottom: 60rpx;}.title {display: block;color: #999;font-size: 28rpx;margin-bottom: 20rpx;}.price {font-size: 60rpx;font-weight: bold;color: #333;}.pay-btn {width: 100%;height: 96rpx;line-height: 96rpx;background: linear-gradient(135deg, #07c160, #06ad56);color: #fff;font-size: 32rpx;border-radius: 48rpx;border: none;}.pay-btn[disabled] {background: #ccc;}</style>
4.3 注意事项
⚠️ 1. timeStamp 必须是字符串类型,不能是数字⚠️ 2. package 参数格式必须是 prepay_id=xxx(前缀不能省)⚠️ 3. signType 必须与后端签名一致(MD5 / HMAC-SHA256)⚠️ 4. 支付前一定要做防重处理,避免重复扣款⚠️ 5. 使用条件编译 #ifdef MP-WEIXIN 区分平台
五、支付结果通知(后端处理)
5.1 回调接口完整代码
// server/notify.jsconst express = require('express');const crypto = require('crypto');const xml2js = require('xml2js');const router = express.Router();const WX_CONFIG = {appid: 'wx1234567890abcdef',mch_id: '1234567890',api_key: 'your_api_key_here',};/*** 验证微信签名*/function verifySign(params, key) {const { sign, ...rest } = params;// 重新计算签名const sortedKeys = Object.keys(rest).sort();const signStr = sortedKeys.filter(k => rest[k] !== '' && rest[k] !== undefined).map(k => `${k}=${rest[k]}`).join('&');const signStrWithKey = `${signStr}&key=${key}`;const calculatedSign = crypto.createHash('md5').update(signStrWithKey, 'utf8').digest('hex').toUpperCase();return calculatedSign === sign;}/*** 处理微信支付回调*/router.post('/notify', async (req, res) => {try {// 1. 获取原始 XML 数据const xmlData = req.body.toString('utf8');console.log('收到微信回调:', xmlData);// 2. 解析 XMLconst parser = new xml2js.Parser({ explicitArray: false });const result = await parser.parseStringPromise(xmlData);const data = result.xml;// 3. 验证签名if (!verifySign(data, WX_CONFIG.api_key)) {console.error('签名验证失败');return res.send(xmlData({ return_code: 'FAIL', return_msg: '签名失败' }));}// 4. 处理返回状态if (data.return_code === 'FAIL') {console.error('微信返回失败:', data.return_msg);return res.send(xmlData({ return_code: 'FAIL', return_msg: 'FAIL' }));}// 5. 业务逻辑处理const { out_trade_no, transaction_id, total_fee, cash_fee, trade_state } = data;// 查询本地订单const order = await OrderModel.findOne({ orderId: out_trade_no });if (!order) {console.error('订单不存在:', out_trade_no);return res.send(xmlData({ return_code: 'FAIL', return_msg: '订单不存在' }));}// 防止重复处理(幂等性)if (order.status === 'paid') {console.log('订单已处理过:', out_trade_no);return res.send(xmlData({ return_code: 'SUCCESS', return_msg: 'OK' }));}// 更新订单状态await OrderModel.updateOne({ orderId: out_trade_no },{status: 'paid',transactionId: transaction_id, // 微信订单号paidAmount: parseInt(total_fee) / 100, // 转换回元paidAt: new Date(),notifyData: data, // 保存原始回调数据});// 6. 处理自定义业务(如:发送通知、发放优惠券等)await handleOrderSuccess(order);console.log('✅ 订单处理成功:', out_trade_no);// 7. 返回 SUCCESS 给微信res.send(xmlData({ return_code: 'SUCCESS', return_msg: 'OK' }));} catch (error) {console.error('回调处理异常:', error);res.send(xmlData({ return_code: 'FAIL', return_msg: 'ERROR' }));}});/*** 发送 XML 响应*/function xmlData(data) {let xml = '<xml>';for (const [key, value] of Object.entries(data)) {xml += `<${key}><![CDATA[${value}]]></${key}>`;}xml += '</xml>';return xml;}/*** 订单成功后的自定义业务处理*/async function handleOrderSuccess(order) {// 1. 发送订阅消息通知用户// 2. 发放积分/优惠券// 3. 库存扣减// 4. 触发其他业务系统}module.exports = router;
5.2 回调验证要点
⚠️ 1. 必须返回 XML 格式:<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>⚠️ 2. 必须验证签名,防止伪造回调⚠️ 3. 订单状态更新要做幂等处理(多次回调只处理一次)⚠️ 4. 回调地址必须 HTTPS 且外网可访问⚠️ 5. 处理完成后立即返回 SUCCESS,不要有延迟操作⚠️ 6. 建议记录完整日志,方便排查问题
六、查单接口(防止掉单)
6.1 为什么会掉单?
用户支付成功↓微信返回成功(前端看到)↓异步回调失败(网络问题 / 服务重启)↓订单状态未更新(掉单!)↓用户看到支付成功,但订单显示未付款
6.2 后端查单接口
// server/query.jsrouter.post('/query', async (req, res) => {try {const { orderId } = req.body;// 1. 先查本地订单const localOrder = await OrderModel.findOne({ orderId });if (!localOrder) {return res.json({ code: 404, msg: '订单不存在' });}// 2. 如果本地已是支付成功,直接返回if (localOrder.status === 'paid') {return res.json({code: 200,msg: 'success',data: { status: 'paid', orderInfo: localOrder },});}// 3. 本地未支付,查询微信const nonceStr = generateNonceStr();const params = {appid: WX_CONFIG.appid,mch_id: WX_CONFIG.mch_id,out_trade_no: orderId,nonce_str: nonceStr,};params.sign = makeSign(params, WX_CONFIG.api_key);const xmlData = objectToXml(params);const wxResponse = await axios.post('https://api.mch.weixin.qq.com/pay/orderquery',xmlData,{ headers: { 'Content-Type': 'text/xml' } });const result = await xmlToObject(wxResponse.data);const { trade_state, transaction_id, total_fee, time_end } = result.xml;// 4. 微信已支付,更新本地订单if (trade_state === 'SUCCESS') {await OrderModel.updateOne({ orderId },{status: 'paid',transactionId: transaction_id,paidAt: new Date(time_end.replace(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, '$1-$2-$3 $4:$5:$6')),});return res.json({code: 200,msg: 'success',data: { status: 'paid', orderInfo: await OrderModel.findOne({ orderId }) },});}// 5. 微信也未支付,返回当前状态res.json({code: 200,msg: 'success',data: { status: trade_state, orderInfo: localOrder },});} catch (error) {console.error('查单失败:', error);res.json({ code: 500, msg: '查单失败' });}});
6.3 前端查单策略
// 前端查单最佳实践/*** 支付后轮询查单(兜底方案)*/async function pollOrderStatus(orderId, maxAttempts = 5) {for (let i = 0; i < maxAttempts; i++) {await new Promise(r => setTimeout(r, 2000)); // 每 2 秒查一次try {const res = await uni.request({url: 'https://yourdomain.com/api/order/query',method: 'POST',data: { orderId },});if (res.data.data.status === 'paid') {console.log('订单已支付');return true;}} catch (e) {console.error('查单失败', e);}}return false;}/*** 支付成功但状态未更新时的兜底处理*/async function handlePaymentSuccess(orderId) {const updated = await pollOrderStatus(orderId);if (updated) {// 跳转成功页uni.navigateTo({ url: `/pages/order/success?orderId=${orderId}` });} else {// 最多等待 10 秒后仍无结果,提示用户稍后查看uni.showModal({title: '提示',content: '支付成功,订单可能存在延迟,请稍后在"我的订单"中查看',showCancel: false,});}}
七、常见问题与避坑(重点!)
7.1 问题排查表
| 签名错误 | 2. 检查 package 格式是否为 prepay_id=xxx3. 确认 signType 与后端一致 | |
| appid 和 mch_id 不匹配 | 或 关联小程序 | |
| prepay_id 为空 | 2. 检查 total_fee 是否为整数分 3. 检查 openid 是否正确 | |
| 订单已支付 | 2. 后端订单状态判断 3. 前端支付后主动查单 | |
| 支付成功但订单未更新 | 2. 检查签名验证逻辑 3. 使用查单接口兜底 | |
| 沙箱环境报错 | 沙箱环境需单独申请沙箱密钥 | |
| URL 未注册 | 添加授权支付目录 | |
| 调用JSAPI缺少参数 | 检查 code 是否有效 |
7.2 避坑指南
坑 1:金额精度问题
// ❌ 错误:浮点数运算可能导致金额错误const total_fee = 0.1 * 100; // 可能是 10.000000000000004// ✅ 正确:使用整数运算(分)const total_fee = 1; // 直接用整数// ✅ 正确:金额转分const price = 0.01;const total_fee = Math.round(price * 100);
坑 2:签名参数名大小写
// ❌ 错误:参数名拼写错误{ time_stamp: '123', package: 'prepay_id=xxx' }// ✅ 正确:严格按照微信文档{ timeStamp: '123', package: 'prepay_id=xxx' }
坑 3:package 前缀丢失
// ❌ 错误:直接传 prepay_idpackage: 'wx1234567890'// ✅ 正确:必须带前缀package: `prepay_id=${prepay_id}`
坑 4:回调未返回 SUCCESS
// ❌ 错误:处理完业务逻辑后才返回await processOrder(); // 这里可能失败res.send(successXml); // 如果上面失败,永远不会返回 SUCCESS// ✅ 正确:先返回 SUCCESS,再处理业务res.send(successXml); // 立即返回await processOrder(); // 异步处理其他业务
坑 5:未处理用户取消
// ❌ 错误:未区分取消和其他错误fail: (err) => {uni.showToast({ title: '支付失败', icon: 'none' });}// ✅ 正确:区分取消和其他错误fail: (err) => {if (err.errMsg.includes('cancel')) {// 用户主动取消,不做提示或轻提示console.log('用户取消支付');} else {uni.showToast({ title: '支付失败', icon: 'none' });}}
7.3 调试技巧
// 1. 前端打印完整支付参数console.log('支付参数:', payData);// 2. 后端打印签名过程console.log('签名原文:', signStrWithKey);console.log('计算签名:', calculatedSign);console.log('微信签名:', sign);// 3. 使用微信支付签名校验工具验证// https://pay.weixin.qq.com/wiki/doc/apiv3/tools/signtool.shtml// 4. 沙箱环境调试// 开通沙箱:商户平台 → 开发者工具 → 沙箱环境// 获取沙箱密钥后替换 api_key
八、快速复制模板
8.1 完整下单页面(UniApp Vue3 + setup)
<!-- pages/pay/pay.vue --><template><view class="pay-page"><view class="goods-card"><image class="goods-img" src="/static/goods.png" mode="aspectFill" /><view class="goods-info"><text class="goods-name">{{ goodsInfo.name }}</text><text class="goods-price">¥{{ goodsInfo.price }}</text></view></view><buttonclass="pay-btn":loading="loading"@click="onPay">微信支付 ¥{{ goodsInfo.price }}</button></view></template><script setup>// #ifdef MP-WEIXINimport { ref, onMounted } from 'vue';const goodsInfo = ref({ id: '001', name: '测试商品', price: '0.01' });const loading = ref(false);const payLock = ref(false);onMounted(() => {// 获取页面参数const pages = getCurrentPages();const currentPage = pages[pages.length - 1];const options = currentPage.options || {};if (options.goodsId) {goodsInfo.value.id = options.goodsId;}});async function onPay() {// 防重if (payLock.value || loading.value) return;payLock.value = true;loading.value = true;try {// 1. 获取 openidconst openid = await getOpenid();// 2. 创建订单获取支付参数const { data: res } = await uni.request({url: 'https://yourdomain.com/api/order/create',method: 'POST',data: {goodsId: goodsInfo.value.id,price: goodsInfo.value.price,openid,},});if (res.code !== 200) {throw new Error(res.msg);}// 3. 调起支付await uni.requestPayment({provider: 'wxpay',...res.data,});// 4. 支付成功uni.showToast({ title: '支付成功', icon: 'success' });setTimeout(() => uni.navigateBack(), 1500);} catch (err) {const msg = err.errMsg?.includes('cancel') ? '已取消支付' : (err.message || '支付失败');uni.showToast({ title: msg, icon: 'none' });} finally {payLock.value = false;loading.value = false;}}function getOpenid() {return new Promise((resolve, reject) => {uni.login({provider: 'weixin',success: async (loginRes) => {try {const { data } = await uni.request({url: 'https://yourdomain.com/api/auth/openid',method: 'POST',data: { code: loginRes.code },});resolve(data.data.openid);} catch (e) {reject(e);}},fail: reject,});});}// #endif</script><style>.pay-page { padding: 40rpx; }.goods-card {display: flex;background: #fff;padding: 30rpx;border-radius: 16rpx;margin-bottom: 60rpx;}.goods-img { width: 160rpx; height: 160rpx; border-radius: 12rpx; }.goods-info { margin-left: 30rpx; display: flex; flex-direction: column; justify-content: space-between; }.goods-name { font-size: 32rpx; color: #333; }.goods-price { font-size: 40rpx; color: #f60; font-weight: bold; }.pay-btn {width: 100%;height: 96rpx;line-height: 96rpx;background: #07c160;color: #fff;font-size: 32rpx;border-radius: 48rpx;}</style>
8.2 完整后端订单接口
// server/routes/order.jsconst express = require('express');const router = express.Router();const axios = require('axios');const crypto = require('crypto');const xml2js = require('xml2js');const WX_CONFIG = {appid: process.env.WX_APPID,mch_id: process.env.WX_MCH_ID,api_key: process.env.WX_API_KEY,notify_url: process.env.WX_NOTIFY_URL,};// ==================== 工具函数 ====================const genNonceStr = (len = 32) => {const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join('');};const makeSign = (obj, key) => {const str = Object.keys(obj).sort().filter(k => obj[k] !== undefined && obj[k] !== '').map(k => `${k}=${obj[k]}`).join('&') + `&key=${key}`;return crypto.createHash('md5').update(str, 'utf8').digest('hex').toUpperCase();};const toXml = (obj) => {let xml = '<xml>';for (const [k, v] of Object.entries(obj)) {xml += `<${k}><![CDATA[${v}]]></${k}>`;}return xml + '</xml>';};const fromXml = async (xml) => {return (await xml2js.parseStringPromise(xml, { explicitArray: false })).xml;};// ==================== 创建订单 ====================router.post('/create', async (req, res) => {try {const { goodsId, price, openid, body = '小程序支付' } = req.body;if (!goodsId || !price || !openid) {return res.json({ code: 400, msg: '参数缺失' });}const outTradeNo = `ORDER${Date.now()}${genNonceStr(6)}`;const nonceStr = genNonceStr();const timeStamp = Math.floor(Date.now() / 1000).toString();// 统一下单const orderParams = {appid: WX_CONFIG.appid,mch_id: WX_CONFIG.mch_id,nonce_str: nonceStr,body,out_trade_no: outTradeNo,total_fee: Math.round(parseFloat(price) * 100), // 金额转分spbill_create_ip: req.ip,notify_url: WX_CONFIG.notify_url,trade_type: 'JSAPI',openid,};orderParams.sign = makeSign(orderParams, WX_CONFIG.api_key);// 保存订单到数据库(示例)await saveLocalOrder({ outTradeNo, goodsId, price, status: 'pending' });// 请求微信const wxRes = await axios.post('https://api.mch.weixin.qq.com/pay/unifiedorder',toXml(orderParams),{ headers: { 'Content-Type': 'text/xml' } });const wxData = await fromXml(wxRes.data);if (wxData.return_code !== 'SUCCESS' || wxData.result_code !== 'SUCCESS') {return res.json({ code: 500, msg: wxData.return_msg || wxData.err_msg });}// 二次签名(返回给前端)const paySignParams = {appId: WX_CONFIG.appid,timeStamp,nonceStr,package: `prepay_id=${wxData.prepay_id}`,signType: 'MD5',};res.json({code: 200,msg: 'success',data: {orderId: outTradeNo,timeStamp,nonceStr,package: paySignParams.package,signType: 'MD5',paySign: makeSign(paySignParams, WX_CONFIG.api_key),},});} catch (err) {console.error('创建订单失败', err);res.json({ code: 500, msg: '服务器错误' });}});// ==================== 订单查询 ====================router.post('/query', async (req, res) => {try {const { orderId } = req.body;// 查本地const localOrder = await getLocalOrder(orderId);if (!localOrder) return res.json({ code: 404, msg: '订单不存在' });if (localOrder.status === 'paid') {return res.json({ code: 200, msg: 'success', data: { status: 'paid' } });}// 查微信const nonceStr = genNonceStr();const params = {appid: WX_CONFIG.appid,mch_id: WX_CONFIG.mch_id,out_trade_no: orderId,nonce_str: nonceStr,};params.sign = makeSign(params, WX_CONFIG.api_key);const wxRes = await axios.post('https://api.mch.weixin.qq.com/pay/orderquery',toXml(params),{ headers: { 'Content-Type': 'text/xml' } });const wxData = await fromXml(wxRes.data);// 微信已支付,更新本地if (wxData.trade_state === 'SUCCESS') {await updateLocalOrder(orderId, { status: 'paid', transactionId: wxData.transaction_id });return res.json({ code: 200, msg: 'success', data: { status: 'paid' } });}res.json({ code: 200, msg: 'success', data: { status: wxData.trade_state } });} catch (err) {console.error('查询失败', err);res.json({ code: 500, msg: '查询失败' });}});// ==================== 模拟数据库操作 ====================async function saveLocalOrder(order) { /* 实际项目中写入数据库 */ console.log('创建订单', order); }async function getLocalOrder(orderId) { /* 实际项目中从数据库读取 */ return null; }async function updateLocalOrder(orderId, data) { /* 实际项目中更新数据库 */ console.log('更新订单', orderId, data); }module.exports = router;
8.3 完整支付回调代码
// server/routes/notify.jsconst express = require('express');const router = express.Router();const crypto = require('crypto');const xml2js = require('xml2js');const WX_CONFIG = {appid: process.env.WX_APPID,mch_id: process.env.WX_MCH_ID,api_key: process.env.WX_API_KEY,};router.post('/notify', async (req, res) => {try {// 1. 解析 XMLconst xml = req.body.toString('utf8');const parser = new xml2js.Parser({ explicitArray: false });const data = (await parser.parseStringPromise(xml)).xml;console.log('微信回调:', JSON.stringify(data));// 2. 验证签名const { sign, ...rest } = data;const verifySign = (params) => {const str = Object.keys(params).sort().filter(k => params[k]).map(k => `${k}=${params[k]}`).join('&') + `&key=${WX_CONFIG.api_key}`;return crypto.createHash('md5').update(str, 'utf8').digest('hex').toUpperCase();};if (verifySign(rest) !== sign) {console.error('❌ 签名验证失败');return res.send('<xml><return_code><![CDATA[FAIL]]></return_code></xml>');}// 3. 支付失败if (data.return_code !== 'SUCCESS') {console.error('支付失败:', data.return_msg);return res.send('<xml><return_code><![CDATA[FAIL]]></return_code></xml>');}// 4. 更新订单(幂等处理)const order = await getLocalOrder(data.out_trade_no);if (order && order.status !== 'paid') {await updateLocalOrder(data.out_trade_no, {status: 'paid',transactionId: data.transaction_id,paidAmount: parseInt(data.total_fee) / 100,});console.log('✅ 订单更新成功:', data.out_trade_no);}// 5. 返回 SUCCESSres.send('<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>');} catch (err) {console.error('回调异常:', err);res.send('<xml><return_code><![CDATA[FAIL]]></return_code></xml>');}});// 辅助函数async function getLocalOrder(orderId) { return null; }async function updateLocalOrder(orderId, data) { console.log('更新订单', orderId, data); }module.exports = router;
九、完整流程总结
9.1 文字版流程
┌─────────────────────────────────────────────────────────────────┐│ 小程序支付完整流程 │├─────────────────────────────────────────────────────────────────┤│ ││ 【前端】UniApp 小程序 ││ │ ││ ├── 1. 获取用户 openid(通过 uni.login + 后端交换) ││ │ ││ ├── 2. 调用后端创建订单接口 ││ │ ││ └── 3. 拿到支付参数后调用 uni.requestPayment() ││ ├── timeStamp (字符串) ││ ├── nonceStr (随机字符串) ││ ├── package (prepay_id=xxx) ││ ├── signType (MD5) ││ └── paySign (签名) ││ ││ 【后端】Node.js 服务 ││ │ ││ ├── 1. 创建订单接口 ││ │ ├── 生成 out_trade_no ││ │ ├── 调用微信统一下单 API ││ │ ├── 获取 prepay_id ││ │ ├── 二次签名 ││ │ └── 返回支付参数给前端 ││ │ ││ ├── 2. 支付回调接口(notify_url) ││ │ ├── 接收微信异步通知 ││ │ ├── 验证签名 ││ │ ├── 更新订单状态 ││ │ └── 返回 SUCCESS ││ │ ││ └── 3. 订单查询接口 ││ ├── 前端查单兜底 ││ └── 后端主动查询微信 ││ │└─────────────────────────────────────────────────────────────────┘
9.2 各环节注意事项汇总
| 金额 | |
| 签名 | prepay_id= 前缀 |
| 回调 | |
| 防重 | |
| 掉单 | |
| openid | |
| IP白名单 |
9.3 快速自检清单
□ manifest.json 已配置 appid□ 微信开发者工具已开启支付功能□ 后端接口已配置 HTTPS□ 商户号已绑定小程序 appid□ notify_url 已配置且可访问□ API 密钥已正确配置□ 金额计算已用整数(分)□ 前端已添加支付防重锁□ 后端已实现幂等处理□ 已实现查单兜底机制
结语
小程序支付对接并不复杂,关键点就三个:
后端完成所有签名和订单创建
前端只负责调起支付(uni.requestPayment)
通过回调 + 查单双重保证订单状态正确
遇到问题先看报错信息,再对照本文的排查表,基本都能解决。祝对接顺利! 🚀
夜雨聆风