搞网络请求隐私这件事,说多了都是泪。我前两年接过一个电商类 Flutter 应用,订单接口路径直接是/order/12345678,用户 ID 也是自增的,网关日志随便一翻就能看到每天的订单量、用户增量,推广链接甩出去,别人拿着你的 ID 从 1 开始遍历,基本等于把业务底裤露在外面。这些数字就是典型的数字指纹。后来我花了两个版本迭代,把 Flutter 侧的hashids2库适配到了鸿蒙 HarmonyOS 平台上,用加盐哈希把自增 ID 映射成短小、唯一、对外不可逆的字符串,在 URL 全链路前面加了一层深度隐匿映射的防御层。这篇文章就把原理、选型、鸿蒙适配过程和踩坑记录完整拆一遍。
适合谁看:在 Flutter 或鸿蒙上做应用、对 URL 参数暴露有困扰的开发者;刚接触鸿蒙三方库适配的人;想给 URL 隐私加一层低成本防御的团队。如果你现在还是把数字 ID 丢进 Base64 就以为万事大吉,建议先别改,看完这篇文章你会明白为什么那是掩耳盗铃。
1. 为什么要在 URL 链路里隐藏数字指纹
1.1 数字指纹是怎么泄露的
URL 是移动端应用里最容易被采集的“明文数据”。它要打给网关、经过 CDN、会写进服务端访问日志、被埋点 SDK 上报、甚至会被用户复制到剪贴板发给别人。只要 URL 上带着业务主键,就等于把这些信息广播给了链路里所有能看到流量的人。常见泄露路径有三个:
- 网关和接入层日志:Nginx 和网关会记录完整 request URI,自增 ID 直接暴露业务量级。一天多少订单、新增多少用户,脚本拉一遍日志就算出来了。
- 埋点平台:很多公司把页面路径和参数全量上报给数据分析平台,这些平台往往不止你一家在用,权限管控稍微松一点,敏感 ID 就飞出去了。
- 分享与营销链接:活动页 URL 里带着邀请人 ID、渠道 ID,用户截图、复制外发后,抓包工具直接还原,竞对可以批量注册验证哪些 ID 是有效的。
这里面的共同点是什么?数字 ID 本身有强烈的规律:自增、连续、可枚举。攻击者不需要破解任何加密,只需要把 1 改成 2、把 2 改成 3,就能批量遍历你的数据接口。所以在今天的环境中,把自增 ID 直接暴露在 URL 里,已经不是“简洁”而是“裸奔”。
我早期看到一个比较极端的案例:一个资讯 App 的文章详情页 URL 是/article/1024,评论区接口是/comment/list?articleId=1024。某团队做竞品分析时,直接从 1 到 20000 把文章 ID 全拉了一遍,文章标题、作者、发布时间、评论数全部拿到。这就是数字指纹泄露的直接后果——不需要攻击加密,只是利用了 ID 的规律性。
1.2 常见的“假防护”为什么没用
很多团队意识到问题后的第一反应是“把 ID 加密一下”,但这里坑特别多。最典型的三种:
- Base64/Hex 编码:看起来像乱码,其实一键解码,连盐都不需要。
- 可逆加密(AES/DES):安全强度够了,但密文太长,URL 直接爆炸;而且可逆加密的结果是确定性的,同一个 ID 永远输出同一个密文,仍然可以作为指纹关联请求;密钥一旦出现在客户端,反编译就能拿到。
- 自研的位运算/字符替换:没有成熟算法的雪崩效应,攻击者多抓几组对应关系就能反推出映射规则。
真正合适的方案,是找一个“短、唯一、对外不可逆、内部可还原”的映射工具。哈希单向散列虽然不可逆,但服务端没法从哈希值还原 ID,没法用于查询;UUID 太长了,对 URL 不友好;随机短码需要服务端维护映射表,成本和一致性问题又来了。于是 hashids 这类的算法就成了很自然的中间选项:它不是密码学意义上的哈希,能编也能解,但加了盐、打乱了字母表之后,外部没有盐和算法参数几乎没法从密文倒推明文。放到 URL 场景里,它就像给数字 ID 穿上了一件“马甲”,不透明、不可预测、短小精悍,同时内部服务还能随时脱掉马甲还原真实 ID。
把用户 ID、订单号、文件 ID 这类参数用 hashids2 编码后再拼 URL,链路里所有日志、埋点、分享链接看到的都是无规律短码,业务数字指纹就被藏起来了。这就是标题里说的“数字指纹泄露防御层”。但请记住,它防的是“一眼看穿”和“顺序枚举”,不是防“针对性密码分析”。
2. hashids2 在 Flutter 侧的核心原理与选型
2.1 hashids 算法拆解:盐、字母表与随机化
先说清楚:hashids 不是哈希算法,它是一种可逆的混淆编码算法,输入是一个或多个非负整数,输出是一个基于自定义字母表和盐生成的字符串。常见的结论是“不同盐生成的编码完全不同,没有盐几乎无法还原”。
一个典型调用长这样:
import 'package:hashids2/hashids2.dart'; final HashIds hashIds = HashIds( salt: 'your-random-salt', minLength: 8, alphabet: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', ); void main() { String encoded = hashIds.encode([9527]); print(encoded); // 例如 EY0aBx List<int> decoded = hashIds.decode(encoded); print(decoded); // [9527] }它内部做的大概是这几件事:
- 把输入整数转换到对应进制,进制位数由字母表长度决定;
- 用盐生成一个打乱后的字母表,作为编码字符集;
- 在编码过程中混入随机化逻辑,并通过最小长度参数控制输出长度;
- 如果传入多个数字,还会把数字序列的哈希作为内容的一部分参与计算,保证同一个数字在不同上下文中也能有不同表现。
这里最核心的“盐”起着两重作用:一是打乱字母表顺序,二是参与编码流的混洗。换一个盐,同一组数字生成的字符串就完全不一样。所以盐的强度直接决定防御层的厚度,如果盐是 “123456” 这种,攻击者枚举字母表也没有难度,等于没防。真实项目里我建议盐控制在至少 32 字节以上,最好用随机生成的字符串,并且和业务无关。
“唯一”在这里的含义要澄清一下:在同一个盐、同一套字母表和最小长度参数下,同一组数字只会生成同一个字符串,这是确定性映射。之所以能做到“唯一”,是因为算法没有引入随机 nonce,每次编码结果可复现。可复现是必须的,因为服务端要用同样的盐和参数解码。但同时也要注意,这种可复现性意味着相同 ID 会暴露“同一性”,所以后面我会讲,真正敏感的场景还需要叠加请求签名和时间戳,让 URL 整体不可重放。
还有一个容易被忽略的点:hashids2 支持一次编码多个数字,比如encode([2024, 11, 23])。这在某些场景很有用,比如把“年、月、日”或“用户ID+订单ID”打包成一个短码。我实际用过一次,发现它并不适合所有组合场景,因为一旦其中一个数变化,整个字符串都会变,排列组合会让调试和排查变得比较痛苦。除非你确实需要隐藏“字段间的绑定关系”,否则我更建议每个业务 ID 单独编码,简单可维护。
2.2 为什么选 hashids2 而不是自研或其它库
选型时我对比过几个思路,列个表直观一点:
| 方案 | URL 长度 | 服务端还原 | 安全性 | 适用场景 |
|---|---|---|---|---|
| 自增 ID | 最短 | 无需还原 | 极低,可枚举 | 内部调试接口 |
| UUID | 36 字符 | 无需还原 | 高,但太长 | 不适合用户可见 URL |
| Base64(ID) | 中等 | 简单但可逆 | 纸糊,一键解码 | 不推荐 |
| 自定义混淆算法 | 可控 | 需要同步实现 | 取决于算法质量,易翻车 | 有密码学团队 |
| hashids2 | 短小 | 同盐可还原 | 较高,依赖盐的强度 | URL 参数隐匿、短链 ID |
具体到 Flutter/Dart 生态,hashids2 是社区里维护比较活跃的 Dart 实现,空安全兼容,API 简单,纯 Dart 无原生依赖。这意味着它天然适合跨端,尤其适合鸿蒙适配。相比之下,自研混淆算法最容易栽在“只有客户端加密、服务端解不开”或“长度不可控”这类低级问题上。而 hashids2 的编码结果长度可以通过 minLength 控制,最短可以压到几个字符,同时保留足够字母表空间,对 URL 友好。
但也要泼盆冷水:hashids2 不是银弹。它的安全边界是“盐未知+样本有限”时难以还原,如果盐泄露、或者你的数字 ID 取值范围很小(比如 0-10000)且被攻击者拿到了大量对应关系,那仍然可以通过字典枚举猜出来。所以我一直把它定位成“数字指纹防御层”,而不是加密层;真正的数据传输安全还是要靠 HTTPS 和请求签名。在需求评审时我会直接跟产品说清楚:这个方案解决的是“URL 上的数字指纹泄露”,解决不了“接口被刷”和“数据被拖库”。
3. 鸿蒙适配:从 Flutter 插件到 ohos 平台的落地
3.1 鸿蒙 Flutter 三方库适配的基本判断标准
鸿蒙生态这几年的适配路径已经很清晰了:先看要适配的三方库是不是纯 Dart 实现,再看它是否依赖原生平台通道,最后才是考虑怎么把原生能力映射到 ArkTS 侧。绝大多数情况可以分成三类:
- 纯 Dart 库:不依赖任何原生 API,理论上在鸿蒙工程里直接引入就能跑,只需要处理工程配置和编译验证。
- 含原生代码的插件:通过 MethodChannel/EventChannel 调用原生能力,需要为 ohos 平台实现对应的 ArkTS 代码,并注册到插件注册表。
- 依赖系统能力(指纹、安全存储、定位等):除了平台通道,还要确认鸿蒙 SDK 是否提供对应能力接口,比如 HUKS、位置服务等。
hashids2 属于第一类,这是它的最大优势。整个库就是一个 Dart 文件加少量测试,代码完全跑在 Flutter engine 的 Dart 虚拟机里,不涉及 iOS/Android/鸿蒙任何原生 API。因此它在鸿蒙上的适配核心不是“移植代码”,而是“把工程和环境配好,跑通端到端验证”。
这里补充一个实际经验:纯 Dart 库看起来简单,但一旦工程里同时存在多个 Flutter 插件,鸿蒙侧的编译顺序和插件注册可能互相影响。我遇到过明明代码没改,只是新增了一个鸿蒙插件,结果 hashids2 所在模块的单元测试在 ohos 模拟器上全部超时,最后发现是插件注册表里的包名冲突。所以即便是“直接引入”的纯 Dart 库,也不要跳过真机验证。
3.2 鸿蒙工程落地步骤:从依赖引入到端到端验证
我用的环境大致是:DevEco Studio 适配的 HarmonyOS NEXT 版本,Flutter SDK 侧开通 ohos 平台支持,工程结构里同时存在 android、ios、ohos 目录。具体步骤如下:
- 在 Flutter 工程的
pubspec.yaml中添加 hashids2 依赖:
dependencies: flutter: sdk: flutter hashids2: ^2.0.0在 ohos 模块的工程配置里确认支持鸿蒙的三方依赖管理方式。鸿蒙侧现在用 ohpm 管理原生依赖,但纯 Flutter 库不需要额外处理,只需要保证 Flutter SDK 和鸿蒙引擎版本匹配。
写一个统一的 ID 编码解码封装,方便业务侧调用,也方便后续切换算法:
class IdObfuscator { IdObfuscator._(this._hashIds); final HashIds _hashIds; static IdObfuscator create({required String salt, int minLength = 8}) { return IdObfuscator._( HashIds( salt: salt, minLength: minLength, alphabet: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', ), ); } String encodeId(int id) => _hashIds.encode([id]); int? decodeId(String code) { final result = _hashIds.decode(code); return result.isEmpty ? null : result.first; } }在鸿蒙真机或模拟器里写一个冒烟测试页,输入几个边界数字编码,再用同一个实例解码,确认往返一致。重点测 0、1、接近 int 最大值的数字,以及重复编码的稳定性。这个验证是鸿蒙适配中最容易忽略的一步,很多纯 Dart 库在 Android 上没问题,换到鸿蒙后因为 SDK 路径、最小 SDK 版本、编译器优化差异而行为不一致的情况是真实存在的,不要想当然。
如果工程里用到了鸿蒙的安全存储能力,再补一个“从 HUKS 读取盐并注入 IdObfuscator”的联调用例。这一步可以后做,但一定要做。
3.3 盐的安全存储:用鸿蒙安全内核的 HUKS 能力
在传统 Android/iOS 上,很多人会把盐直接写死在 Dart 代码里,这是我这几年看到的最常见翻车点。客户端代码可以被反编译,盐一旦暴露,整个防御层就归零。鸿蒙安全内核体系里恰好提供了 HUKS(HarmonyOS Universal Keystore)能力,可以生成和保存不可导出的密钥材料,我们可以用它在 ArkTS 侧保存用于混淆的盐。
大致思路是:Flutter 侧通过 MethodChannel 向 ohos 原生侧请求“读取盐”,原生侧优先从 HUKS 创建/读取加密盐,如果不存在则生成随机盐并写入 HUKS。这样盐不会以明文出现在 HAP 包里,就算设备被提权,密钥材料也不可导出,比硬编码在 Dart 里安全得多。
ArkTS 侧的关键调用大致长这样(示意代码,API 版本以官方为准):
import { huks } from '@kit.UniversalKeystoreKit'; // 生成随机盐并存储 huks.generateKeyItem('alias_salt', { ...huks.HuksOptions }); // 读取时通过 huks.exportKeyItem / huks.getKeyItemProperties 获取这里我不展开具体 API 细节,因为不同 API 版本差异较大,重点在于“密钥不出客户端,业务代码只拿引用”。Flutter 侧接收盐后,可以放在内存里使用,不要持久化到本地普通文件。如果团队暂时不想接原生通道,退一步的做法是用字符串混淆加一份独立配置文件,但强度和 HUKS 完全不在一个量级。我的建议是:既然都为了鸿蒙做适配了,就一步到位把盐的安全存储也做了,整体方案才算闭环。
这里再补充一个我在鸿蒙适配时踩过的坑:MethodChannel 在鸿蒙上的线程模型和 Android 不太一样,如果 Flutter 侧在 UI 线程同步等待“从 HUKS 读取盐”,很容易触发平台通道的响应超时。正确做法是异步初始化,把IdObfuscator的构建放在一个FutureBuilder或启动路由加载完之后再执行。不然一启动就白屏几秒,体验非常糟糕。
4. 全链路 URL 打码的工程实现
4.1 内外有别的编码策略:可逆与不可逆的取舍
很多团队对“URL 里的 ID 要不要编码”纠结很久,其实核心不是“要不要”,而是“谁需要还原”。我落地时把 URL 参数分成两类:
- 面向客户端展示和请求的公开参数:比如订单号、活动 ID,这类参数对外必须不可猜测,但后端服务需要真实 ID 去查询,所以采用“同盐可逆”的 hashids2 编码,后端在网关或服务入口统一解码。
- 面向内网监控、排查链路的数据:比如 traceID、内部流水号,内部系统之间不做编码,但离开内网边界时必须在日志系统里做脱敏,只保留前后各两位字符,中间打星号。
“不可逆”不是指 hashids2 本身不能解,而是指对外部调用方不可逆。客户端只负责编码,解码逻辑只存在于服务端信任环境。这样设计的好处是:客户端拿不到真实 ID 的解码能力,即使被逆向,攻击者手里的也只是混淆码,没有盐照样还原不了。
我见过一个团队在这里走极端:他们要求所有内部服务之间也不能传真实 ID,结果排查问题时每个部门都要先找“解码网关”换一次 ID,链路追踪全断了。技术方案要做的不是过度设计,而是明确边界。对外隐藏,对内可查,这才是“护航全链路 URL 隐私”的正确姿势。
4.2 在 Dio 请求拦截器里接入 hashids2
我用的 HTTP 客户端是 Dio,利用拦截器在请求发出前统一替换路径和 query 中的数字 ID,业务层代码基本不用改。示例:
class UrlPrivacyInterceptor extends Interceptor { UrlPrivacyInterceptor(this._obfuscator); final IdObfuscator _obfuscator; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // 替换路径中的数字ID,例如 /order/123456 -> /order/AbCdEf options.path = options.path.replaceAllMapped( RegExp(r'/order/(\d+)'), (match) => '/order/${_obfuscator.encodeId(int.parse(match.group(1)!))}', ); // 替换 query 参数中的用户ID,例如 ?userId=8888 -> ?userId=xxxYyy if (options.queryParameters.isNotEmpty) { final encoded = options.queryParameters.map((key, value) { if (key == 'userId' && value is String && int.tryParse(value) != null) { return MapEntry(key, _obfuscator.encodeId(int.parse(value))); } return MapEntry(key, value); }); options.queryParameters.clear(); options.queryParameters.addAll(encoded); } handler.next(options); } }这样一个拦截器挂在 Dio 上之后,业务代码里仍然写着userId: 8888,发出去的 URL 已经变成/order/AbCdEf?userId=xxxYyy。日志、埋点、网关看到的全是混淆短码。
后端解码参考:
final HashIds serverHashIds = HashIds(salt: serverSalt, minLength: 8); List<int> realOrderId = serverHashIds.decode('AbCdEf');这里有个工程细节:客户端和服务端的盐必须完全一致,minLength 和 alphabet 也必须一致,否则解码直接失败。所以配置管理上要把这三项当成同一个“参数组”来发布,通常放在统一的配置中心里,避免客户端升级和服务端发布不同步导致线上大量解码失败。我建议在配置中心加一个“hashids 参数组版本号”,发布时先发服务端,再发客户端,中间留一个版本重叠期。
另外,拦截器里要小心不要误伤那些本来就是字符串的路径参数。比如/user/abc这种就不是数字,不应该编码。所以上面的正则只匹配数字串,这是最保守的做法。如果你更激进一点,想对混合型和枚举型参数也做混淆,那就要维护一张“需要打码的参数名”白名单,避免把语义词也替换掉。
4.3 日志、埋点与分享链路同步脱敏
URL 打码不能只管“发出去的请求”,链条上的日志和埋点同样要处理。我在实践中做了三件事:
- 网关层增加脱敏规则:对符合
/order/{混淆码}的路径不做二次修改,但日志存储结构改为两级——原始完整 URL 只保留在内网审计系统,业务侧日查系统只暴露混淆后的 URL。 - 埋点 SDK 上报前进行处理:在埋点初始化阶段设置参数回调,对包含数字 ID 的参数统一替换,避免敏感 ID 进数据分析平台。
- 分享链接强制使用混淆码:客户端所有生成分享链接的入口都走同一个 URL Builder,Builder 内部只接受编码后的 ID,不接受原始 ID 拼链接。
这步做完,整个链路才算真的“全链路护航”,而不是只堵了请求这一个口子。我还见过一个反面案例:请求层打码做得很好,结果分享出去的短链 URL 在落地页又被运营同学改成了带原始 ID 的活动链接,等于前面全白做。所以一定要从产品流程上禁止“原始 ID 进入分享 URL”这条路径,最好在代码评审里加一条硬性约束。
5. 常见问题与排查技巧实录
5.1 编码结果不稳定、过长、碰撞问题
先说“不稳定”。如果你发现同一个 ID 在不同环境编码出不同字符串,大概率是盐、字母表或 minLength 不一致。跨端最容易出现 Dart 和 Java/Go 版本参数不统一的问题,尤其字母表顺序不对,会导致完全不同的结果。排查时先把两端参数组打印出来比对,确认盐和 alphabet 字节级一致。
然后是“过长”。hashids 的 minLength 设置了最小长度,但实际输出长度还受输入数值大小影响,最大位数越大的数字编码后可能越长。如果 URL 对长度敏感,有两种处理方式:一是提高字母表长度,用大小写加数字加更多符号,增加字符多样性;二是拆分数字范围,高频区用短参数、低频区用充足长度。不要为了强行缩短把 minLength 设成 1,过短输出会显著增加被穷举的概率。
关于碰撞,hashids 理论上不同输入组合可能编码成相同字符串?实际上算法设计中包含序列校验,但如果你传入了超大数字或用错 API 签名,仍可能踩到异常。我建议在接入层做个冒烟用例,覆盖最大业务 ID 边界,确保编码-解码闭环。
这里给一段简单的冒烟测试参考:
void smokeTest(IdObfuscator obfuscator) { final samples = [0, 1, 42, 999, 10000, 2147483647]; for (final id in samples) { final code = obfuscator.encodeId(id); final decoded = obfuscator.decodeId(code); if (decoded != id) { throw StateError('hashids round-trip failed for $id -> $code -> $decoded'); } } }我在两个项目里都是把这段逻辑放在 CI 的单元测试里的,每次改配置都会跑一遍,大大减少了“上线后发现某段数字解不开”的尴尬。
5.2 鸿蒙适配里容易踩的平台坑
我在鸿蒙上适配时遇到三类问题比较典型:
- 平台通道类型映射差异。Flutter 侧的
List<dynamic>、Map<String, dynamic>、Uint8List在鸿蒙 ArkTS 侧的类型推断偶尔会不一致,导致 MethodChannel 调用直接报错。绕行方案是统一用 JSON 字符串传递盐和密钥相关数据,在 ArkTS 侧解析后再调 HUKS。 - 鸿蒙 SDK 版本差异。不同 HarmonyOS 版本的 HUKS 接口命名有变化,比如从
@ohos.security.huks到@kit.UniversalKeystoreKit的迁移,代码不能一处写死,要加版本判断或统一封装。 - 纯 Dart 库的编译缓存问题。鸿蒙侧 Flutter 引擎与 Android 的 AOT 产物格式不完全一样,升级 Flutter SDK 后一定要在真机重跑一遍冒烟用例,不要在模拟器上看到通过就收工。
我在第一次适配时还犯过一个错:在鸿蒙模拟器上测试通过后就直接发版,结果用户在真机上出现偶发解码失败。后来发现是模拟器和真机的minLength行为有点差异,模拟器上minLength=8稳定输出 8 位,真机上同一个数字因为系统版本差异多输出了一位。这种问题只能靠真机矩阵测试来兜底,最好把几台高低端鸿蒙设备都跑一遍边界用例。
这里给个自查清单:
- 真机/模拟器同时验证 encode-decode 往返
- 同一个 ID 连续编码 10 次,确认输出一致
- 服务端和客户端使用同一组盐、字母表、minLength
- 切一次盐后,旧 URL 能否优雅失败并提示刷新
- HUKS 读写盐的通道在断网和重复初始化时不会 crash
5.3 盐轮换与历史数据迁移
线上系统总有一天要换盐,可能是因为盐疑似泄露,也可能单纯是安全策略升级。但 hashids2 的特性决定了:换盐之后,所有旧 URL 全部失效。我在实践中的做法是“双盐并行”:
- 服务端维护两套配置:新盐用于新 URL 编码,旧盐用于解码历史 URL。
- 客户端接口给一个“配置版本号”,发布新盐时客户端拉到新配置后只编码新链接,但服务端在一段过渡期内同时支持旧盐解码。
- 过渡期结束前,用离线任务把存量业务 ID 重新编码一遍并更新缓存,跑完后彻底下线旧盐。
这听起来简单,实际操作里最大的坑是“双盐并行时缓存没有区分盐版本”,导致同一条 URL 在缓存里被解成两套 ID。我的解决办法是在缓存 key 里带上盐版本号,比如order:code:v2:{code},避免新旧数据互相覆盖。
我还整理过一个盐轮换的决策表,方便团队评估:
| 场景 | 是否需要双盐并行 | 过渡期建议 |
|---|---|---|
| 内部小流量业务 | 不需要,直接换盐并通知客户端强更 | 1 天 |
| 线上用户可见链接多 | 需要双盐并行 | 1-2 周 |
| 分享链接被大量外部收藏 | 需要双盐并行,并保留旧 URL 301 跳转 | 1 个月以上 |
最后再补一个经验:无论你怎么换盐,都一定要在日志里打印“当前使用哪个盐版本”,否则线上出了解码问题,你根本不知道客户端到底用的是 v1 还是 v2。我见过排查了一晚上,最后发现是某台缓存服务器没更新配置,还在用 v1 解码 v2 的 URL,导致部分请求 4xx。这种问题不算难,但很容易让人心态爆炸。
我个人在鸿蒙项目里把 hashids2 落地后,最大的体会是:它解决的其实不是“加密”问题,而是“暴露面”问题。很多团队一开始追求 AES 加密、追求不可逆哈希,反而把 URL 搞得又长又重。hashids2 这种加盐混淆思路,胜在短小、可复现、业务改动小,配合日志脱敏和 HUKS 盐存储,能在不大动干戈的前提下把 URL 链路的数字指纹藏起来。最后再提醒一句:无论盐怎么存、编码怎么混淆,都不要忘了 HTTPS 和请求签名,这些才是数据安全的底线。