SpringBoot+Mybatis数据安全插件:基于拦截器实现字段自动加解密与脱敏

发布时间:2026/7/6 8:59:55
SpringBoot+Mybatis数据安全插件:基于拦截器实现字段自动加解密与脱敏 1. 项目概述与核心价值最近在做一个内部系统涉及到不少敏感数据的处理比如用户的手机号、身份证号、邮箱这些。直接明文存库或者在前端展示心里总是不踏实万一有个SQL注入或者日志泄露那就是大问题。市面上当然有成熟的加解密方案和脱敏组件但要么太重集成起来麻烦要么不够灵活没法针对我们业务里那些特殊的字段处理规则做定制。所以我就琢磨着能不能基于我们最熟悉的SpringBoot和Mybatis技术栈自己动手写一个轻量级的数据安全处理小插件。这个插件的核心目标很明确在数据持久化层DAO层实现自动化的加解密与脱敏。简单说就是数据从Java对象进入数据库之前自动加密从数据库查询出来映射回Java对象时自动解密。同时在数据返回给前端时能根据配置对特定字段进行脱敏展示比如手机号显示为138****8888。这样一来业务代码几乎不用做任何改动只需要在实体类的字段上加个注解安全处理就自动完成了开发体验非常流畅。它的价值在于用最小的侵入性为SpringBoot Mybatis项目提供了一层可靠的数据安全兜底。特别适合那些对数据安全有要求但又不想引入复杂安全中间件的中小型项目。接下来我就把这个插件的设计思路、实现细节以及我踩过的坑完整地分享出来。2. 整体设计与核心思路拆解2.1 为什么选择Mybatis拦截器作为切入点要实现数据在出入库时的自动处理最关键的是要找到一个合适的“钩子”Hook。在Mybatis的体系里拦截器Interceptor正是这样一个完美的切入点。Mybatis的拦截器可以拦截四大核心对象Executor, StatementHandler, ParameterHandler, ResultSetHandler的方法执行允许我们在SQL执行前后插入自定义逻辑。对于数据安全处理我们主要关注两个时机参数设置时入库前当Java对象作为参数传入即将被拼接到SQL语句中时我们需要拦截并对其中的敏感字段进行加密。这对应拦截ParameterHandler的setParameters方法。结果集映射时出库后当从数据库ResultSet中取出数据准备设置回Java对象的属性时我们需要拦截并对敏感字段进行解密。这对应拦截ResultSetHandler的handleResultSets方法。选择Mybatis拦截器而不是在Service层或Controller层做处理有以下几个好处无侵入性业务代码Service, Controller完全感知不到加解密的存在只需关注业务逻辑。统一处理所有通过Mybatis进行的数据库操作增、删、改、查都能被覆盖避免遗漏。高性能在ORM框架层面处理比在业务层循环处理效率更高尤其是处理批量数据时。2.2 插件核心架构设计整个插件围绕“注解驱动”和“拦截器执行”两个核心思想来构建。架构上可以分为三层注解层定义用于标记字段的注解这是插件与业务代码交互的契约。FieldEncrypt标记需要加解密的字段可指定加密算法如AES、密钥等信息。FieldSensitive标记需要脱敏的字段可指定脱敏策略如手机号、身份证、邮箱等。核心处理层包含加解密执行器和脱敏执行器。它们根据注解信息调用具体的算法完成数据转换。加解密执行器持有加密算法实例如AES实现提供encrypt和decrypt方法。脱敏执行器根据FieldSensitive的策略类型提供desensitize方法。拦截器层实现Mybatis的Interceptor接口包含两个核心拦截器。加密拦截器绑定到ParameterHandler在setParameters方法中遍历参数对象查找带有FieldEncrypt注解的字段调用加密执行器进行加密然后用加密后的值替换原值。解密与脱敏拦截器绑定到ResultSetHandler在handleResultSets方法中遍历查询结果对象先对带有FieldEncrypt的字段调用解密执行器进行解密再对带有FieldSensitive的字段调用脱敏执行器进行脱敏处理。此外还需要一个配置类用于在SpringBoot启动时自动注册这些拦截器到Mybatis的配置中并初始化加解密所需的密钥等配置。这种设计确保了插件的高内聚、低耦合扩展性也很好。如果想增加一种新的加密算法比如SM4只需要实现一个新的加解密执行器并在注解中指定即可其他部分无需改动。3. 核心细节解析与实操要点3.1 自定义注解的定义与解析注解是插件的“开关”和“配置说明书”。定义时需要考虑可扩展性和实用性。// 字段加密注解 Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface FieldEncrypt { /** * 加密算法类型默认为AES */ AlgorithmType algorithm() default AlgorithmType.AES; /** * 密钥的配置名称用于从环境配置中获取避免硬编码 */ String keyProperty() default plugin.security.encrypt.key; } // 字段脱敏注解 Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface FieldSensitive { /** * 脱敏策略类型 */ SensitiveType strategy(); } // 算法类型枚举 public enum AlgorithmType { AES, // 未来可扩展 SM4, RSA等 } // 脱敏策略枚举 public enum SensitiveType { /** 手机号138****8888 */ MOBILE_PHONE, /** 身份证号110***********1234 */ ID_CARD, /** 邮箱a***example.com */ EMAIL, /** 自定义需配合customPattern使用 */ CUSTOM }实操要点与避坑密钥管理切忌在注解中硬编码密钥字符串我们通过keyProperty指定一个配置项的名字如plugin.security.encrypt.key实际密钥写在应用的application.yml或配置中心里。这样既安全也便于不同环境开发、测试、生产使用不同的密钥。注解继承注意Mybatis在映射结果集时如果使用了继承注解是否能被正确识别取决于你的反射工具。通常我们需要使用ClassUtil.getAllFields(clazz)这样的工具方法来获取类及其所有父类的字段确保不会遗漏父类中带有注解的字段。性能考虑每次操作都通过反射解析注解是比较耗时的。一个常见的优化点是使用缓存。我们可以缓存“类 - 需要处理的字段列表”的映射关系避免每次拦截都进行全量反射扫描。3.2 加解密引擎的实现与选型加解密是安全的核心必须保证其可靠性和合规性。1. 算法选型对称加密推荐如AES。加解密速度快适合对大量数据进行加密。对于绝大多数内部系统使用AES-256-GCM模式足以满足安全需求。GCM模式还能同时提供机密性和完整性校验。非对称加密如RSA。加解密速度慢通常用于加密密钥本身即“信封加密”而非直接加密数据。在本插件中如果对安全性要求极高可以考虑采用“RSA加密AES密钥AES加密数据”的混合模式但复杂度会显著增加。2. AES-GCM实现示例 GCM模式需要Initialization Vector (IV)且IV不需要保密但绝不能重复使用同一个IV和密钥对。通常将IV和密文一起存储。Component public class AesEncryptor implements Encryptor { Value(${plugin.security.encrypt.key}) private String secretKey; // 从配置读取Base64编码的密钥 private SecretKeySpec keySpec; private static final String ALGORITHM AES/GCM/NoPadding; private static final int IV_LENGTH 12; // GCM推荐IV长度为12字节 private static final int TAG_LENGTH 16; // GCM标签长度128位 PostConstruct public void init() throws Exception { byte[] decodedKey Base64.getDecoder().decode(secretKey); this.keySpec new SecretKeySpec(decodedKey, AES); } Override public String encrypt(String rawData) throws Exception { if (rawData null) return null; Cipher cipher Cipher.getInstance(ALGORITHM); byte[] iv new byte[IV_LENGTH]; SecureRandom random new SecureRandom(); random.nextBytes(iv); // 每次加密生成随机IV GCMParameterSpec parameterSpec new GCMParameterSpec(TAG_LENGTH * 8, iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, parameterSpec); byte[] cipherText cipher.doFinal(rawData.getBytes(StandardCharsets.UTF_8)); // 组合 IV 密文 进行存储或传输 byte[] combined new byte[iv.length cipherText.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); return Base64.getEncoder().encodeToString(combined); } Override public String decrypt(String encryptedData) throws Exception { if (encryptedData null) return null; byte[] combined Base64.getDecoder().decode(encryptedData); byte[] iv Arrays.copyOfRange(combined, 0, IV_LENGTH); byte[] cipherText Arrays.copyOfRange(combined, IV_LENGTH, combined.length); Cipher cipher Cipher.getInstance(ALGORITHM); GCMParameterSpec parameterSpec new GCMParameterSpec(TAG_LENGTH * 8, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec); byte[] plainText cipher.doFinal(cipherText); return new String(plainText, StandardCharsets.UTF_8); } }3. 关键注意事项密钥安全生产环境的密钥必须妥善保管绝不能提交到代码仓库。推荐使用配置中心或容器秘钥管理服务。IV管理如上例必须使用密码学安全的随机数生成器SecureRandom为每次加密生成唯一的IV并和密文一起存储/传输。异常处理加解密过程可能抛出各种异常如密钥错误、数据被篡改导致认证失败AEADBadTagException。在拦截器中必须妥善捕获并处理是记录日志后返回空值还是直接抛出异常让上层处理需要根据业务场景决定。我建议在测试环境采用“失败快速”原则直接抛异常生产环境则可记录详细日志后降级处理如返回脱敏后的空值。字段类型我们的注解加在String类型字段上最直接。如果字段是其他类型如Long型的手机号需要在加解密前后进行类型转换。3.3 脱敏策略的灵活实现脱敏的逻辑相对加解密简单核心是根据策略对字符串进行截取和替换。Component public class SensitiveDataProcessor { public String desensitize(String data, SensitiveType type) { if (data null || data.isEmpty()) { return data; } switch (type) { case MOBILE_PHONE: return data.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2); case ID_CARD: if (data.length() 15) { return data.replaceAll((\\d{6})\\d{6}(\\d{3}), $1******$2); } else if (data.length() 18) { return data.replaceAll((\\d{6})\\d{8}(\\d{4}), $1********$2); } return data; // 格式不符原样返回或做其他处理 case EMAIL: int atIndex data.indexOf(); if (atIndex 1) { return data.charAt(0) *** data.substring(atIndex); } return data; case CUSTOM: // 可以结合注解中的自定义正则进行脱敏 // 例如FieldSensitive(strategy SensitiveType.CUSTOM, customPattern (?\\w{3})\\w(?\\w{4})) // 处理逻辑... default: return data; } } }实操心得保持数据原貌脱敏仅用于展示。任何需要基于原始数据进行计算、匹配的业务逻辑如按手机号查询必须在解密后的原始数据上进行。因此脱敏操作通常放在返回给前端之前的最后一步或者在DTO转换时进行。国际化考虑不同国家的手机号、身份证号格式不同。如果你的系统面向国际用户脱敏策略需要可配置化或者设计得更通用如保留前N位和后M位。性能无虞脱敏操作是简单的字符串处理性能开销极小一般无需特别优化。4. Mybatis拦截器的实现与集成这是插件最核心的部分将注解、加解密、脱敏串联起来。4.1 加密拦截器实现加密拦截器负责在SQL参数设置时对入参对象进行加密。Intercepts({ Signature(type ParameterHandler.class, method setParameters, args {PreparedStatement.class}) }) Component public class EncryptionInterceptor implements Interceptor { Autowired private EncryptorManager encryptorManager; // 管理不同算法的加密器 Autowired private FieldAnnotationCache annotationCache; // 注解缓存 Override public Object intercept(Invocation invocation) throws Throwable { ParameterHandler parameterHandler (ParameterHandler) invocation.getTarget(); // 通过反射获取ParameterHandler内部的parameterObject即我们的实体对象 Field parameterField parameterHandler.getClass().getDeclaredField(parameterObject); parameterField.setAccessible(true); Object parameterObject parameterField.get(parameterHandler); if (parameterObject ! null) { // 处理参数对象可能是一个对象也可能是Map、集合等 processParameterObject(parameterObject); } // 继续执行原方法 return invocation.proceed(); } private void processParameterObject(Object parameterObject) throws IllegalAccessException { if (parameterObject instanceof Map) { // 处理Mybatis传递的Map参数例如Param注解标注的参数 ((Map?, ?) parameterObject).values().forEach(value - { if (value ! null !isPrimitiveOrWrapper(value.getClass())) { try { encryptFields(value); } catch (Exception e) { throw new RuntimeException(加密字段处理失败, e); } } }); } else if (parameterObject instanceof Collection) { // 处理批量插入/更新 for (Object item : (Collection?) parameterObject) { if (item ! null !isPrimitiveOrWrapper(item.getClass())) { encryptFields(item); } } } else if (!isPrimitiveOrWrapper(parameterObject.getClass())) { // 处理单个实体对象 encryptFields(parameterObject); } } private void encryptFields(Object obj) throws IllegalAccessException { ListField encryptedFields annotationCache.getEncryptedFields(obj.getClass()); for (Field field : encryptedFields) { field.setAccessible(true); Object originalValue field.get(obj); if (originalValue instanceof String) { FieldEncrypt annotation field.getAnnotation(FieldEncrypt.class); Encryptor encryptor encryptorManager.getEncryptor(annotation.algorithm()); String encryptedValue encryptor.encrypt((String) originalValue); field.set(obj, encryptedValue); // 将加密后的值设回字段 } } } // ... 辅助方法 isPrimitiveOrWrapper }4.2 解密与脱敏拦截器实现这个拦截器更复杂一些它需要在Mybatis完成结果集映射后对对象字段进行解密和脱敏。Intercepts({ Signature(type ResultSetHandler.class, method handleResultSets, args {Statement.class}) }) Component public class DecryptionAndDesensitizationInterceptor implements Interceptor { Autowired private EncryptorManager encryptorManager; Autowired private SensitiveDataProcessor sensitiveDataProcessor; Autowired private FieldAnnotationCache annotationCache; Override public Object intercept(Invocation invocation) throws Throwable { // 1. 先执行原方法获取Mybatis映射后的结果 Object result invocation.proceed(); // 2. 对结果进行处理 if (result ! null) { if (result instanceof ArrayList) { for (Object item : (ArrayList?) result) { processObject(item); } } else { // 单个对象结果 processObject(result); } } return result; } private void processObject(Object obj) throws Exception { if (obj null || isPrimitiveOrWrapper(obj.getClass())) { return; } Class? clazz obj.getClass(); ListField encryptedFields annotationCache.getEncryptedFields(clazz); ListField sensitiveFields annotationCache.getSensitiveFields(clazz); // 先解密 for (Field field : encryptedFields) { field.setAccessible(true); Object currentValue field.get(obj); if (currentValue instanceof String) { FieldEncrypt annotation field.getAnnotation(FieldEncrypt.class); Encryptor encryptor encryptorManager.getEncryptor(annotation.algorithm()); String decryptedValue encryptor.decrypt((String) currentValue); field.set(obj, decryptedValue); } } // 后脱敏基于解密后的值 for (Field field : sensitiveFields) { field.setAccessible(true); Object currentValue field.get(obj); if (currentValue instanceof String) { FieldSensitive annotation field.getAnnotation(FieldSensitive.class); String desensitizedValue sensitiveDataProcessor.desensitize((String) currentValue, annotation.strategy()); field.set(obj, desensitizedValue); } } } }关键点解析执行顺序在handleResultSets方法中必须先invocation.proceed()让Mybatis完成默认的结果集映射拿到已经填充了数据库值的对象然后我们才能对这些对象进行后处理。处理多种返回类型Mybatis查询可能返回单个对象、List、Map甚至是嵌套结果。上述示例处理了ArrayListMybatis默认返回的List实现和单个对象的情况。对于更复杂的Map或自定义结果处理器需要额外判断和处理。解密与脱敏的顺序必须先解密再脱敏。因为数据库存储的是加密后的密文需要先解密得到原始明文才能对明文进行脱敏操作。这个顺序绝对不能错。4.3 SpringBoot自动配置与插件注册为了让插件开箱即用我们需要提供一个自动配置类。Configuration AutoConfigureAfter(MybatisAutoConfiguration.class) // 在Mybatis自动配置完成后进行 public class DataSecurityPluginAutoConfiguration { Bean ConditionalOnMissingBean public EncryptorManager encryptorManager(ListEncryptor encryptors) { return new EncryptorManager(encryptors); // 收集所有Encryptor实现 } Bean ConditionalOnMissingBean public FieldAnnotationCache fieldAnnotationCache() { return new FieldAnnotationCache(); } Bean public EncryptionInterceptor encryptionInterceptor() { return new EncryptionInterceptor(); } Bean public DecryptionAndDesensitizationInterceptor decryptionAndDesensitizationInterceptor() { return new DecryptionAndDesensitizationInterceptor(); } /** * 关键将拦截器注册到Mybatis的SqlSessionFactory中 */ Bean public ConfigurationCustomizer mybatisConfigurationCustomizer( EncryptionInterceptor encryptionInterceptor, DecryptionAndDesensitizationInterceptor decryptionInterceptor) { return configuration - { // 添加拦截器到拦截器链 configuration.addInterceptor(encryptionInterceptor); configuration.addInterceptor(decryptionInterceptor); }; } }在META-INF/spring.factories文件中声明这个自动配置类org.springframework.boot.autoconfigure.EnableAutoConfiguration\ com.yourcompany.plugin.config.DataSecurityPluginAutoConfiguration这样只要项目引入了我们这个插件的JAR包SpringBoot启动时就会自动注册两个拦截器完成插件的装载。5. 使用示例与效果验证5.1 实体类与Mapper定义假设我们有一个用户实体User。Data public class User { private Long id; private String username; FieldEncrypt(algorithm AlgorithmType.AES, keyProperty plugin.security.encrypt.key) private String mobilePhone; // 手机号存储时加密 FieldEncrypt(algorithm AlgorithmType.AES) FieldSensitive(strategy SensitiveType.ID_CARD) private String idCard; // 身份证号存储加密查询结果脱敏 private String email; }Mapper接口和XML文件无需任何特殊改动照常编写即可。Mapper public interface UserMapper { Insert(INSERT INTO user(username, mobile_phone, id_card, email) VALUES(#{username}, #{mobilePhone}, #{idCard}, #{email})) int insert(User user); Select(SELECT * FROM user WHERE id #{id}) User selectById(Long id); }5.2 应用配置在application.yml中配置加密密钥。plugin: security: encrypt: key: your-32-byte-base64-encoded-aes-key-here # 示例使用 openssl rand -base64 32 生成5.3 测试与验证编写一个简单的Service进行测试。Service Slf4j public class UserService { Autowired private UserMapper userMapper; public void testPlugin() { User user new User(); user.setUsername(张三); user.setMobilePhone(13812348888); user.setIdCard(110101199001011234); user.setEmail(zhangsanexample.com); // 插入数据 userMapper.insert(user); log.info(插入后对象内存中已加密: {}, user); // 查询数据 User dbUser userMapper.selectById(user.getId()); log.info(查询结果已自动解密和脱敏:); log.info( - 用户名: {}, dbUser.getUsername()); // 张三 log.info( - 手机号: {}, dbUser.getMobilePhone()); // 13812348888 (明文已解密) log.info( - 身份证: {}, dbUser.getIdCard()); // 110***********1234 (已解密并脱敏) log.info( - 邮箱: {}, dbUser.getEmail()); // zhangsanexample.com // 可以直接查看数据库mobile_phone和id_card字段存储的是加密后的密文 } }控制台输出预期插入后对象内存中已加密: User(idnull, username张三, mobilePhoneEncryptedStringHere..., idCardEncryptedStringHere..., emailzhangsanexample.com) 查询结果已自动解密和脱敏: - 用户名: 张三 - 手机号: 13812348888 - 身份证: 110***********1234 - 邮箱: zhangsanexample.com数据库存储内容mobile_phone和id_card字段的值将是AES加密后的Base64字符串类似5f4dcc3b5aa765d61d8327deb882cf99...而非明文。6. 常见问题、排查技巧与进阶优化在实际开发和上线过程中我遇到了不少问题这里总结一下。6.1 常见问题排查表问题现象可能原因排查步骤与解决方案字段未加密/解密1. 拦截器未生效。2. 注解未正确扫描。3. 字段类型非String。1. 检查Component扫描包路径是否包含拦截器类。2. 检查Intercepts签名是否正确特别是args参数类型。3. 在拦截器内打日志确认是否进入intercept方法及processObject方法。4. 检查缓存FieldAnnotationCache是否成功加载了目标类的字段信息。5. 确认加密字段是否为String类型或其他支持的类型。加解密时报错如InvalidKeyException1. 密钥配置错误或长度不符。2. 加密数据被篡改或IV损坏。1. 确认application.yml中的密钥配置项名称与注解中keyProperty一致。2. 确认密钥是Base64编码的且长度符合算法要求AES-256需32字节。3. 检查加解密代码中SecretKeySpec的生成是否正确。4. 对于GCM模式确认加解密时IV的提取和组合逻辑一致。脱敏未生效1. 解密未成功脱敏作用于密文。2. 脱敏策略不匹配。3. 结果集类型未覆盖。1.确保解密拦截器在脱敏之前执行。检查两个拦截器的注册顺序在代码中addInterceptor的顺序。2. 检查FieldSensitive注解的strategy是否正确。3. 检查拦截器的processObject方法是否处理了实际返回的结果类型如Map。批量操作性能差每次操作都反射解析类字段。引入FieldAnnotationCache缓存类与需处理字段的映射关系。使用ConcurrentHashMap存储键为Class?值为ListField。与Mybatis-Plus等插件冲突多个拦截器执行顺序问题。Mybatis拦截器执行顺序与注册顺序相反后注册先执行。可以通过实现org.apache.ibatis.plugin.Interceptor的getOrder()方法或实现org.springframework.core.Ordered接口来指定顺序。确保加解密拦截器在关键插件如分页插件之后执行。查询条件加密问题使用加密字段作为WHERE条件时传入的是明文但数据库存的是密文导致查不到。这是本插件的一个局限。解决方案1.手动加密在Service层对作为查询条件的参数手动调用加密器加密后再传入Mapper。2.扩展拦截器更复杂但优雅的方式是再实现一个拦截StatementHandler的拦截器在SQL执行前解析WHERE条件中的参数对匹配加密字段的参数进行加密替换。但这涉及SQL解析复杂度较高。6.2 进阶优化建议支持更多字段类型目前主要支持String类型。可以扩展支持BigDecimal、LocalDate等类型在加解密时进行String与目标类型的转换。多租户密钥隔离在SaaS系统中不同租户可能需要不同的加密密钥。可以在注解或拦截器中动态获取当前租户ID并从密钥管理服务中获取对应的密钥。审计日志在拦截器中可以记录哪些字段在何时被加密或解密用于安全审计。注意日志本身需要脱敏避免泄露敏感信息。与Spring Cloud Config或Nacos集成将加密密钥存放在配置中心实现密钥的动态管理和轮转。懒加载Lazy Loading处理如果实体类关联了懒加载的集合属性在拦截器处理主对象时这些集合可能未被初始化是代理对象。需要小心处理避免触发不必要的懒加载查询或者忽略对代理对象的字段处理。单元测试务必为插件编写完善的单元测试和集成测试覆盖单字段、多字段、嵌套对象、批量操作、各种SQL语句INSERT, UPDATE, SELECT等场景确保插件行为符合预期。6.3 一个真实的“坑”Mybatis的TypeHandler冲突我在一次集成中发现如果一个字段同时被标注了FieldEncrypt和Mybatis的TypeHandler例如用于处理枚举类型可能会出现问题。因为TypeHandler会在拦截器之后进行类型转换。这意味着拦截器加密后的String值在存入数据库前又被TypeHandler当成枚举或其他类型处理了导致报错或数据错误。解决方案调整处理逻辑。对于有自定义TypeHandler的字段加解密操作应该在TypeHandler内部完成或者避免同时使用两种机制。一个更通用的思路是在拦截器中通过MetaObject获取字段的TypeHandler如果存在且不是默认的StringTypeHandler则跳过该字段的自动加解密交由业务方在TypeHandler中自行处理安全逻辑。这增加了复杂性但保证了兼容性。这个小插件从构思到稳定运行花了差不多两周的业余时间。最大的收获不是代码本身而是对Mybatis拦截器机制、SpringBoot自动装配以及数据安全实践有了更深的理解。它现在安静地运行在我们好几个内部系统里开发者几乎无感知但数据安全多了一份保障。如果你也在用SpringBoot和Mybatis不妨试试自己实现一个过程中遇到的每一个问题都是绝佳的学习机会。