微信小程序接入虚拟支付你踩过坑了吗?

2026年9月10日 · 13 分钟阅读 · 440 次浏览

微信小程序接入虚拟支付你踩过坑了吗?

提审被拒两次,才明白这事绕不过去


我们做明策小筑(一个招标信息聚合的智能体),要开始会员订阅——这属于虚拟商品。

一开始我们想小程序付费这玩意儿我们之前搞过太多了,微信商户号都开了现成的,直接关联一下小程序用微信支付(wx.requestPayment + JSAPI)不就得了。

很快就集成了支付,提交了审核,结果提审被连拒两次,审核的反馈说得很那什么:

小程序服务内提供虚拟产品、虚拟商品购买服务,其购买和支付均需接入官方「小程序虚拟支付」能力。

也就是说:只要卖的是虚拟商品,普通微信支付这条路就断了,绕不过去。

但是这个虚拟支付是微信小程序刚刚推出的,大家都没什么经验,于是我们老老实实从普通微信支付迁移到官方「小程序虚拟支付」(wx.requestVirtualPayment)。

整个过程也是踩了无数的坑,从"服务端报错查不到日志"到"iOS 不能支付",每一个都是"知道就不难,不知道就会卡半天"的典型。

今天我们把全过程、每个坑的根因和解法都分享出来,给同样要接微信虚拟支付的团队排排雷。

不是普通的微信商户号支付的"换 API"

这是最关键的认知——虚拟支付不是普通支付换个接口,而是一套全新的体系:

维度
普通微信支付
小程序虚拟支付
下单发起方
服务端统一下单拿 prepay_id
客户端
 wx.requestVirtualPayment 发起
服务端角色
下单 + 拉起
只生成签名参数
(signData/paySig/signature)
签名体系
RSA 单签名
paySig + signature 双签名
(HMAC-SHA256)
商户号
普通商户号
新开一个虚拟支付二级商户号
商品
任意传价格
后台配置"道具",两端必须严格一致
回调
回调 JSON
消息推送,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 联调完再处理。

错误码速查(实测)


错误码
含义
根因
-15002
outTradeNo 重复
复用旧单号 → 每次换 _rN
-15005
用户态签名错
session_key 不一致 → 刷新
-15006
paySig 错
AppKey 与 env 不匹配(现网/沙箱两套)
-15013
goodsPrice 不一致
服务端价格与道具价格没对齐
-15014
道具未生效
改价/新发布后立刻测试 → 等 10 分钟
400
服务端拒绝
session_key 空 / 已有待支付订单
500
服务端异常
prepay_id 字段超长

排查心法:先看 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(招标信息聚合智能体),点击下面的小程序卡片体验一下吧。

在线客服
微信公众号
返回顶部