
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 字典排序与kv拼接的细节确定好参与签名的字段后把所有参数的 key 按 ASCII 字典序升序排列。这个排序不是你想的“字母顺序”那么随意它严格按字节值排大写字母在小写字母前面数字在字母前面。所以在 Python 里直接用sorted(params.keys())在 Java 里用TreeMap都是按自然顺序处理的问题不大。举个例子一组参数如果包含appId、cityCode、nonce、timestamp、userType排好序之后就是appId cityCode nonce timestamp userType然后按keyvalue的格式用连接起来得到appId100001cityCode110100nonce3f0a1c2btimestamp1720000000userType1这里千万要注意排序后的拼接串里参数值是否要做 URL 编码很多平台有严格约定。如果 cityCode 是纯数字 110100这里自然没影响但如果某个参数值包含中文、空格、加号或特殊符号就直接决定了拼接串是什么进而影响 MD5 结果。后面第 4 节我会单独讲这个坑。2.3 密钥追加方式与输出格式约定拿到上面那串拼接内容后下一步是追加密钥。常见的追加方式有三种方式形式备注尾部追加键值对拼接串secretKey你的密钥很多平台采用这种直观、不容易产生歧义尾部直接追加字符串拼接串你的密钥密钥不参与排序只做简单拼接头部拼接你的密钥拼接串相对少见但部分老系统在用我这次遇到的某东方规则用的是第一种在拼接串末尾追加secretKey密钥之后再计算 MD5。密钥本身是商户申请接口权限时生成的服务端存的是同一个密钥这才能保证双方算出一样的摘要。输出格式上MD5 函数算出来默认是 32 位小写十六进制字符串有些平台要求转成大写有些要求保持小写。这个必须严格按文档来大小写不一致也会导致验签失败。另外有些平台要求对 MD5 结果再做一次 Base64 编码那就是另一套规则了不是本文讨论的范围。2.4 完整手算示例含终端命令我直接给一组可以照着算的参数appId100001 cityCode110100 nonce3f0a1c2b timestamp1720000000 userType1密钥暂定为5f4dcc3b5aa765d61d8327deb882cf99。按前面的步骤先排序拼接appId100001cityCode110100nonce3f0a1c2btimestamp1720000000userType1再追加密钥appId100001cityCode110100nonce3f0a1c2btimestamp1720000000userType1secretKey5f4dcc3b5aa765d61d8327deb882cf99然后在终端执行echo -n appId100001cityCode110100nonce3f0a1c2btimestamp1720000000userType1secretKey5f4dcc3b5aa765d61d8327deb882cf99 | 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排序后会出现两个不同的 keysign 自然对不上。编码前被 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?appId100001cityCode110100nonce3f0a1c2btimestamp1720000000userType1sign上面的MD5结果用 curl 验证时我习惯把请求写成文件确保没有细碎空格混进去curl -X GET \ https://api.example.com/openapi/v1/flight/search?appId100001cityCode110100nonce3f0a1c2btimestamp1720000000userType1sign你计算出的sign \ -H Content-Type: application/json如果签名通过服务端会返回正常业务数据如果还是报城市编码为空请立刻检查你 URL 里实际拼出来的cityCode110100是不是被终端或脚本里的引号吃掉了。我遇到过一次 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 签名算法版本不一致的诡异现象某东方平台的签名规则也经历了多个版本有的调用方可能在代码里带了signVersion2但服务端网关实际还在按 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 用脚本重算一遍再动手。宁可多花一分钟验证签名也不要对着一个“城市编码不能为空”的消息空想半小时。这大概就是这类搞怪报错教会我的最实在的习惯了。