MyBatis-Plus数据加密实战:如何用注解+拦截器保护敏感字段(附完整代码)

📅 发布时间:2026/7/7 22:10:44 👁️ 浏览次数:
MyBatis-Plus数据加密实战:如何用注解+拦截器保护敏感字段(附完整代码)
MyBatis-Plus数据加密实战如何用注解拦截器保护敏感字段附完整代码在今天的应用开发中数据安全早已不是一道选择题而是关乎产品信誉和用户信任的必答题。无论是处理用户的身份证号、手机号还是管理金融交易中的金额、账户信息一旦这些敏感数据在存储或传输环节出现纰漏后果往往不堪设想。作为Java生态中广受欢迎的ORM增强工具MyBatis-Plus本身并未内置一套开箱即用的字段级加密方案这恰恰给了我们一个机会去构建一套既贴合业务需求又具备良好扩展性的安全防护层。这篇文章我想和你分享的不是教科书式的理论而是一套经过实际项目锤炼的、基于MyBatis-Plus的字段加解密实战方案。我们将绕过那些复杂的、需要改造数据库驱动或重写SQL解析器的重型方案聚焦于一种更为轻巧、侵入性更低的方式自定义注解配合MyBatis拦截器。这种方式的核心思想是在数据“入库”和“出库”的关键节点进行拦截对标记了特定注解的字段进行自动的加密和解密操作。对于开发者而言几乎是无感的——你只需要在实体类上打上几个注解剩下的脏活累活框架替你干了。接下来我会带你从零开始一步步拆解这套机制的每一个齿轮是如何咬合的。我们会设计自己的注解、编写加解密工具、实现核心的拦截器逻辑并最终将它们组装成一个可复用的组件。更重要的是我会分享在实现过程中遇到的那些“坑”以及如何让这套方案不仅能工作还能工作得优雅、高效。无论你是正在为即将上线的新项目寻找数据安全方案还是希望对现有系统进行安全加固相信接下来的内容都能给你带来直接的启发和可落地的代码。1. 架构设计与核心思想为何选择拦截器在动手写代码之前我们得先想清楚为什么要走“注解拦截器”这条路。市面上实现数据字段加密的方案不少各有优劣理解我们选择的背景能帮助你在未来遇到更复杂场景时做出更好的决策。一种常见的思路是在业务层进行加解密。即在Service层保存数据前手动调用加密方法查询出数据后手动调用解密方法。这种方法直白但缺点也很明显侵入性强容易遗漏。每个涉及敏感字段的增删改查方法都需要添加加解密代码不仅增加了开发工作量更可怕的是一旦有某个方法被遗忘就会导致明文数据被意外存入数据库形成安全漏洞。另一种思路是使用数据库自身的加密功能如透明数据加密TDE。这固然强大但通常依赖于特定的数据库版本如企业版且加密粒度是表空间或整个数据库文件无法做到针对个别字段的灵活控制同时也将安全策略与特定的数据库产品深度绑定。相比之下在MyBatis/MyBatis-Plus的框架层面通过拦截器实现字段加解密具有显著优势无侵入性业务代码完全感知不到加密的存在。实体类就是普通的POJOService层进行常规的save、update、select操作即可。集中管理加解密逻辑被封装在少数几个拦截器类中一处修改处处生效。安全策略的调整和维护变得非常简单。灵活性高我们可以通过自定义注解精确控制哪些实体类、哪些字段需要被加密。不同字段甚至可以采用不同的加密算法如手机号用AES金额用自定义算法。与框架生态融合好MyBatis的插件拦截器机制非常成熟和稳定我们是在其设计模式内进行扩展兼容性和稳定性有保障。这套方案的核心流程可以概括为两条主线写入INSERT/UPDATE流程MyBatis执行器准备将参数设置到SQL语句时被我们的EncryptInterceptor拦截。拦截器检查参数对象如果其类上标记了特定注解则遍历其字段对标记了加密注解的字段值进行加密然后再放行。读取SELECT流程MyBatis将数据库结果集映射为Java对象后在返回给调用者之前被我们的DecryptInterceptor拦截。拦截器检查结果对象对标记了加密注解的字段值进行解密然后将解密后的对象返回。整个过程中数据库里存储的始终是密文而内存中的Java对象则是“纯净”的明文。业务逻辑在明文的维度上运作安全在数据持久化的维度上得到保障。2. 基石构建加解密工具与注解定义任何加密方案都离不开加解密算法的支撑。为了保持示例的清晰和可运行我们这里使用Hutool工具包提供的AES对称加密工具。在实际生产环境中密钥的管理如从配置中心、KMS服务获取是重中之重绝不能像示例中这样硬编码在代码里。首先引入必要的依赖以Maven为例dependency groupIdcn.hutool/groupId artifactIdhutool-crypto/artifactId version5.8.22/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.6/version /dependency接下来我们创建两个核心注解。注解的作用是“打标记”告诉拦截器“嘿这个类/这个字段需要你特殊关照一下。”import java.lang.annotation.*; /** * 标记需要加解密的实体类。 * 被此注解标注的类其内部被EncryptedField标注的字段会在数据库操作时被自动处理。 */ Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface EncryptedTable { }import java.lang.annotation.*; /** * 标记需要加解密的字段。 * 此注解必须用在被EncryptedTable标注的类的字段上。 */ Retention(RetentionPolicy.RUNTIME) Target(ElementType.FIELD) public interface EncryptedField { }注解定义好了我们就需要一个强大的“工具箱”来执行实际的加解密操作。这个工具类需要能处理不同类型的字段String, BigDecimal等并且能智能地识别注解。import cn.hutool.core.util.ObjectUtil; import cn.hutool.crypto.SecureUtil; import cn.hutool.crypto.symmetric.AES; import org.springframework.core.annotation.AnnotationUtils; import java.lang.reflect.Field; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.Objects; /** * 加解密核心工具类。 * 注意此处密钥为示例实际项目必须从安全配置源获取 */ public class EncryptDecryptUtils { // 示例密钥生产环境务必使用安全方式管理 private static final byte[] KEYS YourSuperSecretKey16.getBytes(StandardCharsets.UTF_8); private static final AES AES_INSTANCE SecureUtil.aes(KEYS); /** * 加密对象。 * 检查对象类是否被EncryptedTable标注如果是则加密其下所有被EncryptedField标注的字段。 */ public static void encryptObject(Object parameterObject) throws IllegalAccessException { if (ObjectUtil.isEmpty(parameterObject)) { return; } Class? clazz parameterObject.getClass(); EncryptedTable tableAnnotation AnnotationUtils.findAnnotation(clazz, EncryptedTable.class); if (Objects.nonNull(tableAnnotation)) { Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { if (field.isAnnotationPresent(EncryptedField.class)) { encryptField(field, parameterObject); } } } } /** * 解密对象。 * 逻辑与加密对称将密文字段还原为明文。 */ public static void decryptObject(Object resultObject) throws IllegalAccessException { if (ObjectUtil.isEmpty(resultObject)) { return; } Class? clazz resultObject.getClass(); // 同样需要检查类注解确保只处理我们标记的类 EncryptedTable tableAnnotation AnnotationUtils.findAnnotation(clazz, EncryptedTable.class); if (Objects.nonNull(tableAnnotation)) { Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { if (field.isAnnotationPresent(EncryptedField.class)) { decryptField(field, resultObject); } } } } /** * 加密单个字段。 * 这里演示了如何处理String和BigDecimal类型。 * 你可以根据需要扩展其他类型如Integer、Long或自定义对象。 */ private static void encryptField(Field field, Object targetObj) throws IllegalAccessException { field.setAccessible(true); Object originalValue field.get(targetObj); if (originalValue null) { return; // 空值不处理 } if (originalValue instanceof String) { String strValue (String) originalValue; // 使用AES加密输出Hex字符串存储 String encryptedHex AES_INSTANCE.encryptHex(strValue); field.set(targetObj, encryptedHex); } else if (originalValue instanceof BigDecimal) { // 对于金额等数值有时需要特殊处理。这里是一个简单示例放大并偏移后存储。 // 注意这并非强加密生产环境应对金额使用更安全的算法或保持String加密。 BigDecimal decimalValue (BigDecimal) originalValue; // 示例将金额放大10000倍并加上一个固定偏移量后转为Long存储 long transformedValue decimalValue.multiply(BigDecimal.valueOf(10000)) .add(BigDecimal.valueOf(1234567890L)) .longValue(); field.set(targetObj, BigDecimal.valueOf(transformedValue)); } // 可以继续添加 else if 处理其他类型 } /** * 解密单个字段。 * 逻辑与加密过程严格互逆。 */ private static void decryptField(Field field, Object targetObj) throws IllegalAccessException { field.setAccessible(true); Object encryptedValue field.get(targetObj); if (encryptedValue null) { return; } if (encryptedValue instanceof String) { String encryptedHex (String) encryptedValue; try { // 尝试解密如果解密失败可能是历史明文数据则直接返回原值 String decryptedStr AES_INSTANCE.decryptStr(encryptedHex); field.set(targetObj, decryptedStr); } catch (Exception e) { // 记录日志但不要抛出异常避免影响查询。这里选择原样返回。 // log.warn(字段解密失败返回原始值。字段: {}, 值: {}, field.getName(), encryptedHex); field.set(targetObj, encryptedHex); } } else if (encryptedValue instanceof BigDecimal) { BigDecimal transformedValue (BigDecimal) encryptedValue; // 逆向操作先转Long减去偏移量再缩小10000倍 long longValue transformedValue.longValue(); BigDecimal originalDecimal BigDecimal.valueOf(longValue) .subtract(BigDecimal.valueOf(1234567890L)) .divide(BigDecimal.valueOf(10000)); field.set(targetObj, originalDecimal); } } }注意上面的BigDecimal处理仅仅是一个演示性质的变形并非密码学意义上的加密。对于金融等敏感数据建议将其转换为String后使用强加密算法如AES处理或者寻求专业的加密库。密钥KEYS的硬编码是严重的安全反模式务必通过环境变量、配置中心或专业的密钥管理服务KMS来获取。3. 核心引擎实现MyBatis加密与解密拦截器工具和注解准备就绪现在我们来打造拦截器这个“引擎”。拦截器需要实现MyBatis的Interceptor接口并在正确的时机插入我们的加解密逻辑。3.1 加密拦截器守护数据入库加密拦截器EncryptInterceptor的目标是在SQL语句执行前拦截设置参数的步骤将明文替换为密文。它需要拦截ParameterHandler.setParameters方法。import org.apache.ibatis.executor.parameter.ParameterHandler; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.*; import org.springframework.stereotype.Component; import java.lang.reflect.Method; import java.sql.PreparedStatement; import java.util.*; /** * 加密拦截器。 * 拦截点ParameterHandler.setParameters在SQL执行前对参数进行加密。 */ Intercepts({ Signature(type ParameterHandler.class, method setParameters, args {PreparedStatement.class}) }) Component public class EncryptInterceptor implements Interceptor { // 用于反射获取参数对象的方法名 private static final String GET_PARAMETER_OBJECT_METHOD getParameterObject; Override public Object intercept(Invocation invocation) throws Throwable { // 1. 获取被拦截的对象确认是ParameterHandler Object target invocation.getTarget(); if (!(target instanceof ParameterHandler)) { return invocation.proceed(); } ParameterHandler parameterHandler (ParameterHandler) target; // 2. 通过反射获取当前要设置的参数对象 // MyBatis的ParameterHandler实现类DefaultParameterHandler有getParameterObject方法 Method getParamObjMethod parameterHandler.getClass() .getDeclaredMethod(GET_PARAMETER_OBJECT_METHOD); getParamObjMethod.setAccessible(true); Object parameterObject getParamObjMethod.invoke(parameterHandler); if (parameterObject ! null) { // 3. 处理参数对象 processParameterObject(parameterObject); } // 4. 继续执行原始流程此时参数对象的字段值已被加密 return invocation.proceed(); } /** * 处理参数对象可能是一个实体也可能是MyBatis的ParamMap。 */ private void processParameterObject(Object paramObj) throws IllegalAccessException { // 情况一参数是MyBatis传入的ParamMap常见于多参数方法或使用Param注解 if (paramObj instanceof Map) { // 遍历Map中的所有值对每个是实体对象的值进行加密 // 使用IdentityHashMap或HashSet记录对象身份哈希避免对同一个对象实例重复加密 SetInteger processedIdentityHashCodes new HashSet(); Map?, ? paramMap (Map?, ?) paramObj; for (Object value : paramMap.values()) { if (value ! null processedIdentityHashCodes.add(System.identityHashCode(value))) { EncryptDecryptUtils.encryptObject(value); } } } else { // 情况二参数是单个实体对象 EncryptDecryptUtils.encryptObject(paramObj); } } Override public Object plugin(Object target) { // 使用MyBatis提供的Plugin.wrap方法来创建代理对象 return Plugin.wrap(target, this); } // 如果需要可以在这里设置拦截器属性通常从Spring配置注入 // private Properties properties; // Override // public void setProperties(Properties properties) { // this.properties properties; // } }这个拦截器的关键点在于processParameterObject方法。它聪明地处理了MyBatis传递参数的两种主要方式单个实体对象和包含多个参数的Map。对于Map我们通过System.identityHashCode()来确保同一个对象实例只被加密一次避免重复操作。3.2 解密拦截器还原数据出库解密拦截器DecryptInterceptor的目标是在数据库结果集被转换成Java对象后、返回给调用者前将密文字段还原为明文。它需要拦截ResultSetHandler.handleResultSets方法。import org.apache.ibatis.executor.resultset.ResultSetHandler; import org.apache.ibatis.plugin.*; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.stereotype.Component; import java.sql.Statement; import java.util.*; /** * 解密拦截器。 * 拦截点ResultSetHandler.handleResultSets在结果集映射为对象后对其进行解密。 */ Intercepts({ Signature(type ResultSetHandler.class, method handleResultSets, args {Statement.class}) }) Component public class DecryptInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 1. 执行原方法获取数据库查询结果 Object result invocation.proceed(); if (result null) { return null; } // 2. 处理结果 decryptResult(result); // 3. 返回解密后的结果 return result; } /** * 对查询结果进行解密。 * 支持单个实体对象和集合List的递归处理。 */ SuppressWarnings(unchecked) private void decryptResult(Object result) throws IllegalAccessException { if (result instanceof Collection) { // 处理查询列表的结果 CollectionObject resultList (CollectionObject) result; for (Object item : resultList) { if (item ! null needToDecrypt(item)) { EncryptDecryptUtils.decryptObject(item); } } } else { // 处理查询单个对象的结果 if (needToDecrypt(result)) { EncryptDecryptUtils.decryptObject(result); } } } /** * 判断一个对象是否需要解密即其类是否被EncryptedTable注解标记。 * 这是一个优化避免对无关的实体类进行反射操作。 */ private boolean needToDecrypt(Object object) { Class? clazz object.getClass(); EncryptedTable annotation AnnotationUtils.findAnnotation(clazz, EncryptedTable.class); return annotation ! null; } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }解密拦截器的逻辑相对清晰拿到查询结果后判断结果是单个对象还是集合然后遍历并调用工具类进行解密。needToDecrypt方法是一个简单的性能优化先检查类级别的注解如果连EncryptedTable都没有就无需进行后续的字段级反射操作。4. 实战应用与进阶优化现在让我们把前面所有的零件组装起来看看它们在实际项目中如何协同工作。同时我也会分享几个让这套方案变得更健壮、更实用的进阶技巧。4.1 完整使用示例首先定义一个需要加密的实体类例如Userimport lombok.Data; Data EncryptedTable // 标记整个实体类需要加解密处理 public class User { private Long id; private String username; EncryptedField // 标记此字段需要加密存储 private String mobilePhone; EncryptedField // 标记此字段需要加密存储 private String idCardNumber; private String email; // 此字段不会被加密 }然后在你的Mapper或Service中像平常一样操作即可Service public class UserService { Autowired private UserMapper userMapper; public void createUser(User user) { // 插入时拦截器会自动加密 mobilePhone 和 idCardNumber userMapper.insert(user); // 此时数据库中的mobilePhone和idCardNumber字段已是密文 } public User getUserById(Long id) { // 查询时拦截器会自动解密 mobilePhone 和 idCardNumber User user userMapper.selectById(id); // 此时user对象中的mobilePhone和idCardNumber字段已是明文 return user; } public void updatePhone(Long userId, String newPhone) { User user new User(); user.setId(userId); user.setMobilePhone(newPhone); // 设置新手机号明文 // 更新时拦截器同样会自动加密这个字段 userMapper.updateById(user); } }对于MyBatis-Plus你还需要确保拦截器被正确注册到Spring容器中。由于我们使用了Component注解并且MyBatis-Plus的自动配置通常会扫描Interceptor它们应该能自动生效。如果遇到拦截器不生效的情况可以检查一下MyBatis的配置# application.yml mybatis-plus: configuration: # 确保日志能打印出SQL方便调试 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 如果你需要显式配置插件可以在这里添加通常自动注册即可 # global-config: # interceptor-list: # - com.yourpackage.interceptor.EncryptInterceptor # - com.yourpackage.interceptor.DecryptInterceptor4.2 关键进阶优化点在实际使用中你可能会遇到一些边缘情况或性能考量。这里有几个我踩过坑后总结的优化方向1. 类型处理的扩展性我们的工具类目前只处理了String和BigDecimal。现实项目中你可能需要加密Integer类型的用户ID、LocalDate类型的生日甚至是复杂的JSON对象。建议将类型处理逻辑抽象成策略模式。// 定义一个加密处理器接口 public interface FieldEncryptor { boolean supports(Class? fieldType); Object encrypt(Object originalValue, String fieldName); Object decrypt(Object encryptedValue, String fieldName); } // 为String类型实现一个处理器 Component public class StringAesEncryptor implements FieldEncryptor { Override public boolean supports(Class? fieldType) { return String.class.equals(fieldType); } Override public Object encrypt(Object originalValue, String fieldName) { // ... AES加密逻辑 } Override public Object decrypt(Object encryptedValue, String fieldName) { // ... AES解密逻辑 } } // 在EncryptDecryptUtils中注入一个ListFieldEncryptor遍历找到支持的处理器来执行加解密。2. 条件加密与密钥轮转不是所有场景都需要加密。你可以为EncryptedField注解增加属性比如boolean enabled()或String algorithm()来实现条件加密或选择不同算法。更高级的场景是密钥轮转即定期更换加密密钥。这需要在解密时能够识别数据是用哪一版密钥加密的通常可以在密文中添加版本头或元数据并选择对应的密钥进行解密。3. 性能考量与开关控制反射操作有一定开销。如果实体类字段很多但只有少数几个需要加密needToDecrypt的类级别检查能过滤掉大部分无关对象。此外强烈建议为整个加解密功能提供一个全局开关例如通过ConfigurationProperties读取一个配置项data.encryption.enabled。在工具类和拦截器中先检查这个开关如果为false则直接跳过所有加解密逻辑。这在开发、测试环境或者需要临时排查问题时非常有用。4. 与MyBatis-Plus的Lambda查询兼容性如果你在Service层使用MyBatis-Plus的LambdaQueryWrapper例如queryWrapper.eq(User::getMobilePhone, “13800138000”)这里传入的查询条件是明文。我们的加密拦截器目前只拦截了ParameterHandler处理的是最终设置到PreparedStatement的参数对象。对于QueryWrapper中的条件值它们通常会被MyBatis-Plus转换为SQL语句的一部分。要让条件值也自动加密需要更深入地介入MyBatis-Plus的SQL解析过程或者更简单一点在构建Wrapper时手动调用加密工具对条件值进行加密。public User getUserByEncryptedPhone(String plainPhone) { String encryptedPhone EncryptDecryptUtils.encryptString(plainPhone); // 需要一个直接加密字符串的方法 QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(mobile_phone, encryptedPhone); // 这里直接使用密文查询 return userMapper.selectOne(wrapper); }这种方式要求你知道查询条件对应的是加密字段并且手动处理。对于更自动化的方案可以考虑自定义一个LambdaQueryWrapper的子类重写其eq等方法在内部判断字段是否被EncryptedField注解如果是则自动加密传入的值。这实现起来更复杂但能提供更好的开发体验。5. 数据库索引与模糊查询的挑战这是一个无法回避的痛点。一旦对字段加密原本在明文上建立的数据库索引将失效因为索引是基于密文存储的。同时LIKE这样的模糊查询也变得不可能因为密文数据的微小变化会导致明文完全无法预测。对于需要模糊查询或高效等值查询的字段如手机号前缀查询常见的折中方案是保留明文哈希额外存储一个字段如phone_hash SHA256(明文手机号)用于等值查询和建立索引。但无法支持模糊查询。可搜索加密研究同态加密或确定性加密等高级密码学方案但这会引入极大的复杂性和性能损耗。业务设计调整从根本上思考是否真的需要对这些敏感字段进行频繁的模糊查询能否通过其他非敏感字段如用户ID、昵称来定位数据最后别忘了为你的加解密组件编写全面的单元测试和集成测试覆盖各种数据类型、空值、集合查询、嵌套对象等场景。数据安全无小事每一行处理敏感数据的代码都值得被慎重对待。这套基于MyBatis-Plus拦截器的方案提供了一个平衡了开发效率、安全性和灵活性的起点你可以根据自己项目的具体情况进行裁剪和增强。