代码 小程序卖虚拟商品真机报「支付能力已被限制」:改接 wx.requestVirtualPayment 的三个签名坑

2026-09-10 09:01:13

小程序卖虚拟商品:真机报「支付能力已经被限制」,改接 wx.requestVirtualPayment 的三个签名坑

场景与排查结论

小程序做会员订阅(9.9/月、99/年),第一版用 wx.requestPayment + 微信支付 V3 JSAPI 实现。开发者工具和体验版测试一切正常,提交审核后在真机体验版支付时直接报:

小程序对应的支付能力已经被限制

依次排查了商户号状态、AppID 与商户号关联、结算验证、JSAPI 产品是否开通,全部正确,问题依然存在。

最终结论:会员订阅属于虚拟商品,必须走虚拟支付通道,不能用实物支付的 JSAPI;需要单独申请米大师(Midas)虚拟支付专用商户号。

两套体系的边界

wx.requestPayment(JSAPI)wx.requestVirtualPayment(虚拟支付)
商品类型实物商品,不支持虚拟商品(iOS)虚拟商品:会员、课程、道具、数字内容
iOS 支持不支持支持,底层走 Apple IAP
后端通道微信支付 V3 API米大师 Midas
商户号普通商户号虚拟支付专用商户号
签名算法RSA-SHA256 证书签名HMAC-SHA256 对称签名
费率Android 约 0.6%~1%iOS 虚拟支付约 12%(含苹果 30% 分成)

判断标准只有一条:用户花钱买的是数字内容而不是实体包裹,就必须用虚拟支付。想用 JSAPI 绕过去的方案在真机审核环节会直接卡死。

完整链路 6 步

  1. 小程序端选择套餐,请求后端创建订单。
  2. 后端构建 signData JSON,计算 paySigsignature,返回三要素。
  3. 小程序端调用 wx.requestVirtualPayment({signData, paySig, signature, mode})
  4. 微信客户端弹出 Midas 支付弹窗(iOS 走 Apple IAP)。
  5. 支付成功后,Midas 推送 xpay_goods_deliver_notify 到后端回调地址。
  6. 后端验签、更新订单、发货。

signData 结构与签名公式

{
  "offerId": "1450595140",        // Midas 应用ID(小程序后台获取),注意必须是字符串
  "buyQuantity": 1,
  "env": 0,                       // 0=正式环境,1=沙箱环境
  "currencyType": "CNY",
  "productId": "monthly_vip",     // 道具ID(Midas 道具管理配置)
  "goodsPrice": 990,              // 价格(分)
  "outTradeNo": "AT17843356...",  // 商户订单号
  "attach": ""
}

签名公式:

paySig    = HMAC-SHA256(AppKey,      "requestVirtualPayment" + "&" + signData)
signature = HMAC-SHA256(session_key, signData)

前端调用参数:signData(JSON 字符串)、paySigsignaturemode: 'short_series_goods'(道具直购模式)。

signData 必须原样传递,前端不能再 JSON.stringify 一次——签名基于服务端序列化出的确切字符串计算,前端重新序列化会改变字段顺序导致验签失败。

session_key 来自 wx.logincode2session 接口的返回值,必须在登录时保存到服务端数据库,每次登录更新;绝对不能暴露给前端,只在服务端使用。

签名工具(Java)

private static String hmacSha256(String key, String message) {
  Mac mac = Mac.getInstance("HmacSHA256");
  SecretKeySpec spec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
  mac.init(spec);
  byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
  StringBuilder hex = new StringBuilder(hash.length * 2);
  for (byte b : hash) hex.append(String.format("%02x", b));
  return hex.toString();
}

构建 signDataTreeMap,保证 JSON key 为字母序。

六个高频坑

坑 1:paySig 缺少 & 分隔符

现象:无论怎么调,始终返回 PAY_SIG_INVALID

根因:公式是 HMAC-SHA256(AppKey, uri + '&' + signData),漏掉中间的 &

❌ "requestVirtualPayment" + signData
✅ "requestVirtualPayment&" + signData

坑 2:offerId 的类型

Midas 后台配置里的 OfferID 是纯数字 1450595140,但在 signData 中它必须是字符串。如果序列化成数字(不带引号),签名同样失败。

坑 3:signData 的 JSON key 顺序

不同 JSON 库序列化出的 key 顺序可能不同,而 Midas 验签对 key 顺序敏感。最稳妥的做法是用 TreeMap 保证字母序。

坑 4:沙箱环境不发回调

沙箱(env=1)下支付弹窗能正常调起,但 Midas 不会推送 xpay_goods_deliver_notify 发货通知,表现就是「支付成功但没到账」。

处理方式:支付成功后客户端主动调后端确认订单接口;生产环境仍保留 Midas 回调作为最终保障。

坑 5:道具创建后有同步延迟

在 Midas 道具管理新建道具后立即测试,会报 COIN_OR_PRODUCT_ID_CREATED_IN_RECENTLY,需要等 10~15 分钟同步到支付网关。

坑 6:session_key 会过期

长时间不登录会失效,支付时 sessionKeynull,需要提示用户重新登录;每次 wx.login 成功后都要更新数据库里的 session_key

沙箱切生产

沙箱调通后切生产前,务必确认 Midas 道具管理的「现网环境」下也已创建商品,否则生产支付会报 PAY_SIG_INVALID

发货回调与幂等

同样的发货请求,outTradeNo 可能因为网络原因被请求多次;Midas 在有限时间内会尽量保证触达一次,直到明确返回发货成功为止。开发者需要自行保证只发货一次,并且回包要和第一次一样返回发货成功。

通知周期:15s / 15s / 30s / 3m / 10m / 20m / 30m / 30m / 30m / 60m / 3h / 3h / 3h / 6h / 6h

必须开启道具发货推送(并接入消息推送能力、配置推送 url)才能收到回调。

成功回包:

{"ErrCode":0}

失败回包:

{"ErrCode":99999,"ErrMsg":"internal error"}

发货消息关键字段:

  • OutTradeNo:订单号
  • OpenId:玩家
  • Env:0 现网 / 1 沙箱
  • GoodsInfoProductId / Quantity / ZoneId / OrigPrice / ActualPrice / Attach / OrderSource
  • WeChatPayInfoMchOrderNo / TransactionId

错误码对照

错误码含义
-1系统失败
-2支付取消
-6下单参数类型不对
-15001缺少参数
-15002参数不合法
-15003订单重复
-15005appId 权限被封禁
-15007订单已支付
-15009健康系统限制超限额
-15012SIGNATURE 错误
-15015sessionkey 过期
-15016道具价格错误
-15017订单已关闭
1003代币/分区未发布,或对应商户号被封禁
701001iOS 禁止支付(小游戏侧)

配置清单

  • 小程序后台 → 功能 → 虚拟支付 → 基本配置:获取 OfferIDAppKey(沙箱和现网各一套)。
  • 道具管理:创建商品,道具 ID 和价格必须与后端代码一致。
  • 数据库:user 表加 session_key 字段,price_config 表加 product_id 字段。

参考文档

本文基于微信小程序基础库 3.16.2、Spring Boot 3.2.5、米大师虚拟支付接口整理。部分字段与费率以官方文档与商户后台实际配置为准(未逐项实测)。

推荐文章

程序员茄子在线接单