微信小程序接入虚拟支付你踩过坑了吗?
2026年9月10日 · 13 分钟阅读 · 440 次浏览
提审被拒两次,才明白这事绕不过去

我们做明策小筑(一个招标信息聚合的智能体),要开始会员订阅——这属于虚拟商品。
一开始我们想小程序付费这玩意儿我们之前搞过太多了,微信商户号都开了现成的,直接关联一下小程序用微信支付(wx.requestPayment + JSAPI)不就得了。
很快就集成了支付,提交了审核,结果提审被连拒两次,审核的反馈说得很那什么:
小程序服务内提供虚拟产品、虚拟商品购买服务,其购买和支付均需接入官方「小程序虚拟支付」能力。
也就是说:只要卖的是虚拟商品,普通微信支付这条路就断了,绕不过去。
但是这个虚拟支付是微信小程序刚刚推出的,大家都没什么经验,于是我们老老实实从普通微信支付迁移到官方「小程序虚拟支付」(wx.requestVirtualPayment)。
整个过程也是踩了无数的坑,从"服务端报错查不到日志"到"iOS 不能支付",每一个都是"知道就不难,不知道就会卡半天"的典型。
今天我们把全过程、每个坑的根因和解法都分享出来,给同样要接微信虚拟支付的团队排排雷。

不是普通的微信商户号支付的"换 API"
这是最关键的认知——虚拟支付不是普通支付换个接口,而是一套全新的体系:
客户端wx.requestVirtualPayment 发起 | ||
| 只生成签名参数 | ||
| paySig + signature 双签名 | ||
| 新开一个虚拟支付二级商户号 | ||
| 后台配置"道具",两端必须严格一致 | ||
| 消息推送,XML 格式 |
所以别把它当普通支付的升级版——当做一个全新能力接入,心态就对了。
流程是这样的:
微信小程序──1. POST /pay/create(planCode)──▶ 服务端──2. 返回{orderId, virtualPay}──◀ 生成签名参数──3. wx.requestVirtualPayment {signData, paySig, signature, mode}──4. 支付成功(注意:iOS 走的是 Apple 支付)──5. 微信服务器推送发货消息(XML)▼ POST /pay/virtual-notify(echo验证+Token校验+解析)▼ 订单置paid→ 激活订阅→ 小程序内通知
踩的坑
坑 1:审核被拒——虚拟商品用了普通支付
根因:审核扫到 requestPayment 直接判死。虚拟商品必须 wx.requestVirtualPayment。
解决:全量迁移,三处调用点(支付确认页、订单列表、订单详情)全部替换。
建议:迁移时全局搜索 requestPayment 确认无残留,审核会扫代码。
坑 2:session_key 没保存——下单 400「登录态已过期」
根因:虚拟支付的用户态签名 signature = HMAC-SHA256(session_key, signData),必须用当前有效的 session_key。之前登录时 code2session 拿到的 session_key 只用来换 openid,没落库 → 下单查不到直接 400。
解决:
User 表加 session_key字段,登录时保存/刷新- 真实用户不能要求重登
——新增 /auth/refresh-session:token 有效即可,wx.login拿新 code 只刷 session_key 不换 token,用户无感 三处支付入口在发起支付前都先静默刷新 session_key
坑 3:prepay_id 字段超长——下单 500「服务器没有报错」
根因:Order 表 prepay_id 是 STRING(64)(原来只存普通支付 prepay_id)。迁移后我们把整个虚拟支付参数 JSON(300+ 字符)存进去,MySQL 报 Data too long → 500。
解决:prepay_id 改 TEXT。 教训:迁移支付体系时,订单表存支付参数的字段长度要重新评估。生产 500 且日志不明显,优先怀疑 DB 字段/约束问题。
坑 4:goodsPrice 与道具价格不一致——-15013
根因:signData 里的 goodsPrice(分)必须与小程序后台虚拟支付配置的道具配置价格完全一致,是防篡改校验。测试时服务端传 1 分,后台道具价格对不上。
解决:goodsPrice 与后台道具价格严格对齐。测试期后台就配 0.01 元,服务端传 1;上线两边一起改回原价。
坑 5:道具 ID 不符合规范——后台添加被拒
根因:道具 ID 要求英文字母+数字,≤20 位。我们用了数据库自增 id(1,2,3...)。
解决:给每个套餐配独立语义化道具 ID,存 subscription_plans.product_id:如 xiaotongPlus / xiaotongStandard....
关键:每个价格档一个独立道具,别共用 productId——道具价格绑在道具上,共用会导致各档价格无法区分。
坑 6:道具发布未生效——-15014
根因:道具新增/改价后需重新发布,约 10 分钟才生效,生效前下单一律 -15014。
我们踩的变体:上线把价格从 0.01 改回 99 元,改价重新发布后立刻测试 → -15014,一度怀疑服务端的问题。
解决:等 10 分钟。
建议:道具的"发布生效延迟"写进上线清单,改价后至少等 10 分钟再验支付,别误判为代码问题。
坑 7:outTradeNo 重复使用——待支付订单无法再次支付
根因:虚拟支付 outTradeNo 全局唯一,每次下单只能用一次。我们的订单 ID 固定,第一次拉起后微信已记录,复用必失败(-15002)。
解决:每次生成支付参数都换新单号——订单 ID 加 _r2、_r3 递增后缀(22 字符 + _rN ≤ 32 限制内):
/pay/create复用 pending 订单时重新生成参数 发货推送解析时剥离 _rN回溯真实订单号
铁律:支付参数不缓存,每次现签现发。虚拟支付不存在"同一个单号反复拉起"的概念,每次拉起都是新下单。
坑 8:发货推送解析不了——支付成功但订单不刷新
根因(三层叠加,最隐蔽的坑):
1. 微信推送 XML,服务端原来的都是解解析的json,body 就为空
2. 手写正则解析时没处理 CDATA(< ![CDATA[...]]> 被当字面值)
3. 改用 fast-xml-parser 后结果包了一层根节点({xml:{Event:...}}),直接取 raw.Event 仍 undefined
解决:
1. 路由挂({type:'/'}) 接收原始 body
2. 用 fast-xml-parser(别手搓),解析后先取 .xml 内层再读字段:
const parsed = xmlParser.parse(body);const raw = parsed?.xml && typeof parsed.xml === 'object' ? parsed.xml : parsed;
建议:后台"数据格式"选什么,服务端就必须实现对应的解析,两边要闭环。XML 用成熟库,别手搓正则。
坑 9:发货推送 URL 验证失败——后台配置保存不了
根因:微信保存推送配置前先发一个 GET 验证请求(带 signature/timestamp/nonce/echostr),服务端校验签名后必须原样返回 echostr。我们只写了 POST。
解决:补 GET handler:
router.get('/virtual-notify', (req, res) => {const { signature, timestamp, nonce, echostr } = req.query;const sha1 = crypto.createHash('sha1') .update([token, timestamp, nonce].sort().join('')).digest('hex');if (sha1 === signature)return res.send(echostr);res.status(403).send('signature error');
建议:所有"推送 URL"配置(公众号/小程序/虚拟支付)都遵循GET 验证 + POST 推送,两个 handler 都要有。
坑 10:Token(令牌)不知道填什么
根因认知:Token 是我们自定义的字符串(不是微信下发),作用让服务端校验推送来源。而且两端必须一致:MP 后台填一个值,.env 存同一个值,SHA1 校验。
建议:openssl rand -hex 16 生成一个,两端同步。明文模式下 Token 校验就是唯一安全防线,别留空。
坑 11:iOS 支付不可用——「当前商户未开启iOS支付」

根因:iOS 走的是 Apple 支付通道,需要单独配置,而且必须要开:
后台「虚拟支付-基础配置」配小程序简称(Apple 展示名) 开启 iOS 支付能力
iOS 附加限制:
最低支付 1 元(< 1元 在 iOS 不可支付) iOS 15+、微信 8.0.68+ Apple 支付不支持沙箱(env 只能 0)
建议:iOS 配置在接入初期就做掉,别等 Android 联调完再处理。
错误码速查(实测)
_rN | ||
排查心法:先看 errCode → 再查服务端 signData 实际内容(落库到 order.prepay_id 可直接核对 productId/goodsPrice/outTradeNo/env)→ 最后才怀疑签名算法。
三个"必须"的工程习惯
1. 数据必须落库:每次微信推送原文存 pay_notify_logs(raw/parsed/status),对账、排查、审计全靠它,这是救命稻草。
2. session_key 必须无感刷新:真实用户不能要求重登。支付前静默 refreshSession() 是最省事的兜底。
3. 支付必须双保险:发货推送 + 客户端轮询都做,避免单点依赖推送导致订单状态不刷新。
芯知解读
微信小程序虚拟支付这趟走下来,最深的一点体会是:微信后台配置与服务端代码必须像素级对齐——道具 ID、道具价格、推送 URL、Token、数据格式,任何一项不一致,都会以各种神秘错误码的形式暴露出来,且极难定位。
这跟我们做智能体、做知识库的教训一模一样:系统的脆弱性往往不在单点,而在"两处必须一致"的约定。
任何"两端同步"的配置,都是潜在的故障点——要么做校验、要么做落库留痕、要么写进上线清单强制核对。
虚拟支付不是微信独有的问题,只要是"平台规则强约束"的场景(审核、签名、道具、推送),就要把"对齐后台配置"当成一等公民的工程事项,而不是最后上线的杂活。
如果你也正在对接虚拟支付,把上面每个坑的 checklist 过一遍,能帮你少熬两个通宵。
我们的项目:明策小筑 Tongue(招标信息聚合智能体),点击下面的小程序卡片体验一下吧。