1. 表面报错“城市编码不能为空”,实际卡在sign签名校验
前一阵我在对接某东方生活服务开放平台的新接口,第一轮联调就撞上一面墙:请求发出去,服务端返回的 JSON 里永远挂着一句城市编码不能为空。我第一反应是查自己代码里 cityCode 的赋值,明明北京区域映射的是 110100,日志也正常打印出来了,服务端却一口咬定为空。换了另一个城市编码再试,还是一样的提示。折腾了大半个下午,最后才定位到根因——这个报错根本不是我传的城市编码有问题,而是请求里的 sign 签名压根没通过服务端校验,网关层统一抛出了一句模糊的业务参数错误。
这个现象在很多强调签名鉴权的开放平台上都存在。如果你也遇到过“某某参数不能为空”这类提示,但抓包看请求里参数明明带上了,那大概率不是参数本身的问题,而是签名环节出了岔子。这篇文章我把完整的排查思路、MD5 签名算法的逐步计算过程,以及城市编码这种基础参数在签名链路里容易翻车的地方都拆开讲一遍,对正在联调带签名接口的朋友应该有参考价值。
1.1 服务端网关的验签顺序决定了你看到哪条提示
大多数开放平台的请求处理流程,并不是“先查业务参数,再验签名”,而是倒过来的:网关收到请求后,先校验签名是否合法、时间戳是否在有效窗口内、随机数 nonce 有没有被重放,只有这一层全部通过,请求才会被转发到真正的业务服务里去执行城市编码、用户信息之类的字段校验。
也就是说,签名这一步就是大门,业务参数校验是门里面的第二道关卡。你连第一道门都没进去,服务端自然不会真的去检查你的 cityCode 值是多少,它只是在网关层发现验签失败后,扔了一条看起来像是业务校验失败的通用错误。很多平台故意这么设计,本质上是为了不让调用方太容易探测到签名规则,同时也避免直接暴露“签名错误”给未授权请求任何有价值的信息。
所以下单看提示,一旦出现“某个必传业务参数为空”而你又确认参数确实传了,最值得怀疑的不是参数名拼错,而是你生成的 sign 和服务端根据请求参数重新计算出来的 sign 不一致。
1.2 我从业务参数一路追到签名的排查过程
我当时踩过的路径是这样的,先汇总自己的排查步骤,你可以直接照着试:
- 把客户端实际发出的请求体完整保存下来,包含所有 query 参数和 body 参数,不要只记参数名。
- 对照开放平台文档,确认哪些字段参与签名,哪些字段不参与,比如 sign 本身、文件流这类通常都要排除。
- 用平台官方提供的签名工具,或者自己按文档写一个临时脚本,把保存下来的参数原样算一遍 sign。
- 把算出来的 sign 和请求里实际带上的 sign 做比对,不一致就说明签名串的拼接规则或参与签名的字段集合有问题。
我这次的情况,问题出在签名串拼接时用了user_type这个下划线字段名,而实际请求参数里是userType。因为 MD5 签名计算是针对原始字符串的,参数名稍有不同就会导致排序后的拼接串完全不一样,服务端自然验不过。而我一开始只顾着看城市编码字段,压根没想到去核对那个看起来人畜无害的 userType,所以浪费了不少时间。
1.3 为什么网关偏偏要把验签失败包装成“参数为空”
有人可能会问,直接返回“签名校验失败”不好吗?这里有两层考虑。第一层是安全,签名算法是开放平台的核心关卡,如果直接报签名错误,调用方一遍遍猜参数顺序和密钥拼接格式时会更容易验证自己的猜测是否命中;统一返回业务参数类错误,等于把探测路径堵死了。第二层是网关架构,网关层通常不关心具体业务,它只负责通用鉴权和转发,错误码也往往是一套通用的“必填参数缺失”模板,具体业务参数名是网关从配置中心取来的,真实原因早就被吞掉了。
理解了这层机制,后面所有排查都顺了:遇到提示“城市编码不能为空”,先去确认签名,不要急着查城市编码。
2. MD5签名算法的完整链路:从参数整理到32位摘要
搞清楚签名才是突破口之后,我把某东方平台的 MD5 签名规则完整梳理了一遍。不同平台的细节会有差异,但整体套路非常相似,这里以我实际在用的规则为例,把每一步都拆开讲清楚。
2.1 参与签名的范围:哪些字段进,哪些字段不进
签名计算的第一步是确定参数集合。我见过不少新手直接把整个请求体塞进去算,连 sign 字段自己也算进去了,这样服务端永远验不过,因为服务端计算的时候会用“请求中原有的 sign 以外的参数”,不会把传入 sign 本身再算一遍。
按平台文档约定,通常这几类参数不参与签名:
sign字段本身;- 文件上传类字段,因为文件内容的序列化方式在网关层不可控;
- 文档里明确标注“不参与签名”的扩展字段;
- 值为空字符串或 null 的字段,某些平台要求剔除后再算;
- 部分平台要求参与的字段只有业务参数,Meta 信息如 appId 也参与,要看具体约定。
这里有一个容易忽略的点:值为 0 的数字到底算不算空,每个平台约定不一样。有的平台把空串和 null 都剔除,但 0 是有效值要保留;有的平台接口定义里 0 就代表“未选择”,签名时同样要保留,只是业务校验会拒绝。最好的办法是直接看文档里的“签名参数说明”表格,并对着官方 SDK 的实现去核对。
2.2 字典排序与k=v拼接的细节
确定好参与签名的字段后,把所有参数的 key 按 ASCII 字典序升序排列。这个排序不是你想的“字母顺序”那么随意,它严格按字节值排:大写字母在小写字母前面,数字在字母前面。所以在 Python 里直接用sorted(params.keys()),在 Java 里用TreeMap,都是按自然顺序处理的,问题不大。
举个例子,一组参数如果包含appId、cityCode、nonce、timestamp、userType,排好序之后就是:
appId cityCode nonce timestamp userType然后按key=value的格式用&连接起来,得到:
appId=100001&cityCode=110100&nonce=3f0a1c2b×tamp=1720000000&userType=1这里千万要注意:排序后的拼接串里,参数值是否要做 URL 编码,很多平台有严格约定。如果 cityCode 是纯数字 110100,这里自然没影响,但如果某个参数值包含中文、空格、加号或特殊符号,就直接决定了拼接串是什么,进而影响 MD5 结果。后面第 4 节我会单独讲这个坑。
2.3 密钥追加方式与输出格式约定
拿到上面那串拼接内容后,下一步是追加密钥。常见的追加方式有三种:
| 方式 | 形式 | 备注 |
|---|---|---|
| 尾部追加键值对 | 拼接串&secretKey=你的密钥 | 很多平台采用这种,直观、不容易产生歧义 |
| 尾部直接追加字符串 | 拼接串你的密钥 | 密钥不参与排序,只做简单拼接 |
| 头部拼接 | 你的密钥拼接串 | 相对少见,但部分老系统在用 |
我这次遇到的某东方规则用的是第一种,在拼接串末尾追加&secretKey=密钥之后再计算 MD5。密钥本身是商户申请接口权限时生成的,服务端存的是同一个密钥,这才能保证双方算出一样的摘要。
输出格式上,MD5 函数算出来默认是 32 位小写十六进制字符串,有些平台要求转成大写,有些要求保持小写。这个必须严格按文档来,大小写不一致也会导致验签失败。另外有些平台要求对 MD5 结果再做一次 Base64 编码,那就是另一套规则了,不是本文讨论的范围。
2.4 完整手算示例(含终端命令)
我直接给一组可以照着算的参数:
appId=100001 cityCode=110100 nonce=3f0a1c2b timestamp=1720000000 userType=1密钥暂定为5f4dcc3b5aa765d61d8327deb882cf99。
按前面的步骤,先排序拼接:
appId=100001&cityCode=110100&nonce=3f0a1c2b×tamp=1720000000&userType=1再追加密钥:
appId=100001&cityCode=110100&nonce=3f0a1c2b×tamp=1720000000&userType=1&secretKey=5f4dcc3b5aa765d61d8327deb882cf99然后在终端执行:
echo -n "appId=100001&cityCode=110100&nonce=3f0a1c2b×tamp=1720000000&userType=1&secretKey=5f4dcc3b5aa765d61d8327deb882cf99" | md5sum执行后你会得到一个 32 位的十六进制摘要,把结果按平台文档要求转成大写或小写,这个值就是要传的 sign。为了验证你的本机 MD5 命令没装错,可以先拿一个已知结果试试:
echo -n "abc" | md5sum标准结果是900150983cd24fb0d6963f7d28e17f72。如果和这个一致,说明你的计算环境没问题,再回去算你的真实签名串即可。
这一步里最容易犯的错误,是拼接串里多了或少了空格、换行符,甚至把密钥里的特殊字符也做了 URL 编码。MD5 非常敏感,任何字节差异都会导致完全不同的摘要,所以建议尽最大可能用官方 SDK 来生成签名,而不是自己从头拼一遍。
3. 城市编码从哪来、怎么传才不会被服务端判空
签名链路捋顺之后,再回头解决城市编码这个具体字段。很多业务接口都要求城市编码作为锚点,用于定位城市维度下的价格、库存、门店列表。不同平台的编码规则不同,有的直接用行政区划代码,比如 110100 代表北京市辖区,有的用自建业务编号,甚至还分“城市编码”和“地区编码”两套体系。下面说说我这边的实践经验。
3.1 城市编码的三种来源
第一种是开放平台提供的城市列表文件,一般是一个 CSV 或 Excel,里面维护了省份、城市、区县和对应的 cityCode,适合一次性同步到本地配置文件或数据库。第二种是调用平台的城市查询接口动态获取,入参可以是省份名或经纬度,返回值里带 cityCode。第三种是平台 SDK 内置了城市映射表,直接通过 SDK 提供的方法取,这种最省事,但要注意 SDK 版本更新后编码表可能变化。
我建议不要凭记忆或网上搜来的代码写死城市编码,至少要在联调环境里调用一次城市查询接口,确认你要用的编码真的存在。编码错了,服务端在业务校验里同样可能返回“城市编码不能为空”,因为它拿着你传的编码去查城市字典,查不到就当不存在处理。
3.2 签名算法里最容易把城市编码搞丢的几种写法
即使你拿到了正确的城市编码并放进了请求体,签名阶段也容易把它弄丢。我这里列举几个实际见过的做法:
- 过滤空值时把 0 误伤:如果城市编码用了数字 0 开头,比如某些平台 010 代表北京,代码写在过滤逻辑里判断
if not value,会把字符串 "010" 当作空值过滤掉。更隐蔽的是解析 JSON 时把"010"转成了整数 10,再转回字符串就变成了 "10",编码彻底变了。 - 参数名大小写不一致:请求体里用的是
cityCode,签名拼接时却用了city_code,排序后会出现两个不同的 key,sign 自然对不上。 - 编码前被 URL 编码:如果城市编码本身是类似
110100%20这种带空格或特殊字符的值,签名前先做了一次 URL Encode,而服务端验签时用的是解码后的值,两边就不一致。纯数字编码不会遇到,但如果城市编码里混入了字母(部分平台的海外城市码会),就容易摔倒。 - 字段放在 Header:有些开发者图省事,把城市编码塞在 Header 里传给服务端,业务服务却默认从 body 取,结果自然是“不能为空”。这类字段放在哪里必须按接口文档的约定来,不能自由发挥。
3.3 一份修正后的完整请求示例
假设某东方平台的机票搜索接口要求这些参数:appId、cityCode、timestamp、nonce、userType、sign,其中城市编码从城市列表接口查到为 110100。正确的请求 URL 应该这样拼:
https://api.example.com/openapi/v1/flight/search?appId=100001&cityCode=110100&nonce=3f0a1c2b×tamp=1720000000&userType=1&sign=上面的MD5结果用 curl 验证时,我习惯把请求写成文件,确保没有细碎空格混进去:
curl -X GET \ 'https://api.example.com/openapi/v1/flight/search?appId=100001&cityCode=110100&nonce=3f0a1c2b×tamp=1720000000&userType=1&sign=<你计算出的sign>' \ -H 'Content-Type: application/json'如果签名通过,服务端会返回正常业务数据;如果还是报城市编码为空,请立刻检查你 URL 里实际拼出来的cityCode=110100是不是被终端或脚本里的引号吃掉了。我遇到过一次 Shell 变量前面有个不可见字符,curl 发出去的是空值,折腾了大半小时才发现是变量名拼写多了一个下划线。
4. 联调测试中容易翻车的三个细节
签名和城市编码的问题解决后,接口总算能通了,但我在后续测试里又踩了几个互换的典型陷阱。这些坑不一定都叫“城市编码不能为空”,但现象和排查思路是一样的。
4.1 时间戳、随机数与重放窗口
很多签名规则里都强制要求带 timestamp 和 nonce。timestamp 用来防止过期请求,nonce 用来防止重放。我踩过一次最典型的坑是:本地测试为了方便,把 timestamp 写死成一个固定值,结果签名算得完全正确,服务端也一直报“城市编码不能为空”。后来一查才发现,网关层在验签前先检查时间窗口,发现时间戳太老,直接走了统一错误分支。
这里有个经验:只要发现某个业务参数报错很“顽固”,并且你已经确认签名值本身能算对,赶紧检查签名包里有没有时间戳和 nonce,以及它们是否在有效期内。时间戳最好用服务端返回的服务器时间校准,避免客户端本地时钟偏差过大。nonce 则要保证每次请求都不一样,可以用 UUID,也可以在并发场景下用“时间戳+自增序号”拼接。
4.2 字符编码和URL编码的顺序陷阱
MD5 计算的输入其实是一个字节序列。同一个字符串,用 UTF-8 编码和用 GBK 编码算出来的 MD5 完全不同。所以文档里如果要求签名串“先 URL Encode 再 MD5”,你就必须老老实实先编码;如果要求“先 MD5 再对结果做 URL Encode”,那就不能多此一举。顺序反了,所有接口都会挂。
这个坑在纯数字城市编码上看不见,一旦某个参与签名的参数是中文城市名,或者包含加号、斜杠、字符串里的空格,就立刻爆发。我自己的经验是,尽量让所有参与签名的参数值都保持“文档定义的原始形态”:文档说传明文,就传明文;文档说先编码,再编码。不要在签名串里额外做一层看似合理的转义。
4.3 签名算法版本不一致的诡异现象
某东方平台的签名规则也经历了多个版本,有的调用方可能在代码里带了signVersion=2,但服务端网关实际还在按 v1 验签。这种情况下,你的签名计算逻辑是对的,参数也没漏,可服务端那边用的是老一套拼接规则,两边永远对不上。
排查时如果发现“按文档怎么算都验不过,但官方 SDK 一跑就通”,可以去确认一下请求参数里有没有签名版本号字段,以及它是不是也参与了签名计算。有些版本号的加入会改变参与签名的字段集合,导致排序结果完全变样。稳妥的做法是先拷贝官方 SDK 生成的请求报文,用同样的参数让你的自研代码生成一份,再逐字符对比签名串。
5. 这次踩坑之后我沉淀的自查清单与调试脚本
有了这次“城市编码不能为空”的乌龙经历,后来我再联调任何带签名的接口,都不再一上来就查业务参数了。我把排查逻辑固化成一套清单,按顺序走一遍,基本能在几分钟内定位问题。
5.1 验签不通过之前的五步自查
- 确认请求里所有参与签名的字段,和本地签名脚本里用到的字段完全一致,一个不多,一个不少。
- 确认排序字段按 ASCII 字典序升序排列,大小写和文档完全一致。
- 确认密钥追加方式、追加位置、是否带
&分隔符,和文档一模一样。 - 确认 MD5 转为十六进制后的大小写约定,以及是否需要对摘要再做二次编码。
- 用官方签名工具或官方 SDK 对同一组参数生成一个标准 sign,与自己的结果比对。
第五步是终极验证手段。如果官方工具和自己的脚本算出来的 sign 一致,说明签名链路没问题;如果还不一致,问题一定在请求传输环节,比如实际发出的时候参数被框架重新排序、解码或过滤了。
5.2 城市编码专项检查项
针对“城市编码不能为空”这类具体报错,我在清单里加了几个专项项:
- 城市编码字段名是否和文档完全一致,尤其是大小写和下划线;
- 城市编码是否作为字符串参与签名,而不是整型,避免前导零丢失;
- 从城市查询接口回查一次,确认编码在当前环境有效;
- 抓包或打印原始请求日志,确认实际发出的 body 或 query 里 cityCode 的值没有被框架解析掉;
- 在签名过滤空值逻辑里确认
0这类值是否被误杀。
5.3 一个本地签名脚本帮你5秒定位问题
为了方便调试,我写了一个简单的 Python 脚本,每次联调前用同样的参数跑一遍,直接输出 sign。脚本核心逻辑如下:
import hashlib import urllib.parse def build_sign(params: dict, secret_key: str) -> str: filtered = {} for k, v in params.items(): if k == "sign": continue if isinstance(v, str) and v.strip() == "": continue if v is None: continue filtered[k] = str(v) raw = "&".join(f"{k}={filtered[k]}" for k in sorted(filtered.keys())) raw = raw + "&secretKey=" + secret_key return hashlib.md5(raw.encode("utf-8")).hexdigest() if __name__ == "__main__": test_params = { "appId": "100001", "cityCode": "110100", "nonce": "3f0a1c2b", "timestamp": "1720000000", "userType": "1", } print(build_sign(test_params, "5f4dcc3b5aa765d61d8327deb882cf99"))注意脚本里的过滤逻辑没有把 0 过滤掉,因为0是合法业务值。如果你的平台文档明确说 0 当作空值,那就要额外加判断。这段脚本只是参考,实际对接时务必以平台的官方签名组件为准,如果平台提供了 Java 或 Go 的 SDK,建议直接调用 SDK 的方法来生成签名,能省掉一大批边缘问题。
我个人现在调试这类接口的顺序,已经固定成“先验签,再查参数”。哪怕报错写得再像业务参数缺失,我也至少先把 sign 用脚本重算一遍再动手。宁可多花一分钟验证签名,也不要对着一个“城市编码不能为空”的消息空想半小时。这大概就是这类搞怪报错教会我的最实在的习惯了。