调支付接口,十次报错里,有八次是签名错。而签名错里,十次有九次,不是算法写错了,是签名原文拼错了。

这句话,是我做了这么多年支付对接,最想告诉每一个新手的一句话。你以为签名是"把参数塞进去、调个 MD5 就完事",其实签名最难、也最容易错的地方,是前面那一步——把一堆参数,按规矩拼成一段"待签名字符串"。这段字符串,多一个空格、少一个参数、顺序错一位,算出来的签名就是错的,通道直接给你打回来。

这篇文章,我不跟你讲 MD5 还是 RSA 哪个好(那个我另写过),我就专讲一件事:签名原文,也就是待签名字符串,到底该怎么拼。这是所有签名报错的根源,也是最需要"较真"的地方。

签名的本质:先拼原文,再算摘要

先把原理捋一遍,你才知道为什么"拼原文"这么重要。

签名的目的,是让通道能验证"这个请求确实是你发的、内容没被篡改"。怎么验证?靠一个"摘要"。你把请求里的关键参数,加上你的密钥,拼成一段字符串,然后用算法(MD5、SHA256 或者 RSA)算出一个摘要值,这个摘要值就是签名。通道收到请求后,用同样的参数、同样的密钥、同样的算法,再算一遍摘要,和你传过去的签名比对——一样,就说明请求没被动过;不一样,就拒绝。

看出关键了吗?通道是"重算"一遍来比对的。那通道重算的时候,它得知道"你当初是拿哪些参数、按什么顺序、怎么拼的"。如果你拼原文的方式,和通道预期的有一丁点不一致,两边算出来的摘要就不一样,签名就"错"了。

所以,"拼原文"这件事,不是你觉得怎么拼都行,而是必须和通道文档规定的方式,一字不差地一致。这就是它难的地方——它没有"差不多",只有"完全一致"和"不一致"。

第一条铁律:参数排序,必须按规矩来

拼原文的第一条铁律,是参数要排序。

为什么必须排序?因为如果不排序,参数的顺序就是随机的、不确定的。你这一次拼的时候,参数顺序是 A、B、C;下一次拼的时候,可能变成 B、A、C。而通道那边,它会按一个固定的顺序去拼。两边顺序不一致,摘要就不一样,签名就错。

所以绝大多数通道的文档里,都会明确写一句:"将除 sign 之外的所有参数,按参数名(key)的 ASCII 码从小到大排序"。这里的"按 key 排序",是拼原文的第一道工序,也是最容易做错的一道。

最常见的错,是排序规则搞错。有人按"字典序"排,有人按"自然序"排,有人干脆不排。这几个规则,对纯英文 key 来说可能结果一样,但一旦 key 里混了大小写、数字、下划线,结果就完全不同了。正确的做法,是严格按通道文档说的来——通常是"ASCII 码升序",也就是逐字符比 ASCII 值。大写字母的 ASCII 值比小写字母小,数字比字母小,这些细节都要抠。

第二条铁律:空值参数,到底要不要拼进去

这是拼原文里最大的一个坑,也是我见过最多人栽跟头的地方——空值参数怎么处理。

一个请求里,总有些参数是可选的、可以为空的。比如某个参数 attach(附加数据),你这笔请求没用到,它的值就是空字符串,或者压根没传。那么问题来了:拼原文的时候,这个空值参数,是拼进去,还是跳过?

答案是:看通道文档,而且必须严格照做,不能想当然。因为不同的通道,规则完全不同:

  • 有的通道要求:空值参数不参与签名,拼原文的时候直接跳过,不拼进去。
  • 有的通道要求:空值参数也要拼进去,哪怕值是空字符串,也要占个位置。
  • 有的通道更复杂:null 不拼,空字符串拼,但值为 0 的要拼,区分得很细。

这三种规则,看起来只是"拼不拼一个空参数"的小事,但结果天差地别。你按"跳过空值"拼,通道按"空值也拼"算,两边摘要必然不一致,签名必错。

更坑的是,这种错还特别难查。因为报错信息通常就是一句"签名错误",它不会告诉你"是因为 attach 参数的空值处理不对"。你只能一个参数一个参数地去试,试到崩溃。

所以我的建议是:对接任何一个新通道,动手写代码之前,先把"空值规则"这一条,从文档里抠出来,单独记在本子上。这一条,值得你花十分钟专门确认。

第三条铁律:拼接格式,一个字符都不能差

参数排好序、空值处理好了,下一步就是"拼"了。拼接的格式,又是一个重灾区。

最常见的拼接格式,是这种:key1=value1&key2=value2&key3=value3,也就是"键值对用等号连接,键值对之间用 & 连接"。这看起来很简单,但细节里全是坑:

第一,& 这个符号要不要拼?有的通道拼原文时,键值对之间加 &;有的通道不加,直接连起来;有的通道在最后还要拼密钥(比如 ...&key=你的密钥)。这个"&"的有无、密钥拼在哪,必须照文档来。

第二,value 要不要 URL 编码?这是个大坑。如果参数值里有中文、有特殊字符(比如 &、=、空格),要不要先做 URL 编码(urlencode)再拼?有的通道要求拼原文时用"原始值",有的要求用"URL 编码后的值"。而且编码的规则也不同——有的用 UTF-8 编码,有的用 GBK;有的编码后大写,有的小写。

第三,value 里的特殊字符。比如一个参数的值本身含有 & 或者 =,拼进原文后,会不会被误当成"分隔符"?这就要靠 URL 编码来规避,但前提是你知道要编码。

这些细节,任何一个不一致,签名就错。而且它们特别隐蔽,因为"看起来都对"——你看着拼出来的字符串,觉得没问题,但通道那边按另一套规则拼,结果就是不一样。

第四条铁律:密钥拼在哪个位置

拼原文的时候,密钥(key 或者 appsecret)是要参与进来的,否则签名就只是个公开的摘要,谁都能算。但密钥拼在哪个位置,也是有讲究的。

常见的做法有这么几种:

  • 拼在末尾:...&key=你的密钥,这是最常见的。
  • 拼在开头:你的密钥 + 排序后的参数。
  • 前后都拼:密钥 + 参数 + 密钥,这种少一些,但也有通道用。

位置错了,签名必错。而且这个"位置"往往藏在文档的某个角落,不显眼。很多人的签名错,就是卡在"密钥拼错位置"上——他按"拼末尾"的习惯去写,结果那家通道是"拼开头"的。

第五条铁律:字段遗漏,是最冤的错

最后一条,也是最"冤"的一种错——字段漏了。

拼原文的时候,通道要求"以下这些字段都要参与签名",列了一张表。你写代码的时候,少拼了一个字段——比如漏了 nonce_str(随机字符串),或者漏了 timestamp。这个字段,你请求里可能传了,但拼原文的时候忘了把它拼进去。结果就是:请求里明明有这个字段,签名却"少算"了它,通道一重算,摘要对不上,签名错。

这种错,坑就坑在"请求参数是全的,代码看起来也没问题",但拼原文那一步少了一个字段。排查的时候,光看请求参数、光看签名算法,都看不出问题,必须一行一行核对"拼原文的字段列表"和"通道要求的字段列表"是否完全一致。

我的经验是:拼原文的字段列表,要单独抽出来,和通道文档的字段表,一个一个对着勾一遍。别嫌麻烦,这个勾一遍,能省掉你后面半天的排查。

一个具体的例子:从参数到签名,全程走一遍

光讲规则容易抽象,我拿一个简化例子,带你从头到尾走一遍,你就彻底明白了。

假设你要发一笔支付请求,参数有这么几个:amount(金额,值是 100)、mch_id(商户号,值是 8888)、out_trade_no(订单号,值是 20261108001)、subject(商品名,值是"会员卡")。密钥是 abc123。

第一步,排序。按 key 的 ASCII 码升序排,顺序是:amount、mch_id、out_trade_no、subject。

第二步,拼键值对。假设这家通道的规则是"key=value 用 & 连接,value 要做 URL 编码,密钥拼在末尾"。那拼出来的原文就是:amount=100&mch_id=8888&out_trade_no=20261108001&subject=%E4%BC%9A%E5%91%98%E5%8D%A1&key=abc123。注意这里"会员卡"被 URL 编码成了 %E4%BC%9A%E5%91%98%E5%8D%A1。

第三步,算摘要。对上面这段原文,用通道指定的算法(假设 MD5)算一遍,再按通道要求转大写或小写,得到的就是签名。

看到没有,整个过程中,最容易"差之毫厘、谬以千里"的,就是第二步——那个"会员卡"到底编不编码、密钥拼在哪、& 连不连。任何一处和通道不一致,第三步算出来的签名就是错的。这就是为什么我一直强调:拼原文这一步,必须和通道文档一字不差地对照。

接了多家通道,怎么管理这些"五花八门"的规则

最后补一个工程上的现实问题:你不太可能只接一家通道。微信、支付宝、再加上几个聚合通道,每家通道的拼接规则、编码规则、密钥位置都可能不一样。怎么办?

答案是:把"拼原文"抽象成一个可配置的模板,而不是写死在代码里。

具体做法是,给每家通道配一套"签名规则":参数要不要排序、空值怎么处理、value 要不要编码、用什么编码、密钥拼在哪、最后用什么算法、要不要转大小写。这套规则,可以存成配置,也可以做成一个"签名策略"的类。切换通道的时候,只换策略,不改核心逻辑。

这样做的好处是,接新通道的时候,你不用重写一遍签名代码,只需要把新通道的规则"填"进一个新的策略里。既不容易出错,也方便维护。很多团队的签名 bug,根源就是"每接一家通道就复制一份签名代码,改着改着就改乱了"。统一抽象,是从根上避免这种乱。

怎么高效排查签名错:一个"对照"的思路

讲了五条铁律,最后给你一个排查签名错的总思路,就一个字:对照。

签名错,本质是"你拼的原文"和"通道拼的原文"不一致。那排查的关键,就是把你拼出来的原文,和通道拼出来的原文,拿到一起比。

怎么拿到通道拼的原文?很多通道的文档里,会给出一个"签名示例"——一段参数、一段拼好的原文、一个算好的签名。这就是你的"标准答案"。你拿自己的代码,用同样的参数跑一遍,把拼出来的原文打出来,和文档给的原文逐字符对比。哪里不一样,哪里就是错的。

如果文档没有示例,就自己造一个:随便拼一段参数,自己手算一遍(或者用工具算一遍),再让代码跑一遍,对比两者。一旦原文对上了,签名就对了。

记住这个顺序:先确认原文拼对了,再去怀疑算法。绝大多数签名错,是原文的锅,不是算法的锅。你把原文这一关过了,签名这件事,就通了九成。

关于 MD5 签名的参数组装、排序、空值处理,我之前专门拆解过,写得比较细,你可以看这篇MD5签名完整解析:参数组装、排序、空值处理,各类签名报错根源;如果想把整个 API 对接的参数表、签名算法、可跑代码串起来看,这篇易支付接口平台API对接完整指南:参数表+签名算法+可跑代码是更完整的入口。两篇配合,签名这块基本就通了。