微信小程序虚拟支付接入实录:从零到上线,底层逻辑+标准流程+12个坑

微信小程序虚拟支付接入实录:从零到上线,底层逻辑+标准流程+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 后拿临时票据下载文件 ◀──────────┤


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);


微信同时校验这两串,缺一不可。signData 里还有一个关键字段 env0 = 现网,1 = 沙箱——这就是沙箱/现网的切换开关,由后端环境变量决定,不在任何后台 UI 上

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


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=&timestamp=&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"                # 上线时删除此行


Nginx反代宝塔添加站点 → 申请 SSL → 反代到 http://127.0.0.1:3000

验证顺序

curl http://127.0.0.1:3000/                          # → vp-api ok


curl "http://127.0.0.1:3000/api/vp/notify?signature=bad&timestamp=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 });


阶段 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 💰 沙箱会真扣钱!

最大认知误区。 微信虚拟支付的”沙箱”不是普通微信支付的仿真测试系统(那个才是假数据)。虚拟支付沙箱:

 ✅ 真实资金流——用户的零钱/银行卡真实划扣

 差别仅在于:免技术服务费、不发真实道具资产、后台可发起退款

 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)


排查口诀:遇到 -150013,先对着检查”后台道具价(元)×100 == 代码 goodsPrice(分)”,再看沙箱/现网 key 是否配对,最后才怀疑签名实现。

坑 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'


纪律:服务器上所有文件名一律全小写;上传后 ls -la 亲眼确认。

坑 5 💳 前端过早清除本地订单号 → 重复扣款

原始逻辑:轮询超时(12 秒没查到 paid)就 removeStorageSync(PENDING_KEY) 删掉本地订单号。后果:

用户支付成功但下载失败(比如撞上坑 4)→ 重进小程序再点 → 本地没单号 → 重新下单 → 再扣一次钱

修复:只有「下载成功」或「用户主动取消支付」才清本地单号;轮询超时保留单号,重进页面先查旧单,已 paid 直接走下载。

// 轮询超时:不清!保留订单号,下次进入先查旧单


// 下载成功后才 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-envpm2 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


另外注意 pm2 env 要用数字 idpm2 env 0),用进程名不认。

坑 9 🌐 request 和 downloadFile 是两个白名单

api.你的域名 加在 request 合法域名后,支付、下单全通;但 wx.downloadFile 用的是另一栏——downloadFile 合法域名。漏配的话报:

downloadFile:fail url not in domain list


子域不继承根域api.xxx.com 必须逐字加。配置后真机要彻底杀掉小程序进程再进(微信客户端缓存旧域名配置)。

坑 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


六、写在最后

      这次接入从中午开通到晚上,真正的代码量不到 200 行,但排障时间超过 4 小时——所有的时间都花在了”单位不一致”(元/分)、”大小写不一致”、”环境配对不一致”、”两栏长得一样的白名单”这类细节上。

三个最值得记住的元教训:

1. 涉及钱的认知,先查官方文档再下结论——”沙箱=假钱”这种想当然,代价是真金白银。

2. 支付成功 ≠ 链路通了——发货是异步推送 + 轮询的分布式协作,必须用日志逐环节验证闭环。

3. 凡是不报具体原因的失败(后台保存无反应、PM2 无堆栈退出),先怀疑环境/配置,再怀疑代码。

     愿你的第一笔钱,顺畅到账。💸

滚动至顶部