微信JSAPI支付的签名机制看起来简单:把参数按key排序、拼接成key=value&的字符串、最后拼上密钥做MD5或HMAC-SHA256。但正是这个看似简单的流程里藏着不少坑,其中最阴险的一个就是参数值中的空格处理。不少开发者遇到过这样的情况:本地计算签名和微信支付官方签名校验工具算出来的结果一模一样,可统一下单接口偏偏返回“签名错误”,反复比对参数列表也没发现差异。问题往往就出在参数值的空格上——要么是排序前没有统一Trim,要么是拼接时URL编码方式与微信要求不一致。这篇文章把这个坑彻底讲清楚。

一、微信JSAPI签名算法对排序和拼接的具体要求
微信支付V2接口的签名规则官方文档写得比较简略,核心步骤是四步:第一,将所有非空参数按参数名ASCII码从小到大排序,使用URL键值对的格式拼接成字符串;第二,拼接时键值对之间用&连接,形如string1="key1=value1&key2=value2";第三,在string1最后拼接上&key=密钥;第四,根据商户平台设置的密钥类型对string1做MD5或HMAC-SHA256运算,结果转为大写。V3接口则改用RSA私钥对请求报文做SHA256-RSA签名,机制完全不同,本文重点讨论V2。
这里有一个非常容易忽略的细节:官方要求“参数值为空的参数不参与签名”,但并没有明确要求对参数值做Trim处理。也就是说,如果商品描述body的值本身带有空格,这个空格是要原样参与签名的。很多开发者在拼接签名串时习惯性地对值做了Trim,或者用了自动去除空格的URL编码器,导致计算出的签名与实际发送的报文不匹配。微信服务端是用你实际提交的原始报文验签的,你签的是处理过的值,发的是原始值,自然验签失败。
二、参数值含空格的三个典型踩坑场景
第一个场景是商品描述body字段含空格。比如body的值是"腾讯充值中心-QQ会员充值",中间的横杠没有问题,但如果值是"IOS应用内购买 商品",值中直接包含空格,某些语言的HTTP客户端在发送时会自动把空格编码成%20或+,而你在本地拼接签名串时用的是原始空格。微信服务端收到的是编码后的值,用它重新计算签名,与你提交的签名就对不上了。
坑点一:签名时用原始值,发送时被框架自动编码
这是最常见的错配。以Java的HttpClient为例,如果你用UrlEncodedFormEntity提交表单,空格会被编码为+;而XML报文方式提交时空格保持原样。你需要保证签名用的字符串和实际发送的报文字节完全一致。建议的做法是:先确定最终发送的报文内容,再基于这个最终内容计算签名,而不是反过来。
坑点二:排序时key的大小写和编码问题
排序必须按参数名的ASCII码值比较,而不是按字典序或本地化排序。Go语言的sort.Strings对纯ASCII是没问题的,但Java的String.compareTo在遇到非ASCII字符时行为不同。另外一个隐蔽问题是参数名本身必须区分大小写,appId和appid在排序时位置不同,微信要求统一使用小写的appid、mch_id等官方字段名,自定义字段也必须小写。
坑点三:多语言环境下的编码不一致
签名串必须统一使用UTF-8编码后做MD5。如果PHP文件本身是GBK编码保存的,body中的中文在计算签名时是GBK字节流,而请求头声明的是UTF-8,验签必然失败。空格问题同理:全角空格(\u3000)和半角空格(\u0020)在UTF-8下字节完全不同,肉眼看着一样,实际参与签名的内容差了两个字节。
三、正确处理空格与排序的代码实现
下面给出一段Go语言的正确签名实现,重点看两个地方:排序使用sort.Strings保证ASCII序,参数值原样参与拼接不做任何Trim或编码。
func SignXml(params map[string]string, key string) string {
// 1. 收集所有非空参数名,注意值为空的要跳过
keys := make([]string, 0, len(params))
for k, v := range params {
if v == "" {
continue
}
keys = append(keys, k)
}
// 2. 按ASCII码从小到大排序,不要对key做大小写转换
sort.Strings(keys)
// 3. 拼接,参数值原样使用,包括其中的空格
buf := make([]byte, 0, 256)
for _, k := range keys {
buf = append(buf, k...)
buf = append(buf, '=')
buf = append(buf, params[k]...)
buf = append(buf, '&')
}
// 4. 去掉末尾的&,拼上密钥
buf = buf[:len(buf)-1]
buf = append(buf, ("&key=" + key)...)
sum := md5.Sum(buf)
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
PHP开发者注意一个细节:http_build_query函数默认会把空格编码为+,直接用它的结果拼签名是错的。应该手动遍历拼接,像下面这样:
function signParams($params, $key) {
ksort($params, SORT_STRING); // 按字符串排序,接近ASCII序
$parts = array();
foreach ($params as $k => $v) {
if ($v === "" || $k == "sign") {
continue; // 空值和sign本身不参与签名
}
$parts[] = $k . "=" . $v; // 值原样拼接,不urlencode,不trim
}
$str = implode("&", $parts) . "&key=" . $key;
return strtoupper(md5($str));
}
Java版本的思路一致,重点是不要用TreeMap默认的字典序陷阱,其实TreeMap的自然排序对ASCII参数名是可靠的,真正要小心的是别在拼接前调用String.trim()。有些团队封装工具类时为了“容错”加了trim,结果body值首尾的空格被无声吃掉,与服务端期望的原始值不符。
四、签名错误的完整排查清单
当接口再次返回“签名错误”时,建议按下面的顺序逐项排查,能覆盖绝大多数情况:
- 比对签名串:把本地生成的签名串打印出来,逐字符与预期比对,重点检查参数值首尾是否有不可见字符(空格、\t、\r、\n),十六进制转储是最可靠的方式。
- 确认密钥正确:使用的是商户平台的API密钥而非APIv3密钥,两者不能混用,且密钥末尾不能多复制空格。
- 检查编码一致性:文件编码、HTTP请求的Content-Type中声明的charset、签名计算时的字节编码三者必须统一为UTF-8。
- 核对参数名拼写:appid与appId混用、nonce_str写成nonceStr,都会导致排序位置错误。
- 确认发送内容与签名内容一致:抓包看实际发出的报文,确认空格没有被中间层二次编码。
- 使用官方校验工具:微信支付提供的签名验证工具输入参数和密钥即可复现签名,用它做交叉验证可以快速定位是本地算法错还是数据错。
最后一个实用建议:在开发环境把签名串、密钥类型、编码方式一起打进日志,线上问题排查时这几行日志往往比任何猜测都有效。签名问题的本质是“你签的内容”和“服务端验签的内容”必须逐字节一致,只要牢牢抓住这个原则,空格、编码、排序这些坑都不难避免。