在数据安全日益重要的今天,保护用户的敏感信息(如手机号、身份证号、银行卡号)是每个开发者的重要职责。直接将这类信息明文存储在数据库中,一旦发生数据泄露,后果不堪设想。
传统的做法是在每个业务代码中进行手动加解密,但这会导致代码重复、逻辑分散且容易遗漏。本文将介绍一种基于注解的自动化加解密方案,通过 Spring Boot + MyBatis 实现,让敏感字段的加密存储与解密使用对业务代码完全透明。

传统加密方式面临的问题
1. 代码高度重复
在每个涉及敏感数据的查询和插入操作中,都需要手动调用加解密方法,导致相同逻辑遍布各处。
// 查询时需要手动解密
User user = userMapper.findById(id);
user.setPhone(decrypt(user.getPhone()));
user.setEmail(decrypt(user.getEmail()));
user.setIdCard(decrypt(user.getIdCard()));
return user;
// 插入时需要手动加密
User newUser = new User();
newUser.setPhone(encrypt(phone));
newUser.setEmail(encrypt(email));
userMapper.insert(newUser);
2. 维护与修改困难
- 新增一个需要加密的字段时,需要修改所有相关的业务方法。
- 极易在某个查询或更新操作中遗漏加解密步骤。
- 加解密逻辑与业务逻辑深度耦合,代码可读性差。
3. 维护成本高昂
- 加密逻辑分散在各个业务层,难以统一管理和监控。
- 出现问题(如加解密异常)时,排查链路长,定位困难。
- 新成员理解业务时,还需额外理解分散的加密规则。
理想的解决方案
我们期望的解决方案应具备以下特点:
- 声明式:通过简单的注解标记需要加密的字段。
- 自动化:数据的加密存储与解密读取过程自动完成,无需业务层干预。
- 非侵入:业务代码无需感知加密逻辑的存在。
- 高性能:加解密过程对系统性能影响可控。
解决方案设计
核心思路
- 注解标记:使用自定义的
@Encrypted 注解标注实体类中的敏感字段。
- 拦截处理:利用 MyBatis拦截器 在SQL执行前后自动进行加解密操作。
- 透明操作:对上层业务代码而言,整个加解密过程是无感的。
技术架构
业务层代码 -> MyBatis Mapper -> 拦截器(自动加/解密) -> 数据库
本质是在数据持久化层(MyBatis)与数据库之间,插入一个透明的加解密处理层。
核心实现步骤
1. 定义加密注解
首先创建一个用于标记加密字段的注解。
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Encrypted {
// 是否支持模糊查询(高级功能,此处示例不展开)
boolean supportFuzzyQuery() default false;
}
在实体类中的使用方式:
public class User {
private Long id;
private String username;
@Encrypted // 此字段将被自动加密存储和解密返回
private String phone;
@Encrypted
private String email;
}
2. 实现加密工具类
采用 AES-GCM 算法进行加密,该算法能同时保证机密性和完整性。
public class CryptoUtil {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int IV_LENGTH = 12; // GCM推荐IV长度为12字节
private static final SecretKey secretKey = // 密钥应从安全配置获取,此处简化
public static String encrypt(String plaintext) throws Exception {
// 生成随机初始化向量(IV)
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
// 执行加密
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(128, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// 组合IV与密文,并进行Base64编码存储
byte[] encryptedData = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, encryptedData, 0, iv.length);
System.arraycopy(ciphertext, 0, encryptedData, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(encryptedData);
}
public static String decrypt(String encryptedText) throws Exception {
// Base64解码
byte[] encryptedData = Base64.getDecoder().decode(encryptedText);
// 分离IV和密文
byte[] iv = Arrays.copyOfRange(encryptedData, 0, IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(encryptedData, IV_LENGTH, encryptedData.length);
// 执行解密
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, iv));
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
}
// 简单判断字符串是否已被加密(根据特定格式,实际可根据需求调整)
public static boolean isEncrypted(String value) {
try {
if (value == null) return false;
byte[] data = Base64.getDecoder().decode(value);
// 简单判断长度,一个有效的“IV+密文”结构应大于IV长度
return data.length > IV_LENGTH;
} catch (IllegalArgumentException e) {
return false; // 非Base64字符串,视为未加密
}
}
}
加密后的数据格式为:Base64(IV + 密文)。
3. 实现MyBatis拦截器
这是整个方案的核心,它负责在数据入库前加密、在查询出库后解密。
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
@Slf4j
public class EncryptionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
String methodName = invocation.getMethod().getName();
// 处理 UPDATE/INSERT 操作:对参数对象进行加密
if ("update".equals(methodName)) {
Object parameter = invocation.getArgs()[1];
encryptFields(parameter);
}
// 执行原始SQL逻辑
Object result = invocation.proceed();
// 处理 SELECT 操作:对查询结果进行解密
if ("query".equals(methodName)) {
decryptResult(result);
}
return result;
}
private void encryptFields(Object obj) {
if (obj == null) return;
// 仅处理实体对象,忽略基本类型、Map、集合等
if (obj instanceof Map || obj instanceof Collection || isBasicType(obj.getClass())) {
return;
}
processFields(obj, true);
}
private void decryptResult(Object result) {
if (result instanceof List) {
for (Object item : (List<?>) result) {
processFields(item, false);
}
} else if (result != null) {
processFields(result, false);
}
}
private void processFields(Object obj, boolean isEncrypt) {
Class<?> clazz = obj.getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(Encrypted.class)) {
try {
field.setAccessible(true);
Object value = field.get(obj);
if (value instanceof String) {
String strValue = (String) value;
boolean alreadyEncrypted = CryptoUtil.isEncrypted(strValue);
if (isEncrypt && !alreadyEncrypted) {
// 加密:仅加密未加密的明文
String encrypted = CryptoUtil.encrypt(strValue);
field.set(obj, encrypted);
} else if (!isEncrypt && alreadyEncrypted) {
// 解密:仅解密切实是密文的数据
String decrypted = CryptoUtil.decrypt(strValue);
field.set(obj, decrypted);
}
// 其他情况(如重复加密/解密)保持原样
}
} catch (Exception e) {
log.error("处理加密字段[{}]时发生异常", field.getName(), e);
}
}
}
}
private boolean isBasicType(Class<?> clazz) {
return clazz.isPrimitive() || clazz == String.class || Number.class.isAssignableFrom(clazz) || clazz == Boolean.class;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可读取自定义配置
}
}
4. 自动配置
通过Spring Boot的自动配置机制,方便地启用该拦截器。
@Configuration
@ConditionalOnProperty(name = "app.encryption.enabled", havingValue = "true", matchIfMissing = true)
public class EncryptionAutoConfiguration {
@Bean
public ConfigurationCustomizer encryptionConfigurationCustomizer() {
return configuration -> {
configuration.addInterceptor(new EncryptionInterceptor());
};
}
}
在 application.yml 中简单配置即可启用:
app:
encryption:
enabled: true
方案效果演示
数据库存储对比
原始明文数据:
姓名: 张三
手机: 13812345678
邮箱: zhangsan@example.com
数据库实际存储(加密后):
姓名: 张三
手机: nTuVgMWime1:hFGa9as6JHxLT2vG8dpiRmu4wtxDnkTEr/1x (示例,非真实加密结果)
邮箱: mK7pL9xQ2rS8vN3w:jKxL9mN2pQ7rS8vT3wX4yZ6aB8cD1eF2g
业务层代码使用
业务代码完全无需处理加解密细节,与操作普通字段无异。
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
// 保存用户:手机号和邮箱在入库时会被拦截器自动加密
public void createUser(User user) {
user.setPhone("13812345678");
user.setEmail("zhangsan@example.com");
userMapper.insert(user); // 加密在insert执行前自动发生
}
// 查询用户:查询结果中的加密字段会被拦截器自动解密
public User getUser(Long id) {
User user = userMapper.findById(id);
System.out.println(user.getPhone()); // 输出明文:13812345678
System.out.println(user.getEmail()); // 输出明文:zhangsan@example.com
return user;
}
}
关键的安全考量
1. 密钥管理
切勿将密钥硬编码在代码中。应从安全的环境变量、配置中心或专业的密钥管理服务(如HashiCorp Vault, AWS KMS)中获取。
@Configuration
public class EncryptionConfig {
@Value("${app.encryption.secret-key}")
private String base64EncodedKey;
@Bean
public SecretKey secretKey() {
byte[] keyBytes = Base64.getDecoder().decode(base64EncodedKey);
// 确保密钥长度符合AES要求(如128, 192, 256位)
return new SecretKeySpec(keyBytes, "AES");
}
}
2. 日志脱敏
避免敏感信息在日志中明文输出,应使用脱敏工具进行处理。
@Component
public class SensitiveDataMasker {
public String maskPhone(String phone) {
if (phone == null || phone.length() != 11) return phone;
return phone.substring(0, 3) + "****" + phone.substring(7);
}
public String maskEmail(String email) {
if (email == null) return email;
int atIndex = email.indexOf("@");
if (atIndex <= 2) return "***" + email.substring(atIndex);
return email.substring(0, 2) + "***" + email.substring(atIndex);
}
}
总结与适用场景
方案优势
- 开发友好:通过注解声明,接入成本极低。
- 职责清晰:加解密逻辑集中在拦截器,与业务代码解耦。
- 安全合规:采用 AES-GCM 等强加密算法,满足一般性数据安全要求。
- 易于维护:加密策略统一管理,修改和扩展方便。
适用场景
- 用户中心(保护手机、邮箱、身份证号)
- 支付系统(保护银行卡号、CVV)
- 医疗健康系统(保护病历、健康信息)
- 任何涉及个人敏感信息(PII)存储的业务系统
局限性
- 性能开销:加解密操作会带来额外的CPU消耗,在高并发、大数据量场景下需进行性能评估。
- 查询限制:加密后的数据无法直接进行数据库层面的模糊查询(LIKE)、范围查询和排序。若需此类功能,需设计更复杂的方案(如盲索引)。
- 迁移成本:对已有明文数据的存量系统进行改造时,需要考虑数据迁移方案。
总的来说,这种基于 MyBatis拦截器 的注解驱动加密方案,为Spring Boot应用提供了一种优雅、非侵入的字段级数据保护手段,非常适合在安全合规要求下,对特定敏感字段进行透明加解密的场景。
代码仓库
完整示例代码可参考:
https://github.com/yuboon/java-examples/tree/master/springboot-column-encryption