微信小程序虚拟支付接入实录:从零到上线,底层逻辑+标准流程+12个坑
背景:8月 31 日,我看到一个值得关注的政策变化:个人主体小程序已支持虚拟支付能力。这就意味着个人小程序不用再开公司或个体户就能实现收钱的功能。个人主体的工具类微信小程序,需要在「资料下载」场景接入微信虚拟支付(1 元解锁一份 XLS 表格)。后端为阿里云 + 宝塔面板 + Node.js(Express)+ PM2。本文是今天下午从开通到提审的完整复盘,所有报错、排查、修复均为真实记录。
一、先搞懂底层逻辑(不然每一步都是玄学)
微信虚拟支付(wx.requestVirtualPayment)和普通微信支付完全不是一套东西。核心区别:它是”道具/内容”模式,且签名发生在你自己的服务器上。
1.1 参与角色
┌──────────┐ ①wx.login 拿 code ┌──────────────┐
│ │ ─────────────────────▶ │ │
│ 小程序 │ ②POST /api/vp/create │ 你的后端服务 │
│ (前端JS) │ ◀───────────────────── │ (Node/Express)│
│ │ 返回 signData+paySig │ │ │
└────┬─────┘ └──────┼────────┘
│ ③wx.requestVirtualPayment │
▼ │
┌──────────┐ ④支付成功后异步推送 │
│ 微信支付 │ ────────────────────────────▶│
│ (沙箱/现网)│ xpay_goods_deliver_notify │
└──────────┘ (POST到你配置的推送URL) │
▼
订单置为 paid
⑤前端轮询 /api/vp/check ◀───────────┤
⑥paid 后拿临时票据下载文件 ◀──────────┤
┌──────────┐ ①wx.login 拿 code ┌──────────────┐
│ │ ─────────────────────▶ │ │
│ 小程序 │ ②POST /api/vp/create │ 你的后端服务 │
│ (前端JS) │ ◀───────────────────── │ (Node/Express)│
│ │ 返回 signData+paySig │ │ │
└────┬─────┘ └──────┼────────┘
│ ③wx.requestVirtualPayment │
▼ │
┌──────────┐ ④支付成功后异步推送 │
│ 微信支付 │ ────────────────────────────▶│
│ (沙箱/现网)│ xpay_goods_deliver_notify │
└──────────┘ (POST到你配置的推送URL) │
▼
订单置为 paid
⑤前端轮询 /api/vp/check ◀───────────┤
⑥paid 后拿临时票据下载文件 ◀──────────┤
1.2 三个密钥,职责完全不同(最容易混)
密钥 在哪拿 干什么用 AppSecret 开发管理 → 开发设置 code2session:把 wx.login 的 code 换成 openid + session_key,用于订单归属和用户签名 AppKey(沙箱) 虚拟支付 → 基础配置 沙箱模式下计算 paySig AppKey(现网) 虚拟支付 → 基础配置 现网模式下计算 paySig
虚拟支付的「基础配置」页里没有 AppSecret——它是小程序的全局密钥,不在虚拟支付页。这两个概念混了,后面全乱。
1.3 双重签名(后端最核心的代码)
前端拉起支付前,后端要算好两串签名随 signData 下发:
// paySig:用 AppKey 证明"这个订单是你的后端发起的"
const paySig = hmac_sha256(APP_KEY, 'requestVirtualPayment&' + signData);
// signature:用 session_key 证明"这个用户确实登录过"
const signature = hmac_sha256(SESSION_KEY, signData);
1.4 沙箱/现网切换的真相(网上很多文章说错)
没有后台开关。切换 = 两个东西配对:
|
模式 |
signData.env |
paySig 用的 AppKey |
|
沙箱(开发版/体验版联调) |
1 |
沙箱 AppKey |
|
现网(正式发布) |
0 |
现网 AppKey |
只改其中一个是没用的——后端 VP_ENV 环境变量控制这两者的联动:
const IS_SANDBOX = (process.env.VP_ENV === 'sandbox');
// IS_SANDBOX ? 沙箱key + env=1 : 现网key + env=0
const IS_SANDBOX = (process.env.VP_ENV === 'sandbox');
// IS_SANDBOX ? 沙箱key + env=1 : 现网key + env=0
1.5 发货是异步推送,不是支付回调里同步完成
requestVirtualPayment 的 success 回调可能丢失(用户杀进程、网络切换),所以标准链路是:
1. 微信支付成功 → 微信服务器 POST xpay_goods_deliver_notify 到你在 mp 后台配置的推送 URL
2. 后端收到推送 → 校验 → 把订单置 paid
3. 前端同时在做 轮询查单(/api/vp/check,8 次 × 1.5s)→ 查到 paid → 下载文件
三条腿走路,谁先到都能完成发货。这就是为什么”支付成功但下载失败”时,第一时间去看 pm2 logs 里有没有 [notify] 日志——它能直接区分”推送没到”还是”到了但处理失败”。
二、标准搭建流程(正确顺序)
按这个顺序做,能避开本文后面 90% 的坑。
阶段 1:mp 后台开通与配置
1. 确认前置条件:个人主体 + 「工具」类目 + 已认证 + 域名 ICP 备案(2025 年 8 月起个人主体可开通虚拟支付)
2. 开通:mp 后台 → 支付与交易 → 虚拟支付 → 开通
3. 记录四要素:AppID、OfferID、沙箱 AppKey、现网 AppKey(先都存好,别到用时再找)
4. 创建道具:道具管理 → 新建。注意价格单位是「元」(不是分!),如卖 1 元就填 1。创建后点「发布」,等 10-15 分钟生效
5. 配消息推送(关键!见坑 4):开发管理 → 消息推送 → URL 填 https://你的域名/api/vp/notify + 自定 Token + 随机 EncodingAESKey + 明文模式 + JSON 格式 → 保存前必须先完成后端的 GET 校验路由,否则保存会静默失败
阶段 2:后端服务
最小职责清单(Express 为例):
// ① GET /api/vp/notify —— mp 后台"消息推送"保存时的 Token 校验
// 微信 GET 你得 URL:?signature=×tamp=&nonce=&echostr=
// 规则:sha1(sort([TOKEN, timestamp, nonce])) === signature → 返回 echostr
app.get('/api/vp/notify', (req, res) => {
const { signature, timestamp, nonce, echostr } = req.query;
const hash = crypto.createHash('sha1')
.update([TOKEN, timestamp, nonce].sort().join('')).digest('hex');
if (hash === signature) return res.send(echostr);
return res.status(403).send('bad signature');
});
// ② POST /api/vp/notify —— 接收 xpay_goods_deliver_notify / xpay_refund_notify
// 收到后校验并将订单置 paid(打 console.log 便于排障)
// ③ POST /api/vp/create —— code2session 换 openid → 生成订单 → 算双签名返回前端
// ④ POST /api/vp/check —— 前端轮询查单状态,paid 则返回下载票据
// ⑤ GET /api/vp/file?t=&e=&o= —— HMAC 票据鉴权后 res.download(文件)
npm install # 别忘了,宝塔环境最常见的"无堆栈报错"其实是没装依赖
pm2 start server.js --name vp-api
pm2 save && pm2 startup # 固化 + 开机自启
# /etc/profile.d/vpenv.sh
export APP_SECRET="小程序AppSecret" # code2session 用
export APP_KEY="虚拟支付AppKey" # 沙箱联调期=沙箱key,上线时=现网key
export VP_ENV="sandbox" # 上线时删除此行
验证顺序:
curl http://127.0.0.1:3000/ # → vp-api ok
curl "http://127.0.0.1:3000/api/vp/notify?signature=bad×tamp=1&nonce=2&echostr=x"
# → 403 bad signature(说明GET路由活着)
curl http://127.0.0.1:3000/ # → vp-api ok
curl "http://127.0.0.1:3000/api/vp/notify?signature=bad×tamp=1&nonce=2&echostr=x"
# → 403 bad signature(说明GET路由活着)
阶段 3:mp 后台域名白名单
api.你的域名 要加两处:
● 开发管理 → 开发设置 → 服务器域名 → request 合法域名
● 同页面 → downloadFile 合法域名(这是另一栏!只加 request 的话支付通、下载挂)
阶段 4:前端
// 常量
var API_BASE = 'https://api.你的域名';
var OFFER_ID = '你的OfferID';
var PRODUCT_ID = '道具ID';
var GOODS_PRICE = 100; // 分!1元=100(与后台道具价格1元对应)
// 流程:click → wx.login → POST create → requestVirtualPayment → 存本地单号
// → 轮询 check → paid → downloadFile → openDocument
wx.requestVirtualPayment({
signData: r.data.signData, // 后端返回
paySig: r.data.paySig,
signature: r.data.signature,
mode: 'short_series_goods',
success: function () { /* 开始轮询,别在这里直接当发货成功 */ },
fail: function (e) { /* 用户取消/失败,清本地单号 */ }
});
// openDocument 必须显式指定 fileType(临时文件可能没有扩展名)
wx.openDocument({ filePath: res.tempFilePath, fileType: 'xls', showMenu: true });
// 常量
var API_BASE = 'https://api.你的域名';
var OFFER_ID = '你的OfferID';
var PRODUCT_ID = '道具ID';
var GOODS_PRICE = 100; // 分!1元=100(与后台道具价格1元对应)
// 流程:click → wx.login → POST create → requestVirtualPayment → 存本地单号
// → 轮询 check → paid → downloadFile → openDocument
wx.requestVirtualPayment({
signData: r.data.signData, // 后端返回
paySig: r.data.paySig,
signature: r.data.signature,
mode: 'short_series_goods',
success: function () { /* 开始轮询,别在这里直接当发货成功 */ },
fail: function (e) { /* 用户取消/失败,清本地单号 */ }
});
// openDocument 必须显式指定 fileType(临时文件可能没有扩展名)
wx.openDocument({ filePath: res.tempFilePath, fileType: 'xls', showMenu: true });
阶段 5:上线切换(沙箱 → 现网)
# 1. vpenv.sh:APP_KEY 换成现网值,删除 VP_ENV 行
# 2. 关键!pm2 的 restart --update-env 清不掉 dump 里的旧变量:
pm2 delete vp-api && source /etc/profile.d/vpenv.sh &&
pm2 start server.js --name vp-api && pm2 save
# 3. 验证:应只剩 APP_KEY,没有 VP_ENV
pm2 env 0 | grep -E 'APP_KEY|VP_ENV'
# 1. vpenv.sh:APP_KEY 换成现网值,删除 VP_ENV 行
# 2. 关键!pm2 的 restart --update-env 清不掉 dump 里的旧变量:
pm2 delete vp-api && source /etc/profile.d/vpenv.sh &&
pm2 start server.js --name vp-api && pm2 save
# 3. 验证:应只剩 APP_KEY,没有 VP_ENV
pm2 env 0 | grep -E 'APP_KEY|VP_ENV'
三、踩坑实录(按杀伤力排序)
坑 1 💰 沙箱会真扣钱!
最大认知误区。 微信虚拟支付的”沙箱”不是普通微信支付的仿真测试系统(那个才是假数据)。虚拟支付沙箱:
● ✅ 真实资金流——用户的零钱/银行卡真实划扣
● 差别仅在于:免技术服务费、不发真实道具资产、后台可发起退款
● iOS 完全不支持沙箱(走 Apple IAP),安卓真机预览沙箱支付可能被 PAYMENT_ILLEGAL_IN_SANDBOX 拦截
正确姿势:联调前把道具价格临时改成 0.01 元,或每次测完立即去 mp 后台「虚拟支付 → 交易订单」退款(退款也是异步推送 xpay_refund_notify 回来的)。
坑 2 🔑 道具价格单位:后台是「元」,代码是「分」
mp 后台道具编辑页的价格单位是元,而 signData.goodsPrice 单位是分。后台填了 100(以为是分),实际是 100 元 = 10000 分,与代码里 GOODS_PRICE = 100(1 元)对不上 → 支付直接报:
requestVirtualPayment:fail GOODS_PRICE_INVALID (-150013)
坑 3 🔒 消息推送保存静默失败(缺 GET 校验路由)
mp 后台「消息推送」点保存时,微信会先向你的 URL 发一个 GET 请求做 Token 校验(sha1 排序比对,通过返回 echostr)。如果你的 server.js 只写了 POST 处理、没有 GET 校验路由:
● 后台保存显示失败或无反应(不报具体原因)
● 推送 URL 从未生效
● 于是支付成功了、订单永远 unpaid、前端轮询超时 → 用户看不到货
症状链:支付成功 → pm2 logs 无任何 [notify] → orders.json 全 unpaid → 八成是这个。
坑 4 🐑 Linux 文件名大小写敏感(本地好好的,上线 404)
上传到服务器的文件叫 jsb.XLS(大写扩展名),代码里 res.download(‘jsb.xls’) → Linux 下找不到文件 → 404。本地 Windows 开发时大小写不敏感,所以永远不会提前暴露。
pm2 错误日志里的铁证:
Error: ENOENT: no such file or directory, stat '/www/.../materials/jsb.xls'
坑 5 💳 前端过早清除本地订单号 → 重复扣款
原始逻辑:轮询超时(12 秒没查到 paid)就 removeStorageSync(PENDING_KEY) 删掉本地订单号。后果:
用户支付成功但下载失败(比如撞上坑 4)→ 重进小程序再点 → 本地没单号 → 重新下单 → 再扣一次钱。
修复:只有「下载成功」或「用户主动取消支付」才清本地单号;轮询超时保留单号,重进页面先查旧单,已 paid 直接走下载。
// 轮询超时:不清!保留订单号,下次进入先查旧单
// 下载成功后才 removeStorageSync(PENDING_KEY)
// 轮询超时:不清!保留订单号,下次进入先查旧单
// 下载成功后才 removeStorageSync(PENDING_KEY)
坑 6 🖥️ 宝塔面板 PM2 插件与命令行 pm2 双 daemon 打架
症状:PM2 日志无限循环 online → exited code[1] via SIGINT,没有任何应用报错堆栈,重启十几次后 errored。
根因:宝塔的 PM2 插件 daemon 和命令行全局 pm2(/root/.pm2)同时守护同一个进程,互相发 SIGINT。
正确姿势:宝塔环境 Node 项目只用命令行 pm2,不要用面板 PM2 插件。若已中招:面板里删项目 → pm2 kill && pm2 flush → 命令行重新 start。经验:报错无堆栈 + SIGINT = 先怀疑进程管理权冲突,而不是代码。
坑 7 📦 “Cannot find module ‘express'” —— 只是没装依赖
宝塔新目录下直接 node server.js 报模块找不到,不是代码问题,npm install 即可(国内服务器建议先切 npmmirror 镜像)。
坑 8 ♻️ pm2 环境变量:restart –update-env 清不掉旧值
改了 /etc/profile.d/vpenv.sh 后执行 pm2 restart vp-api –update-env,pm2 env 0 一看——旧的 VP_ENV: sandbox 还在!因为 pm2 save 时把环境快照固化进了 dump.pm2,restart 恢复的是快照。
唯一可靠做法:
pm2 delete vp-api && source /etc/profile.d/vpenv.sh &&
pm2 start server.js --name vp-api && pm2 save
坑 9 🌐 request 和 downloadFile 是两个白名单
api.你的域名 加在 request 合法域名后,支付、下单全通;但 wx.downloadFile 用的是另一栏——downloadFile 合法域名。漏配的话报:
downloadFile:fail url not in domain list
坑 10 📄 openDocument 打不开无扩展名的临时文件
wx.downloadFile 下载的临时文件路径可能没有扩展名(尤其 URL 带编码文件名时),wx.openDocument 猜不出类型直接失败。显式传 fileType: ‘xls’。
坑 11 🔐 隐私接口声明:setClipboardData 也是剪贴板 API
提审时被拦:「开发者读取你的剪贴板,用途【未填写】」。因为「复制手机号/邮箱」用了 wx.setClipboardData——写入剪贴板同样属于隐私接口,和 getClipboardData 一样要在《用户隐私保护指引》里声明用途。
自查正则要写成:Clipboard(覆盖 get/set 两族),别只搜 getClipboardData。不想背声明的话,可以改成 wx.showModal 弹窗展示号码让用户长按复制。
坑 12 🔒 密钥进过聊天记录/代码注释 → 上线前重置
AppSecret、AppKey 在联调过程中难免出现在终端输出、聊天记录里。正式上线前在 mp 后台重置一遍,并同步更新 vpenv.sh。密钥只放服务器环境变量,永远不进代码、不进 git。
四、上线前 Checklist(打印级)
● 道具价格:后台「元」× 100 == 代码 goodsPrice「分」
● 消息推送:后台显示已保存;后端有 GET Token 校验路由;pm2 logs 能看到 [notify] 日志
● 文件就位:服务器 materials/ 目录,文件名全小写,ls -la 确认
● 白名单:request + downloadFile 两栏都加了 API 域名
● 现网切换:pm2 env 0 只剩现网 APP_KEY、无 VP_ENV(用 delete+start+save,不是 restart)
● 前端防重复扣款:下载成功才清本地订单号(随最新代码包上传)
● 支付页明示「虚拟内容不支持退款」
● 隐私指引:后台状态”已更新”;用了 Clipboard/位置/相册等接口的,用途逐项填写
● 禁词扫描:VIP / 交流 / 敬请期待 / 引流 / 微信号 / 二维码 全局清零(个人主体红线)
● 测试订单:orders.json 清空为 [] 后重启
● 密钥:重置过聊天中出现过的 AppSecret/AppKey
● 小程序主体备案(工信部 ICP,不是域名备案)——未备案提交发布会被拦截
● 真机走一遍完整闭环:支付 → 自动下载 → openDocument 打开
五、一图流:完整支付链路排障决策树
支付失败?
├─ GOODS_PRICE_INVALID (-150013)
│ ├─ 后台道具价(元)×100 ≠ 代码goodsPrice(分)? → 对齐
│ └─ 沙箱/现网:env 与 AppKey 配对了吗? → 对齐
├─ 签名错误 → 检查 paySig 前缀 'requestVirtualPayment&'、HMAC-SHA256、字段顺序
└─ PAYMENT_ILLEGAL_IN_SANDBOX → 真机预览不支持沙箱,改现网模式或开发者工具调试
支付成功但下载失败?
├─ pm2 logs 无 [notify] 日志 → 消息推送没配通(GET 校验路由?后台保存成功了吗?)
├─ 日志有 ENOENT ... stat 'xxx' → 文件名大小写/路径不对(Linux 敏感!)
├─ 404 且响应头 x-powered-by: Express → Nginx 全量反代,Express 没匹配到路由
├─ url not in domain list → downloadFile 那一栏白名单没加(和 request 是两栏)
└─ 下载成功但打不开 → openDocument 加 fileType;手机装 WPS
支付失败?
├─ GOODS_PRICE_INVALID (-150013)
│ ├─ 后台道具价(元)×100 ≠ 代码goodsPrice(分)? → 对齐
│ └─ 沙箱/现网:env 与 AppKey 配对了吗? → 对齐
├─ 签名错误 → 检查 paySig 前缀 'requestVirtualPayment&'、HMAC-SHA256、字段顺序
└─ PAYMENT_ILLEGAL_IN_SANDBOX → 真机预览不支持沙箱,改现网模式或开发者工具调试
支付成功但下载失败?
├─ pm2 logs 无 [notify] 日志 → 消息推送没配通(GET 校验路由?后台保存成功了吗?)
├─ 日志有 ENOENT ... stat 'xxx' → 文件名大小写/路径不对(Linux 敏感!)
├─ 404 且响应头 x-powered-by: Express → Nginx 全量反代,Express 没匹配到路由
├─ url not in domain list → downloadFile 那一栏白名单没加(和 request 是两栏)
└─ 下载成功但打不开 → openDocument 加 fileType;手机装 WPS
六、写在最后
这次接入从中午开通到晚上,真正的代码量不到 200 行,但排障时间超过 4 小时——所有的时间都花在了”单位不一致”(元/分)、”大小写不一致”、”环境配对不一致”、”两栏长得一样的白名单”这类细节上。
三个最值得记住的元教训:
1. 涉及钱的认知,先查官方文档再下结论——”沙箱=假钱”这种想当然,代价是真金白银。
2. 支付成功 ≠ 链路通了——发货是异步推送 + 轮询的分布式协作,必须用日志逐环节验证闭环。
3. 凡是不报具体原因的失败(后台保存无反应、PM2 无堆栈退出),先怀疑环境/配置,再怀疑代码。
愿你的第一笔钱,顺畅到账。💸
