1. 项目概述为什么我们需要字段级加密在开发一个用户管理系统时我遇到了一个经典难题用户的手机号、身份证号这类敏感信息明文存储在数据库里总让人心里不踏实。虽然数据库本身有访问控制但万一发生拖库、或者有内鬼直接查看数据库这些信息就完全暴露了。全库加密太重会影响所有查询性能在应用层每个地方手动加解密又容易遗漏代码侵入性太强。这时候字段级加密就成了一个非常优雅的折中方案——只对特定的敏感字段进行加密存储查询时自动解密对业务代码几乎透明。Spring Boot 和 MyBatis 是 Java 后端开发中最主流的组合之一。MyBatis 强大的灵活性让我们有机会在 SQL 执行的生命周期中插入自定义逻辑这正是实现字段级自动加解密的绝佳切入点。这个方案的核心就是利用 MyBatis 的TypeHandler类型处理器和Interceptor拦截器在数据进出数据库的瞬间完成加密和解密操作让业务层像操作普通字符串一样操作加密字段。简单来说我们的目标是在User实体中标注EncryptedField的phone字段在通过 MyBatis 存入数据库时自动变成一串密文从数据库查询出来映射到实体时又自动变回明文。业务层的Service和Controller完全感知不到这个过程。2. 核心思路与架构设计实现字段级加密关键在于选择一个合适的“钩子”来嵌入我们的加解密逻辑。经过评估主要有两个核心组件可供选择它们各有优劣适用于不同场景。2.1 方案选型TypeHandler vs InterceptorMyBatis TypeHandler类型处理器它的职责是处理 Java 类型与 JDBC 类型之间的转换。当 MyBatis 为 PreparedStatement 设置参数或从 ResultSet 中获取结果时如果字段类型匹配就会调用对应的 TypeHandler。优点实现简单、直接与单个字段类型强绑定。加密和解密逻辑集中在setParameter和getResult两个方法里非常清晰。缺点粒度较粗通常以 Java 类型如String为单位。如果想实现“同一个String类型有的字段加密有的不加密”就需要更复杂的判断逻辑例如结合自定义注解。它主要作用于参数设置和结果映射阶段。MyBatis Interceptor拦截器它可以拦截 MyBatis 执行过程中的核心方法例如Executor的update和query方法能接触到完整的 SQL 语句和参数对象。优点能力强大粒度可粗可细。我们可以在执行 SQL 前解析语句修改其中的参数加密也可以在获取结果后遍历结果对象对特定字段进行解密。它更灵活能实现基于注解的、精细化的字段控制。缺点实现相对复杂需要理解 MyBatis 的内部执行流程并且对 SQL 的解析和处理需要谨慎避免性能开销或引入错误。我们的选择与理由对于纯粹的字段级加密TypeHandler 是更简单、更直接的选择。它的工作模式天然契合“数据类型转换”的场景即明文Java String到密文数据库 String的转换。我们只需要为需要加密的字段类型通常是String注册一个自定义的EncryptedStringTypeHandler即可。通过配合自定义注解如EncryptedField我们可以在 TypeHandler 内判断当前处理的字段是否被注解标记从而决定是否进行加解密。这种方案侵入性低易于理解和维护。因此本方案将采用自定义注解 增强型 TypeHandler作为核心。同时我们会设计一个可插拔的加解密服务接口以便轻松替换不同的加密算法如 AES、SM4。2.2 整体架构与数据流整个方案的组件交互和数据流向如下实体层 (Entity)使用EncryptedField注解标记需要加密的字段。public class User { private Long id; private String username; EncryptedField // 关键注解 private String phone; EncryptedField private String idCard; // getters and setters }加解密服务 (CryptoService)一个独立的服务接口提供encrypt(String plainText)和decrypt(String cipherText)方法。默认实现使用 AES 算法。类型处理器 (EncryptedStringTypeHandler)继承BaseTypeHandlerString。setNonNullParameter当 MyBatis 向 PreparedStatement 设置参数时如果该参数对应的字段被EncryptedField注解则调用CryptoService.encrypt()后设置。getNullableResult当 MyBatis 从 ResultSet、CallableStatement 获取 String 类型结果时判断该结果列对应的 Java 字段是否被EncryptedField注解如果是则调用CryptoService.decrypt()。MyBatis 配置在mybatis-config.xml或 Spring Boot 配置中注册这个自定义的TypeHandler。或者更推荐使用扫描包的方式自动注册。业务层像平常一样使用 MyBatis 的Mapper进行增删改查无需关心加解密细节。数据流示例插入用户Controller - Service - Mapper#insert(User)-MyBatis 调用EncryptedStringTypeHandler.setParameter- 发现phone字段有EncryptedField- 调用CryptoService.encrypt(“13800138000”)- 将密文设置到 SQL 参数中 - 执行 INSERT。注意一个关键的局限性。由于加密后数据已变形基于加密字段的模糊查询LIKE ‘%xxx%’、范围查询BETWEEN和排序ORDER BY将完全失效。这是所有应用层字段加密方案的固有缺陷。如果业务需要这些功能需要考虑其他方案如数据库透明加密TDE或使用保序加密等特殊算法但性能和安全强度会折中。3. 核心细节解析与实操要点3.1 加解密服务CryptoService的设计加解密服务是整个方案的安全基石必须设计得健壮且可扩展。1. 密钥管理这是安全的重中之重。绝对禁止将密钥硬编码在代码中。推荐方案将密钥存储在环境变量、配置中心如 Apollo、Nacos或专用的密钥管理服务KMS中。在应用启动时读取。代码示例基于环境变量Component public class AesCryptoService implements CryptoService { private final SecretKeySpec secretKey; private final String transformation “AES/ECB/PKCS5Padding”; // 示例ECB模式不建议用于生产 public AesCryptoService(Value(“${encrypt.aes.key}”) String base64Key) { byte[] key Base64.getDecoder().decode(base64Key); this.secretKey new SecretKeySpec(key, “AES”); } // ... 加解密方法实现 }transformation指定了算法、模式和填充方式。AES/ECB/PKCS5Padding是一个简单示例但ECB 模式不安全因为它会导致相同的明文块加密成相同的密文块容易受到模式分析攻击。生产环境应使用CBC 或 GCM模式并需要妥善管理初始向量IV。2. 算法选择与IV初始化向量AES-CBC 模式需要一个随机的、不可预测的 IV。IV 不需要保密但必须唯一。通常将 IV 和密文一起存储例如将IV 密文拼接后再 Base64 编码存库。解密时先分离出 IV。AES-GCM 模式同时提供加密和认证更安全。它也会产生一个随机 IV在 GCM 中常称为 Nonce同样需要和密文一起存储。国密算法 SM4如果需要符合国内安全标准可以轻松替换为 SM4 算法。只需实现一个Sm4CryptoService并在配置中替换AesCryptoService的 Bean 即可。3. 加解密方法实现要点Override public String encrypt(String plainText) { try { Cipher cipher Cipher.getInstance(transformation); // 如果是CBC或GCM模式需要生成IV byte[] iv new byte[16]; // AES块大小 SecureRandom random new SecureRandom(); random.nextBytes(iv); IvParameterSpec ivSpec new IvParameterSpec(iv); cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 将IV和密文拼接后编码 byte[] combined new byte[iv.length encrypted.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); return Base64.getEncoder().encodeToString(combined); } catch (Exception e) { throw new RuntimeException(“Encryption failed”, e); } }解密方法是逆过程先从 Base64 解码分离出 IV然后用 IV 和密钥初始化解密模式的 Cipher。3.2 自定义注解与字段识别我们需要一个注解来标记哪些字段需要被加密处理器处理。Retention(RetentionPolicy.RUNTIME) // 运行时保留 Target(ElementType.FIELD) // 只能用于字段 Documented public interface EncryptedField { // 可以扩展例如指定不同的加密算法版本或密钥标识 }在TypeHandler中我们需要判断当前正在处理的字段是否带有此注解。但TypeHandler的接口方法并不直接提供Field信息。这里需要一个技巧利用 MyBatis 的MappedStatement和运行时反射或者更简单地在TypeHandler中不直接判断而是通过配置来绑定。更实用的做法我们为加密字段专门定义一个类型比如EncryptedString。但这会污染实体模型。另一种方法是在注册TypeHandler时明确指定它只用于处理String类型然后在TypeHandler内部我们无法直接知道当前是哪个字段。因此更常见的实践是创建一个通用的EncryptedTypeHandler。在 MyBatis 的配置文件中只为特定的字段单独指定这个 TypeHandler而不是全局注册给所有String类型。这样只有被指定的字段才会走加密逻辑。如何在 Spring Boot 中为特定字段指定 TypeHandler在 MyBatis 的 Mapper XML 文件中resultMap id“userResultMap” type“User” id property“id” column“id”/ result property“username” column“username”/ !-- 为 phone 和 idCard 字段指定自定义的 TypeHandler -- result property“phone” column“phone” typeHandler“com.example.handler.EncryptedStringTypeHandler”/ result property“idCard” column“idCard” typeHandler“com.example.handler.EncryptedStringTypeHandler”/ /resultMap在插入或更新时也需要在#{}中指定typeHandlerinsert id“insertUser” INSERT INTO user(username, phone, id_card) VALUES(#{username}, #{phone, typeHandlercom.example.handler.EncryptedStringTypeHandler}, #{idCard, typeHandlercom.example.handler.EncryptedStringTypeHandler}) /insert这种方式虽然配置稍显繁琐但意图非常清晰且不会影响其他普通字符串字段。3.3 增强型TypeHandler的实现如果我们希望结合注解实现一定程度的自动化可以尝试在TypeHandler中通过线程上下文或反射来获取字段信息但这会变得复杂且可能影响性能。一个折中的增强型TypeHandler实现如下它接收一个CryptoService并在加解密时尝试判断当前操作的属性是否被EncryptedField注解。但如前所述在标准的setParameter和getResult中很难直接获取。一个变通方法是假设所有经过此 Handler 的 String 都需要加解密。那么我们只需要在实体类中把需要加密的字段的setter和getter类型改为EncryptedString一个包装类而TypeHandler就处理这个包装类。这样逻辑就清晰了但改变了实体字段类型。考虑到复杂性和清晰度我强烈推荐使用上述“在 XML 中显式指定typeHandler”的方式。它简单、直观、无魔法符合 MyBatis 的设计哲学。接下来我们按这种方式实现一个标准的TypeHandler。4. 完整实现步骤与代码我们按照“显式配置”的方案来实现分为四个步骤创建加解密服务、创建 TypeHandler、配置 MyBatis 映射、测试验证。4.1 第一步实现加解密服务接口首先定义接口然后提供 AES 实现。// 1. 接口定义 public interface CryptoService { String encrypt(String plainText); String decrypt(String cipherText); } // 2. AES实现 (使用CBC模式更安全) Component public class AesCryptoService implements CryptoService { private static final String ALGORITHM “AES/CBC/PKCS5Padding”; private static final String CHARSET “UTF-8”; private final SecretKeySpec secretKey; private final IvParameterSpec ivSpec; // 这里示例使用固定IV生产环境应为每个加密随机生成并存储 public AesCryptoService(Value(“${encrypt.aes.key}”) String base64Key, Value(“${encrypt.aes.iv}”) String base64Iv) { byte[] key Base64.getDecoder().decode(base64Key); this.secretKey new SecretKeySpec(key, “AES”); byte[] iv Base64.getDecoder().decode(base64Iv); this.ivSpec new IvParameterSpec(iv); } Override public String encrypt(String plainText) { try { Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); byte[] encryptedBytes cipher.doFinal(plainText.getBytes(CHARSET)); return Base64.getEncoder().encodeToString(encryptedBytes); } catch (Exception e) { throw new RuntimeException(“加密失败”, e); } } Override public String decrypt(String cipherText) { try { Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); byte[] decodedBytes Base64.getDecoder().decode(cipherText); byte[] decryptedBytes cipher.doFinal(decodedBytes); return new String(decryptedBytes, CHARSET); } catch (Exception e) { throw new RuntimeException(“解密失败”, e); } } }重要提示上述示例为了简化使用了固定的 IV。在生产环境中CBC 模式要求每次加密使用不同的随机 IV。你需要修改encrypt方法使其生成随机 IV并将IV 密文一起编码后返回。相应地decrypt方法需要先解码分离出 IV 部分再用它来初始化解密器。GCM 模式也是类似的道理。4.2 第二步实现自定义TypeHandler这个TypeHandler不再关心注解它只负责调用CryptoService进行转换。MappedTypes(String.class) // 声明它处理 Java 的 String 类型 MappedJdbcTypes(JdbcType.VARCHAR) // 声明它对应 JDBC 的 VARCHAR 类型 public class EncryptedStringTypeHandler extends BaseTypeHandlerString { private final CryptoService cryptoService; // 通过构造器注入 CryptoService public EncryptedStringTypeHandler(CryptoService cryptoService) { this.cryptoService cryptoService; } Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 对传入的 String 参数进行加密后设置到 PreparedStatement 中 String encrypted cryptoService.encrypt(parameter); ps.setString(i, encrypted); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { String columnValue rs.getString(columnName); return decryptIfNotNull(columnValue); } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String columnValue rs.getString(columnIndex); return decryptIfNotNull(columnValue); } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String columnValue cs.getString(columnIndex); return decryptIfNotNull(columnValue); } private String decryptIfNotNull(String columnValue) { if (columnValue null) { return null; } // 假设数据库里该列存储的都是加密后的密文直接解密 // 注意这里有个潜在问题如果该列原本存在未加密的历史数据解密会失败。 // 解决方案可在密文前增加版本前缀如 “v1:密文”TypeHandler根据前缀判断是否需要及如何解密。 return cryptoService.decrypt(columnValue); } }4.3 第三步Spring Boot 配置与 MyBatis 映射1. 配置密钥在application.yml中encrypt: aes: key: “你的32字节Base64编码的AES密钥” # 例如通过 openssl rand -base64 32 生成 iv: “你的16字节Base64编码的初始向量” # 固定IV示例生产环境慎用2. 注册 TypeHandler 为 Spring Bean我们需要让 MyBatis 能使用这个注入了CryptoService的TypeHandler。在 Spring Boot 中可以定义一个配置类Configuration public class MyBatisConfig { Bean public EncryptedStringTypeHandler encryptedStringTypeHandler(CryptoService cryptoService) { return new EncryptedStringTypeHandler(cryptoService); } // 注意仅仅声明为BeanMyBatis不会自动用它处理所有String。 // 我们需要在Mapper XML中显式引用这个Bean。 }但是在 XML 中通过全类名引用typeHandler时MyBatis 会自己实例化它而不会使用 Spring 容器中的 Bean这导致CryptoService无法注入。为了解决这个问题我们需要使用MyBatis-Spring 的SpringBootVFS并确保TypeHandler本身是一个 Spring 组件或者采用另一种方式在SqlSessionFactoryBean中全局注册 TypeHandler。更优方案通过mybatis.type-handlers-package扫描并自动注册让EncryptedStringTypeHandler本身成为一个Component并确保它有一个默认构造器Spring会通过构造器注入CryptoService。然后在application.yml中配置扫描路径。Component MappedTypes(String.class) public class EncryptedStringTypeHandler extends BaseTypeHandlerString { private static CryptoService cryptoService; // 通过 Autowired 注入静态变量不推荐但可行或使用 ApplicationContextHolder。 // 推荐使用 setter 注入 Autowired public void setCryptoService(CryptoService cryptoService) { EncryptedStringTypeHandler.cryptoService cryptoService; } public EncryptedStringTypeHandler() { // 默认构造器MyBatis实例化时需要 } // ... 其他方法实现使用静态的 cryptoService }然后在application.yml中配置mybatis: type-handlers-package: com.example.handler # 你的TypeHandler所在包这样MyBatis 会扫描到这个类并注册。但这种方式下这个TypeHandler会对所有String类型与VARCHAR的映射生效这显然不是我们想要的。结论最可靠、最清晰的方式仍然是放弃全局注册和注解自动发现老老实实在 Mapper XML 中需要加密的字段上显式指定typeHandler。为此我们需要一个无需 Spring 注入、能自己获取CryptoService的TypeHandler。我们可以让CryptoService成为一个静态工具类或者使用单例模式。为了简单演示我们修改CryptoService为静态工具类风格生产环境请考虑更优雅的依赖管理。重构简化版的静态 CryptoUtil 和 TypeHandler// 加密工具类示例生产环境需完善 public class CryptoUtil { private static CryptoService cryptoService; // 由Spring在启动时初始化 public static void setCryptoService(CryptoService service) { cryptoService service; } public static String encrypt(String text) { return cryptoService.encrypt(text); } public static String decrypt(String text) { return cryptoService.decrypt(text); } } // 在某个 Configuration 或主类中初始化 PostConstruct public void initCryptoUtil() { CryptoUtil.setCryptoService(aesCryptoService); } // TypeHandler 修改为使用静态工具类 public class EncryptedStringTypeHandler extends BaseTypeHandlerString { Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) { ps.setString(i, CryptoUtil.encrypt(parameter)); } Override public String getNullableResult(ResultSet rs, String columnName) { String val rs.getString(columnName); return val ! null ? CryptoUtil.decrypt(val) : null; } // ... 其他 getNullableResult 方法类似 }这样TypeHandler就可以被 MyBatis 直接实例化并在 XML 中引用了。3. 编写 Mapper XML!-- UserMapper.xml -- mapper namespace“com.example.mapper.UserMapper” resultMap id“BaseResultMap” type“com.example.entity.User” id column“id” property“id”/ result column“username” property“username”/ result column“phone” property“phone” typeHandler“com.example.handler.EncryptedStringTypeHandler”/ result column“id_card” property“idCard” typeHandler“com.example.handler.EncryptedStringTypeHandler”/ /resultMap insert id“insert” parameterType“User” useGeneratedKeys“true” keyProperty“id” INSERT INTO user (username, phone, id_card) VALUES (#{username}, #{phone, typeHandlercom.example.handler.EncryptedStringTypeHandler}, #{idCard, typeHandlercom.example.handler.EncryptedStringTypeHandler}) /insert select id“selectById” resultMap“BaseResultMap” SELECT id, username, phone, id_card FROM user WHERE id #{id} /select !-- 注意如果需要根据加密字段查询参数也必须经过相同的TypeHandler处理 -- select id“selectByPhone” resultMap“BaseResultMap” SELECT id, username, phone, id_card FROM user WHERE phone #{phone, typeHandlercom.example.handler.EncryptedStringTypeHandler} /select /mapper4.4 第四步测试验证编写一个简单的单元测试或直接运行应用测试。SpringBootTest class UserMapperTest { Autowired private UserMapper userMapper; Test void testEncryption() { User user new User(); user.setUsername(“testUser”); user.setPhone(“13800138000”); user.setIdCard(“110101199001011234”); userMapper.insert(user); User fetchedUser userMapper.selectById(user.getId()); System.out.println(“插入后查询:”); System.out.println(“手机号: ” fetchedUser.getPhone()); // 应显示明文 13800138000 System.out.println(“身份证: ” fetchedUser.getIdCard()); // 应显示明文 110101199001011234 // 直接查数据库phone和id_card列应该是Base64编码的密文 // 测试等值查询 User userByPhone userMapper.selectByPhone(“13800138000”); assertThat(userByPhone).isNotNull(); assertThat(userByPhone.getUsername()).isEqualTo(“testUser”); } }运行测试观察数据库中的数据是否为密文而程序读取出来的实体字段是否为明文。如果一切正常说明字段级加密方案成功运行。5. 常见问题、进阶优化与排查技巧在实际使用中你肯定会遇到一些坑。下面是我在项目中总结的一些经验和解决方案。5.1 常见问题速查表问题现象可能原因解决方案插入数据时报错InvalidEncryptedTextException或解密失败1. 加密密钥或IV与解密时不一致。2. 数据库字段长度不足密文被截断。3. 加密后包含特殊字符在传输或存储时被修改。1. 检查配置确保加解密服务使用的密钥/IV完全相同。2. 将数据库字段类型改为VARCHAR或TEXT并预留足够长度AES加密后Base64长度会增加。3. 确保连接池、数据库驱动没有对字符串做不必要的转义。使用Base64编码可避免大部分特殊字符问题。查询时返回明文但数据库里是明文未加密1. Mapper XML 中未在对应字段的#{}或result上指定typeHandler。2.TypeHandler未正确注册或生效。1. 仔细检查 Mapper XML确保插入/更新和结果映射都指定了typeHandler。2. 检查typeHandler类的全限定名是否正确以及是否在类路径下。查询时返回null1.TypeHandler的getNullableResult方法中解密过程抛出异常被吞没。2. 数据库该字段本身就是NULL。1. 在TypeHandler的解密方法中添加日志或断点查看解密前的密文值是否正确。2. 检查 SQL 查询结果。日志中显示SQL参数已是密文但执行报错密文可能包含 SQL 保留字符如单引号导致 SQL 语句语法错误。MyBatis 的#{}语法使用的是 PreparedStatement参数会被正确转义通常不会有此问题。如果使用${}拼接SQL则绝对禁止必须改为#{}。新增字段加密正常但历史明文数据查询失败TypeHandler默认对所有经过它的数据尝试解密历史明文数据不符合密文格式导致解密异常。实现版本化或标识化。例如在密文前加上前缀{AES}在TypeHandler中判断如果有前缀则解密否则直接返回原值。这需要数据迁移或双写支持。5.2 进阶优化方案1. 平滑处理已存在的历史数据这是上线时最头疼的问题。方案是采用“版本标识”。修改CryptoUtil.encrypt在密文前加上一个版本标识如{v1}。修改TypeHandler的decryptIfNotNull方法判断字符串是否以{v1}开头如果是则去掉标识后解密否则直接返回原字符串即历史明文。对于历史数据可以分批跑迁移脚本读取明文加密后加上标识写回。2. 支持多种加密算法定义加密算法枚举在EncryptedField注解中增加algorithm()属性。public interface EncryptedField { Algorithm algorithm() default Algorithm.AES; } enum Algorithm { AES, SM4 }在TypeHandler中需要通过某种方式如从线程上下文、或解析密文头获取当前字段应使用的算法然后从一个MapAlgorithm, CryptoService中选择对应的服务进行加解密。这需要更复杂的TypeHandler工厂模式。3. 与 MyBatis-Plus 集成如果你使用 MyBatis-Plus过程类似。你可以在MetaObjectHandler自动填充器中尝试处理但更推荐的方式仍然是使用 MyBatis 原生的TypeHandler。MyBatis-Plus 完全兼容 MyBatis 的配置只需在实体类字段上使用 MP 的TableField注解指定typeHandler即可TableField(typeHandler EncryptedStringTypeHandler.class) private String phone;这样配置更加简洁无需修改 XML。4. 性能考量加解密是 CPU 密集型操作。如果单次操作数据量极大如导出全表可能会对服务端造成压力。建议在数据库连接池配置和 MyBatis 执行器层面避免超大规模的批量操作一次性解密。对于列表查询如果列表很长且包含多个加密字段解密开销需要关注。可以考虑在TypeHandler中加入简单的缓存如使用 ThreadLocal 缓存当前结果集的解密结果但要小心线程安全和内存泄漏。5.3 排查技巧实录场景测试环境加密正常上线后部分用户数据解密失败。第一步检查密钥一致性立刻确认生产环境配置文件中的密钥是否与测试环境不同。确保构建部署流程没有覆盖或错误替换配置文件。第二步检查数据库编码曾经遇到过一次因为数据库表字段是latin1编码存储 Base64 密文中的某些字符如在某种传输环境下被错误转换导致解密时 Base64 解码失败。将字段编码改为utf8mb4后解决。第三步查看完整密文从数据库直接复制出密文字段的值在本地写一个简单的解密测试程序用同样的密钥解密。如果本地成功说明问题可能出在应用读取数据库的过程如结果集处理如果本地也失败则问题在密文本身或密钥。第四步开启详细日志在TypeHandler的setParameter和getResult方法中加入DEBUG级别日志打印出入参和出参的摘要注意不要打印完整密文到日志以防泄露。这能帮你确定加解密发生在哪个环节。字段级加密是一个在安全性和便利性之间取得平衡的优秀方案。它不能防御所有类型的攻击如数据库文件被窃取后的离线破解但能极大增加拖库后的数据利用难度符合“纵深防御”的安全原则。实现的关键在于理解 MyBatis 类型处理器的工作机制并妥善处理好密钥管理和历史数据迁移问题。希望这份详细的指南能帮助你顺利落地这一功能。