SpringBoot集成国密SM4:过滤器实现HTTP请求响应全链路加密 1. 项目概述为什么要在SpringBoot中集成国密SM4如果你正在开发一个面向国内市场的金融、政务或涉及敏感数据的应用那么“国密算法”这个词对你来说一定不陌生。最近几年我经手的几个项目在等保测评和合规审查时都明确要求使用国家商用密码算法。其中SM4作为对称加密算法的代表因其高效、安全且是国家标准成为了数据加密场景下的首选。简单来说这个项目就是在你的SpringBoot应用里给HTTP请求和响应的“身体”部分即请求体和响应体套上一层SM4加密的“盔甲”。想象一下你的API在网络上传输时就像明信片一样谁都能看到内容。而我们的目标就是把这明信片放进一个只有你和服务器有钥匙的保险箱里再寄出去。这不仅仅是满足合规要求如等保2.0更是对用户数据安全负责任的表现。网上很多教程只给个工具类就结束了但真实项目落地远不止如此。你会遇到请求流只能读一次的问题、需要灵活控制哪些接口加密、加解密性能如何不影响主流程、密钥如何安全管理等一系列“坑”。接下来我将结合一个完整的、基于过滤器的实现方案带你从原理到实操一步步构建一个健壮的SM4加密通信层并分享那些文档里不会写的实战经验。2. 核心原理与方案选型为什么是过滤器SM4 CBC模式在动手写代码之前我们先厘清两个核心问题为什么选择过滤器Filter作为切入点以及为什么使用SM4的CBC模式理解这些“为什么”能帮助你在遇到问题时快速定位甚至设计出更适合自己业务的方案。2.1 拦截层选型Filter vs Interceptor vs AOPSpring Boot中处理HTTP请求的拦截点主要有三个过滤器Filter、拦截器Interceptor和面向切面编程AOP。我们选择过滤器主要基于以下几点考量执行时机最早Filter是Servlet规范的一部分它在请求进入Spring MVC框架之前就已经工作。这意味着我们可以在请求体被任何Spring组件如RequestBody注解解析之前就完成解密操作从源头上保证控制器拿到的是明文。如果使用Interceptor虽然也能拿到请求但此时输入流可能已被部分读取处理起来更麻烦。对请求/响应包装能力强加解密的核心操作需要替换原始的请求体和响应体。Filter可以方便地使用HttpServletRequestWrapper和HttpServletResponseWrapper对原始对象进行包装和替换这个机制是Servlet层原生支持的非常成熟和稳定。职责单一性能影响可控加解密是一个相对独立的基础设施功能。将其放在Filter层与业务逻辑Controller、Service完全解耦符合单一职责原则。我们可以通过FilterRegistrationBean精确控制其拦截的URL模式避免对静态资源、健康检查等接口造成不必要的性能损耗。实操心得曾经在一个项目中尝试用AOP环绕通知来做解密发现处理HttpServletRequest的输入流异常棘手而且对RequestBody这种已经完成参数绑定的场景不友好。最终回归Filter方案世界一下子清净了。记住越底层的封装对于这类全局性、协议层面的修改越有优势。2.2 加密算法与模式SM4与CBC模式详解SM4是一种分组密码算法分组长度和密钥长度均为128位16字节。它采用32轮非线性迭代结构安全性经过充分验证。但我们直接使用SM4算法还不够还需要选择一个“工作模式”。ECB模式最简单的模式相同的明文块加密后得到相同的密文块。这会导致模式泄露安全性很差绝对不要用于加密有规律的数据。CBC模式我们选择的模式。它引入了一个初始化向量IV使得每个明文块在加密前都会先与前一个密文块进行异或操作。这样即使明文相同加密后的密文也会不同安全性更高。为什么选择CBC模式因为它消除了ECB的模式缺陷是实践中最常用、最平衡的模式之一。它需要两个关键参数密钥一个16字节的字符串这是加解密的根本必须妥善保管。初始化向量也是一个16字节的字符串用于增加加密的随机性。IV不需要保密但同一个密钥下不应重复使用否则会降低安全性。通常可以随机生成并随密文一起传输放在密文头部。在我们的工具类中会将密钥和IV硬编码为常量。请注意这只是为了演示。在生产环境中这是极其危险的做法密钥必须从安全的配置中心、环境变量或硬件安全模块中动态获取。关于Bouncy Castle库的版本选择网络资料中常看到bcprov-jdk15on和bcprov-jdk18on。简单来说-jdk15on支持JDK 1.5到1.8。-jdk18on为JDK 1.8及以上版本优化包含最新的安全补丁和性能改进。 如果你的项目使用JDK 8或更高版本请毫不犹豫地选择bcprov-jdk18on。3. 核心模块实现从工具类到可运行的过滤器理论清晰后我们开始构建三个核心模块SM4工具类、自定义请求包装器和加密过滤器。3.1 构建SM4加解密工具类工具类是加解密能力的提供者。这里我们实现一个基于CBC模式和PKCS7填充Bouncy Castle默认的Sm4Util。PKCS7填充能确保明文长度是16字节的倍数。import org.bouncycastle.crypto.engines.SM4Engine; import org.bouncycastle.crypto.modes.CBCBlockCipher; import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher; import org.bouncycastle.crypto.params.KeyParameter; import org.bouncycastle.crypto.params.ParametersWithIV; import java.util.Base64; /** * SM4 加解密工具类 (CBC模式PKCS7填充) * 警告生产环境严禁硬编码密钥和IV */ public class Sm4Util { // TODO: 生产环境应从安全配置源获取如Apollo、K8s Secret、HSM等 private static final String DEFAULT_KEY 0123456789abcdef; // 16字节 private static final String DEFAULT_IV fedcba9876543210; // 16字节 (注意补全一位) /** * 加密 * param plainText 明文 * return Base64编码的密文 */ public static String encrypt(String plainText) throws Exception { return encrypt(plainText, DEFAULT_KEY, DEFAULT_IV); } /** * 解密 * param encryptedText Base64编码的密文 * return 明文 */ public static String decrypt(String encryptedText) throws Exception { return decrypt(encryptedText, DEFAULT_KEY, DEFAULT_IV); } /** * 指定密钥和IV的加密方法 */ public static String encrypt(String plainText, String key, String iv) throws Exception { // 1. 参数校验 validateKeyAndIv(key, iv); // 2. 初始化加密器 PaddedBufferedBlockCipher cipher new PaddedBufferedBlockCipher(new CBCBlockCipher(new SM4Engine())); cipher.init(true, new ParametersWithIV(new KeyParameter(key.getBytes()), iv.getBytes())); // 3. 执行加密 byte[] input plainText.getBytes(UTF-8); byte[] output new byte[cipher.getOutputSize(input.length)]; int length1 cipher.processBytes(input, 0, input.length, output, 0); int length2 cipher.doFinal(output, length1); // 4. 返回Base64 return Base64.getEncoder().encodeToString(java.util.Arrays.copyOf(output, length1 length2)); } /** * 指定密钥和IV的解密方法 */ public static String decrypt(String encryptedText, String key, String iv) throws Exception { validateKeyAndIv(key, iv); PaddedBufferedBlockCipher cipher new PaddedBufferedBlockCipher(new CBCBlockCipher(new SM4Engine())); cipher.init(false, new ParametersWithIV(new KeyParameter(key.getBytes()), iv.getBytes())); byte[] input Base64.getDecoder().decode(encryptedText); byte[] output new byte[cipher.getOutputSize(input.length)]; int length1 cipher.processBytes(input, 0, input.length, output, 0); int length2 cipher.doFinal(output, length1); return new String(output, 0, length1 length2, UTF-8); } private static void validateKeyAndIv(String key, String iv) { if (key null || key.getBytes().length ! 16) { throw new IllegalArgumentException(密钥必须为16字节长度); } if (iv null || iv.getBytes().length ! 16) { throw new IllegalArgumentException(初始化向量必须为16字节长度); } } }关键点与避坑指南字符编码在getBytes()和new String()时务必显式指定字符编码如UTF-8避免因系统默认编码不同导致加解密前后数据不一致。IV长度示例中我补全了IV为16字节fedcba9876543210。请务必检查你的IV长度。异常处理工具类方法抛出了Exception这是为了演示简洁。在生产代码中你应该定义更具体的业务异常如EncryptException、DecryptException。输出大小cipher.getOutputSize(input.length)会计算可能的最大输出长度包含填充字节。doFinal返回的是实际写入的数据长度。最后使用Arrays.copyOf进行裁剪避免结果包含未初始化的数组元素。3.2 解决Servlet请求体单次读取问题自定义RequestWrapper这是集成过程中最容易踩坑的地方。Servlet规范规定HttpServletRequest的输入流getInputStream()或读取器getReader()只能被读取一次。我们的过滤器需要读取请求体进行解密而后续的Spring MVC框架处理RequestBody也需要读取它。直接读取会导致控制器报错IllegalStateException: getReader() has already been called for this request。解决方案是装饰器模式我们创建一个自定义的HttpServletRequestWrapper在构造时就将解密后的请求体内容保存起来并重写getInputStream()和getReader()方法使其每次都返回基于这个已保存内容的新流。import jakarta.servlet.ReadListener; import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequestWrapper; import java.io.*; /** * 自定义请求包装器用于缓存请求体数据支持多次读取。 */ public class CachedBodyHttpServletRequestWrapper extends HttpServletRequestWrapper { private final byte[] cachedBody; /** * 构造方法读取并存储原始请求体。 */ public CachedBodyHttpServletRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 将原始请求体读取到字节数组中缓存 InputStream requestInputStream request.getInputStream(); this.cachedBody requestInputStream.readAllBytes(); // JDK 9 // 如果是JDK 8可以使用IOUtils.toByteArray(requestInputStream) (需要commons-io) } /** * 构造方法直接使用提供的字符串作为请求体。 * 这是解密后使用的关键方法。 */ public CachedBodyHttpServletRequestWrapper(HttpServletRequest request, String body) { super(request); this.cachedBody body.getBytes(); // 注意编码应与工具类一致 } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(cachedBody); return new ServletInputStream() { Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 对于缓存的数据不需要异步监听器 } Override public int read() throws IOException { return byteArrayInputStream.read(); } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(this.getInputStream(), this.getCharacterEncoding())); } /** * 获取缓存的请求体内容。 */ public String getBody() { return new String(cachedBody, java.nio.charset.StandardCharsets.UTF_8); } }实操心得第一个构造方法CachedBodyHttpServletRequestWrapper(HttpServletRequest request)非常有用。它不仅用于解密场景任何需要多次读取请求体的过滤器如日志记录、签名验证都可以用它。我通常会把它放到一个通用的工具包里。readAllBytes()是JDK 9引入的方法。如果你的项目是JDK 8可以使用Apache Commons IO的IOUtils.toByteArray()或者自己写一个循环读取的代码。重写getReader()时注意使用this.getCharacterEncoding()来保持与原始请求一致的编码或者像示例中一样硬指定为UTF-8但要确保全局统一。3.3 核心枢纽实现加解密过滤器现在我们将工具类和包装器组合起来创建核心的SmCryptoFilter。它的职责是拦截请求 - 读取加密体 - 解密 - 替换请求体 - 放行 - 获取响应 - 加密 - 写回响应。import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.web.util.ContentCachingResponseWrapper; import java.io.IOException; Slf4j public class SmCryptoFilter implements Filter { // 可以从配置文件注入例如 Value(${sm4.filter.url-patterns:/api/secure/*}) private String[] urlPatterns {/api/secure/**}; // 是否启用加密响应某些场景可能只需要解密请求 private boolean encryptResponse true; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; String requestURI httpRequest.getRequestURI(); // 1. 判断当前请求是否需要经过加解密处理 boolean needProcess false; for (String pattern : urlPatterns) { // 这里可以使用Spring的AntPathMatcher进行更复杂的模式匹配 if (requestURI.startsWith(pattern.replace(/**, ))) { needProcess true; break; } } if (!needProcess) { chain.doFilter(request, response); return; } // 2. 处理请求解密 CachedBodyHttpServletRequestWrapper requestWrapper null; try { // 先读取原始加密请求体 String encryptedRequestBody new String(httpRequest.getInputStream().readAllBytes()); log.debug(收到加密请求体: {}, encryptedRequestBody); // 调用SM4工具类解密 String decryptedRequestBody Sm4Util.decrypt(encryptedRequestBody); log.debug(解密后请求体: {}, decryptedRequestBody); // 用解密后的内容创建可重复读的RequestWrapper requestWrapper new CachedBodyHttpServletRequestWrapper(httpRequest, decryptedRequestBody); } catch (Exception e) { log.error(请求解密失败, URI: {}, requestURI, e); httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST); httpResponse.getWriter().write({\code\:400,\msg\:\请求解密失败\}); return; // 解密失败直接返回不继续向下传递 } // 3. 包装响应以便后续读取和加密 ContentCachingResponseWrapper responseWrapper new ContentCachingResponseWrapper(httpResponse); try { // 4. 将解密后的请求和可缓存的响应传递给后续过滤器及控制器 chain.doFilter(requestWrapper, responseWrapper); } catch (Exception e) { // 业务处理异常记录日志但响应可能已被部分提交需谨慎处理 log.error(业务处理异常, e); throw e; } // 5. 处理响应加密 if (encryptResponse) { try { // 获取控制器返回的原始响应内容 byte[] responseContent responseWrapper.getContentAsByteArray(); String originalResponseBody new String(responseContent, responseWrapper.getCharacterEncoding()); log.debug(原始响应体: {}, originalResponseBody); // 加密响应体 String encryptedResponseBody Sm4Util.encrypt(originalResponseBody); log.debug(加密后响应体: {}, encryptedResponseBody); // 重置原始响应写入加密后的内容 responseWrapper.resetBuffer(); responseWrapper.setContentType(application/json;charsetUTF-8); responseWrapper.getWriter().write(encryptedResponseBody); } catch (Exception e) { log.error(响应加密失败, e); // 加密失败可以选择返回一个预定义的错误密文或抛出异常 // 这里简单返回一个错误信息明文因为加密流程已失败 responseWrapper.resetBuffer(); responseWrapper.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); responseWrapper.getWriter().write({\code\:500,\msg\:\响应加密失败\}); } } // 6. 将修改后的响应内容拷贝到原始响应对象发送给客户端 responseWrapper.copyBodyToResponse(); } Override public void init(FilterConfig filterConfig) { // 可以读取初始化参数用于配置urlPatterns等 String patterns filterConfig.getInitParameter(urlPatterns); if (patterns ! null) { this.urlPatterns patterns.split(,); } log.info(SmCryptoFilter initialized, processing URLs: {}, String.join(, , urlPatterns)); } Override public void destroy() { log.info(SmCryptoFilter destroyed); } }关键点与避坑指南URL模式匹配示例中使用了简单的startsWith匹配。对于复杂路由如/api/user/{id}建议注入Spring的AntPathMatcher或PathMatcher进行精确匹配。响应包装器ContentCachingResponseWrapper是Spring提供的神器它允许我们在响应提交后仍然能读取到响应体内容。务必在最后调用copyBodyToResponse()否则客户端收不到任何数据。异常处理加解密过程必须做好异常捕获。解密失败通常是客户端问题传了非法的密文应返回400状态码。加密失败是服务端问题应返回500。要确保异常发生时依然能向客户端返回一个结构化的响应哪怕是错误信息。日志级别加解密的内容非常敏感debug日志中打印请求/响应体仅限调试阶段生产环境必须关闭或对日志进行脱敏处理。4. 配置与集成让过滤器在Spring Boot中生效有了过滤器我们需要告诉Spring Boot它并指定它拦截哪些请求。我们使用FilterRegistrationBean进行注册这种方式比单纯使用Component注解更灵活可以控制过滤器的顺序和URL映射。4.1 注册过滤器并配置规则创建一个配置类FilterConfigimport org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; Configuration public class FilterConfig { Bean public FilterRegistrationBeanSmCryptoFilter smCryptoFilterRegistration() { FilterRegistrationBeanSmCryptoFilter registrationBean new FilterRegistrationBean(); // 1. 设置过滤器实例 registrationBean.setFilter(new SmCryptoFilter()); // 2. 设置拦截的URL模式非常重要 registrationBean.addUrlPatterns(/api/secure/*); // 可以添加多个模式 // registrationBean.addUrlPatterns(/internal/*, /data/*); // 3. 设置过滤器名称 registrationBean.setName(smCryptoFilter); // 4. 设置执行顺序数值越小优先级越高 // 通常加解密过滤器应该在日志、跨域等过滤器之后但在Spring Security等安全过滤器之前。 registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE 10); // 5. 设置初始化参数可选 // registrationBean.addInitParameter(encryptResponse, false); return registrationBean; } // 你可以注册其他过滤器并通过Order控制顺序 // Bean // public FilterRegistrationBeanLoggingFilter loggingFilter() {...} }4.2 编写测试控制器现在创建一个简单的控制器来验证我们的加解密链路是否通畅。关键点在于控制器接收到的RequestBody已经是解密后的明文了。import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/secure) public class TestController { PostMapping(/test) public ApiResponse processEncryptedData(RequestBody UserRequest userRequest) { // 这里直接拿到了解密后的Java对象 System.out.println(接收到解密后的数据: userRequest); // 模拟业务处理 String result 你好, userRequest.getName() ! 你的用户ID是: userRequest.getUserId(); // 返回结果过滤器会自动将其加密 return ApiResponse.success(result); } // 定义一个不需要加密的接口作为对比 GetMapping(/health) public String health() { return OK; // 这个接口的响应不会被加密 } } // 请求DTO Data // 使用Lombok class UserRequest { private String name; private Long userId; } // 统一响应DTO Data class ApiResponseT { private int code; private String msg; private T data; public static T ApiResponseT success(T data) { ApiResponseT response new ApiResponse(); response.setCode(200); response.setMsg(success); response.setData(data); return response; } }5. 进阶优化与生产环境实践上面的代码已经可以跑起来了但要用于生产还有很长的路要走。下面是我在多个项目中总结出的进阶实践。5.1 密钥的安全管理重中之重硬编码密钥是安全大忌。以下是几种更安全的方案环境变量/启动参数java -jar your-app.jar --sm4.key${SM4_KEY} --sm4.iv${SM4_IV}在application.yml中引用sm4: key: ${SM4_KEY:defaultKeyForDevOnly} iv: ${SM4_IV:defaultIvForDevOnly}然后在Java代码中通过Value注入。配置中心将密钥存储在Apollo、Nacos等配置中心并开启配置加密功能。应用启动时拉取。K8s Secrets / 云厂商密钥管理服务在容器化部署中使用K8s Secrets注入环境变量。或者使用阿里云KMS、华为云DEW等服务应用通过API动态获取密钥甚至可以实现密钥轮转。硬件安全模块对于金融级安全要求使用HSM来执行加解密运算密钥永远不出硬件设备。最佳实践建议至少采用“配置中心环境变量”双重保障。在Sm4Util中改造从Spring Context中获取密钥Bean而不是静态常量。Component public class DynamicSm4Util { private final String key; private final String iv; public DynamicSm4Util(Value(${sm4.key}) String key, Value(${sm4.iv}) String iv) { this.key key; this.iv iv; } public String encrypt(String plainText) throws Exception { // ... 使用 this.key 和 this.iv ... } }5.2 性能优化考量加解密是CPU密集型操作在高并发下可能成为瓶颈。连接池与线程隔离如果你的过滤器解密操作很重比如还涉及RSA解密考虑使用异步Servlet或WebFlux避免阻塞IO线程。对于同步应用确保Tomcat/Jetty的线程池配置合理。选择性加密不是所有接口都需要加密。通过精确配置FilterRegistrationBean的urlPatterns只对真正传输敏感数据的接口如/api/secure/payment启用加密。健康检查、监控、静态资源接口务必排除在外。缓存与预热Cipher对象的初始化有一定开销。可以考虑使用ThreadLocal缓存线程安全的Cipher实例但要注意正确管理其生命周期。响应压缩与加密顺序如果同时启用了GZIP压缩和加密顺序应该是先压缩再加密。因为加密后的数据是随机的压缩率几乎为0。在过滤器中你可以判断响应头Content-Encoding如果已经是gzip压缩过的字节流需要先解密再解压处理业务再压缩最后加密。这个逻辑非常复杂通常建议在网关层做压缩应用层只负责加解密。5.3 增强健壮性处理非加密请求与降级优雅处理非加密请求不是所有发给/api/secure/*的请求都一定是加密的。客户端可能出错。在过滤器中可以尝试解密如果解密失败捕获到IllegalArgumentException或BadPaddingException等特定异常可以尝试判断是否为明文请求例如检查内容类型或一个特定的请求头并提供一个降级策略或者直接返回明确的错误信息。请求头标识一个常见的做法是让客户端在请求头中携带一个标识如X-Encrypted: true。过滤器先检查这个头如果为true则执行解密否则直接放行。这样更清晰也便于后期扩展支持多种加密方式。熔断与降级在极端情况下如HSM不可用可以考虑提供一个开关临时关闭加解密功能保证核心业务可用同时记录告警。5.4 完整的请求响应流程与联调让我们梳理一下一个加密请求的完整生命周期客户端构造业务JSON例如{name: 张三, userId: 123}。使用与服务端约定的相同密钥和IV调用SM4 CBC加密得到密文。将密文作为整个请求体发送POST请求到https://your-domain.com/api/secure/test。请求头可考虑添加Content-Type: text/plain(因为传的是密文字符串) 或X-Encrypted: true。服务端SmCryptoFilter匹配到/api/secure/*模式。读取请求体密文。调用Sm4Util.decrypt()解密得到明文JSON。将解密后的明文JSON放入CachedBodyHttpServletRequestWrapper。过滤器链继续Spring MVC的RequestBody会从包装器中读取到明文并反序列化为UserRequest对象。服务端TestController正常处理UserRequest对象执行业务逻辑。返回ApiResponse对象。服务端SmCryptoFilter拦截返回的响应获取ApiResponse序列化后的JSON字符串。调用Sm4Util.encrypt()加密。将密文写回响应流。客户端收到响应密文。使用相同的密钥和IV解密得到业务响应JSON。联调工具建议使用Postman或curl进行测试。先单独测试Sm4Util的加解密确保两端算法、模式、填充、编码一致。再测试完整接口。查看服务端日志解密后、加密前是定位问题的关键。6. 常见问题排查与实战技巧即使按照步骤实现你也可能会遇到下面这些问题。这里是我踩过坑后的总结。6.1 问题速查表问题现象可能原因排查步骤与解决方案控制器收到空请求体或RequestBody绑定失败1. 过滤器未正确包装请求。2. 过滤器URL模式不匹配请求未走解密逻辑。3. 解密失败但异常被吞没包装器构造失败。1. 在过滤器的chain.doFilter前打日志确认requestWrapper中的body是否正确。2. 检查FilterRegistrationBean的urlPatterns用*临时匹配所有请求测试。3. 在Sm4Util.decrypt方法内部和调用处添加详细try-catch打印异常栈。响应为空或客户端解密失败1. 过滤器未正确加密响应。2.ContentCachingResponseWrapper的copyBodyToResponse()未调用。3. 业务控制器抛出异常跳过了响应加密步骤。1. 在过滤器加密响应前后打日志确认原始响应和加密后内容。2. 确保doFilter和最后的copyBodyToResponse在try-catch-finally中被正确调用。3. 考虑使用ControllerAdvice全局异常处理器确保异常时也能返回一个加密的错误响应。加解密前后数据不一致1.字符编码不一致这是最常见的原因。2. 密钥或IV长度不对。3. 客户端和服务端使用的算法模式或填充方式不同。1.强制统一使用UTF-8编码。在String.getBytes(“UTF-8”)和new String(bytes, “UTF-8”)处检查。2. 确认密钥和IV都是16字节。中文字符在UTF-8下可能占3字节要小心。3. 确认双方都使用SM4/CBC/PKCS7Padding。Bouncy Castle的PaddedBufferedBlockCipher默认使用PKCS7。性能瓶颈接口响应变慢1. 加解密本身消耗CPU。2. 过滤器拦截了不该拦截的请求如图片、静态资源。3. 请求体过大内存拷贝耗时。1. 使用jstack或Arthas查看线程栈确认耗时在加解密。2.收紧URL模式排除/static/**,/health等。3. 对于超大请求体评估是否真的需要全量加密或考虑分块加密。在Spring Security之后解密失败过滤器顺序问题。Spring Security的过滤器链如UsernamePasswordAuthenticationFilter可能早于你的过滤器执行并已经读取了请求体。调整过滤器顺序确保SmCryptoFilter在SecurityFilterChain之前执行。在FilterRegistrationBean中设置较低的order值如Ordered.HIGHEST_PRECEDENCE。6.2 独家避坑技巧单元测试先行不要直接上HTTP测试。为Sm4Util编写完整的单元测试覆盖中英文、特殊字符、边界长度如刚好16字节倍数的加解密。用JUnit验证“加密-解密”的循环结果与原数据相等。分离加解密与业务过滤器我建议将CachedBodyHttpServletRequestWrapper的创建即读取和缓存请求体与SM4加解密逻辑分离。先做一个通用的CachingFilter它只负责缓存请求体。然后再做一个Sm4CryptoFilter它放在CachingFilter之后直接从缓存中取数据加解密。这样逻辑更清晰也方便复用。使用Filter而非Interceptor这一点再强调一次。曾经有同事试图在HandlerInterceptor的preHandle中修改请求体过程痛苦且不优雅。对于请求/响应体的全局修改Filter是唯一正确的位置。密文作为JSON的一个字段上述方案是将整个请求体作为密文。另一种更常见的工业实践是保持JSON结构将密文放在一个字段里如{“encryptedData”: “BASE64密文”, “sign”: “签名”}。这样请求头可以保持application/json并且可以方便地扩展其他字段如时间戳、加密算法版本号。过滤器需要解析JSON取出encryptedData解密再将解密后的内容作为新的请求体。这增加了复杂度但兼容性更好。密钥轮转方案长期使用一个密钥是不安全的。设计一个支持密钥版本号的方案。例如在请求头或密文元数据中携带密钥版本keyVersion服务端根据版本号从密钥库中选取对应的密钥进行解密。定期生成新版本密钥并让客户端逐步升级。集成国密SM4到Spring Boot项目远不止是引入一个依赖和写一个工具类。它涉及到底层Servlet容器的理解、过滤器链的编排、异常处理、性能安全以及生产环境的健壮性设计。希望这篇从原理到实战、从代码到配置的详细指南能帮你避开我当年踩过的那些坑构建出既符合安全合规要求又稳定高效的服务。记住安全无小事每一个细节都值得反复推敲和测试。