文章目录

三级等保要求敏感字段(手机号、身份证、银行卡等)在数据库里不能明文存储。SmartOA 的方案是 @EncryptField 注解 + MyBatis 拦截器:写库自动加密、读库自动解密,业务代码无感。但实现过程中踩了一个隐蔽的坑——拦截器没加入 MyBatis 插件链,导致解密静默失效。这篇记录完整方案与踩坑过程。

一、方案设计:注解驱动,业务无感

目标:业务代码里正常读写字段,不感知加密存在。设计三层:

1
Entity 字段打注解:需要加密的字段标记 @EncryptField
@EncryptField
↓
2
MyBatis 拦截器:拦截 INSERT/UPDATE 自动加密,拦截 SELECT 自动解密
Interceptor
↓
3
Entity → VO 转换层:确保出参是明文、入参自动解密
convert

二、核心实现:拦截器

// 加密字段注解
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptField { }

// Entity 用法
public class Employee {
    private Long employeeId;
    @EncryptField
    private String phone;      // 写库加密,读库解密
    @EncryptField
    private String idCard;     // 敏感字段
}

拦截器核心逻辑:解析 Entity 的 @EncryptField 字段集合,在 update/insert 时加密参数值,在 query 时解密结果集。加密算法用 AES-GCM(带随机 IV,同一明文每次密文不同,防碰撞分析)。

三、坑 1:拦截器没进插件链,解密静默失效

最隐蔽的坑:加密拦截器注册了,但没加入 MyBatis 的插件链。表现是——写库字段确实加密了(因为拦截器恰好被某种方式触达),但读库时不解密,接口返回密文;更糟的是有些路径看起来正常(缓存路径读到的是解密前的旧明文),线上表现忽好忽坏。

🚨 坑:MyBatis 拦截器必须显式加入插件链(mybatis-plus.configuration.add-interceptor 或 Spring 配置注入),否则 @Intercepts 注解不会自动生效。验证方法:单测里直接断言「插入后查出来的值是明文」。
// 修复:把加密拦截器加入 MyBatis 插件链
@Configuration
public class MybatisConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(EncryptInterceptor encrypt) {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(encrypt);   // 之前漏了这行
        return interceptor;
    }
}

修完后立刻发现另一个问题:拦截器执行顺序。加密拦截器要在分页插件之前(先加密再分页,否则分页 SQL 拿到的是密文条件)。

四、坑 2:Entity → VO 数据流重构

加密只是存储层的事,出参必须明文。原来 Controller 直接返回 Entity,加密字段会带着密文出去(或解密后返回但绕过缓存)。重构为数据流单向:Entity(密文)→ VO(明文):

// 出参:Entity → VO,敏感字段显式转换
public EmployeeVO toVO(Employee e) {
    EmployeeVO vo = new EmployeeVO();
    SmartBeanUtil.copyProperties(e, vo);
    // 拦截器已解密,VO 直接拿明文;若走了缓存路径则此处兜底解密
    vo.setPhone(decryptIfNeeded(e.getPhone()));
    return vo;
}

五、坑 3:存量数据迁移与 ENC 前缀

上线前数据库里已有明文存量数据。方案:

💡 注意:迁移任务开启时,写入路径要先关闭加密(enabled=false),否则迁移读出来的「明文」会被当作新数据再加密一次,产生双重加密。顺序:先关写入加密 → 迁移存量 → 再开写入加密。

六、坑 4:解密失败必须抛异常

审计发现解密逻辑存在「静默失败」:解密抛异常时 catch 住返回空串。这等于把「解密失败」伪装成「数据为空」——攻击者改一个密文字节,接口返回空而不是报错,绕过密码校验类逻辑(M3 修复)。

🚨 教训:解密失败必须抛 RuntimeException 让请求失败,绝不静默返回空串。安全链路里「吞异常」就是「开后门」。

七、密钥管理

加密密钥按环境隔离,禁止写死在 yaml 里:

环境密钥来源规则
prod / pre环境变量注入强制注入,无默认值(缺了直接启动失败)
dev / test环境变量 + fallback保留本地默认值方便开发
# application-prod.yaml:无默认值,缺了启动报错
api-encrypt:
  key: ${ENCRYPT_PASSWORD}

八、踩坑清单(可直接复用)

  1. 拦截器必须进插件链:注册 ≠ 生效,单测断言「插入后读出来是明文」
  2. 执行顺序:加密拦截器先于分页插件,否则密文条件参与分页
  3. Entity → VO 单向流:Entity 存密文,出参转 VO 明文,禁止 Entity 直接出参
  4. ENC 前缀 + 幂等迁移:存量明文识别前缀,迁移先关写入加密再开
  5. 解密失败抛异常:绝不静默返回空串
  6. 密钥环境隔离:prod 强制环境变量注入无默认值
✅ 最终成果:敏感字段写库加密、读库解密,业务无感;存量数据完成迁移;解密失败显式报错;密钥按环境隔离。2026-08 因运维需求关闭加密并将 ENC 存量解密回明文(TD-22),方案保留随时可再启用。

评 论