
前两周给一家做医疗信息化的团队做技术评审对方安全负责人提了个很现实的需求身份证号、手机号、银行卡这些字段在接口传输里全是明文虽然开了HTTPS但等保评测和客户审计都盯着这一点要求“不能直接看到明文”。这种场景在金融、医疗、政务、企业服务里越来越常见API接口的敏感数据加解密已经不是“要不要做”的问题而是“怎么做得优雅”的问题。这里的“优雅”有两个层面一是技术方案本身要经得起推敲性能损耗可控、密钥管理安全、兼容性处理好二是对业务代码的侵入要最小化别让每个Controller都手写一遍加解密逻辑。这篇文章我把整套方案的演进过程、选型思路、落地代码和线上踩坑完整拆开讲适合后端开发、架构师以及对接口安全有要求的团队参考。1. 一个被审计逼出来的思考HTTPS 为什么兜不住敏感数据1.1 反直觉的真相HTTPS 只保护传输管道不保护接口本身很多人觉得上了HTTPS就可以高枕无忧这是典型的“传输层安全”思维惯性。HTTPS解决的是客户端和服务端之间的链路窃听与中间人问题数据到了服务端之后在网关层、业务层、日志系统里流转时已经没有任何保护。换句话说HTTPS保护的是“路”不是“货”。举几个实际场景你就明白了第三方系统通过接口拉取用户手机号对方服务端把请求日志打到ELK里URL参数或者Body里明晃晃的手机号全量落盘一旦ELK泄露整库数据裸奔。你们自己的网关层通常会打印调试日志线上排查问题时顺手把请求体打出来里面有身份证号。运维同事截图发到群里数据就这么“合法”地出去了。接口返回给前端的敏感字段是明文前端的内存、浏览器的开发者工具、第三方统计脚本都有可能接触到这些数据。安全审计、等保合规、客户合同里的数据安全条款都会要求敏感数据在接口传输层具备加密能力。这个“传输层”指的不光是SSL/TLS还包括HTTP报文本身的承载内容。所以在应用层再做一层加解密是合理的互补而不是重复建设。1.2 接口加解密要解决的四个核心问题在动手设计之前先把需求拆明白。一套完整的接口敏感数据加解密方案至少要覆盖四个问题机密性。报文体里的敏感字段不能是明文抓包、日志、中间链路都读不到真实数据。这是最基本的要求。完整性。数据在传输过程中不能被篡改哪怕加密了也要防止攻击者把密文替换成别的密文或者截断重放。所以光有加密不够还得有签名或MAC机制。防重放。攻击者把之前截获的合法密文请求原样重放一遍服务端能不能识别并拒绝这需要时间戳、随机数或流水号加上服务端缓存来做。对业务透明。业务代码里不应该出现“解密”“加密”这样的调用否则几十个接口每个人写一遍一定会有人写错、漏写。优雅的方案是把这个能力下沉到框架层或中间件层。这四个问题不是并列关系而是层层递进。很多人做接口加密只做了第一点把字段用AES一加密就上线了这是典型的“只做了半套”。完整方案里的签名和防重放才是区分专业和业余的分水岭。下面我先讲方案选型再讲怎么把这些能力透明地嵌入到现有系统里。2. 选型博弈对称、非对称与混合加密为什么最终选了混合方案2.1 三种路线的对比与适用边界接口数据加解密底层逃不开对称加密和非对称加密这两个家族。绕开算法细节直接说结论。对称加密AES的特点是快加解密吞吐量能达到Gbps级别对业务接口的耗时影响微乎其微。但痛点在于密钥怎么安全地传给对方。如果直接用AES做接口加密你得把同一个密钥写在客户端和服务端的配置里一旦密钥泄露全部历史报文都能被解开而且无法追溯是哪个环节泄露的。非对称加密RSA的特点是安全性模型好公钥加密、私钥解密公钥可以公开分发。但慢尤其是RSA私钥解密2048位密文的性能比AES慢几个数量级。用RSA加密大数据体超过密钥容量还有长度限制需要分段处理复杂度直接起飞。混合加密的思路是扬长避短用RSA来协商一个临时的AES密钥再用这个AES密钥去加密实际的数据。每次请求都可以生成一个新的AES密钥术语叫“一话一密”用对方的公钥加密后传过去。对端拿到后用私钥解开得到AES密钥再解数据。这样既有AES的高性能又有RSA的安全分发能力。实际项目中我基本都会推荐混合加密。可能有人会说那直接用国密SM2/SM4不更好国密当然可以合规要求必须用的时候直接用方案框架完全一样把算法参数换掉就行。下面的实现我还是以AESRSA这个最通用、最容易表述的组合来讲。2.2 混合加密的完整链路一话一密 密钥协商把一次完整的加密请求链路画出来就是下面这个流程客户端生成一个随机的AES对称密钥比如16字节这个密钥只用于本次请求。客户端用服务端的RSA公钥加密这个AES密钥得到密文Key。客户端用AES密钥加密请求体的业务数据得到密文Data。客户端把密文Key 密文Data 随机IV 签名 时间戳 随机数封装成统一报文发给服务端。服务端先用RSA私钥解密密文Key得到AES密钥。服务端再用AES密钥解密Data还原业务数据。服务端业务逻辑处理完用同一个AES密钥加密响应数据返回给客户端。客户端用自己生成的AES密钥解密响应。这个流程的关键点在于AES密钥是每次请求动态生成的临时密钥。服务端不需要保存这个密钥客户端也不需要预配置这个密钥它只是在这一问一答的瞬间存在。这样就避免了传统对称加密方案里“密钥静态存储”的风险。RSA在整个链路里只做一件事保护AES密钥的传输。AES只做一件事加密真实业务数据。各司其职这个设计是整个方案的核心骨架。3. Spring Boot 落地拦截器层完成透明加解密业务代码零侵入3.1 加密数据包的协议格式设计动手写代码之前先定义好报文格式。这个格式一旦定下来客户端和服务端都要跟着走所以要考虑周全{ encryptKey: Base64编码的RSA加密后AES密钥, iv: Base64编码的AES初始向量, data: Base64编码的AES密文, sign: HMAC-SHA256签名值, timestamp: 毫秒级时间戳, nonce: 随机字符串防止重放 }几个字段各司其职encryptKey和iv负责让服务端恢复出AES密钥和初始向量。IV每次随机生成确保即使两次请求的明文数据相同密文也不同。这是一开始就要定的规范不然后面想加都没法平滑加。data是真正的业务密文。我建议统一做成Base64编码的字符串避免二进制流在JSON里被转义、编码折腾。sign是对encryptKey iv data timestamp nonce做的HMAC签名密钥单独走一套签发体系用来保证报文整体不可篡改。timestamp和nonce组合起来做防重放后面的章节单独展开。响应报文的格式和请求对称客户端用自己生成的AES密钥直接解data字段即可。响应里的encryptKey可以省略因为客户端本身就知道AES密钥是什么服务端返回响应时也不需要重新协商密钥。3.2 服务端过滤器实现请求解密与响应加密服务端最优雅的实现方式是用Filter而不是AOP也不是在每个Controller里手动调用。原因很简单Filter在Servlet容器的最早阶段就能拦截到原始请求并且能包装请求体、在返回前包装响应体。业务代码完全无感知甚至连Controller的入参都不会意识到自己经历过解密。先定义一个注解用来标记哪些接口需要走加解密Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface ApiCrypto { // 标记该接口的请求体和响应体需要进行加解密 }然后是基于注解判断的加密过滤器。判断逻辑用到RequestMappingHandlerMapping在过滤器中通过getHandler(request)提前拿到要执行的HandlerMethod检查方法或者类上有没有ApiCrypto注解Component public class ApiCryptoFilter extends OncePerRequestFilter { Autowired private RequestMappingHandlerMapping handlerMapping; Autowired private CryptoService cryptoService; // 核心加解密服务 Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 只处理带 ApiCrypto 注解的接口 ApiCrypto crypto resolveCryptoAnnotation(request); if (crypto null) { chain.doFilter(request, response); return; } // 1. 解密请求体 CryptoRequestWrapper requestWrapper new CryptoRequestWrapper(request); String plainText cryptoService.decryptRequest(requestWrapper.getBody()); requestWrapper.setBody(plainText); // 2. 包装响应以便在返回前统一加密 ContentCachingResponseWrapper responseWrapper new ContentCachingResponseWrapper(response); try { chain.doFilter(requestWrapper, responseWrapper); } finally { // 3. 把业务写入的响应体取出来加密后重新写回 byte[] responseBody responseWrapper.getContentAsByteArray(); String encryptedBody cryptoService.encryptResponse(responseBody); responseWrapper.resetBuffer(); responseWrapper.getOutputStream().write(encryptedBody.getBytes(StandardCharsets.UTF_8)); responseWrapper.copyBodyToResponse(); } } private ApiCrypto resolveCryptoAnnotation(HttpServletRequest request) { try { HandlerExecutionChain chain handlerMapping.getHandler(request); if (chain null) { return null; } Object handler chain.getHandler(); if (handler instanceof HandlerMethod handlerMethod) { ApiCrypto methodCrypto handlerMethod.getMethodAnnotation(ApiCrypto.class); if (methodCrypto ! null) { return methodCrypto; } ApiCrypto classCrypto handlerMethod.getBeanType().getAnnotation(ApiCrypto.class); return classCrypto; } } catch (Exception e) { // 这里不建议抛异常保持过滤器的容错性 log.warn(解析加密注解失败, e); } return null; } }这里有个细节很多人会忽略RequestMappingHandlerMapping.getHandler内部可能会执行跨域、拦截器等前置逻辑我在实际项目里加了全局异常兜底避免解析失败时直接让请求404。CryptoRequestWrapper的作用是把请求流缓存下来因为Filter里读一次请求体之后Controller就读不到了。实现方式是用HttpServletRequestWrapper重写getInputStream和getReaderpublic class CryptoRequestWrapper extends HttpServletRequestWrapper { private byte[] body; public CryptoRequestWrapper(HttpServletRequest request) throws IOException { super(request); this.body IOUtils.toByteArray(request.getInputStream()); } public String getBody() { return new String(body, StandardCharsets.UTF_8); } public void setBody(String content) { this.body content.getBytes(StandardCharsets.UTF_8); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(body); return new ServletInputStream() { Override public int read() { return byteArrayInputStream.read(); } Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener listener) {} }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }注意setBody这个方法是自定义的Spring MVC里注解RequestBody解析时走的还是getInputStream()所以从底层把字节流替换掉是成立的。整个 Filter 的加解密过程对 Controller 层完全不可见。3.3 核心加解密服务AESRSA 混合实现的完整代码CryptoService是整套方案的心脏包含解密请求和加密响应两个方法。先看解密Service public class CryptoService { Value(${api.crypto.rsa.private-key}) private String rsaPrivateKey; Value(${api.crypto.sign.secret}) private String signSecret; private static final int AES_KEY_SIZE 128; private static final String RSA_ALGORITHM RSA/ECB/PKCS1Padding; private static final String AES_ALGORITHM AES/GCM/NoPadding; public String decryptRequest(String encryptedBody) { // 1. 解析统一报文 JSONObject payload JSON.parseObject(encryptedBody); String encryptKey payload.getString(encryptKey); String iv payload.getString(iv); String data payload.getString(data); String sign payload.getString(sign); long timestamp payload.getLongValue(timestamp); String nonce payload.getString(nonce); // 2. 验签逻辑见第4章 verifySign(encryptKey, iv, data, timestamp, nonce, sign); // 3. RSA私钥解开AES密钥 byte[] aesKeyBytes rsaDecrypt(Base64.getDecoder().decode(encryptKey)); SecretKeySpec aesKey new SecretKeySpec(aesKeyBytes, AES); // 4. 用AES密钥解密业务数据 byte[] plainBytes aesGcmDecrypt(Base64.getDecoder().decode(data), Base64.getDecoder().decode(iv), aesKey); return new String(plainBytes, StandardCharsets.UTF_8); } private byte[] rsaDecrypt(byte[] cipherBytes) { try { PrivateKey privateKey KeyFactory.getInstance(RSA) .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(rsaPrivateKey))); Cipher cipher Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(cipherBytes); } catch (Exception e) { throw new RuntimeException(RSA解密失败, e); } } private byte[] aesGcmDecrypt(byte[] cipherData, byte[] iv, SecretKeySpec key) { try { Cipher cipher Cipher.getInstance(AES_ALGORITHM); GCMParameterSpec spec new GCMParameterSpec(128, iv); cipher.init(Cipher.DECRYPT_MODE, key, spec); return cipher.doFinal(cipherData); } catch (Exception e) { throw new RuntimeException(AES解密密失败, e); } } public String encryptResponse(byte[] responseBody) { // 每个请求的AES密钥从哪来 // 这里用ThreadLocal在decryptRequest时把当前请求的AES密钥暂存 SecretKeySpec aesKey CurrentCryptoContext.getAesKey(); byte[] iv CurrentCryptoContext.getIv(); byte[] cipher aesGcmEncrypt(responseBody, iv, aesKey); JSONObject result new JSONObject(); result.put(data, Base64.getEncoder().encodeToString(cipher)); result.put(iv, Base64.getEncoder().encodeToString(iv)); return result.toJSONString(); } }响应加密这里有一个容易绕晕的点AES密钥是客户端生成的服务端怎么拿它加密响应所以服务端必须在解密请求时把这个AES密钥用ThreadLocal缓存到当前线程上下文中等Controller处理完、过滤器准备加密响应时再从这个上下文里取出来用。public class CurrentCryptoContext { private static final ThreadLocalSecretKeySpec AES_KEY new ThreadLocal(); private static final ThreadLocalbyte[] IV new ThreadLocal(); public static void set(SecretKeySpec key, byte[] iv) { ... } public static SecretKeySpec getAesKey() { return AES_KEY.get(); } public static byte[] getIv() { return IV.get(); } public static void clear() { AES_KEY.remove(); IV.remove(); } }使用ThreadLocal时要特别注意清空防止线程池复用导致串数据。我在finally块里调用CurrentCryptoContext.clear()这是线上容易漏掉但又必须做的一步。AES的GCM模式自带认证能力密文被篡改后解密时会直接抛异常这给了我们第一层完整性保护。但GCM只保护data字段本身不保护encryptKey、timestamp、nonce这些元数据。所以还需要单独做签名来覆盖整个报文这就是第4章的内容。4. 签名、时间戳与流水号把防篡改和防重放补完整4.1 为什么有加密还不够中间人改请求怎么办加密能防“看”但防不了“改”。攻击者虽然解不开你的AES密文但他可以把你整个密文报文原封不动地截下来然后一遍一遍地重放。还可以把密文报文里的加密密钥替换成一个他自己生成的密钥发给服务端服务端用私钥解不出来直接报错这算是DoS。更隐蔽的是如果接口设计得不好重放一次“转账请求”就是一次真实的转账操作。所以加密必须搭配三个机制签名字段保证完整性和来源可信时间戳保证请求的时效性nonce缓存保证一个请求只能被执行一次。这三者配合才能堵住上面的漏洞。4.2 HMAC 签名与验签的落地方案签名的算法选择上HMAC-SHA256是标准做法。签名用的密钥是单独从服务端和客户端约定的通过配置中心下发不能直接用RSA私钥或者AES密钥。理由很简单如果混用密钥一个环节泄露就可能连累其他环节。签名的计算逻辑在客户端和服务端要保持完全一致我把伪代码写出来public String computeSign(String encryptKey, String iv, String data, long timestamp, String nonce, String secret) { String content encryptKey iv data timestamp nonce; Mac mac Mac.getInstance(HmacSHA256); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HmacSHA256)); byte[] hash mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); return HexUtil.encodeHexStr(hash); }服务端验签时先按同样的规则计算出本地签名再与报文里的sign做恒定时间比较防止时序攻击private void verifySign(String encryptKey, String iv, String data, long timestamp, String nonce, String sign) { // 1. 时间窗校验允许5分钟时钟偏差 long now System.currentTimeMillis(); if (Math.abs(now - timestamp) 5 * 60 * 1000L) { throw new InvalidSignatureException(请求时间戳已过期); } // 2. 重放校验见4.3 // 3. 签名比对 String localSign computeSign(encryptKey, iv, data, timestamp, nonce, signSecret); if (!MessageDigest.isEqual(localSign.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8))) { throw new InvalidSignatureException(签名校验失败); } }有了签名攻击者想改任何一个字段都会导致签名对不上服务端直接拒绝。同时签名密钥是独立的即使RSA私钥泄露签名体系还可以单独轮换不至于整体推翻。4.3 重放攻击的拦截timestamp nonce Redis时间戳能挡住大部分“延迟重放”但挡不住“5分钟内的快速重放”。所以要配合nonce服务端把每个处理过的timestamp nonce组合存到Redis里设置过期时间5分钟。同一个nonce在有效期内第二次出现直接拒绝。private void checkReplay(long timestamp, String nonce) { String redisKey api:crypto:replay: timestamp : nonce; Boolean firstSeen redisTemplate.opsForValue().setIfAbsent(redisKey, 1, Duration.ofMinutes(5)); if (Boolean.FALSE.equals(firstSeen)) { throw new InvalidSignatureException(重复请求已拒绝); } }用Redis的SETNX指令做这个事是天然原子的不需要加锁。高并发场景下需要注意如果同一个nonce真的并发到达Redis能够保证只有一个请求会拿到true其余全部拒绝。对于非重放校验的幂等性我补充一下重放拦截和业务幂等不是一回事。重放拦截要求每次请求的nonce都不相同而业务幂等要求同一笔业务请求即使发送多次也只会生效一次。所以在转账、下单这类接口里服务端还应该用自己的业务流水号和幂等表再兜一层两套机制是互补关系。5. 实测数据与踩坑记录性能损耗、字符集和 Base64 陷阱5.1 压测结果加解密对整体耗时的影响方案上线前要测一下性能损耗不然上线后被性能问题搞到回滚就尴尬了。我用的是8核16G的虚拟机Spring Boot应用压测工具用wrk模拟100并发跑30秒。结果如下场景平均耗时(ms)TP99(ms)QPS明文直通基线28423400全报文加密AESRSA35512900加密后平均耗时增加约7msQPS下降约15%。这7ms里大头是RSA私钥解密AES密钥那一次操作大约2-4msAES加解密数据本身对几百字节的报文来说几乎可以忽略。如果换成SM2/SM4国密算法RSA换成SM2之后耗时会更明显一点但一般也不会超过20ms。这个性能损耗对绝大多数业务接口来说是可以接受的。如果你们有超高频的接口每秒几万次建议只对包含真正敏感字段的接口开启加密不要全局一刀切。另外可以上RSA缓存优化如果客户端能支持会话级的密钥协商一次协商多次使用能进一步减少RSA调用但复杂度会高很多一般业务量级没必要。5.2 踩过的三个坑字符集、RSA长度限制、报文体积膨胀先说字符集。RSA解密出来的字节数组转成AES密钥时直接用就够了。但encryptKey在传输过程中经过JSON序列化、Base64转码两端之间容易出UTF-8和ASCII的编码错位问题。我的经验是全程统一UTF-8Base64之后不要再用URLEncoder二次编码需要放URL里的时候就换成Base64URL安全版本把和/替换成-和_即可。然后是RSA长度限制。RSA用2048位密钥、PKCS1Padding时最多只能加密245字节。AES的key加IV也不超过32字节所以一般不会撞到上限。但有一种情况会如果你设计成“用RSA加密一个包含大量额外信息的JSON串”比如把签名密钥、过期时间、用户ID都放进RSA加密的payload里很容易超过245字节然后报Data must not be longer than 245 bytes。所以RSA加密的内容越短越好职责单一。最后是报文体积膨胀。Base64本身增加33%体积AES密文又有填充加密后整个请求体可能是明文的1.4-1.5倍。如果接口本来就有几MB的上传报文膨胀会更严重。所以大文件、大报文不要走应用层加解密应该走HTTPS 对象存储的服务端加密或者分块加密不要混在一个方案里。5.3 日志脱敏忘了这步等于白做接口加解密上线后最容易出现的一个“白做”环节是日志。请求到了服务端Filter解完密之后Controller打印日志、业务打印日志如果把明文写进日志那加密就形同虚设了。而且这比HTTPS的问题更严重——日志数据是长期保存的泄露后无法撤回。我的习惯是两件事同时做第一件在Filter层做一个脱敏的日志切面。解密后的JSON体如果最终要打印统一走一个DesensitizedJsonUtils把phone、idCard、bankCard、name这类字段的值只在日志里保留前后两位和掩码符号。第二件在日志框架层面配置一个全局过滤器。Logback里实现了MessageConverter可以拦截所有输出内容把常见敏感字段正则匹配后打上掩码。这样即使有人不小心在代码里log.info(用户身份证{}, idCard)打出来的也还是脱敏后的内容。这两道防线不冲突都上了才算闭环。我在评审时遇到很多团队加密做了、签名做了、防重放做了最后死于一行日志这是非常可惜的。6. 密钥管理、灰度切换与后续演进6.1 密钥存储一两行配置承载不了高风险方案里的私钥和签名密钥如果直接写在application.yml里等于把保险柜钥匙挂在保险柜上。生产环境的私钥应该放到专门的密钥管理系统KMS里代码运行时通过网络接口获取私钥而不是在配置中心明文分发。如果没有KMS至少要保证以下几点私钥不出内网不能进Git仓库不能出现在日志中。私钥文件权限收紧到服务运行用户只读。签名密钥和RSA私钥分开保存、分开轮换。定期轮换密钥并保证轮换时新旧密钥有一段重叠生效期让客户端平滑切换。密钥轮换的一个实际做法是给密钥加版本号报文里带上密钥版本标识服务端根据版本号选择对应的解密密钥。我见过一个客户就是没做版本号轮换密钥当天所有存量客户端全部调不通最后只能紧急回滚。这个坑不踩一次很难意识到版本号的重要性。6.2 双版本灰度与客户端兼容接口安全升级最怕的是“客户端没跟着升级服务端先切了”。所以上线策略建议走双版本灰度服务端保留明文接口和密文接口两个入口通过配置开关控制哪些客户端、哪些接口走加密。客户端SDK里内置版本号请求时带上crypto-version头服务端根据版本分发到不同处理链路。灰度顺序先让内部系统切到加密链路再灰度核心合作伙伴最后强制全部切换。如果你们是纯自研的B/S系统前后端都是自己的灰度会更简单。前端SDK统一封装好加解密逻辑后发版顺序还是建议“先前端后后端”确保所有请求到了服务端都是能被解密的状态。还有一个细节加解密失败时的错误响应不要明文返回具体异常栈。可以用统一的错误码比如40001加密参数缺失、40002验签失败、40003解密异常避免攻击者通过异常信息探测算法和密钥结构。6.3 后续可能的扩展方向这套方案做完以后还能往几个方向延伸。一是把加解密能力下沉到API网关层。如果公司有统一的网关可以在网关做一次统一的解密内部服务之间继续走明文这样业务服务的改造量更小。但要注意网关解密后内部明文暴露面变大适用于内部网络可信度较高的场景。二是在响应敏感字段上按需加密。有些接口大部分字段可以明文只有某个字段需要加密。这种情况下可以在报文里定义一个encryptFields数组服务端只对指定字段加密。相比全报文加密按字段加密的兼容性和性能都更好但方案复杂度会上升。我一般建议先用全报文加密简单直接等业务量大了再考虑按字段优化。三是适配国密算法。如果你们的客户或监管有国密要求把AES换成SM4、RSA换成SM2整体框架不变只是替换具体实现和密钥生成方式。这个我在项目里做过一次切换改动量可控关键是把算法实现封装在CryptoProvider单一接口后面不要散落在业务代码里。最后分享一个我个人的经验这种安全方案一定要专门拿出来一部分时间做“攻击推演”而不是只写代码。拿你自己写的过滤器试着用Burp Suite重放一个请求试着改一下签名里的任意一个字符试试超过时间窗口的请求能不能被拒。自己先攻击一轮比上线后被安全团队打回来要体面得多。我每次上线安全相关功能都会做一遍这个流程抓出来的问题比测试用例多得多也少挨了很多批评。