小程序卖虚拟商品:真机报「支付能力已经被限制」,改接 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 步
- 小程序端选择套餐,请求后端创建订单。
- 后端构建
signDataJSON,计算paySig与signature,返回三要素。 - 小程序端调用
wx.requestVirtualPayment({signData, paySig, signature, mode})。 - 微信客户端弹出 Midas 支付弹窗(iOS 走 Apple IAP)。
- 支付成功后,Midas 推送
xpay_goods_deliver_notify到后端回调地址。 - 后端验签、更新订单、发货。
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 字符串)、paySig、signature、mode: 'short_series_goods'(道具直购模式)。
signData 必须原样传递,前端不能再 JSON.stringify 一次——签名基于服务端序列化出的确切字符串计算,前端重新序列化会改变字段顺序导致验签失败。
session_key 来自 wx.login 后 code2session 接口的返回值,必须在登录时保存到服务端数据库,每次登录更新;绝对不能暴露给前端,只在服务端使用。
签名工具(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();
}
构建 signData 用 TreeMap,保证 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 会过期
长时间不登录会失效,支付时 sessionKey 为 null,需要提示用户重新登录;每次 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 沙箱GoodsInfo:ProductId/Quantity/ZoneId/OrigPrice/ActualPrice/Attach/OrderSourceWeChatPayInfo:MchOrderNo/TransactionId
错误码对照
| 错误码 | 含义 |
|---|---|
| -1 | 系统失败 |
| -2 | 支付取消 |
| -6 | 下单参数类型不对 |
| -15001 | 缺少参数 |
| -15002 | 参数不合法 |
| -15003 | 订单重复 |
| -15005 | appId 权限被封禁 |
| -15007 | 订单已支付 |
| -15009 | 健康系统限制超限额 |
| -15012 | SIGNATURE 错误 |
| -15015 | sessionkey 过期 |
| -15016 | 道具价格错误 |
| -15017 | 订单已关闭 |
| 1003 | 代币/分区未发布,或对应商户号被封禁 |
| 701001 | iOS 禁止支付(小游戏侧) |
配置清单
- 小程序后台 → 功能 → 虚拟支付 → 基本配置:获取
OfferID、AppKey(沙箱和现网各一套)。 - 道具管理:创建商品,道具 ID 和价格必须与后端代码一致。
- 数据库:
user表加session_key字段,price_config表加product_id字段。
参考文档
- 虚拟支付 2.0 道具直购
- 技术手册 - 虚拟支付篇
- 虚拟支付 2.0 游戏币
- wx.requestVirtualPayment API
- wx.requestMidasPayment API
- 微信开放社区关于 PAY_SIG_INVALID 的讨论帖
本文基于微信小程序基础库 3.16.2、Spring Boot 3.2.5、米大师虚拟支付接口整理。部分字段与费率以官方文档与商户后台实际配置为准(未逐项实测)。