微信JSAPI支付在电商、会员充值等场景中使用非常广泛,但接入过程中最容易踩的坑之一就是签名验证失败。其中有一种情况相当隐蔽:下单接口明明返回成功,前端调起支付时微信却提示签名错误,后台反复检查API密钥也没问题,最后才发现是某个参数值为空字符串时处理方式不对,导致拼出来的签名串和微信服务器计算的签名串不一致。这篇文章就来详细拆解这个问题。

微信支付签名的生成规则与空值参数的处理争议
微信支付V2接口使用MD5或HMAC-SHA256签名,基本流程是:将所有非空参数按key的ASCII码从小到大排序,用URL键值对的形式拼接成字符串,最后在末尾拼接&key=密钥再计算摘要。生成规则文档里写得清楚——参数值为空的参数不参与签名。正是这句话让不少开发者产生了误解:既然空值不参与签名,那是不是拼签名串前把空值参数直接丢弃就行?
问题在于“丢弃”这个动作在什么时机做。如果你的业务代码先构造了一个完整的参数Map,其中某些字段的值是空字符串(比如device_info=""或detail=""),在拼签名串时过滤掉了它们,但在最终提交的XML或表单数据里却又把空字段带上了,或者反过来提交时丢掉了但签名时算上了,两边就会不一致。微信服务器会拿你实际提交的参数重新计算一遍签名,任何一边多算或漏算一个参数,签名必然对不上。
还有一种更常见的情况:开发者使用了第三方的支付SDK,SDK内部对空值的过滤规则和自己的代码不一致。比如自己拼的签名串包含空值参数,SDK提交时却自动剔除了空字段,结果签名验证失败,而错误信息只会笼统地提示“签名错误”,很难直接看出根源。
典型错误代码分析与正确写法
先看一段典型的错误Java代码,问题就出在空值处理上:
Map<String, String> params = new TreeMap<>();
params.put("appid", "wx1234567890");
params.put("mch_id", "10000100");
params.put("body", "商品描述");
params.put("detail", ""); // 空字符串
params.put("attach", "");
params.put("nonce_str", "abc123");
params.put("out_trade_no", "20240101120000");
params.put("total_fee", "100");
params.put("spbill_create_ip", "127.0.0.1");
params.put("notify_url", "https://ipipp.com/notify");
params.put("trade_type", "JSAPI");
params.put("openid", "oUpF8xxxxxxxx");
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
// 错误点:只判断了null,没判断空字符串
if (entry.getValue() != null) {
sb.append(entry.getKey()).append("=")
.append(entry.getValue()).append("&");
}
}
sb.append("key=").append(API_KEY);
String sign = DigestUtils.md5Hex(sb.toString()).toUpperCase();
上面这段代码的问题在于detail和attach的值是空字符串,null判断拦不住它们,于是这两个空参数被拼进了签名串,形式为detail=&。而如果后续提交给微信的XML里没有这两个字段(或者反过来有),微信侧计算的签名串就少了这两个片段,签名自然对不上。正确做法是统一判断:entry.getValue() != null && !entry.getValue().isEmpty(),并且保证拼签名的参数集合和最终提交的参数集合完全一致。
PHP开发者也常犯类似的错误,看下面这段:
function makeSign($params, $key)
{
ksort($params);
$str = '';
foreach ($params as $k => $v) {
// 错误写法:空字符串也参与了签名
if ($v !== null) {
$str .= $k . '=' . $v . '&';
}
}
$str .= 'key=' . $key;
return strtoupper(md5($str));
}
修正方法是加上$v !== ''的过滤条件,或者在组装参数数组之前就干脆不要放空值字段进去。原则只有一条:参与签名的参数集合,必须和最终发送给微信的报文中实际携带的参数集合一模一样。空值字段要么两边都不出现,要么两边都出现并且以相同形式参与签名。
JSAPI调起支付时再次签名的注意事项
很多人不知道,JSAPI支付其实要算两次签名。第一次是统一下单接口的请求签名,第二次是拿到prepay_id之后,前端调起支付时的paySign。第二次签名的参数只有五个:appId、timeStamp、nonceStr、package、signType。这里也有几个高频踩坑点。
第一,大小写问题。第二次签名的参数名采用驼峰格式,比如是appId而不是appid,是nonceStr而不是nonce_str。如果直接复用了下单接口的签名工具方法,参数名很容易写错。第二,package的值必须是prepay_id=wx...这样的字符串,prepay_id本身不要加引号。第三,如果timeStamp传给前端的是字符串而签名时用的是数字,或者反过来,签名值也会不同。示例如下:
Map<String, String> payParams = new HashMap<>();
payParams.put("appId", appId);
payParams.put("timeStamp", String.valueOf(System.currentTimeMillis() / 1000));
payParams.put("nonceStr", nonceStr);
payParams.put("package", "prepay_id=" + prepayId);
payParams.put("signType", "RSA"); // V3使用RSA,V2为MD5或HMAC-SHA256
// 按字典序拼装后再签名,注意这里不能复用包含空值的通用Map
String paySign = wxPayUtil.createSign(payParams, apiKey);
如果你的通用签名工具会对Map做空值过滤,而这个Map里恰好有误放进去的空值字段,过滤行为可能和前端拿到的参数不一致,导致前端用paySign调起支付时失败。建议第二次签名使用一个干净的、只包含这五个参数的Map,不要往里面塞任何多余字段。
系统性排查签名错误的思路
当签名错误发生时,除了空值参数这个隐蔽原因,建议按下面的顺序系统排查,能省下大量反复试错的时间。
首先是核对API密钥。多商户号、多公众号的项目里密钥混用非常常见,A商户的证书配到了B商户的请求上,签名必然失败。其次是检查字符编码,请求报文必须是UTF-8,如果参数值包含中文且编码不是UTF-8,两边算出的摘要一定不同。再次是确认参数排序规则,必须按key的ASCII码升序,注意大写字母的ASCII码小于小写字母。最后是打印出实际的签名串原文,和微信支付官方提供的签名验证工具比对,直接定位是哪一个参数导致的差异。
- 确认参与签名的参数集合与提交报文的参数集合一致,空值字段两边统一处理
- 检查API密钥是否对应正确的商户号,避免多账号配置串位
- 确保所有参数按ASCII码升序排序,报文使用UTF-8编码
- JSAPI第二次签名的参数名使用驼峰格式,且只包含五个必需字段
- 利用微信支付官方的签名比对工具,逐字符比对签名串定位差异
总结一下,空值参数不参与签名这条规则本身没错,错的是实现时签名集合和提交集合不一致。只要在代码里保证“同一个参数集合,同一种空值过滤逻辑”,签名串两边就会完全一致,这类问题基本可以杜绝。