
我记得年前联调一个对公接口时同事在本地脚本里跑得好好的挪到 Postman 里就报验签失败。一开始怀疑算法写错了后来一行行对比才发现报文体里有三个动态字段脚本每次生成的顺序和取值时机不同导致参与签名的明文和实际发送出去的密文根本不是同一份数据。Postman 的Pre-request Script和Tests是两段独立的沙箱脚本很多人把它们当成一个普通 Node 环境来写结果漏掉了变量传递、执行顺序、加密编码这些细节。这篇就把Postman 脚本加密 JSON 报文 动态参数这件事从头到尾拆开动态参数怎么生成、怎么塞进报文体、怎么和签名/加密链路配合以及跑通之后最容易翻车的几个坑。适合给网关接口做过联调、正在写接口自动化用例的测试同学也适合后端同学作为代码级参考。1. 先别写脚本理清动态参数与加密边界很多人一上来就写CryptoJS.AES.encrypt写到一半才发现报文体里有时间戳、随机数、业务流水号这些字段每次都变那到底先加密再放参数还是放完参数再加密顺序反了服务端一解密就散架。1.1 报文体里的动态到底指什么所谓动态参数本质上是每次请求都不相同、且参与业务语义的字段。我习惯归成三类类型示例特点是否参与加密时间类timestamp、reqTime秒级或毫秒级随请求时刻变化通常参与随机类nonce、random每次请求随机生成防重放通常参与业务类bizNo、orderId、serialNo业务唯一键本次请求生成参与这里有个很容易被忽略的点appId、version这类固定字段属于静态参数也要参与加密和签名但它们不属于动态生成的范畴。动态参数的难点不在于加密本身而在于你要保证脚本生成的这一份参数值被同时用于三条链路——明文拼装、签名计算、最终 JSON 发送。三个地方用到的值有任何不一致服务端验签就必然失败。1.2 为什么整个报文加密会踩更多坑一种常见的做法是把整个 JSON body 全部加密成一段密文再塞进外层data字段。这样确实简单但会带来两个问题。第一外层必留字段。就算业务报文全加密网关层仍然要解析appId、timestamp、sign这些字段来做路由、鉴权和验签所以你不能全部加密。最终请求体往往是{ appId: your-app-id, timestamp: 1735689600, nonce: a1b2c3d4, data: U2FsdGVkX1sQqHj..., sign: 9f8d7a... }第二验签的粒度不清晰。加密后生成的密文是动态的服务端怎么验证这条密文是不是被篡改过通常的做法是先把原始明文做一次摘要比如 SHA256把摘要放进明文一起加密签名则覆盖外层可见字段 密文摘要。这样动态参数既进了密文又通过摘要牵制住了篡改行为。所以搞清楚哪些字段参与摘要、哪些字段参与签名、哪些字段只做传输比急着写加密代码重要得多。2. 选型决策SIGN、AES-CBC 还是 HMAC网关到底想防什么加密报文这四个字看着具体实际切开是三种完全不同的需求。我在项目里见过太多人把加密和签名混为一谈最后联调时被服务端以各种姿势拒绝。2.1 加密、摘要、签名三件事别混在一起加密AES/RSA目标是保密。让别人读不懂报文用的是对称密钥或公钥。摘要SHA256 等目标是完整性。给一段明文算指纹防止内容被改。签名HMAC/RSA-Sign目标是认证和防篡改。确认发的人确实持有约定密钥。Postman 脚本里这三件事往往会同时出现。比如一个典型的网关请求改造链路是对timestamp nonce 明文摘要做 HMAC-SHA256 生成sign再用 AES 加密报文。服务端先验签再解密再校验摘要。每一步的产物不同千万别图省事用一个函数输出代替另一步的输入。2.2 加密粒度选型字段级签名与整体密文我在实际项目里遇到的接口大致有两种风格。第一种外层只做签名报文明文传输。适用于对保密性要求不高的内部系统重点是防篡改、防重放。此时动态参数按规则拼进签名串即可脚本简单调试也直观。第二种报文整体加密 外层签名。适用于报文里包含敏感业务数据卡号、手机号、金额的接口。此时脚本里需要两步先加密得到data再对data以及timestamp、nonce生成sign。一定注意签名覆盖的是密文不是明文。我见过有人把sign对明文生成结果服务端一验签就发现签名和收到的密文对不上——因为明文在传输中根本不可见。2.3 参数细节CBC/ECB 模式与密钥长度不是随便填的无论网关文档怎么写出现 AES 几乎必然要求某种模式。我的默认选择是AES-CBC PKCS7Padding 随机 IV这也是大多数服务端默认实现。用 Postman 里的 CryptoJS 时几个参数的真实含义值得说清楚CryptoJS 参数含义容易踩的坑mode: CryptoJS.mode.CBC分组模式和 ECB 混用会导致各分块独立密文可被重排padding: CryptoJS.pad.Pkcs7填充方式后端若用 NoPadding 恰好在块整数倍时会解密失败iv初始向量固定 IV 会被重放攻击建议每次动态生成同时放进报文key对称密钥必须严格 16/24/32 字节长度不对后端直接报错关于密钥长度这里有个很隐蔽的细节如果你传给 CryptoJS 的 key 是字符串CryptoJS 会把它当作 passphrase走 OpenSSL 的 KDF 派生真实密钥而大多数服务端语言Java/Golang会直接拿原始字节做密钥。两边算法表面上一致实际根本对不上。解决办法是用CryptoJS.enc.Utf8.parse()强制转成 WordArray后面模板部分会写清楚。3. Pre-request Script 的核心动作生成动态参数并把它们缝进请求体Postman 的脚本执行顺序是Pre-request Script先跑 → 发送请求 →Tests再跑。动态参数必须在 Pre-request 阶段生成并在这个阶段完成变量写入、报文拼装、签名加密全套动作。3.1 为什么用环境变量做桥接而不是直接改 Body 再发送很多人会尝试在脚本里直接pm.request.body.raw ...。这个 API 在 Postman 里确实能改请求体但它有个问题改了之后Postman 的 UI 上不会同步显示且一旦脚本报错请求体还是旧的排查起来极其迷惑。我强烈推荐的做法是在请求 Body 的 raw JSON 里写一个占位符{{requestBody}}脚本里用pm.environment.set(requestBody, 最终JSON)来喂数据。这样发送前 Postman 会自动把占位符替换成环境变量里的值逻辑清晰调试时把环境变量的值打印出来也一目了然。3.2 动态参数注入的两种写法模板替换与整段重设如果你的报文体里只有少量动态字段可以用模板替换const rawBody pm.environment.get(templateBody) || {}; const finalBody rawBody .replace({{timestamp}}, timestamp) .replace({{nonce}}, nonce) .replace({{bizNo}}, bizNo); pm.environment.set(requestBody, finalBody);如果报文体本身就是脚本拼装的结果那么直接构造对象即可不需要模板替换。我建议对报文复杂的接口一律走整段重设因为字段一多模板替换容易漏换某个{{var}}最终发出去的是带占位符的坏 JSON。3.3 生成时间戳、nonce 与业务流水号的标准模板动态参数生成不是随便写个Date.now()就完事要考虑服务端的校验窗口和格式偏好。我现在的标准写法// 秒级时间戳避免毫秒数导致签名不一致 const timestamp Math.floor(Date.now() / 1000).toString(); // 16 字节随机数转成十六进制字符串 const nonce CryptoJS.lib.WordArray.random(8).toString(); // 业务流水号时间戳 随机段保证全局唯一 const bizNo T timestamp CryptoJS.lib.WordArray.random(4).toString();注意时间戳位数。如果服务端接口文档写的是 13 位毫秒时间戳那你签名串里就必须用 13 位不能混用。我在排查线上问题时就遇到过Pre-request 里生成的是 10 位秒级请求体里手动填了 13 位毫秒级两个值不一致服务端认为时间戳被篡改。4. 可照抄的完整模板AES-CBC SHA256 摘要 HMAC-SHA256 签名下面是一套我实际在用、可以直接复制进 Pre-request Script 的完整脚本。整个流程分六个动作生成参数、拼装明文、计算摘要、AES 加密、HMAC 签名、注入环境变量。为了贴合常见网关场景我把明文摘要 密文签名的混合方案写进去这是多数安全要求中等偏上的服务端接口采用的策略。4.1 完整 Pre-request Script// 1. 生成动态参数 const timestamp Math.floor(Date.now() / 1000).toString(); const nonce CryptoJS.lib.WordArray.random(8).toString(); const bizNo B timestamp CryptoJS.lib.WordArray.random(4).toString(); // 2. 组装业务明文 const appId pm.environment.get(APP_ID); const rawPayload { appId: appId, bizNo: bizNo, amount: 100.00, timestamp: timestamp, nonce: nonce }; // 3. 对规范化明文计算 SHA256 摘要 // key 按字典序排列拼成 kvkv 形式保证服务端用同一规则重算 const canonicalStr Object.keys(rawPayload) .sort() .map(k ${k}${rawPayload[k]}) .join(); const digest CryptoJS.SHA256(canonicalStr).toString(CryptoJS.enc.Hex); // 摘要放进明文一起加密 const payloadToEncrypt Object.assign({}, rawPayload, { digest: digest }); // 4. AES-CBC 加密 const aesKey CryptoJS.enc.Utf8.parse(pm.environment.get(AES_KEY)); const aesIv CryptoJS.enc.Utf8.parse(pm.environment.get(AES_IV)); const ciphertext CryptoJS.AES.encrypt( JSON.stringify(payloadToEncrypt), aesKey, { iv: aesIv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ).toString(); // 5. HMAC-SHA256 签名 // 这里签名覆盖请求方法路径动态参数密文摘要注意是 ciphertext 的 digest const cipherDigest CryptoJS.SHA256(ciphertext).toString(CryptoJS.enc.Hex); const signStr [ POST, pm.environment.get(API_PATH), timestamp, nonce, cipherDigest ].join(\n); const signature CryptoJS.HmacSHA256( signStr, pm.environment.get(SIGN_KEY) ).toString(CryptoJS.enc.Hex); // 6. 注入请求体 const finalBody { appId: appId, timestamp: timestamp, nonce: nonce, bizNo: bizNo, // 业务号外层可见便于服务端幂等去重 data: ciphertext, sign: signature }; pm.environment.set(requestBody, JSON.stringify(finalBody)); // 方便调试时在控制台确认 console.log(canonicalStr:, canonicalStr); console.log(finalBody:, finalBody);这套脚本有几个值得说明的细节。签名串的拼接顺序不是随便定的。我见过不同网关有截然不同的拼法有的要求keyvaluekeyvalue有的要求每个字段换行还有的直接对密文做取摘要后再签名。上面写的join(\n)是常见的格式但也必须替换成你服务端文档约定的格式。顺序影响结果别和我一样想当然。摘要和签名虽然都是 SHA256但用途不同。digest保证明文在传输中没被改动signature保证外层身份和防重放。两者一个进密文一个放外层缺一个安全模型就不完整了。4.2 请求 Body 的写法与 Tests 里的校验请求 Body 的 raw 区只要写一个变量占位符就够了{{requestBody}}Tests 里建议至少做三层校验状态码、JSON 结构、业务码。如果响应体也是密文的可以顺手在 Tests 里解一次密直接确认服务端返回逻辑正确。const resp pm.response.json(); pm.test(HTTP 200, () { pm.expect(pm.response.code).to.eql(200); }); pm.test(响应体包含 code, () { pm.expect(resp).to.have.property(code); }); // 如果响应体也有密文 data 字段可以解密后断言业务字段 if (resp resp.data) { const decrypted CryptoJS.AES.decrypt( resp.data, CryptoJS.enc.Utf8.parse(pm.environment.get(AES_KEY)), { iv: CryptoJS.enc.Utf8.parse(pm.environment.get(AES_IV)), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ).toString(CryptoJS.enc.Utf8); pm.test(响应解密成功, () { pm.expect(decrypted).to.include(success); }); }4.3 一个完整的联调场景验证我拿这套模板配过某网关的项目物料接口流程大概是声明环境变量APP_ID、AES_KEY、AES_IV、SIGN_KEY、API_PATH、BASE_URL。在请求 Body 写{{requestBody}}。把 Pre-request 脚本贴进去在 Tests 里做解码校验。第一轮就遇到了签名失败原因是服务端要求的签名串是axxbyy排序后的结果我代码里用了换行拼接。改成拼接后一次通过。这说明一件事算法本身在 Postman 里跑通并不难难的是对齐服务端的约定。所以模板只解决了在 Postman 里怎么构造加密报文的问题最终签名串格式、加密内容、时间戳精度都必须以服务端文档为准。5. 复盘常见坑这五个问题基本占我联调排错的八成脚本写完、请求能通只是开始。我后来把遇到的问题汇总了一下发现几乎都出在下面五个方面任何一个都可能让你白白浪费一整个下午。5.1 执行顺序与变量作用域为什么 Tests 能看到值Pre-request 却读不到Postman 的脚本执行顺序是确定的Pre-request 先跑然后发送请求最后跑 Tests。在 Tests 里设置的环境变量在本次请求的 Pre-request 中是读不到的只能在下次请求里读到。如果你某个接口的第二个请求依赖第一个请求响应的token必须确认把读取 token的逻辑写在第二个请求的 Pre-request 里而不是试图在第一个请求的 Tests 里直接发起第二次请求。另外Collection Runner 跑批时每次迭代都会重新执行 Pre-request环境变量会被重新覆盖。如果你在 Tests 里把它们改成了别的东西下一次迭代的脚本很可能读到脏值。建议每条用例跑完把中间变量清理掉防止串数据。5.2 CryptoJS 编码Key 到底该用字符串还是 WordArray这是 CryptoJS 里最容易踩、但报错信息又最不明显的问题。直接写法// 错误写法key 是普通字符串CryptoJS 会走 KDF 派生 CryptoJS.AES.encrypt(some text, 1234567890abcdef);这种写法在本地自测时可能可以加解密但服务端在 Java 里拿到同一把密钥直接分组解密结果必然是一堆乱码或解密异常。正确写法是// 正确写法先 parse 成 WordArray const key CryptoJS.enc.Utf8.parse(1234567890abcdef); const iv CryptoJS.enc.Utf8.parse(1234567890abcdef);还有一点摘要结果也是一样。CryptoJS.SHA256(str).toString()默认是十六进制字符串如果你服务端期望的是 Base64就必须写.toString(CryptoJS.enc.Base64)。两边的编码习惯不一致也会产生测了一个下午都验签失败的情况。5.3 时间戳窗口签名通过但服务端返回请求过期这类问题的隐蔽点在于签名没问题但服务端对时间有校验窗口常见 5 分钟或 10 分钟。一旦 Pre-request 生成的timestamp和请求体里实际发送的timestamp不是同一个值服务端拿到的时间戳要么和签名对不上要么已经超窗。我排查过的一个实际案例是脚本里生成的是秒级时间戳但环境变量里残留了一个旧的 13 位毫秒时间戳。Pre-request 没有覆盖旧值签名串用的又不是同一个来源导致服务端验完时间后直接拒绝。建议在脚本开头先pm.environment.unset(timestamp)确保每次都重新生成不残留上一轮的值。5.4 Base64 里的号与 URL 编码问题CryptoJS 加密输出是标准 Base64里面可能出现、/、。如果接口要求把密文放在 query string 或表单里传输会被解析成空格服务端解码时 Base64 直接错位。解决方式有两种// 方式一传给请求体之前先 URL 编码 const encodedCipher encodeURIComponent(ciphertext); // 方式二直接生成 Base64URL 变体 const b64url ciphertext .replace(/\/g, -) .replace(/\//g, _) .replace(/$/, );选第二种方式的话要确保服务端也支持 Base64URL 解码否则两边格式不一致。另外Postman 的 raw JSON 请求体走的是 JSON 传输用通常没问题但如果你把它塞进pm.request.headers.add或动态拼进 URL就要特别小心。5.5 密钥管理为什么我不建议把密钥直接写死在脚本里脚本里硬编码密钥的坏处不用多说一旦 Collection 分享给团队等于把加密体系透了个底。Postman 的环境变量支持标记为 Secret设置后变量的值在 UI 里被隐藏但脚本中依然可以通过pm.environment.get()读取。比较稳的习惯是密钥放环境变量而不是全局变量避免跨环境串用区分测试环境与生产环境的密钥变量创建独立 Environment分享 Collection 时不勾选分享环境变量值选项只分享脚本结构重要密钥不要写入console.log我见过有人调试完忘了删日志结果整个密钥被打进报告里。如果团队有专门的密钥管理服务也可以让 Pre-request 脚本通过某个内部接口动态拉取密钥再放到当前环境变量中。不过这种方式会引入网络依赖和缓存策略安全性确实更高但也需要多写不少代码可以根据实际需求取舍。我现在的习惯是任何涉及加密报文的接口跑通第一遍之后一定会在 Pre-request Script 末尾把关键中间值打印一遍包括规范化明文、摘要、密文和签名串。不是为了炫耀是真的方便排查——下次谁再说验签失败我只要看一眼日志就能判断是拼接规则问题、编码问题还是时间戳问题。这套模板你拿去改改应该能少踩不少坑。