回调报 NumberFormatException: For input string “PUB”:微信支付公钥灰度的验签路由与 100% 门槛
相关文档:
标注:带「文档结论」的来自微信支付官方文档或官方社区回复;PHP、Go 片段未在线上实测。
现场:serial 是 PUB_KEY_ID,不是十六进制
回调请求头里出现:
{"nonce":"UqfMeJR3A5rSzMzGWjeEPjlQ6qIJ5LYG","serial":"PUB_KEY_ID_0117140904342025041500441584001200","signature":"...","timeStamp":"1751026276"}
Java 侧报:
java.lang.NumberFormatException: For input string: "PUB" under radix 16
官方回复:代码只支持 16 进制的 Wechatpay-Serial,商户号是平台证书方案但开启了公钥切换灰度,回调已自动灰度一小部分用公钥,PUB_XXX 为公钥 ID(原帖)。
这不是密钥错,是把 Wechatpay-Serial 当成了固定格式的十六进制序列号。公钥灰度下,这个头可能是 PUB_KEY_ID_xxx,也可能是 40 位十六进制平台证书序列号。任何 radix 16 解析都会在 PUB 三个字母处炸。
| 报错 | 根因 | 处理 |
|---|---|---|
NumberFormatException: For input string "PUB" under radix 16 | 把 Wechatpay-Serial 当十六进制序列号解析 | 按原样字符串查验签材料 map |
publicKeyId[PUB_KEY_ID_0113214...] and serialNumber[7B77373C...] are not equal | 公钥 ID 与商户 API 证书序列号互填 | 下单接口要商户证书序列号的地方别填公钥 ID,反之亦然 |
Cannot found the serial(...)'s configuration; which's from the response(header:Wechatpay-Serial) | SDK 没配置对应 serial 的验签材料 | 同时加载平台证书序列号和 PUB_KEY_ID_ 公钥 ID |
| 应答比例卡住不到 100% | SDK 初始化类或回调 NotificationConfig 选错 | 见下文代码层 |
为什么切,哪些情况不用切
文档结论:平台证书有效期 5 年,到期须主动更换;公钥模式无有效期,省掉每 5 年的换证。只用了 APIv2 的无需切;新商户号且全新开发、从未用过平台证书模式的,直接用公钥,也不存在“切换”。
平台证书切换到公钥是按商户号维度,多个商户号要逐个切。「平台证书切换公钥」不能与「灰度更换新平台证书」同时进行;若正在灰度换新平台证书,要先停止该灰度。
多项目共用同一商户号时,每个项目的发起请求和回调验签都要统一适配;单平台证书场景只能停服切换,见接入易错点。
两个比例:回调由微信控,应答由你控
文档结论:两个比例都到 100%,才能在商户平台点「确认切换」。确认后,除敏感字段加解密场景外,请求头可以不带 Wechatpay-Serial。
| 比例 | 谁控制 | 触发与口径 | 到 100% 的条件 | 常见卡点 |
|---|---|---|---|---|
| 回调使用公钥比例 | 微信 | 开始更换后按第一天 0.1%、第二天 1%、第三天 5%、第四天 10%、第五天 20%、第六天 50%、第七天 100% 自动灰度 | 微信侧灰度完成 | 代码只认十六进制;代理丢 Wechatpay-* 头 |
| 应答使用公钥比例 | 商户 | 请求头带 Wechatpay-Serial: PUB_KEY_ID_xxx 则微信用私钥签名、你用公钥验;不带则该请求仍走平台证书签名。比例为近 7 天用公钥调接口次数 / 近 7 天 v3 接口总调用次数,数据每小时更新 | 全部请求带公钥 ID 后,还要等 7 天滚动到 100% | SDK 初始化类没换;100% 后还用 Combined 回调配置自动下载平台证书 |
回调比例商户不可操作。灰度期间回调部分用公钥私钥签名、部分用平台证书签名,头里的 Wechatpay-Serial 分别是 PUB_KEY_ID_xxx(公钥 ID)或 40 位十六进制平台证书序列号。商户必须按头路由验签,不能假设回调已经全量公钥,也不能假设还是平台证书。
应答比例是商户自己控的:发起 v3 请求时主动带 Wechatpay-Serial: PUB_KEY_ID_xxx,应答才走公钥验签;不带就仍走平台证书签名。把全部 v3 请求都带公钥 ID 后,统计仍按近 7 天滚动、每小时更新,所以还要等 7 天应答比例才到 100%。
排期风险:平台证书有 5 年有效期。开始灰度前先看证书到期日,按回调 7 天节奏和应答近 7 天滚动预留时间,别让证书先过期而公钥灰度没完成(文档结论)。
代码层:按头查 map,别解析 serial
Java SDK 原用 RSAAutoCertificateConfig.Builder(),切换后用 RSAPublicKeyConfig.Builder(),需要 merchantId、商户 API 私钥、微信支付公钥(pub_key.pem)、公钥 ID(PUB_KEY_ID_ 前缀)、商户 API 证书序列号、APIv3 密钥。
回调兼容:切换期用 RSACombinedNotificationConfig,它会持续自动下载平台证书,兼容两种签名;回调比例到 100% 后必须改成 RSAPublicKeyNotificationConfig,否则会一直有自动下载平台证书的流量,导致「应答比例」永远到不了 100%。初始化类选错、回调配置选错,是两个常见的比例卡点。
官方明确:不应差别对待该字段值的构成,例如 PUB_KEY_ID_ 前缀;用 key/value Map 加载实例。
PHP 片段(按官方示例思路整理,未实测):
<?php
use WeChatPay\Crypto\Rsa;
// serial 是原样字符串:可能是平台证书序列号,也可能是 PUB_KEY_ID_ 公钥 ID
$platformPublicKeyMap = [
'7132D72A03E93CDDF8C03BBD1F37EEDF********' => Rsa::from('/path/platform_cert.pem', Rsa::KEY_TYPE_PUBLIC),
'PUB_KEY_ID_00000000000000000000000000000000' => Rsa::from('/path/pub_key.pem', Rsa::KEY_TYPE_PUBLIC),
];
$serial = $_SERVER['HTTP_WECHATPAY_SERIAL'] ?? '';
if ($serial === '' || !array_key_exists($serial, $platformPublicKeyMap)) {
http_response_code(500); // 非 2xx,等微信重投
exit;
}
$timestamp = $_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_WECHATPAY_NONCE'] ?? '';
$signature = $_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // 必须原始报文
$message = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
if (!Rsa::verify($message, $signature, $platformPublicKeyMap[$serial])) {
http_response_code(400); // 验签失败,让微信重投
exit;
}
Go 标准库等价片段(未实测):
package pay
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
)
func verifyWechatpayNotification(r *http.Request, body []byte, keys map[string]*rsa.PublicKey) error {
serial := r.Header.Get("Wechatpay-Serial")
pub, ok := keys[serial]
if !ok {
return fmt.Errorf("unknown Wechatpay-Serial: %q", serial)
}
ts := r.Header.Get("Wechatpay-Timestamp")
nonce := r.Header.Get("Wechatpay-Nonce")
sigB64 := r.Header.Get("Wechatpay-Signature")
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
return err
}
msg := ts + "\n" + nonce + "\n" + string(body) + "\n"
sum := sha256.Sum256([]byte(msg))
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig)
}
keys 同时放平台证书公钥和公钥 ID 对应公钥;验签失败由调用处返回 4xx/5xx。
发起请求主动用公钥时,请求头示例:
curl -X GET https://api.mch.weixin.qq.com/v3/xxx \
-H "Authorization: WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\",..." \
-H "Wechatpay-Serial: PUB_KEY_ID_0000..."
验签串、SIGNTEST 与排查
验签串固定三行:
{timestamp}\n{nonce}\n{body}\n
含最后一行换行;body 为空时最后一行只有一个 \n。必须用原始报文主体,任何框架重新序列化都会失败。
签名探测:微信会在极少数应答/回调中故意给错误签名(值以 WECHATPAY/SIGNTEST/ 开头)探测商户是否真的验签。不要特殊放行,按正常流程验签失败处理:应答验签失败就丢弃/重试;回调验签失败返回 4xx/5xx 让微信重投。别把这类失败当线上故障告警拉满(文档)。
签名验证失败排查五条(文档):
- 核对
Wechatpay-Serial与当前使用的证书序列号/公钥 ID 是否一致。 - 核对私钥证书与算法
SHA256withRSA。 - 看签名值是否
WECHATPAY/SIGNTEST/开头。 - 用官方验签工具。
- 优先用官方 SDK。
其他坑
- 某些代理服务器或 CDN 转发时会过滤微信支付的扩展 HTTP 头(
Wechatpay-*),导致应用层拿不到签名。建议调整代理配置或回调直连。 - 公钥 ID 与商户 API 证书序列号是两个东西,不能互填;下单接口某些字段要的是商户证书序列号。
- 需要回退时:商户平台 → 账户中心 → API 安全 → 微信支付公钥,关闭切换即可退回平台证书(官方社区回复)。回退后旧平台证书验签路径不能删,否则回退即故障。
- APIv3 密钥只用于解密(
AEAD_AES_256_GCM),不能当验签密钥用。
自检清单
- 回调/应答是否都按
Wechatpay-Serial原样字符串查 map,不再按十六进制解析。 - map 是否同时加载平台证书序列号和
PUB_KEY_ID_公钥 ID。 - 验签串是否为
timestamp\nnonce\n原始 body\n,body 是否未被重新序列化。 - 发起 v3 请求是否全部带
Wechatpay-Serial: PUB_KEY_ID_xxx。 - Java 初始化是否从
RSAAutoCertificateConfig改为RSAPublicKeyConfig。 - 回调比例到 100% 后是否从
RSACombinedNotificationConfig改为RSAPublicKeyNotificationConfig。 - 商户平台两个比例是否都 100% 才点「确认切换」。
- 是否确认没有同时灰度更换新平台证书。
- 多商户号是否逐个切;多项目是否统一适配。
- 平台证书到期日是否早于灰度完成时间。
- 代理/CDN 是否保留
Wechatpay-*头。 - 回退路径是否保留平台证书验签;APIv3 密钥是否仅用于解密。
SIGNTEST验签失败是否按正常失败处理,未拉满告警。