很多项目做到后面,真正的安全感不是来自"能用HTTPS再配个网关鉴权",而是来自接口的参数本身经得起抓包推敲。我最近在一套微服务架构里做了一件事:用RequestBodyAdvice把外部请求参数在进入业务代码之前完成解密,同时用ResponseBodyAdvice把返回结果加密后再吐出去。整个过程没有侵入Controller的一行业务逻辑,所有接口的请求响应在传输层和应用层都变成了密文。这篇文章就把这套方案的设计思路、完整实现、踩坑记录,以及微服务场景下真正值得注意的那些细节,原原本本分享出来。
这篇文章适合微服务架构下的后端开发、安全和架构同学看。不管你是刚接触Spring MVC扩展点,还是已经在做接口安全改造但总觉得方案不够干净,这篇内容都能给你一个可落地的参考框架。特别是标题里提到的"巧用Aop",我不会只讲AOP概念,而会把Spring MVC提供的RequestBodyAdvice这个比传统AOP更贴近参数解析链路的扩展点讲透,让你明白为什么用它来做"全局请求参数解密"是正解。
1. 请求参数加密的价值所在
1.1 一个让我下决心做全局加密的场景
之前接手一个对外提供服务的项目,业务方反馈说他们担心用户数据在接口传输过程中被第三方恶意抓取。当时项目已经申请了HTTPS证书,网关也做了签名校验,自以为安全等级足够。但当我实际去抓包验证时发现,虽然TLS链路是加密的,可一旦认证通过、请求到达业务系统之后,日志、监控、数据库、消息队列里全是明文请求参数和明文响应体。如果你把日志系统打通给运维或者第三方做数据分析,这些敏感数据就等于在另一个维度又裸奔了一遍。
更麻烦的问题是,很多外部对接方并不会直接使用我们提供的SDK,而是靠RESTful API文档手动拼报文。只要对方知道接口路径和参数结构,就能构造一个看起来完全合法的请求。这种情况下,传输层加密只能保证"路上不被听",不能保证"来的请求是真的"。所以我才意识到,必须把安全问题往前推一步:让所有进入业务系统的请求参数,先经过一道统一解密关卡,让所有对外返回的数据,也必须以密文形式出去。
1.2 为什么不用传统AOP切面,而是RequestBodyAdvice
提到全局增强,很多人的第一反应是写一个AOP切面,拦截Controller方法,对入参和返回值做处理。这个方向本身没有错,但放在Spring MVC的请求进来流程里,它有一个致命的时间差问题:AOP切面执行的时候,方法参数已经被反序列化成对象了。也就是说,如果请求体传递的是密文,切面拿到的入参是一个"密文对应的Java对象",你需要在这个对象上再做一次额外的解密转换,最后还得手动改掉方法参数值。道理上讲得通,实操上非常别扭,因为Spring的依赖注入和方法参数解析根本不是切面能接管的部分。
而RequestBodyAdvice是Spring MVC在反序列化RequestBody之前预留的一个扩展点。请求进来之后,它会先经过HttpMessageConverter的读取过程,RequestBodyAdvice就挂在读取之前。在此时拿到的输入还是一个InputStream,你可以把密文在这里拦截下来,解密成明文JSON,再包装成一个新的InputStream交给后续的Converter去读。这样Controller方法里拿到的就已经是解析好的明文DTO,业务代码完全无感知。
从设计思想上看,这正是AOP的"横切关注点"的体现,只是它比普通AspectJ切面更精准地切在了MVC参数解析的骨节上。所以标题里说的"Aop",准确理解应该是"面向切面的思想",而不是狭义上的@Aspect注解。这也是很多人在搜"请求参数加密"时容易绕弯路的地方:先去找切面写法,却发现怎么改参数都像在做hack,其实Spring早就把更合适的钩子放在那里了。
2. RequestBodyAdvice扩展点原理解剖
2.1 它在Spring MVC请求链路中的位置
一次带@RequestBody的POST请求到达Spring MVC后,HandlerAdapter会找到对应的方法处理器,进而使用RequestMappingHandlerAdapter中的参数解析器去处理方法的参数。这就是接口参数从InputStream变成Java对象的关键过程。而在这个过程中的HttpMessageConverter,负责把HTTP请求字节流按照Content-Type约定转换成方法里的参数对象。
如果你注册了RequestBodyAdvice的实现类,AbstractMessageConverterMethodArgumentResolver在处理时,会先生成一个RequestResponseBodyAdvice链。请求真正进入Converter之前,advice有机会修改body读取逻辑;Converter读取完成之后,advice还有机会对转换出来的对象做后置增强。整条链路的位置顺序是:请求头解析 -> 找到@RequestBody参数 -> 调用RequestBodyAdvice.supports判断是否介入 -> 调用beforeBodyRead读取并替换body -> Converter反序列化 -> 调用afterBodyRead对对象做后处理。正是因为这个顺序,beforeBodyRead成了做"密文替换明文"最顺手的切入点。
2.2 四个核心方法的职责拆解
RequestBodyAdvice接口一共定义了4个方法,每个都有明确的职责:
supports()最先被调用,用于决定当前请求是否走加解密逻辑。你可以根据方法注解、方法所在类、请求体类型、目标参数类型来做动态判断。
beforeBodyRead()在Converter读取body之前被调用。如果我们在这里对InputStream里的密文做替换,实际返回一个包含明文JSON的新的HttpInputMessage,后面Converter读取到的就是解密后的数据。
afterBodyRead()在Converter完成对象转换后被调用。此时body已经变成Java对象,你可以在这里对对象做二次加工,比如日志脱敏、字段校验,也可以直接原样返回。要注意的是,这里如果返回一个新对象,它会替代原来的body作为方法参数。
handleEmptyBody()用于处理请求体为空的情况。如果前端传了一个空body,supports返回true的情况下,空body会被送到这个方法里。实现时通常返回null,或者根据业务需要返回一个含有默认值的新对象。
把这四个方法串起来看,RequestBodyAdvice其实就是Spring MVC给参数解析流程装了一道活门。你想在哪个环节插入逻辑,就在哪个方法里动手,这种设计本身就是为"全局增强"而生的。
2.3 解密放在beforeBodyRead的底层逻辑
为什么要坚持在beforeBodyRead里替换InputStream,而不是等Converter读完再处理字符串?因为一旦交给Converter去读,Controller方法里的参数类型就决定了反序列化目标。比如参数是UserDTO,Converter看到的是密文,反序列化必然失败。你如果等反序列化失败后在做异常兜底,那实现复杂度就上去了。而beforeBodyRead时,Controller的MethodParameter虽然已经知道目标参数类型,但它还没有被Converter使用。此时把密文替换成明文,Converter拿到的就是一份合法的JSON结构,和目标DTO天然匹配。整个过程对后面的代码没有任何干扰。
另外,beforeBodyRead阶段还没有发生任何业务对象创建,替换流的成本极低,不会牵涉到Bean的生命周期,也不会触发代理逻辑。这也是它比Filter更加轻量的原因。如果用过滤器在Servlet层面统一处理,你需要自己包装HttpServletRequest,让getInputStream()返回解密后的流,再配合ContentCachingRequestWrapper做缓存,代码量大不说,还容易在处理完忘记重置流导致后续读取为空。而RequestBodyAdvice天然就是为这个场景设计的,何必舍近求远。
3. 全局加解密链路的完整落地
3.1 加解密参数与算法约定
在动手写代码之前,有个关键决策:使用什么加密算法、采用什么编码方式、前后端如何约定密文格式。
这里我采用AES对称加密,密钥由服务端配置管理,前端和后端共用。算法模式选的是AES/CBC/PKCS5Padding,相比ECB模式,CBC模式引入了IV向量,相同明文在不同IV下产生不同密文,能有效避免"相同数据块特征泄露"的问题。IV是一个16字节的随机值,不要求保密,但为了解密时需要同一个IV,我把IV拼接在密文前面一起传输,服务端解密时提取前16字节作为IV,再对剩余部分做解密。这样做还有一个好处:即使两个完全相同的请求,密文也不同,进一步提高了抗分析能力。
密文整体使用Base64编码传输,方便在HTTP体里直接放字符串。最终约定的请求体格式是:base64(iv[16字节] + AES加密后的密文)。响应的密文格式保持一致,这样前端只需要维护一套加解密工具。
为了后续扩展,我没有把密钥硬编码在代码里,而是放到配置文件,再从配置中心拉取。密钥为16字节的UTF-8字符串,对应AES-128,既满足强度要求,也兼容绝大多数JDK环境。如果项目安全等级要求更高,可以换成AES-192或AES-256,但要注意JDK8早期版本默认不支持256位密钥,需要额外安装JCE无限制权限策略文件,别到时候在测试环境踩了坑。
3.2 后端解密RequestBodyAdvice实现
先看核心代码,我把解密Advice写成一个独立的Spring组件:
@Component public class DecryptRequestBodyAdvice implements RequestBodyAdvice { @Value("${api.security.secret-key}") private String secretKey; @Override public boolean supports(MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { // 只对JSON消息转换器生效,避免干扰String、Form等其它类型 return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType) && !parameter.hasMethodAnnotation(SkipEncrypt.class); } @Override public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException { String cipherText = StreamUtils.copyToString(inputMessage.getBody(), StandardCharsets.UTF_8); if (StringUtils.isBlank(cipherText)) { throw new BizException("请求体不能为空"); } String plainText = AesEncryptUtil.decrypt(cipherText, secretKey); return new DecryptInputMessage( new ByteArrayInputStream(plainText.getBytes(StandardCharsets.UTF_8)), inputMessage.getHeaders() ); } @Override public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { return body; } @Override public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { return null; } private static class DecryptInputMessage implements HttpInputMessage { private final InputStream body; private final HttpHeaders headers; public DecryptInputMessage(InputStream body, HttpHeaders headers) { this.body = body; this.headers = headers; } @Override public InputStream getBody() { return this.body; } @Override public HttpHeaders getHeaders() { return this.headers; } } }这段代码里有一个细节值得注意:supports()里限制converterType为MappingJackson2HttpMessageConverter。为什么不无条件返回true?因为如果Controller某个方法参数是String类型,请求的Content-Type是text/plain,这时候处理消息的Converter就不走Jackson。如果我的advice贸然介入,读取到的字符串会先被当成密文解密,再传给StringHttpMessageConverter,结果可能是一个解密后的JSON字符串,而不是原始的字符串参数,会产生预期之外的转换错误。加上converterType校验,等于明确告诉Spring:我只管JSON请求,其他类型你们按默认行为走。
3.3 响应体加密ResponseBodyAdvice实现
光有请求解密还不够,响应数据如果不加密,前端拿到明文JSON一样等于裸奔。我再用ResponseBodyAdvice做一层响应加密:
@Component public class EncryptResponseBodyAdvice implements ResponseBodyAdvice<Object> { @Value("${api.security.secret-key}") private String secretKey; @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType) && !returnType.hasMethodAnnotation(SkipEncrypt.class); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body == null) { return null; } String plainJson; if (body instanceof String) { plainJson = (String) body; } else { plainJson = JSON.toJSONString(body); } String encryptText = AesEncryptUtil.encrypt(plainJson, secretKey); return encryptText; } }这里有一个常见误区:很多同学写ResponseBodyAdvice时,会直接返回一个加密后的String,但忘记考虑返回类型覆盖的问题。Spring MVC对返回值是String的处理方式和对普通对象不一样,它走的是StringHttpMessageConverter,而不是Jackson。结果你会发现加密字符串被原样写出去之外,还把Content-Type变成了text/plain,和接口文档里写的application/json不一致。
所以我强烈建议在Controller层统一定义返回包装类,例如Result ,所有接口都返回这个对象。这样beforeBodyWrite中body就永远不会是String,统一走JSON.toJSONString序列化后加密,不会出现类型分支混乱。前端拿到的响应体就变成了一段完整的Base64密文,解出来才是一个标准Result结构,使用体验完全一致。
3.4 灵活控制哪些接口需要加解密
实际项目中确实存在一些接口不需要走加解密链路,比如支付回调、文件下载、健康检查、Swagger相关接口。强行全部加密反而会影响调试效率和第三方对接。
我最终的方案是引入一个@SkipEncrypt注解,加在Controller类或方法上后,advice的supports()直接返回false,跳过加解密。默认全量加密,例外面包给特定接口。这样安全基准是统一且严格的,而不是靠开发人员自觉去给需要加密的接口打注解,因为"忘记打注解"和"漏掉一个接口"是这类方案最容易出的安全事故。
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface SkipEncrypt { }这样设计干净利落,Controller里什么都不用改,加密是默认行为,需要放行的接口用这个注解显式声明。对于支付回调这类需要明文验签的接口特别重要,因为对方拿着我们签发的公钥校验签名,如果请求体是密文,人家根本没法验。
3.5 配套工具类AesEncryptUtil
再补一个工具类,注意要用ThreadLocal保存Cipher实例,避免并发场景下的线程安全问题。javax.crypto.Cipher不是线程安全的,多个线程共用同一个实例会出现奇怪的解密失败甚至死锁。
public class AesEncryptUtil { private static final String ALGORITHM = "AES"; private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final int IV_LENGTH = 16; private static final ThreadLocal<Cipher> ENCRYPT_CIPHER = new ThreadLocal<>(); private static final ThreadLocal<Cipher> DECRYPT_CIPHER = new ThreadLocal<>(); public static String encrypt(String plainText, String secretKey) { try { Cipher cipher = ENCRYPT_CIPHER.get(); if (cipher == null) { cipher = Cipher.getInstance(TRANSFORMATION); ENCRYPT_CIPHER.set(cipher); } byte[] ivBytes = new byte[IV_LENGTH]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(ivBytes); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes); SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); ByteBuffer byteBuffer = ByteBuffer.allocate(ivBytes.length + encrypted.length); byteBuffer.put(ivBytes); byteBuffer.put(encrypted); return Base64.getEncoder().encodeToString(byteBuffer.array()); } catch (Exception e) { throw new BizException("加密失败"); } } public static String decrypt(String cipherText, String secretKey) { try { Cipher cipher = DECRYPT_CIPHER.get(); if (cipher == null) { cipher = Cipher.getInstance(TRANSFORMATION); DECRYPT_CIPHER.set(cipher); } byte[] decoded = Base64.getDecoder().decode(cipherText); byte[] ivBytes = Arrays.copyOfRange(decoded, 0, IV_LENGTH); byte[] encryptedData = Arrays.copyOfRange(decoded, IV_LENGTH, decoded.length); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes); SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted = cipher.doFinal(encryptedData); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new BizException("解密失败"); } } }IV随机生成并拼在密文前面,这个设计兼顾了安全性和解密便利性。每次加密的IV都不一样,即使请求内容相同,密文也不同。解密的时候先取前16字节还原IV,后面那部分才是真正的密文数据。
4. 落地过程中踩过的坑与实战排查
4.1 全局异常处理器接不住解密异常
这是我最开始遇到的最头疼的问题。beforeBodyRead里抛出BizException后,Spring MVC并不会直接把它当作一个普通的Controller异常交给@RestControllerAdvice处理。在参数解析阶段的异常,很多会被包装成HttpMessageNotReadableException或者MethodArgumentNotValidException,然后被Converter层消化掉。结果是前端收到的提示永远是系统繁忙,排查半天才发现是解密链路的异常没被正确往外抛。
我最终的解决办法是在全局异常处理器里专门增加一个对HttpMessageNotReadableException的兜底,同时在beforeBodyRead里不再抛自定义业务异常,而是将解密失败包装成一个带错误码的Result通过响应体返回。如果你希望异常能被统一兜底,也可以直接抛自定义异常,但要注意异常处理优先级,让自定义异常处理器跑在框架默认处理器之前。
实际上更稳的做法是,在beforeBodyRead里不要直接throw,而是用一个布尔值控制流程。解密失败时返回一个含有明文"报错信息"的InputMessage,后面反序列化时就会因为JSON结构不对抛Jackson异常。但这个方案的错误提示比较糟糕。所以我还是选了抛异常加兜底处理的桥路,实测下来最省心。
4.2 supports方法没有控制converterType,导致String类型参数也被加密
我一开始supports()直接返回true,以为所有请求都会走我的解密逻辑。结果项目里有一个接口的参数是String类型,前端传的是普通字符串,经过解密advice后变成了另一串乱码,再被StringHttpMessageConverter读取,方法拿到的参数完全是错的。
后来排查发现,只要@RequestBody的参数类型不是POJO,Spring会走不同的消息转换器。String类型用的是StringHttpMessageConverter,表单类型用的是FormHttpMessageConverter。这些Converter的读取逻辑和Jackson完全不一样,强行插入解密逻辑就会导致非预期转换。所以我在supports()里明确判断converterType,让advice只管MappingJackson2HttpMessageConverter,其他类型全部放行。如果你的项目用了其他JSON序列化框架,比如FastJsonHttpMessageConverter、GsonHttpMessageConverter,也要在supports里相应增补。
4.3 Content-Type不带charset导致中文乱码和长度问题
前端在POST请求里没有显式带charset,默认的Content-Type是application/json。因为我的解密逻辑用StandardCharsets.UTF_8去读InputStream,问题倒不大。但有一种情况比较隐晦:如果密文经过URL编码之后长度发生变化,而前端没有设置正确的Content-Length,服务端读Request body时可能会截断或重排缓冲区,导致解密出来的明文不完整。
我在实际项目中就把自己的HTTP工具测试改成只设置Content-Type: application/json,不手动指定Content-Length,让框架自己计算,同时确认密文是基于UTF-8的Base64编码。因为Base64本身就是ASCII字符,不涉及中文,但解密后的明文JSON包含中文时,反过来由UTF-8编码,这必须在前后端同时约定,否则一个用了GBK一个用了UTF-8,解出来的就是一堆乱码。
4.4 加密后Swagger调试体验极差
Swagger本身并没有加解密能力,只要我们的全局加解密生效,你在Swagger UI里直接填明文JSON,发送之后服务端会尝试把明文当成密文去解密,百分百报错。这算是一个非常头疼的团队协作问题,前端同学调试时也很不方便。
我做的处理是给Swagger相关的接口全部加上@SkipEncrypt注解,并且只在非生产环境开放。另外给开发调试单独留一个"明文直通"开关,通过配置中心动态控制,改动配置后不需要重启服务,很方便。生产环境这个开关强制关闭,不管怎么配置都加密。这种"环境差异+显式开关"的组合,既保证安全,也不把开发体验完全牺牲掉。
5. 微服务架构下的纵深安全设计
5.1 对称加密不是终点,还要防重放
用AES做参数加密,解决了"请求参数可读"的问题,但它依然不能防重放。攻击者抓到一个合法的加密请求,即使不知道明文内容,也可以原封不动地重新发送一次。如果这个请求是支付下单或者密码修改接口,后果就很严重。
所以我在加密明文中额外塞了一个时间戳和随机数nonce字段,服务端解密后检查时间戳是否在有效时间窗内(比如5分钟),并校验nonce有没有在Redis里出现过。只有两个条件同时满足才放行。这个做法在安全上其实是签名体系的补充,跟AES加密不冲突。你可以在DTO里直接加这两个字段,也可以单独定义一个包一层的外部结构。实际项目中我建议把它放进明文JSON结构的最外层,统一处理,避免污染每个业务DTO。
5.2 密钥如何管理与动态轮换
微服务架构下,多实例部署意味着每台实例都要能拿到同一个密钥。如果只是写死在application.yml里,相当于大家用同一个密码本,而且改密码必须改配置重启服务。
我用的是配置中心管理密钥,比如Spring Cloud Config或者Nacos。密钥在配置中心加密存储,服务启动时拉取放入内存。为了支持平滑轮换,我在解密逻辑里支持多密钥版本号。密文前面加一个版本标识,比如v1密文、v2密文,解密时根据版本号选择对应密钥解密。这样即使新密钥已经上线,旧客户端发的还是旧密钥密文,也能正常解。等确认所有调用方都切换完毕,再在配置中心下线旧密钥。这个方案在真实的多方对接场景里非常重要,因为你没法要求所有第三方在同一时间点更新密钥。
5.3 把加解密能力做成公共组件,而不是每个服务重复实现
在微服务里做全局增强,最大的浪费是每个团队自己写一遍加解密逻辑。不同服务之间密钥不统一、实现不一致、算法选择不同,最后联调时就是一场灾难。
我在改造中直接把加解密逻辑抽成了一个spring-boot-starter,通过注解自动装配。任何一个服务引入这个starter后,在启动类上加一个@EnableApiSecurity注解,就自动注册RequestBodyAdvice和ResponseBodyAdvice,加解密配置从配置中心统一拉取。团队再也不用关心底层的flow细节,只管加注解和配置密钥。
这个starter除了加解密,还顺带做了签名校验和重放拦截的starter级支持。比如在advice解密之后,立刻校验明文里内置的sign字段;签名不合法就直接返回错误。这样每个服务无需额外开发,就具备了"先验签>再解密>再执行业务"的安全基线。微服务体系里,把横切能力沉淀为公共组件,是性价比最高的增强方式。
注意:加解密starter被内部多个服务引用时,要特别注意它不能影响服务间调用的现有逻辑。如果内部Feign调用都是明文JSON,建议通过注解或配置默认关闭加解密,只对需要暴露给外部的接口开启。不然调试内部链路时会出现"A服务发明文,B服务当密文解"的经典事故。
5.4 日志、监控与审计的适配
加解密上线之后,安全是变好了,Debug却更麻烦了。搜索一次真实请求参数,在日志里看到的是一段密文,想定位业务问题根本无从下手。
我的做法是在解密后的DTO上做一次脱敏日志输出,不打印完整明文字段,只打印关键标识和经过脱敏的手机号、身份证号,这也是对隐私保护合规的呼应。同时给加解密链路本身增加可观测指标:解密耗时、解密成功率、密钥版本分布、重放拦截次数等,统一接入监控系统。这样出问题时,运维可以通过版本分布快速判断是不是有调用方还在使用旧密钥,也能通过耗时指标判断加解密链路是否成为性能瓶颈。
实测下来,AES加解密的耗时在毫秒级以下,即使加上Base64编解码,在常规QPS下对接口整体耗时的影响可以忽略不计。真正的性能开销往往出在序列化环节,比如对一个大对象做JSON.toJSONString再做AES加密,如果对象体积很大,耗时才会明显上升。所以建议在网关层对请求体大小做限制,超出阈值直接拒绝,避免把加解密变成大payload的性能放大器。
写在最后的一点体会
这套方案上线之后,我最直观的感受是,安全改造不该靠堆砌Filter或者到处埋点,而是应该找到框架里最合适的扩展点,把横切逻辑收口成一条完整的链路。RequestBodyAdvice和ResponseBodyAdvice的组合,让我用极少的侵入式代码,完成了请求解密、响应加密、签名校验、重放拦截这些原本要在各个Controller里反复手写的能力。
最后再分享一个小技巧:如果你在落地过程中对"哪些接口需要加密哪些不需要"的判断觉得困难,可以先用全量加密+白名单放行的模式跑一段时间,从监控里看哪些接口的调用方开始报解密失败,再逐个确定放行原因。这种做法虽然前期有点折腾,但比一开始就白名单枚举要稳妥得多,因为安全默认值永远是"不放过任何一个入口",而不是让开发逐个去记得给接口加注解。等稳定下来,你会发现在微服务里做全局安全增强,也就那么回事,关键是找对那个"钩子"。