接口联调最让人抓头的场景之一,就是对方文档里赫然写着“所有请求参数需加密并签名”。你把参数填进 Postman,点下 Send,返回{"code":"40001","msg":"签名校验失败"},把参数翻来覆去对了一遍,一个字符都没错,还是失败。改一个字段值就得重新算一遍摘要和签名,算完粘贴进 Header,再点一次 Send,又错。这时候真正缺的不是耐心,而是一套能跑在 Postman 里的请求参数加密加签预处理流程——在请求真正发出去之前,自动完成参数排序、拼串、摘要、加密、签名、回填这一整条链路。这篇文章就把这套东西从头到尾拆一遍:Pre-request Script 怎么写、参数怎么排、CryptoJS 怎么用、签名往哪塞、签不对时按什么顺序排查。不管你是刚接触加密接口的新手,还是已经手动算了半年签名的老联调人,下面这些内容都能直接抄去改。
1. 先搞清楚预处理到底在处理什么
1.1 加密、加签、编码,三件经常被混为一谈的事
很多人一上来就写脚本,写着写着就乱了,根源往往是把这三件事当成了同一件。
加密是可逆的,目的是让内容在传输路上别人看不懂,典型算法是 AES、RSA。服务端拿到密文能解出来,解出来之后内容跟原文一模一样。
加签是不可逆的,目的是证明“这份内容确实是我发的,而且中途没被改过”。做法是把参数按规则拼成一个字符串,再用密钥做一次摘要运算,得到一个短字符串附在请求里。服务端用同样的规则、同样的密钥重算一遍,两个结果对得上才算通过。这里的关键是:签名保护的从来不是参数的保密性,而是参数的完整性。
编码就更简单了,Base64、URL 编码、十六进制转换都属于这一类,它们不带任何密钥,只是换个格式存放数据。把 Base64 当成加密是新手最常见的误解之一,一串 Base64 字符串随便找个在线工具就能还原,它保护不了任何东西。
这三者的关系在实际接口里经常是叠加使用的:先把敏感字段 AES 加密,再把加密后的密文跟其他明文参数一起参与签名,最后整个请求体可能还要做一次 Base64。搞清楚每一层在干什么,脚本才不会写成一团浆糊。
| 类型 | 是否可逆 | 是否需要密钥 | 典型算法 | 主要作用 |
|---|---|---|---|---|
| 加密 | 可逆 | 需要 | AES、RSA、SM4 | 保护内容不被读取 |
| 加签 | 不可逆 | 需要 | MD5、SHA256、HMAC-SHA256、RSA 签名 | 防篡改、验身份 |
| 编码 | 可逆 | 不需要 | Base64、Hex、URL 编码 | 格式转换、适配传输 |
1.2 为什么值得把逻辑搬进 Postman
手动算签名的问题不在于麻烦,而在于不可复现。你今天算对了,明天换个参数再算一次,中间漏了一个字段、多了一个空格,结果就不一样,而你没有任何手段去对比两次运算的输入是否一致。更麻烦的是,一旦要跑几十个用例做回归,手动算签名这件事根本没法规模化。
也有人选择“在前端代码里算”。这在前后端都写完了的联调阶段是可行的,但在接口刚定下来、前端页面还没影的时候,后端只能干等。而 Postman 的 Pre-request Script 恰好卡在这个空档里:接口协议一确定,脚本就能先写出来,把整条链路跑通,后端不用等前端,测试也不用等开发。
这套预处理真正带来的东西有三样。第一是自动化,参数改了就改,脚本每次发请求前自动重算,不用伸手。第二是可复用,脚本写在集合层级,一个集合里几十个接口全部共享同一套签名逻辑,改一处全生效。第三是可验证,脚本里可以随时console.log出参与签名的原始串,服务端说签不对,你把中间串往那一贴,问题立刻定位到具体哪个字符。
代价也是有的。脚本跑在沙箱里,调试手段比本地 IDE 弱得多,报错信息有时候就一句TypeError,得靠打印一点点摸。密钥放在环境变量里,如果环境被共享出去,密钥也就跟着共享出去了,这个风险得自己心里有数。
1.3 一条典型签名规则的逐条拆解
各家平台的签名规则大同小异,我拿一套最常见的约定来拆,你在实际项目里遇到的基本都是它的变体:
- 取所有参与签名的参数,排除空值和
sign字段本身; - 按参数名的字典序升序排列;
- 拼成
key1=value1&key2=value2的形式; - 末尾再拼上
&key=密钥; - 整体做 MD5,结果转大写;
- 放到请求头的
X-Sign字段里。
看着简单,每一条背后都埋着坑。
第一条,什么叫“空值”?null、undefined、空字符串""、字符串"null",这四种在 JavaScript 里是完全不同的东西,服务端过滤规则稍有不一致,拼出来的串就不一样。最稳的做法是拿服务端的参考实现或者官方 SDK 跑一组样例出来,用它的输入输出当标尺,而不是靠猜。
第二条,字典序到底是按什么排?ASCII 码序还是 Unicode 码序?在纯英文小写参数名的场景下两者一致,但只要出现大小写混排(appId和appid),大写字母的码点更小,会排到前面去。有些平台的参考实现里用的是数据库的排序规则,那又是另一回事。这一条如果对不上,整条链路全废。
第三条,拼串时值要不要做 URL 编码?有的平台要求先encodeURIComponent再拼,有的要求原样拼,还有的要求拼完再整体编码一次。这三种做法得到的结果完全不同,而且从文档上经常看不出来,只能靠样例反推。
第五条,MD5 输出默认是小写十六进制,转大写这一步忘了,服务端一比对就失败,而错误信息通常只说“签名错误”,不会告诉你大小写不对。
2. 动手之前:变量、脚本入口和调试手段
2.1 密钥、地址和可变参数分别该放哪
Postman 的变量有一整套作用域优先级,从高到低大致是:局部变量(脚本里pm.variables.set设置的)> 数据变量(Runner 传入的 CSV/JSON 数据)> 环境变量 > 集合变量 > 全局变量。pm.variables.get会按这个顺序逐层往上找,找到第一个就返回。
这个优先级机制直接决定了密钥该放哪儿。全局变量的可见范围是整个工作区,你在 A 集合里设的密钥,B 集合里也能读到,一旦工作区共享给其他人,密钥就等于公开了。所以:
appKey、secret、私钥这类东西放环境变量,并且用自己本地那份环境,不要跟着集合一起共享出去;- 接口路径、公共参数、固定的版本号放集合变量,跟着集合走,团队里谁拉下来都能直接用;
- 每次请求都会变的
timestamp、nonce、sign不要落任何持久变量,在脚本里用局部变量算完直接塞进请求,避免上一次运行的残值污染下一次。
这里有个特别容易忽略的点:pm.environment.set写进去的值是持久化的。你在脚本里设了timestamp,下一次运行如果脚本没覆盖它,读出来的还是上次那个旧值。时间戳一旦过期,服务端直接拒签,而你会以为是算法写错了,白白排查半天。所以时间戳这类每次都要新的字段,别往环境变量里塞。
2.2 三个脚本入口的执行时机
Postman 的脚本不是只有请求级的 Pre-request Script 一个位置,实际执行顺序是:集合级 Pre-request → 文件夹级 Pre-request → 请求级 Pre-request → 发送请求 → 请求级 Tests → 集合级 Tests。
这个顺序对签名逻辑的组织方式影响很大。如果你有一堆接口共用同一套签名规则,最合理的做法是把拼串和签名的逻辑写在集合级 Pre-request里,让它先跑,算完之后把结果写进集合变量;请求级的 Pre-request 只负责把这个变量取出来塞到对应的 Header 或 Body 里。这样规则变了只改一处,几十个接口同时生效。
需要注意的是,集合级脚本执行的时候,请求级脚本还没跑,所以集合级脚本里pm.request.body未必是最终形态。如果签名要覆盖 Body 里的字段,而 Body 里的某些字段是请求级脚本动态加的,那就得把顺序倒过来——签名逻辑放到最后执行的那一层去。
2.3 第一条调试脚本:先能打印,再谈签名
我见过太多人一上手就写完整签名逻辑,跑不通之后完全不知道错在哪。正确的顺序是反过来的:先让脚本能读到请求的真实内容。
在请求的 Pre-request Script 里敲这几行:
console.log('URL:', pm.request.url.toString()); console.log('Query:', JSON.stringify(pm.request.url.query.all())); console.log('Headers:', JSON.stringify(pm.request.headers.all())); console.log('Body raw:', pm.request.body ? pm.request.body.raw : 'empty');然后打开 Postman Console(Windows 上是Ctrl + Alt + C,Mac 上是Cmd + Option + C),点 Send,看打印出来的内容是不是你以为的那样。
这一步的价值在于,pm.request.body.raw拿到的字符串和你肉眼在 Body 编辑器里看到的可能不一样。编辑器里显示的 JSON 是格式化过的,带缩进和换行,而raw拿到的是你实际输入的那一份原文。如果你后面要把参数解析出来重排再序列化回去,序列化出来的字符串必然和原文不同(键的顺序、空格、缩进都会变),这时候签名用的串和实际发送的串就分家了。这个问题在第 3.4 节会展开讲。
3. 主链路实现:排序、拼串、加密、加签、回填
3.1 参数排序与规范化拼接
先说排序这件事为什么非做不可。JSON 对象在 JavaScript 里虽然保留了插入顺序,但服务端可能是 Java、Go、Python,各自的 Map 实现顺序不同,如果双方都按插入顺序拼串,只要客户端传入顺序和服务端解析顺序有差异,签出来的结果必然不同。所以几乎所有签名规则都强制要求按参数名字典序排序,把顺序这件事从“约定”变成“规范”。
JavaScript 的Array.prototype.sort()默认按 UTF-16 码点比较,对纯 ASCII 的参数名是够用的,但有两个细节要注意。一是大小写混排时大写字母排前面,如果服务端是按转小写后再排的,那你就得先统一转小写。二是如果参数名里出现了中文或特殊字符,码点顺序和服务端的排序规则可能对不上,这种情况就只能看参考实现。
再看拼串时的空值处理,这是最容易出分歧的地方。下面这段函数是我常用的基础版本:
function buildSignString(params, secret, excludeKeys) { const skip = excludeKeys || ['sign']; return Object.keys(params) .filter(function (k) { if (skip.indexOf(k) !== -1) return false; const v = params[k]; if (v === undefined || v === null) return false; if (typeof v === 'string' && v === '') return false; return true; }) .sort(function (a, b) { return a < b ? -1 : (a > b ? 1 : 0); }) .map(function (k) { return k + '=' + params[k]; }) .join('&') + '&key=' + secret; }用显式的比较函数替代默认排序,是为了让顺序行为完全可控,不依赖引擎实现。参数值的处理上,如果值是数组或对象,直接String()出来会得到1,2或者[object Object],这两种结果服务端大概率不认,所以嵌套结构一般不参与签名,或者要求先序列化成 JSON 字符串再参与。这一点必须跟对接方确认清楚,不能想当然。
提示:签名串的拼接规则一定要拿服务端 SDK 或者在线验签工具跑一组固定样例验证。选定一组参数,手工算出签名串,对比 SDK 的输出,逐字符比对。这一步花十分钟,能省后面几小时的排查。
3.2 摘要、HMAC 与对称加密的写法
Postman 沙箱里内置了 CryptoJS,直接全局可用,不用 require。几个高频操作的标准写法如下:
// MD5,默认输出小写 hex const md5Hex = CryptoJS.MD5('hello').toString(); // MD5 转大写 const md5Upper = CryptoJS.MD5('hello').toString().toUpperCase(); // SHA256,默认就是 Hex const sha256Hex = CryptoJS.SHA256('hello').toString(); // SHA256 输出 Base64 const sha256B64 = CryptoJS.SHA256('hello').toString(CryptoJS.enc.Base64); // HMAC-SHA256,输出 Hex const hmacHex = CryptoJS.HmacSHA256('hello', 'secret').toString(CryptoJS.enc.Hex); // HMAC-SHA256,输出 Base64 const hmacB64 = CryptoJS.HmacSHA256('hello', 'secret').toString(CryptoJS.enc.Base64);这里有一个必须点出来的坑:CryptoJS 的摘要函数返回的是一个 WordArray 对象,不是字符串。你直接+拼到别的字符串上,或者塞进 Header,得到的是一串奇怪的东西。必须调用.toString(),而且要明确指定编码方式,默认的toString()是 Hex,如果你要 Base64 就得显式传CryptoJS.enc.Base64。很多“签出来是乱码”的问题,根子就在这一步。
AES 加密的坑更深一层。看下面两种写法:
// 错误写法:把字符串当口令 const wrong = CryptoJS.AES.encrypt('data', 'mykey123').toString(); // 正确写法:key 显式解析成 WordArray const key = CryptoJS.enc.Utf8.parse('mykey1234567890'); // 16 字节 const iv = CryptoJS.enc.Utf8.parse('0000000000000000'); const right = CryptoJS.AES.encrypt('data', key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString();第一种写法里,CryptoJS 会把它当成“口令”,走 OpenSSL 的 EVP_BytesToKey 派生流程,还自动加了随机盐,每次加密结果都不一样,服务端用固定密钥根本解不开。第二种才是标准的、和服务端对称的写法。这个问题在社区里被问过无数次,但每次还是有人踩。
还有几个 AES 相关的细节:key 的字节长度必须符合算法要求(AES-128 是 16 字节,AES-192 是 24 字节,AES-256 是 32 字节),不够要补、超了要截,具体补法看对接方约定;输出格式是 Base64 还是 Hex 也要对齐,CryptoJS 的toString()默认给的是 Base64,有的服务端要 Hex,那就得.toString(CryptoJS.enc.Hex)。
3.3 签名结果往哪里放:Header、Query 还是 Body
算完签名,得把它塞进请求里。三种位置的写法各不相同。
塞 Header 最常用,Postman 提供了upsert方法,有则覆盖、无则新增:
pm.request.headers.upsert({ key: 'X-Sign', value: signValue }); pm.request.headers.upsert({ key: 'X-Timestamp', value: String(ts) });塞 Query 参数,操作的是 URL 的查询参数列表:
pm.request.url.query.upsert({ key: 'sign', value: signValue });这里要注意一个顺序问题:如果签名串本身就包含了 URL 参数,那么先拼串算签名,再把签名塞进 query,顺序不能反。反了的话,签名的输入里就不包含sign自己,但实际发出去的 URL 里多了个参数,服务端如果按“全部参数参与验签”来算就永远对不上。好在这类规则里sign通常是被显式排除的,但排除的规则你得先确认清楚。
塞 Body 的情况最麻烦,因为 Body 有多种类型。JSON 类型的 Body 修改方式如下:
const bodyObj = JSON.parse(pm.request.body.raw); bodyObj.sign = signValue; bodyObj.timestamp = ts; pm.request.body.update(JSON.stringify(bodyObj));pm.request.body.update()在较新的版本里可用,接受字符串或者对象。旧版本上可能要用pm.request.body.raw = JSON.stringify(bodyObj)这种直接赋值,而直接赋值在某些版本里改完之后实际发出去的还是旧内容。如果你发现脚本里打印出来的 Body 是对的,但服务端收到的还是老的,八成就是这里版本差异导致的,换个写法重新试一次。
注意:修改 Body 会改变实际发送的字节内容。如果签名的对象包含了 Body 字段,那么修改 Body 必须在签名之前完成,或者签名时就把将要发送的最终 Body 字符串作为输入。顺序搞反,签出来的必然对不上。
3.4 时间戳、随机串与防重放参数
绝大多数签名规则里都会带timestamp和nonce两个字段,作用是防重放:服务端检查时间戳和当前时间的偏差是否在允许窗口内(常见是 5 分钟),同时用nonce去重,同一个随机串短时间出现两次直接拒绝。
时间戳的写法要看清单位:
const tsSec = String(Math.floor(Date.now() / 1000)); // 10 位,秒 const tsMill = String(Date.now()); // 13 位,毫秒文档写“10 位时间戳”就绝不能给 13 位,这种错误服务端只会告诉你“签名失败”或者“时间戳非法”,具体差在哪儿得自己看。
随机串用内置的 WordArray 就够:
const nonce = CryptoJS.lib.WordArray.random(16).toString(); // 32 位 hex也可以用require('uuid')拿到uuid.v4(),效果差不多。关键是每次请求都重新生成,不要缓存到环境变量里。我见过一个案例,脚本里把 nonce 存进了环境变量,本意是“方便复用”,结果跑批量用例时每次请求带的都是同一个 nonce,服务端按防重放规则全给拒了,报错信息还只有一句“请求非法”,查了半天才发现问题在这儿。
还有一个容易被忽略的点是本地时钟。如果本机时间跟标准时间差了十几分钟,服务端的时间窗口校验会直接不通过,而这个现象和签名算法错误长得一模一样。遇到“算法肯定没错但就是不通”的情况,先看一眼系统时间准不准,是一分钟能省一小时的操作。
4. 非对称签名与复杂请求的兜底方案
4.1 RSA 这类算法在 Postman 里怎么处理
HMAC 这种对称算法在 Postman 里是原生支持的,因为双方共用一个密钥。但有些平台用的是非对称签名,客户端拿私钥签,服务端用公钥验,这时候 CryptoJS 就不够用了,因为它是纯 JS 的对称加密库,没有 RSA 签名能力。
第一步先探测原生模块能不能用。在 Pre-request Script 里写:
try { const crypto = require('crypto'); console.log('node crypto available:', typeof crypto.createSign); } catch (e) { console.log('node crypto not available:', e.message); }不同版本的沙箱对原生模块的支持不一样,先打印出来看结果,能省很多猜测。如果可用,RSA 签名长这样:
const crypto = require('crypto'); const privateKeyPem = pm.environment.get('privateKeyPem'); const signer = crypto.createSign('RSA-SHA256'); signer.update(signString, 'utf8'); const signatureB64 = signer.sign(privateKeyPem, 'base64'); pm.request.headers.upsert({ key: 'X-Sign', value: signatureB64 });私钥从环境变量里取,绝不要硬编码在脚本里。PEM 格式的私钥带着换行符,存在环境变量里的时候换行可能被转义,读出来之后可以先用replace(/\\n/g, '\n')处理一下再交给签名函数。
如果原生模块不可用,思路就变成两条。一条是找一份纯 JS 实现的单文件脚本粘进来,能不能跑取决于沙箱限制,得实测。另一条更现实:在本地起一个极简的签名服务,用pm.sendRequest去调用它,把复杂的密码学运算外包出去,Postman 只负责传参和取结果。
pm.sendRequest({ url: 'http://127.0.0.1:18080/sign', method: 'POST', header: { 'Content-Type': 'application/json' }, body: { mode: 'raw', raw: JSON.stringify({ data: signString }) } }, function (err, res) { if (err) { console.error(err); return; } const sign = res.json().sign; pm.request.headers.upsert({ key: 'X-Sign', value: sign }); });这条路的问题是异步。pm.sendRequest是异步的,前面的脚本写完之后它不一定已经返回,请求可能已经发出去了,签名字段还是空的。处理办法是把pm.sendRequest放在集合级 Pre-request 里,把结果写进变量,让请求级的 Pre-request 去读——但即便如此,异步和同步的时序在 Postman 里始终是个坑,实测下来更可靠的做法是本地服务只做算法验证,正式跑的时候还是想办法把算法内联到脚本里。
4.2 GET、表单、文件上传和 JSON 各有各的麻烦
GET 请求的参数都在 URL 上,签名输入自然就是 query 参数。这里有个常见误区:Postman 的 Params 面板里填的值和你实际发出的 URL 里的值可能不一样,因为 Postman 会做 URL 编码。你填中文,实际发出去的是%E4%B8%AD%E6%96%87。签名到底该用哪一个?答案是看服务端怎么解析。如果服务端是先解码再验签,你就用解码后的原值拼串;如果服务端是对原始 query 字符串直接验签,你就得用编码后的值。这个必须问清楚。
x-www-form-urlencoded类型的参数可以通过pm.request.body.urlencoded读取,它是个列表,可以用all()拿到全部键值对,再自己转成对象参与签名。
form-data类型的麻烦在于文件。文件内容在脚本里读不到,你只能拿到字段名和文件名。大部分平台的约定是:文件字段参与签名时只取文件名,或者干脆排除掉,用文件的 MD5 单独做一个头。具体怎么处理只能按对接方的规则来,脚本层面能做的就是按规则拼。
JSON Body 类型最关键的一条原则我前面提过,这里再强调一次:签名用的 JSON 字符串,必须和最终发出去的 JSON 字符串是同一份。不要先JSON.parse出来算一遍签名,再JSON.stringify回去发出去,因为两次序列化的键顺序、空格可能不同。正确做法是:先拿到原始字符串,只做一次序列化,把结果同时用于签名和发送。
// 先把最终要发送的 body 定下来 const finalBody = JSON.stringify(payload); // 用这份字符串参与签名 const signStr = buildSignString(queryParams, secret) + '&body=' + finalBody; // 再把同一份字符串塞回去 pm.request.body.update(finalBody);4.3 把公共逻辑抽出来复用
当你的集合里有几十个接口都要签名,复制粘贴脚本的做法很快就会失控。合理的组织方式是把逻辑分层:
第一层,集合级 Pre-request。放通用的工具函数和主流程,比如拼串、算摘要、生成时间戳和随机数,算完之后把结果写进集合变量。
第二层,请求级 Pre-request。只做两件事:从变量里取出签名结果,按这个接口约定的位置塞进请求。
第三层,接口特有的定制。有些接口签名的字段范围不一样,或者额外要签路径、签 Body 摘要,这部分逻辑用请求级的少量代码处理。
变量传递上用pm.collectionVariables.set('signValue', sign),下一层用pm.collectionVariables.get('signValue')取。注意别用pm.variables.set跨层传递,那是局部变量,作用域只在当前脚本内,下一个脚本读不到。
脚本本身的版本管理也值得说一下。Postman 的脚本是存在集合里的,集合导出成 JSON 之后脚本也跟着走,所以最省事的做法是定期把集合导出备份,或者直接用版本控制工具管这份导出的 JSON。我曾经因为一次误操作把整个集合的 Pre-request 清空了,重写了两个小时——那次之后我养成了改完脚本就导出一次的习惯。
5. 排查手册:签不对的时候按这个顺序查
5.1 一张能照着走的速查表
签名报错的时候,最怕的就是漫无目的地试。按下面这个顺序查,绝大多数问题能在前三步定位。
| 现象 | 最可能的原因 | 定位方法 |
|---|---|---|
| 一直提示签名失败,参数确认无误 | 拼串规则与服务端不一致(排序、分隔符、空值过滤) | 打印出完整签名串,用服务端 SDK 跑同一组参数对比 |
| 手动算的对,脚本算的不对 | 变量取值时机不对,脚本读到的是旧值 | 在脚本里打印每个参与签名的变量实际值 |
| 偶尔成功偶尔失败 | 时间戳过期或 nonce 重复 | 打印时间戳,检查是不是缓存了旧值 |
| 加密结果每次都不一样 | AES key 被当成口令走了 KDF | 检查 key 是否用CryptoJS.enc.Utf8.parse处理过 |
| 签名里出现了奇怪字符 | WordArray 没调toString()或编码方式不对 | 检查每个摘要结果的.toString(...)参数 |
| 服务端收到的 Body 和脚本里改的不一致 | 直接赋值pm.request.body.raw在当前版本不生效 | 改用pm.request.body.update() |
| 中文参数必挂,英文正常 | 编码不统一,UTF-8 与 GBK 差异或 URL 编码不一致 | 打印字节级内容,对比服务端解码后的值 |
Base64 结果末尾多了= | 填充方式不同或做了 URL-safe 转换 | 确认服务端要的是标准 Base64 还是 URL-safe 变体 |
5.2 编码类的坑,一个字符就能让你查一下午
中文编码。JavaScript 里的字符串是 UTF-16,CryptoJS 在转 WordArray 时默认按 UTF-8 处理,这通常没问题。但如果服务端用的是别的字符集,或者某个环节做了一次错误的转码,中文字符就会变成乱码,签出来必然不同。排查办法是拿一个只含中文的参数单独测,看纯净的英文参数能不能通过,能通过就说明算法链路是对的,问题在编码环节。
URL 编码的两种风格。encodeURIComponent会把空格编成%20,而表单提交的传统做法是把空格编成+。这两种在服务端解码时可能有差异,如果你的参数里带空格,一定要测一下。另外encodeURIComponent不会编码!'()*这几个字符,而有些严格实现的 RFC3986 编码会编码它们,这种细微差别在参数里带括号时就会暴露。
Base64 的填充和变体。标准 Base64 用+和/,URL-safe 变体用-和_,末尾的=有时候会被去掉。如果签名要放进 URL 参数里传输,通常要用 URL-safe 变体,否则+会被解码成空格,服务端拿到的签名和客户端算的就不是一个东西。CryptoJS 本身不直接提供 URL-safe 输出,需要自己替换:.toString(CryptoJS.enc.Base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')。
JSON 序列化的转义。JSON.stringify会把/原样输出,但有些服务端序列化会把/转成\/。还有 emoji 之类的字符,会被序列化成代理对形式。如果签名覆盖了 Body,而客户端和服务端的序列化实现不同,签出来的结果就是两个世界。这一条没有通用解法,只能以服务端的实际序列化结果为准,或者干脆约定签名不覆盖 Body 内容,只覆盖 Body 的摘要。
5.3 变量作用域和执行时机造成的怪现象
有一类问题跟密码学完全无关,纯粹是 Postman 的变量机制导致的,但症状和算法错误一模一样。
现象一:改了参数,签名还是旧的。大概率是脚本读了环境变量,而环境变量的值没有被覆盖。环境变量是持久化的,上一次运行写进去的值,这次不主动覆盖就还在。排查办法是在脚本开头把所有读到的变量打印一遍,看是不是你以为的那个值。
现象二:在 Runner 里跑全挂,单独点 Send 是好的。这是变量优先级的问题。Runner 传入的 data 变量优先级高于环境变量,如果 CSV 里有一列叫sign,它会把你脚本算出来的签名覆盖掉。这种情况的处理办法是给数据列换个不冲突的名字,或者用pm.variables.set在脚本最后强制覆盖。
现象三:集合级脚本写的变量,请求级脚本读不到。检查一下读写用的是不是同一类变量。集合级脚本里用pm.collectionVariables.set,请求级就必须用pm.collectionVariables.get或者pm.variables.get。如果集合级用的是pm.variables.set,那它只在那个脚本内部有效,下一个脚本看不到。
现象四:脚本明明改了 Body,发出去的还是原样。前面提过,这多半是 Body 修改方式在版本上的差异。另外还要注意执行顺序:如果 Body 是在请求级 Pre-request 里改的,而集合级 Pre-request 里已经算过签名了,那签名针对的还是旧 Body,自然对不上。
提示:排查这类问题时,最有效的动作不是改代码,而是在脚本里把所有输入打印出来。签名算法是一条确定性的管道,输入一样输出必然一样。把输入打印出来和服务端的输入逐项对比,差异一定藏在某个字段上。
6. 关于脚本管理的一点个人做法
写到这里,链路基本走完了。最后分享几个我在长期使用中固定下来的习惯,都是踩坑之后形成的,没什么高深的东西,但确实省事。
签名脚本一律从集合级开始写。哪怕当前只有一个接口要签名,也写在集合级。因为接口数量增长的速度永远比你预期的快,等到有三五个接口再回头重构,成本比一开始就分层高得多。集合级脚本只放工具函数和主流程,具体接口的差异化部分留给请求级,这个边界一旦划清楚,后面加接口基本是复制粘贴改两行的事。
每个签名脚本的第一行永远是打印语句。不是为了调试,是为了留痕。脚本跑通之后我会把那些console.log注释掉,但不删。下次出问题的时候,取消注释就能直接看到中间态,比重新写打印语句快得多。这个习惯看起来有点土,但确实管用。
环境变量按用途分组命名。app.*放应用凭证,env.*放地址和开关,test.*放测试用的固定值。命名上带前缀的好处是,在变量面板里一眼能看出哪些是密钥、哪些是可以随便改的。密钥类变量我从来不让它出现在集合里,集合导出分享出去的时候,接收方拿到的是空值,得自己去填,这样至少不会出现密钥跟着集合到处飞的情况。
改完脚本就导出一份。Postman 的集合导出成 JSON 之后,脚本内容也在里面,用任何文本对比工具都能看出两次导出的差异。这个动作花不了半分钟,但真出问题的时候,能让你知道“上次是好的”和“这次坏了”之间到底改了什么。
这套东西搭起来之后,加密加签接口的联调节奏会完全不一样。参数想怎么改就怎么改,脚本在发请求之前自动把签名算好,报错了就把中间串打印出来对一遍。真正花时间的从来不是写脚本,而是搞清楚对接方那套规则里的每一个细节,而这件事只能靠一组组样例去试、去对、去确认。脚本只是把确认过的规则固化下来,让它每次都能一模一样地重跑一遍——这个过程本身,才是预处理真正的价值所在。