代码 接口签名总对不上:%20 变 +、漏编码 !'()*、键序被 locale 改过

2026-11-06 09:02:20

接口签名总对不上:%20 变 +、漏编码 !'()*、键序被 locale 改过

同一份参数,前端 JS 算出来能过,后端 PHP/Go 复算就是不一致——九成不是算法选错,是"待签名串"本身不同。MD5/HMAC-SHA256 对字节敏感,签名串差一个字节,结果完全没法比。

所以排查顺序是:先打印两端的原始签名串,逐字节比对;确认一致之后再怀疑密钥、时间戳、算法。下面这些差异,按踩到的频率排。

差异一:空格被编码成 %20 还是 +

application/x-www-form-urlencoded 规定空格编码为 +,RFC 3986 规定为 %20。不同语言默认各站一边:

实现空格说明
JS encodeURIComponent(' ')%20RFC 3986
PHP rawurlencode(' ')%20RFC 3986
PHP urlencode(' ') / http_build_query()+RFC 1738
Java URLEncoder.encode()+表单编码
Go url.QueryEscape() / url.Values.Encode()+表单编码
Python urllib.parse.quote(' ')%20默认 safe='/'

PHP 的 http_build_query 默认走 RFC 1738,要换成 PHP_QUERY_RFC3986:

$q = http_build_query($params, '', '&', PHP_QUERY_RFC3986);

Go 要自己转:

func queryEscapeRFC3986(s string) string {
    return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}

只有当参数值里真的出现空格才会暴露,所以很多测试用例全过,上线碰到商品名带空格就挂。

差异二:encodeURIComponent 不编码 ! ' ( ) *

JS 的 encodeURIComponent 保留 A-Z a-z 0-9 - _ . ! ~ * ' ( )。RFC 3986 的 unreserved 只有 A-Z a-z 0-9 - _ . ~。也就是说,! ' ( ) * 五个字符在 JS 侧是原样输出的,在严格的 RFC 3986 实现里应该变成 %21 %27 %28 %29 %2A。

签名参数里出现这几个字符的概率不低:金额 1,000、备注 hello!、带括号的订单描述。只要一端编一端不编,签名串就不同。

反过来也有坑:有些 SDK 用 encodeURI(不是 encodeURIComponent),它连 : / ? # & = 都不编码,把整个参数区结构都留下了。

差异三:quote 的默认 safe

Python 的 urllib.parse.quote 默认 safe='/',斜杠不编码。参数值是 URL 或 base64 时立刻出问题:

from urllib.parse import quote
quote("a/b")           # 'a/b'
quote("a/b", safe='')  # 'a%2Fb'

Go 的 url.PathEscape 也不适合做参数编码,它保留 $ & + , ; = : @ 这些字符。

差异四:排序不是"按字母"

主流约定是 key 按 ASCII 升序拼接。但下面的写法会把它变成另一种顺序:

  • PHP ksort($arr, SORT_STRING) 是按字符串比,含数字 key 时和 SORT_REGULAR 结果不同;
  • Java String.compareTo 比的是 UTF-16 code unit,遇到 emoji(代理对)和部分生僻字,顺序会与按 UTF-8 字节序不同;
  • 用了 locale 相关排序(比如 C 的 strcoll、shell 的 sort 在某些语言环境)会按本地语言规则排;
  • 有 SDK 先 strtolower(key) 再排序,你没做,大小写混排的 key 就错位。

固定做法:统一 UTF-8 字节序、明确是否大小写归一、不依赖 locale。签名参数最好只用小写 ASCII key。

差异五:空值和 null 到底参不参与

三种写法结论不同:

  • key=(空字符串参与)
  • 直接剔除该 key
  • 传 null 被序列化成字符串 "null"

JS 还有一个静默丢失:JSON.stringify 会把 undefined 的字段直接丢掉。如果先用对象拼签名串,某个字段是 undefined,这个 key 在签名串里就消失了,而服务端拿到的是没这个字段——两边恰好一致还算运气好;只要一边用了 Object.assign 补默认值,就错。

约定写进接口文档:只签非空字符串,空值一律剔除,数组单独规则。然后两端共用一个"构建签名串"的函数,别各写一套。

差异六:数组和重复键

a=1&a=2 和 a[]=1&a[]=2 是两种完全不同的串,而且 [] 会被编码成 %5B%5D。同一个参数名出现两次时,拼接顺序也会影响结果,签名前必须排序或明确按数组下标展开。

差异七:body 用原始串还是重新序列化

这条最隐蔽。以微信支付 APIv3 为例,签名串是五行:

HTTP方法\n
URL(path + query)\n
时间戳\n
随机串\n
请求体\n

第五行是"实际发出去的字节"。如果先算签名、再把 JSON 反序列化后重新序列化发出去,键序或空格一改,服务端算出来的签名就对不上。

同理,反向验签时必须拿原始 body 算,不能用已经解析过的结构体重新 marshal —— Go 的 map 序列化顺序、PHP 的 json_encode 空格设置、Python 的 ensure_ascii,都会改字节。

差异八:输出编码

  • hex 大小写:MD5/HMAC 输出统一转小写再比,别拿大写 hex 和小写 hex 直接比对;
  • HMAC 结果有时要求 base64,注意 URL-safe base64 的 - _ 替换和 padding;
  • Python base64.encodebytes 每 76 字符插换行,签名用 base64.b64encode;
  • 有的接口要求签名前对参数做二次 URL 编码,有的不允许,文档里通常只写一句"参数需 URL 编码",实际指哪一层要看示例值。

排查清单

  1. 两端各打一份签名原文(脱敏:只留 key 与前 8 位),逐字节比对,先定位第一个不同位置。
  2. 拿最小参数集复现:1 个参数、纯数字值,确认算法与密钥没问题;再逐步加特殊字符。
  3. 用对方文档里的示例参数算一遍,与文档给出的签名值对齐。对齐不了就别往下调业务。
  4. 检查请求体:抓一次实际发出的 body,与参与签名的 body 做 byte 级 diff。

前提与不适用

上述内容针对"共享密钥 + 参数拼接"的对称签名(MD5、HMAC-SHA256 等)。RSA/SM2 这类非对称签名,除了签名串规则,还要看摘要算法、签名值编码(hex 还是 base64)、是否先做一次 base64。这一层各平台差异更大,务必以对应平台文档给出的示例向量为准。

另外,服务端如果对 signature 做了缓存,或白名单在压测环境放行,也会造成"线上必错、测试必过"的错觉,排查时先确认走的不是旁路。

文中的语言默认行为(http_build_query、URLEncoder、QueryEscape、quote 的 safe)按各自官方文档描述,未逐一实测所有运行版本的小版本差异,跨版本升级后建议重跑一次示例向量。

复制全文 生成海报 接口对接 签名 URL编码 PHP Go

推荐文章

程序员茄子在线接单