文章目录
三级等保要求敏感字段(手机号、身份证、银行卡等)在数据库里不能明文存储。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 只做存储映射,字段值保持密文
- Service 层 convert 成 VO 时解密(拦截器在查询层已解密则 VO 转换无需再处理)
- 统一
SmartBeanUtil.copyProperties+ 手动转换敏感字段,禁止 Entity 直接出参
// 出参: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 前缀
上线前数据库里已有明文存量数据。方案:
- 加密值加 ENC 前缀标记(
ENC:xxx),解密时识别前缀决定是否解密——没前缀的是旧明文,直接透传 - 启动时跑数据迁移任务(EncryptMigrationRunner):
enabled=true时自动扫描存量明文 → 加密写回 - 迁移任务幂等:已带 ENC 前缀的跳过,避免重复加密
💡 注意:迁移任务开启时,写入路径要先关闭加密(
enabled=false),否则迁移读出来的「明文」会被当作新数据再加密一次,产生双重加密。顺序:先关写入加密 → 迁移存量 → 再开写入加密。
六、坑 4:解密失败必须抛异常
审计发现解密逻辑存在「静默失败」:解密抛异常时 catch 住返回空串。这等于把「解密失败」伪装成「数据为空」——攻击者改一个密文字节,接口返回空而不是报错,绕过密码校验类逻辑(M3 修复)。
🚨 教训:解密失败必须抛 RuntimeException 让请求失败,绝不静默返回空串。安全链路里「吞异常」就是「开后门」。
七、密钥管理
加密密钥按环境隔离,禁止写死在 yaml 里:
| 环境 | 密钥来源 | 规则 |
|---|---|---|
| prod / pre | 环境变量注入 | 强制注入,无默认值(缺了直接启动失败) |
| dev / test | 环境变量 + fallback | 保留本地默认值方便开发 |
# application-prod.yaml:无默认值,缺了启动报错
api-encrypt:
key: ${ENCRYPT_PASSWORD}
八、踩坑清单(可直接复用)
- 拦截器必须进插件链:注册 ≠ 生效,单测断言「插入后读出来是明文」
- 执行顺序:加密拦截器先于分页插件,否则密文条件参与分页
- Entity → VO 单向流:Entity 存密文,出参转 VO 明文,禁止 Entity 直接出参
- ENC 前缀 + 幂等迁移:存量明文识别前缀,迁移先关写入加密再开
- 解密失败抛异常:绝不静默返回空串
- 密钥环境隔离:prod 强制环境变量注入无默认值
✅ 最终成果:敏感字段写库加密、读库解密,业务无感;存量数据完成迁移;解密失败显式报错;密钥按环境隔离。2026-08 因运维需求关闭加密并将 ENC 存量解密回明文(TD-22),方案保留随时可再启用。
评 论