在业务系统中,数据库里经常会存储一些敏感信息,例如:
手机号;
邮箱;
身份证号;
银行卡号;
用户地址;
其他个人隐私数据。
如果这些数据直接以明文形式存储在数据库中,一旦数据库泄露,影响会非常严重。
比较理想的做法是:
数据入库前自动加密,查询出来后自动解密。
业务代码仍然像操作普通字段一样使用,不需要在每个 Service 方法里手写加解密逻辑。
本文记录一种基于 注解 + MyBatis 拦截器 的字段级加解密实现方式。
一、传统手动加解密的问题
最直接的做法,是在业务代码中手动加密和解密。
例如查询用户时:
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));
newUser.setIdCard(encrypt(idCard));
userMapper.insert(newUser);
这种写法可以实现功能,但问题很明显。
1. 代码重复
每个涉及敏感字段的查询、新增、修改逻辑,都要手动处理加解密。
字段一多,代码里会充斥大量重复逻辑。
2. 容易遗漏
如果某个查询场景忘了解密,前端可能直接看到密文。
如果某个写入场景忘了加密,数据库里又可能出现明文数据。
3. 维护成本高
后续新增一个敏感字段时,需要检查所有相关业务方法。
只要漏掉一个入口,就可能产生数据安全问题。
4. 业务代码被污染
加解密本质上是基础设施能力,不应该散落在业务逻辑里。
业务层更应该关注业务本身,而不是关心某个字段什么时候加密、什么时候解密。
二、目标方案
希望实现的效果是:
在实体类字段上加一个注解;
入库前自动加密;
查询后自动解密;
业务代码不感知加解密逻辑;
加解密逻辑集中管理;
可以通过配置控制是否启用。
最终使用方式类似这样:
public class User {
private Long id;
private String username;
@Encrypted
private String phone;
@Encrypted
private String email;
@Encrypted
private String idCard;
}
业务代码仍然按明文使用:
User user = new User();
user.setUsername("张三");
user.setPhone("13812345678");
user.setEmail("zhangsan@example.com");
user.setIdCard("110101199001011234");
userMapper.insert(user);
插入数据库时,拦截器自动加密。
查询数据时,拦截器自动解密。
三、整体设计思路
整体流程如下:
业务代码
↓
MyBatis Mapper
↓
MyBatis 自定义拦截器
↓
自动加密 / 自动解密
↓
数据库
核心思路是:
使用
@Encrypted注解标记需要加解密的字段;使用 MyBatis 拦截器拦截
update和query方法;对新增、修改参数中的敏感字段进行加密;
对查询结果中的敏感字段进行解密;
业务层不直接调用加密或解密工具。
四、定义加密标记注解
先定义一个字段注解,用来标记哪些字段需要自动加解密。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Encrypted {
/**
* 是否支持模糊查询。
* 本文只处理普通加解密,模糊查询场景需要单独设计。
*/
boolean supportFuzzyQuery() default false;
}这里使用:
@Target(ElementType.FIELD)表示注解只能作用在字段上。
使用:
@Retention(RetentionPolicy.RUNTIME)表示运行时可以通过反射读取该注解。
五、实体类使用示例
在敏感字段上添加 @Encrypted 注解。
public class User {
private Long id;
/**
* 用户名,非敏感字段,不加密
*/
private String username;
/**
* 手机号,敏感字段
*/
@Encrypted
private String phone;
/**
* 邮箱,敏感字段
*/
@Encrypted
private String email;
/**
* 身份证号,敏感字段
*/
@Encrypted
private String idCard;
// getter / setter 省略
}
这样,后续 MyBatis 拦截器只需要扫描实体类中带有 @Encrypted 的字段即可。
六、AES-GCM 加解密工具类
加解密算法这里使用 AES-GCM。
GCM 模式相比普通 CBC 模式的好处是,它自带完整性校验,可以发现密文是否被篡改。
示例工具类如下:
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
public class CryptoUtil {
private static final String ALGORITHM = "AES/GCM/NoPadding";
/**
* GCM 推荐 12 字节 IV
*/
private static final int IV_LENGTH = 12;
/**
* 示例密钥。
* 生产环境不要硬编码,应从配置中心、环境变量或密钥管理系统读取。
*/
private static final SecretKey SECRET_KEY = new SecretKeySpec(
"1234567890123456".getBytes(StandardCharsets.UTF_8),
"AES"
);
public static String encrypt(String plaintext) {
if (plaintext == null || plaintext.isEmpty()) {
return plaintext;
}
try {
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmParams = new GCMParameterSpec(128, iv);
cipher.init(Cipher.ENCRYPT_MODE, SECRET_KEY, gcmParams);
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
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);
} catch (Exception e) {
throw new RuntimeException("AES 加密失败", e);
}
}
public static String decrypt(String encryptedText) {
if (encryptedText == null || encryptedText.isEmpty()) {
return encryptedText;
}
try {
byte[] encryptedData = Base64.getDecoder().decode(encryptedText);
byte[] iv = Arrays.copyOfRange(encryptedData, 0, IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(encryptedData, IV_LENGTH, encryptedData.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmParams = new GCMParameterSpec(128, iv);
cipher.init(Cipher.DECRYPT_MODE, SECRET_KEY, gcmParams);
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("AES 解密失败", e);
}
}
public static boolean isEncrypted(String value) {
if (value == null) {
return false;
}
return value.length() > (IV_LENGTH + 8) && isBase64(value);
}
private static boolean isBase64(String value) {
try {
Base64.getDecoder().decode(value);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}
加密后的数据结构是:
Base64(IV + 密文)解密时先 Base64 解码,然后取前 12 字节作为 IV,剩余部分作为密文。
七、实现 MyBatis 拦截器
接下来实现核心的 MyBatis 拦截器。
它需要做两件事:
拦截
update,对入库数据加密;拦截
query,对查询结果解密。
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;
import java.util.Properties;
@Intercepts({
@Signature(
type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class}
),
@Signature(
type = Executor.class,
method = "query",
args = {
MappedStatement.class,
Object.class,
org.apache.ibatis.session.RowBounds.class,
org.apache.ibatis.session.ResultHandler.class
}
)
})
public class EncryptionInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(EncryptionInterceptor.class);
@Override
public Object intercept(Invocation invocation) throws Throwable {
String methodName = invocation.getMethod().getName();
if ("update".equals(methodName)) {
Object parameter = getParameter(invocation);
if (shouldEncrypt(parameter)) {
encryptFields(parameter);
}
}
Object result = invocation.proceed();
if ("query".equals(methodName)) {
decryptResult(result);
}
return result;
}
private Object getParameter(Invocation invocation) {
Object[] args = invocation.getArgs();
if (args.length >= 2) {
return args[1];
}
return null;
}
private boolean shouldEncrypt(Object parameter) {
if (parameter == null) {
return false;
}
Class<?> clazz = parameter.getClass();
return !isBasicType(clazz)
&& !(parameter instanceof Map)
&& !(parameter instanceof Collection);
}
private boolean isBasicType(Class<?> clazz) {
return clazz.isPrimitive()
|| clazz == String.class
|| clazz == Integer.class
|| clazz == Long.class
|| clazz == Double.class
|| clazz == Boolean.class;
}
private void encryptFields(Object obj) {
if (obj == null) {
return;
}
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
if (!field.isAnnotationPresent(Encrypted.class)) {
continue;
}
try {
field.setAccessible(true);
Object value = field.get(obj);
if (value instanceof String && !CryptoUtil.isEncrypted((String) value)) {
String encryptedValue = CryptoUtil.encrypt((String) value);
field.set(obj, encryptedValue);
log.debug("字段 {} 加密完成", field.getName());
}
} catch (Exception e) {
log.error("字段 {} 加密失败", field.getName(), e);
}
}
}
private void decryptResult(Object result) {
if (result instanceof Collection) {
for (Object item : (Collection<?>) result) {
decryptFields(item);
}
} else if (result != null) {
decryptFields(result);
}
}
private void decryptFields(Object obj) {
if (obj == null) {
return;
}
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
if (!field.isAnnotationPresent(Encrypted.class)) {
continue;
}
try {
field.setAccessible(true);
Object value = field.get(obj);
if (value instanceof String && CryptoUtil.isEncrypted((String) value)) {
String decryptedValue = CryptoUtil.decrypt((String) value);
field.set(obj, decryptedValue);
log.debug("字段 {} 解密完成", field.getName());
}
} catch (Exception e) {
log.error("字段 {} 解密失败", field.getName(), e);
}
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以在这里读取插件配置
}
}这里有几个关键点。
1. update 时加密
MyBatis 的 update 方法会覆盖:
insert;
update;
delete。
这里主要关注 insert 和 update 场景。
if ("update".equals(methodName)) {
Object parameter = getParameter(invocation);
if (shouldEncrypt(parameter)) {
encryptFields(parameter);
}
}2. query 后解密
查询执行完成后,对返回结果进行解密:
Object result = invocation.proceed();
if ("query".equals(methodName)) {
decryptResult(result);
}如果返回的是集合,就逐个对象解密。
如果返回的是单个对象,就直接解密该对象。
3. 只处理带注解的字段
if (field.isAnnotationPresent(Encrypted.class)) {
// 加解密
}这样可以避免影响普通字段。
八、注册 MyBatis 拦截器
可以通过 Spring Boot 自动配置注册拦截器。
import org.apache.ibatis.session.Configuration;
import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
@org.springframework.context.annotation.Configuration
@ConditionalOnProperty(
prefix = "encryption",
name = "enabled",
havingValue = "true"
)
public class EncryptionAutoConfiguration {
@Bean
public ConfigurationCustomizer encryptionConfigurationCustomizer() {
return new ConfigurationCustomizer() {
@Override
public void customize(Configuration configuration) {
configuration.addInterceptor(new EncryptionInterceptor());
}
};
}
}然后在配置文件中启用:
encryption:
enabled: true这样项目启动后,MyBatis 会自动加载这个加解密拦截器。
九、业务代码使用效果
业务层不需要关心加解密。
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserMapper userMapper;
public UserService(UserMapper userMapper) {
this.userMapper = userMapper;
}
public void createUser() {
User user = new User();
user.setUsername("张三");
user.setPhone("13812345678");
user.setEmail("zhangsan@example.com");
user.setIdCard("110101199001011234");
userMapper.insert(user);
}
public User getUser(Long id) {
User user = userMapper.findById(id);
System.out.println("手机号:" + user.getPhone());
System.out.println("邮箱:" + user.getEmail());
return user;
}
}
业务层看到的是明文。
数据库里存储的是密文。
例如:
这样可以减少业务代码中的加解密逻辑,也能降低遗漏风险。
十、生产环境中的密钥管理
示例中的密钥是硬编码:
"1234567890123456"这只适合演示。
生产环境不要把密钥写死在代码里。
更推荐的方式是从配置中心、环境变量或密钥管理系统中读取。
例如定义配置类:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
@Configuration
public class EncryptionConfig {
@Value("${encryption.key}")
private String encryptionKeyBase64;
@Bean
public SecretKey secretKey() {
byte[] keyBytes = Base64.getDecoder().decode(encryptionKeyBase64);
return new SecretKeySpec(keyBytes, "AES");
}
}
配置文件中使用 Base64 编码后的密钥:
encryption:
enabled: true
key: MTIzNDU2Nzg5MDEyMzQ1Ng==需要注意:
AES 密钥长度需要是 16、24 或 32 字节分别对应 AES-128、AES-192、AES-256。
十一、日志中的敏感数据要脱敏
字段加密解决的是数据库存储问题。
但如果业务日志里直接打印明文敏感数据,仍然会有泄露风险。
例如:
log.info("用户手机号:{}", user.getPhone());如果此时 user.getPhone() 已经被解密,就会把明文手机号写入日志。
因此日志输出时需要脱敏。
示例工具类:
import org.apache.commons.lang3.StringUtils;
public class SensitiveDataMaskUtil {
public static String maskPhone(String phone) {
if (StringUtils.isBlank(phone) || phone.length() != 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
public static String maskEmail(String email) {
if (StringUtils.isBlank(email)) {
return email;
}
int atIndex = email.indexOf("@");
if (atIndex <= 2) {
return "***" + email.substring(atIndex);
}
return email.substring(0, 2) + "***" + email.substring(atIndex);
}
public static String maskIdCard(String idCard) {
if (StringUtils.isBlank(idCard) || idCard.length() != 18) {
return idCard;
}
return idCard.substring(0, 6) + "********" + idCard.substring(14);
}
}使用时:
log.info("用户手机号:{}", SensitiveDataMaskUtil.maskPhone(user.getPhone()));
这样可以避免日志系统中保存明文敏感信息。
十二、这种方案的优点
1. 业务代码低侵入
业务代码不需要手动调用:
encrypt()
decrypt()只需要在字段上加注解:
@Encrypted
private String phone;
2. 加解密逻辑集中管理
所有加解密逻辑都集中在:
CryptoUtil;EncryptionInterceptor;自动配置类。
后续修改算法或密钥管理方式时,不需要改大量业务代码。
3. 使用方式直观
业务层仍然像操作明文字段一样写代码。
拦截器负责在 MyBatis 执行前后自动处理。
4. 可以通过配置开关控制
通过配置:
encryption:
enabled: true
可以控制是否启用字段级加解密。
十三、需要注意的问题
1. 模糊查询不适合直接使用随机 IV 加密
AES-GCM 每次加密都会生成随机 IV。
同一个明文多次加密,得到的密文通常不同。
例如同一个手机号:
13812345678多次加密后密文可能都不一样。
这对安全性是有好处的,但也意味着数据库不能直接用密文做普通的模糊查询。
例如:
WHERE phone LIKE '%138%'这种查询无法直接基于随机密文实现。
如果业务强依赖模糊查询,需要单独设计方案,例如:
额外存储脱敏字段;
额外存储 Hash 索引字段;
使用可搜索加密方案;
将模糊查询改为精确查询;
使用专门的安全检索方案。
2. 排序和聚合会受影响
加密后的字段不再保留原始语义。
因此不适合直接做:
ORDER BY phone
GROUP BY id_card
COUNT(DISTINCT email)这类基于明文语义的数据库操作。
3. 拦截器示例没有覆盖所有 MyBatis 参数形式
示例代码主要处理普通实体对象。
实际项目中,MyBatis 参数可能是:
Map;@Param包装对象;批量集合;
MyBatis-Plus Wrapper;
XML 中复杂参数;
嵌套对象。
如果要用于生产,需要结合项目实际参数形式继续增强。
4. isEncrypted() 判断只是简单判断
示例中的:
CryptoUtil.isEncrypted()只是通过 Base64 和长度做简单判断。
这并不能百分百准确区分明文和密文。
在生产中可以考虑给密文增加固定前缀,例如:
ENC(...)或者使用更明确的版本标识:
v1:base64(...)这样更容易判断字段是否已经加密,也方便后续密钥轮换和算法升级。
5. 解密后的对象不要直接写回数据库
查询结果被拦截器解密后,实体对象里保存的是明文。
如果后续直接拿这个对象更新数据库,需要确保更新前再次加密。
当前方案通过拦截 update 可以处理一部分场景,但复杂对象、Map 参数、Wrapper 更新等情况仍然要重点测试。
十四、适用场景
这种方案适合:
用户中心系统;
支付系统;
医疗信息系统;
需要保护手机号、邮箱、身份证号等敏感字段的业务系统;
希望业务代码低侵入接入字段加密的项目。
尤其适合这类字段:
只展示
只精确读取
不需要数据库侧模糊查询
不需要排序或聚合十五、不适合的场景
这种方案不太适合:
对性能要求极高的超高频读写接口;
需要对加密字段做大量模糊查询的场景;
需要直接对加密字段排序、分组、聚合的场景;
超大规模数据场景下没有做性能评估的系统;
对密钥轮换、审计、合规要求非常严格,但没有配套密钥管理体系的项目。
如果是强合规场景,字段加密只是其中一部分,还需要结合:
密钥管理;
访问控制;
日志脱敏;
数据库权限;
审计记录;
备份加密;
密钥轮换机制。
结论
字段级加密的核心目标是:
数据库存密文,业务层用明文。
手动在业务代码里写加解密逻辑虽然简单,但容易造成重复代码和遗漏问题。
基于 @Encrypted 注解和 MyBatis 拦截器,可以把加解密逻辑下沉到框架层:
敏感字段使用
@Encrypted标记;插入和更新前自动加密;
查询结果返回后自动解密;
业务代码不直接感知加解密;
加密算法、密钥管理、日志脱敏集中处理。
这种方案可以降低业务侵入性,也能提升敏感数据存储的安全性。
对于手机号、邮箱、身份证号这类敏感字段,可以通过“注解标记 + MyBatis 拦截器”的方式实现自动加解密,让数据库保存密文,同时让业务代码保持明文使用体验。