微信虚拟支付报 -15005/-15006:先分清 signature 和 paySig,再查 signData 序列化
调 wx.requestVirtualPayment 时返回 SIGNATURE_INVALID 或 PAY_SIG_INVALID,很多人第一反应是“支付签名算错了”,然后开始逐字比对 appKey、换 HMAC 库、改拼接顺序。实际上这两个错误码指向两份完全不同的签名,用的密钥也不同。第一步做错,后面所有排查都是白费。
接口文档:wx.requestVirtualPayment;平台能力与开通说明:虚拟支付。基础库 2.19.2 起支持,不支持 Promise 风格,小程序插件内不可用。
两个错误码分别对应哪份签名
| errno | errMsg 关键字 | 官方含义 | 签名密钥 | 待签字符串 |
|---|---|---|---|---|
| -15005 | SIGNATURE_INVALID | 用户态签名 signature 错误 | session_key | signData |
| -15006 | PAY_SIG_INVALID | 支付签名 paySig 错误 | 小程序 AppKey(按 env 选) | uri + '&' + signData |
社区里大量帖子把 -15005 当成 paySig 错误去改算法,方向一开始就偏了。有人在开放社区贴出 iOS 报 {"errMsg":"requestVirtualPayment:fail SIGNATURE_INVALID","errno":-15005}、Android 沙箱却正常,一直在查 paySig,官方回复的意思是:SIGNATURE 不是 paySig,-15005 通常是 session_key 不是最新的,重新 wx.login 取一次再生成 signature。
所以排障顺序是:先看 errno 判定是哪一份,再动对应的密钥和字符串。两份都报过,说明 signData 本身可能有问题——这种情况直接跳到序列化那一节。
-15005:查 session_key,不是查算法
signature = hex(hmac_sha256(sessionKey, signData)),sessionKey 来自 wx.login 拿 code 后服务端调 auth.code2Session 换取。
几个容易踩的点:
session_key会失效,微信没有给出明确的有效时长;获取次数也有限制(文档描述为每日次数不多于打开小程序的用户数)。服务端应当缓存或落库,不要每次现取现用、也不要取完不存。- PHP 里
session_key必须拿原始字符串参与签名,不能做base64_decode或其他处理。社区有人在这里栽过。 - 出现“iOS 报 -15005、Android 正常”这种设备分化时,优先怀疑并发
wx.login:某条链路重新登录把session_key刷新了,旧 key 立刻失效,而手里这份signature还是用旧 key 算的。这不是签名算法问题,改算法永远修不好。 - 前端在支付前校验登录态,失败时重新
wx.login并限制重试次数,否则容易打成死循环。 - 如果错误码是 -15007(
session_key过期),那就更直接,重新登录换 key 即可。
另外,signature 和 paySig 都必须是 64 位小写 hex。社区有帖子把 signature 改成 Base64,立刻返回 SIGNATURE_INVALID;Node 里对 digest('hex') 的结果做 .toUpperCase() 同样会挂。
-15006:AppKey 与 env 必须配对
paySig = hex(hmac_sha256(appKey, uri + '&' + signData)),其中 uri 在 wx.requestVirtualPayment 场景固定为字符串 requestVirtualPayment。
AppKey 在 MP 后台 → 虚拟支付 → 基本配置里,分沙箱和现网两套:
env=0→ 现网 AppKeyenv=1→ 沙箱 AppKey
现网环境传 env=1 会直接报 -15011(现网版本 env 只能是 0)。社区案例 B 里,开发者自查 AppKey 和 OfferID 都取自现网配置、goodsPrice 已转分、HMAC-SHA256 本地校验一致、商品发布超过 30 分钟,仍然持续报 -15006,关键代码里对 hex 结果做了 .toUpperCase()——这类“本地算得对、线上就是不过”的情况,先看大小写和 env 是否匹配,再怀疑算法。
还有一类自创拼接规则:把换行加进去、追加 mode=、把字段展开成 querystring、对 signData 做 rawurlencode。社区有人把这些拼法挨个试了一遍,全是 PAY_SIG_INVALID。官方规则就是 uri + '&' + signData 原文,没有别的变体。
signData 是字符串,不是对象
这是两份签名同时失效时最常见的原因。signData 在接口参数里是字符串:
'{"offerId":"123","buyQuantity":1,"env":0,"currencyType":"CNY","productId":"testproductId","goodsPrice":10,"outTradeNo":"xxxxxx","attach":"testdata"}'
服务端算签名用的那串字符,必须和前端最终传给 wx.requestVirtualPayment 的 signData 逐字节一致。官方文档对序列化的原话是:JSON 数据序列化结果,不同语言/版本结果可能不同;实际使用时只需保证参与签名的 post_body 和真正发起 HTTP 请求的一致。
差异从哪来:
- PHP
json_encode默认转义/和中文,https://x会变成https:\/\/x,中文会变成\uXXXX。加JSON_UNESCAPED_SLASHES、JSON_UNESCAPED_UNICODE会得到另一种字符串。两种都可能是对的——前提是前端收到的就是这一份,别再改。怕的是服务端用默认选项签了名,前端拿到的却是另一份渲染结果。 - PHP 数组类型:空数组加
JSON_FORCE_OBJECT会变{},不加是[];关联数组字段顺序取决于插入顺序,中间插一个字段整串就变了。 - Node 对象 key 顺序:数字型 key 会被提前排序,插入顺序不等于序列化顺序。数字和字符串也容易混:
goodsPrice: 10和"10"是两个字符串。 - 前端拿到后重新
JSON.parse再stringify:社区案例 E 就是这个,服务端生成signData,前端收到后 parse 一遍再 stringify 传给wx,key 顺序或类型一变,服务端签的字符串和实际传的就对不上了。 - 案例 D:PHP 开发者把
signData和paySig一起json_encode成一个数组['signData'=>..., 'paySig'=>...]再做 HMAC,signature必然失败。正确做法是只对signData这一个 JSON 字符串做 HMAC。
实操上最稳的做法:服务端把 signData 字符串原样下发给前端,前端不要再序列化,直接透传。要调试就把服务端参与签名的字符串和前端实际传的字符串分别打日志,逐字符对比。
两份签名的实现
Node:
const crypto = require('crypto');
// 统一输出小写 hex
function hmacHex(key, msg) {
return crypto.createHmac('sha256', key).update(msg, 'utf8').digest('hex');
}
// 小程序端支付签名
const paySig = hmacHex(appKey, 'requestVirtualPayment&' + signData);
// 用户态签名
const signature = hmacHex(sessionKey, signData);
// 服务端 API 的 pay_sig:uri 不带 query,postBody 与实际发送的 body 逐字一致
const apiPaySig = hmacHex(appKey, '/xpay/query_user_balance&' + postBody);
PHP:
function hmac_hex(string $key, string $msg): string {
return hash_hmac('sha256', $msg, $key); // 默认小写 hex
}
$signData = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// 这个 $signData 原样下发前端,前端不要再 parse/stringify
$paySig = hmac_hex($appKey, 'requestVirtualPayment&' . $signData);
$signature = hmac_hex($sessionKey, $signData);
校验实现对不对,可以用官方 Python 示例里的 assert 值(写死、可复现):
calc_pay_sig('/xpay/query_user_balance', '{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}', '12345')
== 'c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5'
calc_signature('{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}', '9hAb/NEYUlkaMBEsmFgzig==')
== '089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7'
自己的 HMAC 跑不出这两个值,先修实现,别急着调参。
服务端 API 场景还有两条:uri 不能带 query,/xpay/query_user_balance 就是 /xpay/query_user_balance;post_body 必须和真实发出的 body 逐字节一致——这跟前面 signData 是同一个问题。
参数层错误码速查
签名对了之后,卡点通常在参数和后台状态。常见几个:
| errno | 含义 | 处理 |
|---|---|---|
| -15001 | 参数错误 | 具体原因看 err_msg |
| -15002 | outTradeNo 重复 | 换新单号;8–32 字符,只能数字/大小写字母/`_- |
| -15010 | 道具 productId 未发布 | 后台发布道具 |
| -15013 | goodsPrice 与后台不一致 | 单位是分,平台会拿它和后台道具价格比对 |
| -15014 | 道具/代币发布未生效 | 大约 10 分钟后生效,期间别硬试 |
| -15016 | signData 格式有问题 | 回到上一节查序列化 |
| -15018 | productId 审核不通过 | 走审核 |
| -15020 / -15021 | 操作过快 / 小程序被限频交易 | 降频、加退避 |
mode(short_series_goods 道具直购 / short_series_coin 代币充值)是 wx.requestVirtualPayment 的顶层参数,不参与签名——这是社区实践,官方文档未明确说明,未实测。
设备路由与最终确认
平台按设备类型路由:Android、鸿蒙、Windows 走微信支付;iOS 走 Apple 支付。Apple 支付不支持沙箱,所以 iOS 必须 env=0。iOS 端还要在后台勾选道具支持 iOS,最低支付金额 1 元(社区反馈)。Windows 客户端也要适配,platform == 'windows' 时同样调 wx.requestVirtualPayment,漏了会出现异常通路。
前端 success 只算弱确认,最终以后端回调 + 主动查单为准。查单走服务端 API,比如 /xpay/query_order、/xpay/query_user_balance。订单状态建议用 PAID / DELIVERED / SUCCESS / FAILED / CLOSED / REFUNDING / REFUNDED 这套。
回调里的 attach 会原样透传,但别把中文业务字段一股脑塞进去,序列化和编码问题容易连带影响签名排查。后台操作退款后,退款通知走 HTTP 而非云函数消息推送(社区反馈,可能仍存在差异)。
一份收尾清单
- 看 errno:-15005 查
session_key与signature,-15006 查 AppKey、env、hex 大小写。别对着错的那份改。 - 打日志:服务端参与签名的
signData字符串、前端实际传入的字符串,逐字节比。 - 确认
env与 AppKey 配对,现网不传env=1。 session_key落库缓存,支付前重新登录,重试次数设上限。- 校验 HMAC 实现时先跑官方 assert 值,再谈业务。
- 道具改完等约 10 分钟,
outTradeNo每单唯一。