代码 回调报 For input string "PUB":微信支付把平台证书切公钥,serial 不能当十六进制解析

2026-11-09 09:03:53

回调报 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 让微信重投。别把这类失败当线上故障告警拉满(文档)。

签名验证失败排查五条(文档):

  1. 核对 Wechatpay-Serial 与当前使用的证书序列号/公钥 ID 是否一致。
  2. 核对私钥证书与算法 SHA256withRSA。
  3. 看签名值是否 WECHATPAY/SIGNTEST/ 开头。
  4. 用官方验签工具。
  5. 优先用官方 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 验签失败是否按正常失败处理,未拉满告警。

推荐文章

程序员茄子在线接单