每当项目进入安全合规阶段,总会听到这样的需求:"数据库里的身份证、手机号必须加密存储!"而且往往是业务已经开发了一半,突然被告知要改造,顿时头大。尤其使用 MyBatis Plus 这样的 ORM 框架时,如何在不影响现有代码的情况下实现加密存储、同时在前端展示时又要做脱敏,成了很多开发者的痛点。本文将分享一套实用的解决方案,帮你优雅地解决这一难题。

加密方案设计

加密算法选择

在选择加密算法时,我们需要综合考虑安全性、性能和易用性:

算法类型优点缺点适用场景
AES对称加密速度快、实现简单密钥管理挑战大批量敏感数据
RSA非对称加密安全性高加解密速度慢少量关键数据
SM4对称加密国密算法、安全合规库支持有限政务/金融系统

对于 MyBatis Plus 环境,我推荐使用AES-GCM 模式,它同时提供了加密和数据完整性验证,性能也相对较好。

flowchart LR
    A[明文数据] --> B[AES-GCM加密]
    B --> C[Base64编码]
    C --> D[存入数据库]
    D --> E[读取数据]
    E --> F[Base64解码]
    F --> G[AES-GCM解密]
    G --> H[原始数据]

以下是优化后的 AES-GCM 加密工具类实现:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public class AESEncryptor {
    private static final Logger log = LoggerFactory.getLogger(AESEncryptor.class);
    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_LENGTH = 16;

    // 检查密钥长度是否合法(AES要求16/24/32字节)
    private static void validateKey(String key) {
        int keyLength = key.getBytes().length;
        if (keyLength != 16 && keyLength != 24 && keyLength != 32) {
            throw new IllegalArgumentException("AES密钥长度必须为16/24/32字节");
        }
    }

    public static String encrypt(String plainText, String key) {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }

        try {
            validateKey(key);

            // 生成随机IV
            byte[] iv = new byte[GCM_IV_LENGTH];
            new java.security.SecureRandom().nextBytes(iv);

            // 初始化加密器
            SecretKey secretKey = new SecretKeySpec(key.getBytes(), "AES");
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);

            // 加密
            byte[] cipherText = cipher.doFinal(plainText.getBytes());

            // 组合IV和密文
            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);

            // Base64编码
            return Base64.getEncoder().encodeToString(encryptedData);
        } catch (Exception e) {
            log.error("数据加密失败", e);
            throw new DataEncryptException("加密操作异常", e);
        }
    }

    public static String decrypt(String encryptedText, String key) {
        if (encryptedText == null || encryptedText.isEmpty()) {
            return encryptedText;
        }

        try {
            validateKey(key);

            // Base64解码
            byte[] encryptedData = Base64.getDecoder().decode(encryptedText);

            // 提取IV
            byte[] iv = new byte[GCM_IV_LENGTH];
            System.arraycopy(encryptedData, 0, iv, 0, iv.length);

            // 提取密文
            byte[] cipherText = new byte[encryptedData.length - GCM_IV_LENGTH];
            System.arraycopy(encryptedData, GCM_IV_LENGTH, cipherText, 0, cipherText.length);

            // 初始化解密器
            SecretKey secretKey = new SecretKeySpec(key.getBytes(), "AES");
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);

            // 解密
            byte[] plainText = cipher.doFinal(cipherText);
            return new String(plainText);
        } catch (Exception e) {
            log.error("数据解密失败", e);
            throw new DataEncryptException("解密操作异常", e);
        }
    }
}

// 自定义加解密异常
class DataEncryptException extends RuntimeException {
    public DataEncryptException(String message, Throwable cause) {
        super(message, cause);
    }
}

支持多算法的加密接口

为了支持不同加密算法(如政务系统需要的 SM4 国密算法),我们可以设计一个通用接口:

public interface Encryptor {
    String encrypt(String plainText, String key);
    String decrypt(String cipherText, String key);
}

// AES实现
public class AESEncryptor implements Encryptor {
    @Override
    public String encrypt(String plainText, String key) {
        // 调用前面定义的AES加密方法
        return AESEncryptor.encrypt(plainText, key);
    }

    @Override
    public String decrypt(String cipherText, String key) {
        return AESEncryptor.decrypt(cipherText, key);
    }
}

// SM4国密实现
public class SM4Encryptor implements Encryptor {
    @Override
    public String encrypt(String plainText, String key) {
        // 实现SM4加密算法
        // ...
        return encryptedText;
    }

    @Override
    public String decrypt(String cipherText, String key) {
        // 实现SM4解密算法
        // ...
        return plainText;
    }
}

// 加密算法工厂
public class EncryptorFactory {
    public static Encryptor getEncryptor(String algorithm) {
        switch (algorithm.toUpperCase()) {
            case "AES":
                return new AESEncryptor();
            case "SM4":
                return new SM4Encryptor();
            default:
                throw new IllegalArgumentException("不支持的加密算法: " + algorithm);
        }
    }
}

密钥管理策略

密钥管理是安全系统的核心。以下是一个改进后的密钥管理器,支持密钥轮换:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

@Component
public class KeyManager {
    private final Map<String, String> currentKeys = new HashMap<>();
    private final Map<String, String> oldKeys = new HashMap<>();

    @Value("${encryption.algorithm:AES}")
    private String algorithm;

    @Value("${encryption.key.idcard}")
    private String idCardKey;

    @Value("${encryption.key.phone}")
    private String phoneKey;

    @Value("${encryption.key.bankcard}")
    private String bankCardKey;

    // 旧密钥(用于密钥轮换过渡期)
    @Value("${encryption.old.key.idcard:}")
    private String oldIdCardKey;

    @Value("${encryption.old.key.phone:}")
    private String oldPhoneKey;

    @Value("${encryption.old.key.bankcard:}")
    private String oldBankCardKey;

    private Encryptor encryptor;

    @PostConstruct
    public void init() {
        // 校验密钥是否配置
        if (StringUtils.isEmpty(idCardKey)) {
            throw new IllegalArgumentException("身份证加密密钥未配置");
        }
        if (StringUtils.isEmpty(phoneKey)) {
            throw new IllegalArgumentException("手机号加密密钥未配置");
        }
        if (StringUtils.isEmpty(bankCardKey)) {
            throw new IllegalArgumentException("银行卡加密密钥未配置");
        }

        // 初始化当前密钥
        currentKeys.put(KeyType.ID_CARD.name(), idCardKey);
        currentKeys.put(KeyType.PHONE.name(), phoneKey);
        currentKeys.put(KeyType.BANK_CARD.name(), bankCardKey);

        // 初始化旧密钥(如果存在)
        if (!StringUtils.isEmpty(oldIdCardKey)) {
            oldKeys.put(KeyType.ID_CARD.name(), oldIdCardKey);
        }
        if (!StringUtils.isEmpty(oldPhoneKey)) {
            oldKeys.put(KeyType.PHONE.name(), oldPhoneKey);
        }
        if (!StringUtils.isEmpty(oldBankCardKey)) {
            oldKeys.put(KeyType.BANK_CARD.name(), oldBankCardKey);
        }

        // 初始化加密算法
        this.encryptor = EncryptorFactory.getEncryptor(algorithm);
    }

    // 获取当前密钥
    public String getCurrentKey(KeyType keyType) {
        return currentKeys.get(keyType.name());
    }

    // 获取旧密钥(如果存在)
    public String getOldKey(KeyType keyType) {
        return oldKeys.get(keyType.name());
    }

    // 加密方法
    public String encrypt(String plainText, KeyType keyType) {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }
        return encryptor.encrypt(plainText, getCurrentKey(keyType));
    }

    // 解密方法(先尝试当前密钥,失败则尝试旧密钥)
    public String decrypt(String cipherText, KeyType keyType) {
        if (cipherText == null || cipherText.isEmpty()) {
            return cipherText;
        }

        try {
            // 先用当前密钥解密
            return encryptor.decrypt(cipherText, getCurrentKey(keyType));
        } catch (Exception e) {
            // 当前密钥解密失败,尝试旧密钥
            String oldKey = getOldKey(keyType);
            if (oldKey != null) {
                return encryptor.decrypt(cipherText, oldKey);
            }
            throw e; // 无旧密钥或旧密钥也解密失败,抛出异常
        }
    }

    // 密钥类型枚举
    public enum KeyType {
        ID_CARD, PHONE, BANK_CARD
    }
}

在实际生产环境中,密钥应当从专业的密钥管理系统(KMS)获取,而非直接存储在配置文件中。

基于注解的 TypeHandler 设计

自定义加密注解

首先,我们定义一个注解来标记需要加密的字段:

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 Encrypt {
    KeyManager.KeyType value();
}

通用加密 TypeHandler

然后实现统一的 TypeHandler,通过 Spring 获取 KeyManager:

import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

@Component
public class EncryptTypeHandler extends BaseTypeHandler<String> implements ApplicationContextAware {

    private static ApplicationContext applicationContext;
    private KeyManager keyManager;
    private KeyManager.KeyType keyType;

    public EncryptTypeHandler() {
        // 默认构造函数,由MyBatis初始化
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        EncryptTypeHandler.applicationContext = applicationContext;
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
            throws SQLException {
        ensureKeyManager();
        try {
            // 加密后存入数据库
            ps.setString(i, keyManager.encrypt(parameter, keyType));
        } catch (Exception e) {
            throw new SQLException("字段加密失败", e);
        }
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String value = rs.getString(columnName);
        return decryptValue(value);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String value = rs.getString(columnIndex);
        return decryptValue(value);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String value = cs.getString(columnIndex);
        return decryptValue(value);
    }

    private String decryptValue(String value) throws SQLException {
        if (value == null) {
            return null;
        }

        ensureKeyManager();
        try {
            // 从数据库读取后解密
            return keyManager.decrypt(value, keyType);
        } catch (Exception e) {
            throw new SQLException("字段解密失败", e);
        }
    }

    // 确保KeyManager和keyType已初始化
    private void ensureKeyManager() {
        if (keyManager == null) {
            keyManager = applicationContext.getBean(KeyManager.class);
        }
    }

    // 由MyBatis Plus配置调用,设置当前处理的字段属性
    public void setConfiguration(Field field) {
        if (!field.isAnnotationPresent(Encrypt.class)) {
            throw new IllegalArgumentException("字段[" + field.getName() + "]未配置@Encrypt注解");
        }
        Encrypt annotation = field.getAnnotation(Encrypt.class);
        this.keyType = annotation.value();
    }
}

自定义 TypeHandler 配置类

为了将 TypeHandler 与 MyBatis Plus 集成,我们需要一个配置类:

import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import javax.sql.DataSource;
import java.lang.reflect.Field;

@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisSqlSessionFactoryBean sqlSessionFactory(DataSource dataSource, EncryptTypeHandler encryptTypeHandler) throws Exception {
        MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);

        // 配置mapper位置
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        factoryBean.setMapperLocations(resolver.getResources("classpath*:/mapper/**/*.xml"));

        // 配置全局TypeHandler
        MybatisConfiguration configuration = new MybatisConfiguration();
        TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();

        // 注册通用加密TypeHandler
        typeHandlerRegistry.register(String.class, encryptTypeHandler);

        factoryBean.setConfiguration(configuration);
        return factoryBean;
    }

    /**
     * 自定义MyBatis Plus处理器,用于拦截实体类字段处理
     */
    @Bean
    public EncryptFieldProcessor encryptFieldProcessor(EncryptTypeHandler encryptTypeHandler) {
        return new EncryptFieldProcessor(encryptTypeHandler);
    }

    /**
     * 实体字段加密处理器,拦截带有@Encrypt注解的字段
     */
    public static class EncryptFieldProcessor {
        private final EncryptTypeHandler encryptTypeHandler;

        public EncryptFieldProcessor(EncryptTypeHandler encryptTypeHandler) {
            this.encryptTypeHandler = encryptTypeHandler;
        }

        // 处理实体类的加密字段
        public void processEncryptFields(Object entity) {
            if (entity == null) {
                return;
            }

            MetaObject metaObject = SystemMetaObject.forObject(entity);
            Class<?> entityClass = entity.getClass();

            // 扫描实体类中的@Encrypt注解
            for (Field field : entityClass.getDeclaredFields()) {
                if (field.isAnnotationPresent(Encrypt.class)) {
                    String fieldName = field.getName();
                    Object fieldValue = metaObject.getValue(fieldName);

                    if (fieldValue instanceof String) {
                        // 设置当前处理的字段属性
                        encryptTypeHandler.setConfiguration(field);

                        // 执行加密操作
                        String encryptedValue = encryptTypeHandler.encrypt((String) fieldValue);
                        metaObject.setValue(fieldName, encryptedValue);
                    }
                }
            }
        }
    }
}

实体类配置

在实体类中,我们只需要使用@Encrypt注解标记需要加密的字段:

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("user_info")
public class UserInfo {
    private Long id;

    private String name;

    @Encrypt(KeyManager.KeyType.ID_CARD)
    private String idCard;

    @Encrypt(KeyManager.KeyType.PHONE)
    private String phoneNumber;

    @Encrypt(KeyManager.KeyType.BANK_CARD)
    private String bankCardNo;

    // 用于模糊查询的辅助字段(存储部分明文)
    private String phoneSearchKey;
}

加密数据查询问题

加密后的数据最大的挑战是如何查询。下面提供两种方案:

1. 服务层加密参数

@Service
public class UserService extends ServiceImpl<UserMapper, UserInfo> {

    @Autowired
    private KeyManager keyManager;

    /**
     * 通过手机号精确查询用户
     */
    public UserInfo findByPhone(String phone) {
        if (phone == null || phone.isEmpty()) {
            return null;
        }

        // 加密查询参数
        String encryptedPhone = keyManager.encrypt(phone, KeyManager.KeyType.PHONE);
        return lambdaQuery().eq(UserInfo::getPhoneNumber, encryptedPhone).one();
    }
}

2. 辅助搜索字段

对于需要模糊查询的场景,单纯的加密字段是无法满足的,因为加密后的数据无法使用 LIKE 操作。解决方案是添加额外的搜索字段:

@Service
public class UserService extends ServiceImpl<UserMapper, UserInfo> {

    /**
     * 插入或更新用户时,自动生成搜索键
     */
    @Transactional
    public boolean saveOrUpdateUser(UserInfo user) {
        // 生成手机号搜索键(例如保留前3位和后4位)
        String phone = user.getPhoneNumber();
        if (phone != null && phone.length() >= 7) {
            // 存储部分明文用于模糊查询
            user.setPhoneSearchKey(phone.substring(0, 3) + "*" + phone.substring(phone.length() - 4));
        }

        return this.saveOrUpdate(user);
    }

    /**
     * 模糊查询手机号
     */
    public List<UserInfo> findByPhoneLike(String phonePattern) {
        return lambdaQuery()
                .like(UserInfo::getPhoneSearchKey, phonePattern)
                .list();
    }
}
sequenceDiagram
    participant App as 应用服务
    participant TypeHandler as EncryptTypeHandler
    participant DB as 数据库

    Note over App,DB: 写入流程
    App->>App: 1. 原始数据(手机号:13800138000)
    App->>App: 2. 生成搜索键(138****8000)
    App->>TypeHandler: 3. 调用saveOrUpdate插入数据
    TypeHandler->>TypeHandler: 4. 自动加密敏感字段
    TypeHandler->>DB: 5. 插入加密数据和搜索键

    Note over App,DB: 查询流程
    App->>App: 1. 查询条件(like '138%')
    App->>DB: 2. 基于搜索键查询
    DB->>TypeHandler: 3. 返回结果(包含加密字段)
    TypeHandler->>TypeHandler: 4. 自动解密敏感字段
    TypeHandler->>App: 5. 返回解密后数据

接口返回脱敏实现

在返回前端数据时,即使已经从数据库中读取并解密了敏感信息,我们也通常需要进行脱敏处理。

自定义脱敏注解

首先定义脱敏注解和策略:

import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 敏感数据脱敏注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveDataSerializer.class)
public @interface SensitiveData {
    // 脱敏类型
    SensitiveType type();

    // 保留前几位(默认值-1表示使用类型默认设置)
    int prefixLength() default -1;

    // 保留后几位(默认值-1表示使用类型默认设置)
    int suffixLength() default -1;

    // 脱敏类型枚举
    enum SensitiveType {
        ID_CARD,      // 身份证号
        PHONE,        // 手机号
        BANK_CARD,    // 银行卡号
        NAME,         // 姓名
        EMAIL,        // 邮箱
        ADDRESS       // 地址
    }
}

脱敏序列化器

然后实现对应的序列化器:

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SensitiveDataSerializer extends JsonSerializer<String> implements ContextualSerializer {

    private SensitiveData.SensitiveType type;
    private int prefixLength = -1;
    private int suffixLength = -1;

    // 默认脱敏规则
    private static final Map<SensitiveData.SensitiveType, MaskRule> DEFAULT_RULES = new ConcurrentHashMap<>();

    static {
        // 初始化默认脱敏规则
        DEFAULT_RULES.put(SensitiveData.SensitiveType.ID_CARD, new MaskRule(3, 4));  // 身份证前3后4
        DEFAULT_RULES.put(SensitiveData.SensitiveType.PHONE, new MaskRule(3, 4));    // 手机号前3后4
        DEFAULT_RULES.put(SensitiveData.SensitiveType.BANK_CARD, new MaskRule(4, 4)); // 银行卡前4后4
        DEFAULT_RULES.put(SensitiveData.SensitiveType.NAME, new MaskRule(1, 0));     // 姓名保留首字
        DEFAULT_RULES.put(SensitiveData.SensitiveType.EMAIL, new MaskRule(3, 0));    // 邮箱前3位
        DEFAULT_RULES.put(SensitiveData.SensitiveType.ADDRESS, new MaskRule(6, 0));  // 地址前6位
    }

    public SensitiveDataSerializer() {
        // 默认构造函数
    }

    public SensitiveDataSerializer(SensitiveData.SensitiveType type, int prefixLength, int suffixLength) {
        this.type = type;
        this.prefixLength = prefixLength;
        this.suffixLength = suffixLength;
    }

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value == null) {
            gen.writeNull();
            return;
        }

        String maskedValue = mask(value, type, prefixLength, suffixLength);
        gen.writeString(maskedValue);
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property)
            throws JsonMappingException {
        if (property != null) {
            SensitiveData annotation = property.getAnnotation(SensitiveData.class);
            if (annotation != null) {
                // 获取注解中的参数
                SensitiveData.SensitiveType type = annotation.type();
                int prefixLength = annotation.prefixLength();
                int suffixLength = annotation.suffixLength();

                return new SensitiveDataSerializer(type, prefixLength, suffixLength);
            }
        }
        return prov.findValueSerializer(property.getType(), property);
    }

    private String mask(String value, SensitiveData.SensitiveType type, int customPrefixLength, int customSuffixLength) {
        if (value == null || value.isEmpty()) {
            return value;
        }

        // 获取默认规则
        MaskRule rule = DEFAULT_RULES.get(type);
        if (rule == null) {
            return value; // 没有对应规则,返回原值
        }

        // 使用自定义长度或默认长度
        int prefixLen = (customPrefixLength >= 0) ? customPrefixLength : rule.prefixLength;
        int suffixLen = (customSuffixLength >= 0) ? customSuffixLength : rule.suffixLength;

        switch (type) {
            case ID_CARD:
            case PHONE:
            case BANK_CARD:
                // 保留前缀和后缀,中间用*替代
                return maskMiddle(value, prefixLen, suffixLen);
            case NAME:
                // 姓名: 仅显示姓,其他用*代替
                return value.substring(0, prefixLen) + "*".repeat(Math.max(0, value.length() - prefixLen));
            case EMAIL:
                // 邮箱: 分开处理@前后的部分
                int atIndex = value.indexOf('@');
                if (atIndex > 0) {
                    int localPartLen = Math.min(prefixLen, atIndex);
                    return value.substring(0, localPartLen) +
                           "*".repeat(atIndex - localPartLen) +
                           value.substring(atIndex);
                }
                return maskMiddle(value, prefixLen, suffixLen);
            case ADDRESS:
                // 地址: 保留前缀,其余用*替代
                int len = Math.min(prefixLen, value.length());
                return value.substring(0, len) + "***";
            default:
                return value;
        }
    }

    private String maskMiddle(String value, int prefixLen, int suffixLen) {
        int len = value.length();

        if (len <= prefixLen + suffixLen) {
            return value;
        }

        String prefix = value.substring(0, prefixLen);
        String suffix = value.substring(len - suffixLen);

        return prefix + "*".repeat(len - prefixLen - suffixLen) + suffix;
    }

    // 脱敏规则
    private static class MaskRule {
        private final int prefixLength;
        private final int suffixLength;

        public MaskRule(int prefixLength, int suffixLength) {
            this.prefixLength = prefixLength;
            this.suffixLength = suffixLength;
        }
    }
}

从配置中心加载脱敏规则

实际项目中,脱敏规则通常需要从配置中心动态加载:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.Map;

/**
 * 从配置中心加载脱敏规则
 */
@Component
@RefreshScope  // 支持配置热更新
public class SensitiveDataConfig {

    @Autowired(required = false)
    private ConfigCenterClient configCenter;  // 配置中心客户端

    @PostConstruct
    public void loadMaskRules() {
        if (configCenter != null) {
            try {
                // 从配置中心加载脱敏规则
                Map<String, MaskRule> rules = configCenter.getMaskRules();
                if (rules != null && !rules.isEmpty()) {
                    // 更新默认规则
                    for (Map.Entry<String, MaskRule> entry : rules.entrySet()) {
                        try {
                            SensitiveData.SensitiveType type = SensitiveData.SensitiveType.valueOf(entry.getKey());
                            DEFAULT_RULES.put(type, entry.getValue());
                        } catch (IllegalArgumentException e) {
                            // 忽略不支持的类型
                        }
                    }
                }
            } catch (Exception e) {
                // 加载失败时使用默认规则
            }
        }
    }
}

在实体类中使用脱敏注解

在实体类中结合加密和脱敏注解:

@Data
@TableName("user_info")
public class UserInfo {
    private Long id;

    @SensitiveData(type = SensitiveData.SensitiveType.NAME)
    private String name;

    @Encrypt(KeyManager.KeyType.ID_CARD)
    @SensitiveData(type = SensitiveData.SensitiveType.ID_CARD)
    private String idCard;

    @Encrypt(KeyManager.KeyType.PHONE)
    @SensitiveData(type = SensitiveData.SensitiveType.PHONE)
    private String phoneNumber;

    @Encrypt(KeyManager.KeyType.BANK_CARD)
    @SensitiveData(type = SensitiveData.SensitiveType.BANK_CARD, prefixLength = 6, suffixLength = 4)  // 自定义银行卡脱敏规则
    private String bankCardNo;

    // 用于模糊查询的辅助字段
    private String phoneSearchKey;
}
flowchart TD
    A[数据库] --> B[MyBatis Plus查询]
    B --> C[EncryptTypeHandler解密]
    C --> D[Java对象]
    D --> E[Jackson序列化]
    E --> F{"存在@SensitiveData?"}
    F -->|是| G[应用脱敏规则]
    F -->|否| H[保持原值]
    G --> I[JSON响应]
    H --> I

密钥轮换处理

在生产环境中,密钥需要定期轮换以提高安全性。以下是一个密钥轮换的处理流程:

@Service
public class KeyRotationService {

    @Autowired
    private KeyManager keyManager;

    @Autowired
    private UserMapper userMapper;

    /**
     * 执行密钥轮换
     * @param keyType 密钥类型
     * @param newKey 新密钥
     */
    @Transactional
    public void rotateKey(KeyManager.KeyType keyType, String newKey) {
        // 1. 备份旧密钥
        String oldKey = keyManager.getCurrentKey(keyType);

        // 2. 更新KeyManager中的密钥(当前密钥 -> 新密钥,旧密钥 -> 当前密钥)
        keyManager.updateKeys(keyType, newKey, oldKey);

        // 3. 安排后台任务迁移历史数据(异步执行)
        asyncMigrateData(keyType, oldKey, newKey);
    }

    /**
     * 异步迁移历史数据
     */
    private void asyncMigrateData(KeyManager.KeyType keyType, String oldKey, String newKey) {
        CompletableFuture.runAsync(() -> {
            try {
                int batchSize = 100;
                int offset = 0;
                boolean hasMore = true;

                while (hasMore) {
                    // 分批查询需要迁移的数据
                    List<UserInfo> users = userMapper.findForMigration(keyType.name(), batchSize, offset);
                    if (users.isEmpty()) {
                        hasMore = false;
                        continue;
                    }

                    // 批量更新
                    for (UserInfo user : users) {
                        // 根据keyType决定要处理的字段
                        String encryptedValue = null;
                        String plainValue = null;

                        switch (keyType) {
                            case ID_CARD:
                                encryptedValue = user.getIdCard();
                                break;
                            case PHONE:
                                encryptedValue = user.getPhoneNumber();
                                break;
                            case BANK_CARD:
                                encryptedValue = user.getBankCardNo();
                                break;
                        }

                        if (encryptedValue != null) {
                            // 用旧密钥解密
                            plainValue = AESEncryptor.decrypt(encryptedValue, oldKey);
                            // 用新密钥加密
                            String newEncryptedValue = AESEncryptor.encrypt(plainValue, newKey);

                            // 更新数据库
                            userMapper.updateEncryptedField(user.getId(), keyType.name(), newEncryptedValue);
                        }
                    }

                    offset += batchSize;
                }
            } catch (Exception e) {
                // 记录错误日志,可考虑重试机制
            }
        });
    }
}
sequenceDiagram
    participant Admin as 管理员
    participant KeySvc as 密钥服务
    participant DB as 数据库

    Admin->>KeySvc: 1. 发起密钥轮换
    KeySvc->>KeySvc: 2. 备份当前密钥
    KeySvc->>KeySvc: 3. 更新密钥配置
    Note right of KeySvc: 新密钥->当前密钥当前密钥->旧密钥
    KeySvc->>KeySvc: 4. 启动异步任务
    KeySvc-->>Admin: 5. 返回轮换已启动

    loop 异步迁移数据
        KeySvc->>DB: 6. 分批查询加密数据
        DB-->>KeySvc: 7. 返回批次数据
        KeySvc->>KeySvc: 8. 旧密钥解密
        KeySvc->>KeySvc: 9. 新密钥加密
        KeySvc->>DB: 10. 更新加密数据
    end

性能优化建议

加解密操作会带来一定的性能开销,可以采取以下措施优化:

1. 缓存机制

使用 Spring Cache 减少频繁加解密:

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserInfo> implements UserService {

    @Cacheable(value = "userCache", key = "#id")
    @Override
    public UserInfo getById(Long id) {
        return super.getById(id);
    }
}

2. 批量操作与异步处理

对大量数据的加解密操作,可以使用异步处理:

@Service
public class BulkProcessService {

    @Autowired
    private UserService userService;

    public CompletableFuture<Void> processBulkData(List<UserInfo> users) {
        return CompletableFuture.runAsync(() -> {
            int batchSize = 100;
            int total = users.size();

            for (int i = 0; i < total; i += batchSize) {
                int end = Math.min(i + batchSize, total);
                List<UserInfo> batch = users.subList(i, end);

                // 批量插入(会触发TypeHandler加密)
                userService.saveBatch(batch);
            }
        });
    }
}

3. 只加密真正敏感的字段

不要过度加密,只对真正需要保护的敏感字段使用加密:

@Data
@TableName("user_info")
public class UserInfo {
    private Long id;

    private String name;         // 普通姓名无需加密,仅脱敏

    @Encrypt(KeyManager.KeyType.ID_CARD)  // 身份证需加密
    private String idCard;

    @Encrypt(KeyManager.KeyType.PHONE)    // 手机号需加密
    private String phoneNumber;

    private String address;      // 普通地址无需加密

    private String email;        // 邮箱可根据需求决定是否加密
}

生产环境部署检查清单

在部署到生产环境前,请确认以下事项:

  • [ ] 密钥是否使用 KMS 或密钥管理服务,而非直接存储在配置文件中
  • [ ] 加解密操作是否添加了性能监控指标(如 Prometheus)
  • [ ] 是否实现了完整的密钥轮换机制
  • [ ] 敏感字段是否添加了适当的字段级权限控制
  • [ ] 脱敏规则是否符合企业合规要求
  • [ ] 是否处理了查询性能问题(如合理使用索引、缓存等)
  • [ ] 是否有完整的异常处理机制

总结

本文详细讲解了在 MyBatis Plus 框架下实现敏感字段加解密和脱敏的完整方案。通过表格总结关键点:

阶段技术方案关键组件优点
加密存储AES-GCM 加密+注解驱动 TypeHandler@Encrypt 注解+EncryptTypeHandler对业务代码无侵入,支持字段级密钥配置
多密钥管理密钥管理器+配置注入KeyManager支持密钥轮换,兼容旧密钥解密
查询处理参数预处理/辅助搜索字段自定义 Service 方法保证加密数据可查询,支持模糊搜索
接口脱敏Jackson 序列化+注解配置@SensitiveData 注解与持久层解耦,支持自定义脱敏规则
性能优化缓存+批量处理Spring Cache+异步处理减少加解密开销,提高系统吞吐量

异常君
1 声望2 粉丝

在 Java 的世界里,永远有下一座技术高峰等着你。我愿做你登山路上的同频伙伴,陪你从看懂代码到写出让自己骄傲的代码。咱们,代码里见!