敏感数据保护:展示脱敏与加密存储深度解析

核心理念

敏感数据保护是两道防线:存储时加密防止数据泄露,展示时脱敏防止信息暴露。

这不仅是技术问题,更是合规要求。在金融、医疗、电商等领域,数据保护是法律红线。

整体架构一览

7-Blog/后端与微服务/assets/敏感数据保护双重防线-2b73dd0e

第一部分:展示脱敏

Step 1:业务理解

7-Blog/后端与微服务/assets/展示脱敏的业务场景-029e8481

Step 2:方案评估

7-Blog/后端与微服务/assets/image-548d39f7

1. 基于自定义注解 + Jackson 序列化器

利用 Jackson 的 ContextualSerializer 接口,在 JSON 序列化时动态读取字段上的注解,应用脱敏逻辑。

  • 实现原理:自定义 @Sensitive 注解

    • 自定义 JsonSerializer
    7-Blog/后端与微服务/assets/Jackson_注解_+_序列化器-056e3fdf
  • 优点

    • 性能较好:作为序列化过程的一部分,无额外反射开销。
    • 支持嵌套:Jackson 天然支持 List、Map 和复杂对象的递归。
    • 无侵入:业务代码无感知,仅需加注解。
  • 缺点

    • 强绑定 JSON 库:如果项目从 Jackson 切换到 Gson 或 FastJson,需要重写序列化器。
完整代码实现
定义脱敏类型枚举
/**
 * 脱敏类型枚举
 * 定义各种敏感数据的脱敏规则
 */
public enum SensitiveType {

    /**
     * 手机号: 138****5678
     */
    MOBILE_PHONE {
        @Override
        public String desensitize(String value) {
            if (value == null || value.length() < 11) return value;
            return value.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
        }
    },

    /**
     * 身份证号: 110***********0312
     */
    ID_CARD {
        @Override
        public String desensitize(String value) {
            if (value == null || value.length() < 15) return value;
            return value.replaceAll("(\\d{3})\\d+(\\d{4})", "$1***********$2");
        }
    },

    /**
     * 中文姓名: 张**
     */
    CHINESE_NAME {
        @Override
        public String desensitize(String value) {
            if (value == null || value.isEmpty()) return value;
            if (value.length() == 2) {
                return value.charAt(0) + "*";
            }
            return value.charAt(0) + "*".repeat(value.length() - 1);
        }
    },

    /**
     * 邮箱: t***@example.com
     */
    EMAIL {
        @Override
        public String desensitize(String value) {
            if (value == null || !value.contains("@")) return value;
            int atIndex = value.indexOf("@");
            if (atIndex <= 1) return value;
            return value.charAt(0) + "***" + value.substring(atIndex);
        }
    },

    /**
     * 银行卡号: 6222 **** **** 1234
     */
    BANK_CARD {
        @Override
        public String desensitize(String value) {
            if (value == null || value.length() < 12) return value;
            return value.substring(0, 4) + " **** **** " +
                   value.substring(value.length() - 4);
        }
    },

    /**
     * 地址: 北京市朝阳区****
     */
    ADDRESS {
        @Override
        public String desensitize(String value) {
            if (value == null || value.length() < 8) return value;
            return value.substring(0, 6) + "****";
        }
    },

    /**
     * 固定电话: ****-1234
     */
    FIXED_PHONE {
        @Override
        public String desensitize(String value) {
            if (value == null || value.length() < 4) return value;
            return "****" + value.substring(value.length() - 4);
        }
    },

    /**
     * 密码: ******
     */
    PASSWORD {
        @Override
        public String desensitize(String value) {
            return "******";
        }
    },

    /**
     * 自定义 - 使用注解参数
     */
    CUSTOM {
        @Override
        public String desensitize(String value) {
            // 自定义类型需要额外参数,由序列化器处理
            return value;
        }
    };

    /**
     * 脱敏处理抽象方法
     */
    public abstract String desensitize(String value);
}
定义 @Sensitive 注解
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.lang.annotation.*;

/**
 * 数据脱敏注解
 * 用于标记需要脱敏的字段
 *
 * 使用示例:
 * @Sensitive(type = SensitiveType.MOBILE_PHONE)
 * private String phone;
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@JacksonAnnotationsInside  // 表示这是一个Jackson组合注解
@JsonSerialize(using = SensitiveSerializer.class)  // 指定序列化器
public @interface Sensitive {

    /**
     * 脱敏类型
     */
    SensitiveType type();

    /**
     * 自定义脱敏时-前缀保留长度
     */
    int prefixLen() default 0;

    /**
     * 自定义脱敏时-后缀保留长度
     */
    int suffixLen() default 0;

    /**
     * 替换符号
     */
    String maskChar() default "*";
}
实现 Jackson 序列化器
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import java.io.IOException;

/**
 * 敏感数据序列化器
 * 核心类 - 实现 ContextualSerializer 以获取字段上的注解信息
 */
public class SensitiveSerializer extends JsonSerializer<String>
        implements ContextualSerializer {

    private SensitiveType sensitiveType;
    private int prefixLen;
    private int suffixLen;
    private String maskChar;

    // 1. 必须保留无参构造,供 Jackson 反射初始化
    public SensitiveSerializer() {}

    // 2. 内部构造,用于创建带有具体策略的新实例
    public SensitiveSerializer(SensitiveType type, int prefixLen,
                               int suffixLen, String maskChar) {
        this.sensitiveType = type;
        this.prefixLen = prefixLen;
        this.suffixLen = suffixLen;
        this.maskChar = maskChar;
    }

    /**
     * 创建上下文序列化器
     * 每个字段都会调用此方法,用于读取该字段的注解配置
     * 这个方法只会在初始化时调用一次,不会影响每次序列化的性能
     */
    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov,
            BeanProperty property) throws JsonMappingException {

        if (property == null) {
            return prov.findNullValueSerializer(property);
        }

        // 如果注解存在,且字段类型是 String
        if (property.getType().getRawClass() != String.class) {
            return prov.findValueSerializer(property.getType(), property);
        }

        // 获取字段上的 @Sensitive 注解
        Sensitive sensitive = property.getAnnotation(Sensitive.class);
        if (sensitive == null) {
            sensitive = property.getContextAnnotation(Sensitive.class);
        }

        if (sensitive != null) {
            return new SensitiveSerializer(
                sensitive.type(),
                sensitive.prefixLen(),
                sensitive.suffixLen(),
                sensitive.maskChar()
            );
        }
        // 如果不是 String 类型,或者没加注解,就用默认的序列化器
        return prov.findValueSerializer(property.getType(), property);
    }

    /**
     * 实际的序列化逻辑
     */
    @Override
    public void serialize(String value, JsonGenerator gen,
            SerializerProvider serializers) throws IOException {

        if (value == null) {
            gen.writeNull();
            return;
        }

        String result;
        if (sensitiveType == SensitiveType.CUSTOM) {
            // 自定义脱敏逻辑
            result = customDesensitize(value);
        } else {
            // 使用枚举定义的脱敏规则
            result = sensitiveType.desensitize(value);
        }

        gen.writeString(result);
    }

    /**
     * 自定义脱敏逻辑
     * 根据前缀长度和后缀长度进行脱敏
     */
    private String customDesensitize(String value) {
        if (value == null || value.isEmpty()) {
            return value;
        }

        int length = value.length();

        // 如果原字符串太短,无法保留指定的前后缀
        if (length <= prefixLen + suffixLen) {
            return maskChar.repeat(length);
        }

        String prefix = value.substring(0, prefixLen);
        String suffix = value.substring(length - suffixLen);
        int maskLen = length - prefixLen - suffixLen;

        return prefix + maskChar.repeat(maskLen) + suffix;
    }
}
在 VO 中使用
import lombok.Data;

/**
 * 用户信息 VO
 * 演示各种脱敏注解的使用
 */
@Data
public class UserVO {

    private Long id;

    @Sensitive(type = SensitiveType.CHINESE_NAME)
    private String name;

    @Sensitive(type = SensitiveType.MOBILE_PHONE)
    private String phone;

    @Sensitive(type = SensitiveType.ID_CARD)
    private String idCard;

    @Sensitive(type = SensitiveType.EMAIL)
    private String email;

    @Sensitive(type = SensitiveType.BANK_CARD)
    private String bankCard;

    @Sensitive(type = SensitiveType.ADDRESS)
    private String address;

    @Sensitive(type = SensitiveType.PASSWORD)
    private String password;

    // 自定义脱敏:保留前2位和后3位
    @Sensitive(type = SensitiveType.CUSTOM, prefixLen = 2, suffixLen = 3)
    private String customField;
}
测试效果
@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/{id}")
    public UserVO getUser(@PathVariable Long id) {
        UserVO user = new UserVO();
        user.setId(id);
        user.setName("张三丰");
        user.setPhone("13812345678");
        user.setIdCard("110101199003070312");
        user.setEmail("test@example.com");
        user.setBankCard("6222021234567890123");
        user.setAddress("北京市朝阳区建国路88号");
        user.setPassword("123456");
        user.setCustomField("ABCDEFGHIJ");
        return user;
    }
}

输出结果:
{
    "id": 1,
    "name": "张**",
    "phone": "138****5678",
    "idCard": "110***********0312",
    "email": "t***@example.com",
    "bankCard": "6222 **** **** 0123",
    "address": "北京市朝阳区****",
    "password": "******",
    "customField": "AB*****HIJ"
}
进阶可设计成条件脱敏
/**
 * 增强版注解 - 支持条件脱敏
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = ConditionalSensitiveSerializer.class)
public @interface Sensitive {

    SensitiveType type();

    /**
     * 是否启用脱敏(可配合SpEL表达式)
     */
    String condition() default "";

    /**
     * 需要的角色才能查看原值
     */
    String[] roles() default {};
}
/**
 * 条件脱敏序列化器
 * 根据当前用户角色决定是否脱敏
 */
public class ConditionalSensitiveSerializer extends JsonSerializer<String>
        implements ContextualSerializer {

    private SensitiveType sensitiveType;
    private String[] roles;

    @Override
    public void serialize(String value, JsonGenerator gen,
            SerializerProvider serializers) throws IOException {

        // 检查当前用户是否有权限查看原值
        if (hasPermission(roles)) {
            gen.writeString(value);  // 不脱敏
        } else {
            gen.writeString(sensitiveType.desensitize(value));  // 脱敏
        }
    }

    private boolean hasPermission(String[] roles) {
        // 从SecurityContext获取当前用户角色
        // 判断是否包含指定角色
        Authentication auth = SecurityContextHolder
            .getContext().getAuthentication();
        if (auth == null) return false;

        return auth.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .anyMatch(r -> Arrays.asList(roles).contains(r));
    }

    // ... createContextual 方法
}

使用示例:

@Data
public class UserVO {

    // 只有ADMIN角色才能看到原始手机号
    @Sensitive(type = SensitiveType.MOBILE_PHONE, roles = {"ADMIN"})
    private String phone;
}

2.基于 AOP (面向切面编程)

在 Controller 层方法返回后,利用切面拦截返回值,通过反射遍历对象属性并修改。

  • 实现原理@Aspect + @AfterReturning + 反射修改对象值。
  • 优点:逻辑与业务完全解耦。
  • 缺点
    • 性能差:反射遍历复杂对象(尤其是深层嵌套 List)非常消耗 CPU。
    • 实现复杂:需要处理基本类型、集合、数组等各种返回结构,容易写出 Bug。
    • 组件化难:依赖 Spring 环境,很难独立抽取成通用 Jar 包。
7-Blog/后端与微服务/assets/AOP_方案的问题-db9217a3
代码实现(仅供了解)

核心痛点代码化: 你会发现代码量大,且充满了反射操作。

import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;

@Aspect
@Component
public class SensitiveAspect {

    @AfterReturning(pointcut = "@annotation(com.example.annotation.SensitiveResult)", returning = "result")
    public void doAfterReturning(JoinPoint joinPoint, Object result) {
        if (result == null) return;

        // 核心痛点:必须递归处理(这里仅展示简单的一层处理,实际生产需要写递归逻辑处理List/Map)
        if (result instanceof Collection) {
            for (Object obj : (Collection<?>) result) {
                handleDesensitization(obj);
            }
        } else {
            handleDesensitization(result);
        }
    }

    private void handleDesensitization(Object obj) {
        // 反射获取所有字段
        Field[] fields = obj.getClass().getDeclaredFields();
        for (Field field : fields) {
            if (field.isAnnotationPresent(SensitiveField.class)) {
                field.setAccessible(true);
                try {
                    Object value = field.get(obj);
                    if (value instanceof String) {
                        // 修改原对象的值!
                        String hidden = performMasking((String) value);
                        field.set(obj, hidden);
                    }
                } catch (IllegalAccessException e) {
                    log.error("脱敏失败", e);
                }
            }
        }
    }

    private String performMasking(String original) {
        return original.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }
}

3. 基于 Hibernate Validator

在数据校验阶段(Validating)修改数据。

  • 实现原理:实现 ConstraintValidator 并在校验通过后修改值。
  • 优点:复用校验逻辑。
  • 缺点场景极其有限。Validator 通常用于入参校验,而脱敏通常是出参需求,方向相反。

核心痛点代码化: 这种写法非常怪异,通常用于清洗数据(如去除空格),而非脱敏输出。

// 1. 定义注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SensitiveValidator.class) // 指定校验器
public @interface SensitiveValid {
    String message() default "脱敏处理";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 2. 实现校验器 (兼职修改数据)
public class SensitiveValidator implements ConstraintValidator<SensitiveValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 这里的本意是校验,但我们利用副作用修改数据(这是反模式!)
        // 注意:Java的String是不可变的,Hibernate Validator 只有在特定钩子下才能改变引用
        // 所以实际上这个方案在很多版本中是行不通的,或者需要配合自定义的Payload处理流程
        return true;
    }
}

注:由于 Hibernate Validator 主要用于验证,想通过它修改值比较困难且非标准,通常不推荐展示此代码作为生产方案。

4.Getter 方法覆写 (朴实无华型)

7-Blog/后端与微服务/assets/Lombok_+_手动_Getter_原理-efb9447d

利用 Java Bean 的特性(以及 Lombok 的机制),手动接管字段的读取逻辑。

虽然实体类使用了 Lombok 的 @Data@Getter 自动生成方法,但 Java 编译器遵循 “手动优先” 原则。 只要我们在类中手动写了 getXxx()方法,Lombok 就不会生成该方法,而是直接使用我们写的。

由于 JSON 序列化工具(Jackson, FastJson, Gson)底层默认都是调用 get 方法来获取值,因此在这里做手脚,可以一统所有序列化框架

代码实现
import lombok.Data;

@Data
public class UserVO {

    private Long id;
    private String name;
    private String phone;
    private String idCard;
    private String email;

    // ===== 手动覆写需要脱敏的字段的Getter =====

    public String getName() {
        return SensitiveUtil.maskName(this.name);
    }

    public String getPhone() {
        return SensitiveUtil.maskPhone(this.phone);
    }

    public String getIdCard() {
        return SensitiveUtil.maskIdCard(this.idCard);
    }

    public String getEmail() {
        return SensitiveUtil.maskEmail(this.email);
    }

    // ===== 如果需要获取原始值,可以提供额外方法 =====

    public String getOriginalPhone() {
        return this.phone;
    }
}

脱敏工具类

/**
 * 敏感数据脱敏工具类
 * 核心工具类 - 可被所有方案复用
 */
public final class SensitiveUtil {

    private SensitiveUtil() {}

    /**
     * 手机号脱敏
     * 13812345678 → 138****5678
     */
    public static String maskPhone(String phone) {
        if (phone == null || phone.length() < 11) {
            return phone;
        }
        return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }

    /**
     * 身份证号脱敏
     * 110101199003070312 → 110***********0312
     */
    public static String maskIdCard(String idCard) {
        if (idCard == null || idCard.length() < 15) {
            return idCard;
        }
        return idCard.replaceAll("(\\d{3})\\d+(\\d{4})", "$1***********$2");
    }

    /**
     * 姓名脱敏
     * 张三 → 张*
     * 张三丰 → 张**
     */
    public static String maskName(String name) {
        if (name == null || name.isEmpty()) {
            return name;
        }
        if (name.length() == 1) {
            return name;
        }
        if (name.length() == 2) {
            return name.charAt(0) + "*";
        }
        return name.charAt(0) + "*".repeat(name.length() - 1);
    }

    /**
     * 邮箱脱敏
     * test@example.com → t***@example.com
     */
    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) {
            return email;
        }
        int atIndex = email.indexOf("@");
        if (atIndex <= 1) {
            return email;
        }
        return email.charAt(0) + "***" + email.substring(atIndex);
    }

    /**
     * 银行卡号脱敏
     * 6222021234567890123 → 6222 **** **** 0123
     */
    public static String maskBankCard(String bankCard) {
        if (bankCard == null || bankCard.length() < 12) {
            return bankCard;
        }
        return bankCard.substring(0, 4) + " **** **** " +
               bankCard.substring(bankCard.length() - 4);
    }

    /**
     * 地址脱敏
     * 北京市朝阳区建国路88号 → 北京市朝阳区****
     */
    public static String maskAddress(String address) {
        if (address == null || address.length() < 8) {
            return address;
        }
        return address.substring(0, 6) + "****";
    }

    /**
     * 通用脱敏方法
     * 保留前prefixLen位和后suffixLen位,中间用maskChar替换
     */
    public static String mask(String value, int prefixLen,
                              int suffixLen, String maskChar) {
        if (value == null || value.isEmpty()) {
            return value;
        }

        int length = value.length();
        if (length <= prefixLen + suffixLen) {
            return maskChar.repeat(length);
        }

        String prefix = value.substring(0, prefixLen);
        String suffix = value.substring(length - suffixLen);
        int maskLen = length - prefixLen - suffixLen;

        return prefix + maskChar.repeat(maskLen) + suffix;
    }
}

分析与对比

7-Blog/后端与微服务/assets/image-cccaa9e5 7-Blog/后端与微服务/assets/展示脱敏方案对比-fffd5c19
  • 对于简单的、仅用于前端展示的 VO 对象: 直接使用 Getter 覆写。它最简单、最稳健,不用担心框架升级或 Json 库替换带来的兼容性问题。
  • 对于大型项目、标准化开发: 建议采用 Jackson 注解 ( @Sensitive) + ContextualSerializer。虽然依赖 Jackson,但它保留了对象在 Java 内部的原始值,只在输出 JSON 的最后一刻才改变形态,既安全又灵活。
  • 如果你的项目可能替换 Json 库: 可以将脱敏算法抽取为独立的 Util 工具类。无论是 Jackson 的 Serializer,还是 FastJson 的 Filter,亦或是手动写的 Getter,都统一调用这个工具类,实现逻辑复用。

Step 3:实现方案

大麦项目选择了最简单直接的方案:在 VO 层重写 get 方法。

@Data
@ApiModel(value="TicketUserVo", description ="购票人数据")
public class TicketUserVo implements Serializable {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(name ="id", dataType ="Long", value ="购票人id")
    private Long id;

    @ApiModelProperty(name ="userId", dataType ="Long", value ="用户id")
    private Long userId;

    @ApiModelProperty(name ="relName", dataType ="String", value ="用户真实名字")
    private String relName;

    @ApiModelProperty(name ="idType", dataType ="Integer", value ="证件类型")
    private Integer idType;

    @ApiModelProperty(name ="idNumber", dataType ="String", value ="证件号码")
    private String idNumber;

    /**
     * 姓名脱敏:只显示第一个字后面的内容
     * 张三 → *三
     * 李小明 → *小明
     */
    public String getRelName() {
        if (StringUtil.isNotEmpty(relName)) {
            return StrUtil.hide(relName, 0, 1);
        }
        return relName;
    }

    /**
     * 证件号脱敏:保留前4位和后4位
     * 110101199001011234 → 1101**********1234
     */
    public String getIdNumber() {
        if (StringUtil.isNotEmpty(idNumber)) {
            return DesensitizedUtil.idCardNum(idNumber, 4, 4);
        } else {
            return idNumber;
        }
    }
}

方案原理分析

7-Blog/后端与微服务/assets/为什么重写_get_方法有效?-3b55f349

Hutool 脱敏工具类

/**
 * 字符串隐藏(掩码)
 *
 * CharSequenceUtil.hide("jackduan@163.com", -1, 4)  → ****duan@163.com
 * CharSequenceUtil.hide("jackduan@163.com", 2, 3)   → ja*kduan@163.com
 * CharSequenceUtil.hide("张三", 0, 1)               → *三
 * CharSequenceUtil.hide("李小明", 0, 1)             → *小明
 */
public static String hide(CharSequence str, int startInclude, int endExclude) {
    return replace(str, startInclude, endExclude, '*');
}

/**
 * 身份证号脱敏
 *
 * idCardNum("110101199001011234", 4, 4)  → 1101**********1234
 * idCardNum("110101199001011234", 6, 4)  → 110101********1234
 */
public static String idCardNum(String idCardNum, int front, int end) {
    if (StrUtil.isBlank(idCardNum)) {
        return StrUtil.EMPTY;
    }
    if ((front + end) > idCardNum.length()) {
        return StrUtil.EMPTY;
    }
    if (front < 0 || end < 0) {
        return StrUtil.EMPTY;
    }
    return StrUtil.hide(idCardNum, front, idCardNum.length() - end);
}

第二部分:加密存储

Step 1:业务理解

7-Blog/后端与微服务/assets/image-1aca39c2

Step 2:技术选型

7-Blog/后端与微服务/assets/image-9c03e1f8 7-Blog/后端与微服务/assets/image-a117ba51

方案一:对称加密(AES/SM4)

原理图解
7-Blog/后端与微服务/assets/image-46c37b87
AES 加密实现
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;

/**
 * AES 加密工具类
 * 使用 AES-GCM 模式(推荐,提供认证加密)
 */
public class AESUtil {

    private static final String ALGORITHM = "AES";
    private static final String TRANSFORMATION = "AES/GCM/NoPadding";
    private static final int KEY_SIZE = 256;      // 密钥长度
    private static final int GCM_IV_LENGTH = 12;  // IV长度
    private static final int GCM_TAG_LENGTH = 128; // 认证标签长度

    /**
     * 生成 AES 密钥
     */
    public static String generateKey() throws Exception {
        KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM);
        keyGenerator.init(KEY_SIZE, new SecureRandom());
        SecretKey secretKey = keyGenerator.generateKey();
        return Base64.getEncoder().encodeToString(secretKey.getEncoded());
    }

    /**
     * AES-GCM 加密
     * @param plainText 明文
     * @param base64Key Base64编码的密钥
     * @return Base64编码的密文(包含IV)
     */
    public static String encrypt(String plainText, String base64Key) throws Exception {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }

        byte[] keyBytes = Base64.getDecoder().decode(base64Key);
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);

        // 生成随机 IV
        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);
        GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);

        byte[] cipherText = cipher.doFinal(plainText.getBytes("UTF-8"));

        // 将 IV 和密文拼接在一起
        byte[] combined = new byte[iv.length + cipherText.length];
        System.arraycopy(iv, 0, combined, 0, iv.length);
        System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length);

        return Base64.getEncoder().encodeToString(combined);
    }

    /**
     * AES-GCM 解密
     * @param cipherText Base64编码的密文(包含IV)
     * @param base64Key Base64编码的密钥
     * @return 明文
     */
    public static String decrypt(String cipherText, String base64Key) throws Exception {
        if (cipherText == null || cipherText.isEmpty()) {
            return cipherText;
        }

        byte[] keyBytes = Base64.getDecoder().decode(base64Key);
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);

        byte[] combined = Base64.getDecoder().decode(cipherText);

        // 分离 IV 和密文
        byte[] iv = new byte[GCM_IV_LENGTH];
        byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
        System.arraycopy(combined, 0, iv, 0, iv.length);
        System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);

        GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);

        byte[] plainText = cipher.doFinal(encrypted);
        return new String(plainText, "UTF-8");
    }
}
SM4 国密算法实现
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.security.Security;
import java.util.Base64;

/**
 * SM4 国密算法工具类
 * 需要引入 BouncyCastle 依赖
 *
 * <dependency>
 *     <groupId>org.bouncycastle</groupId>
 *     <artifactId>bcprov-jdk15on</artifactId>
 *     <version>1.70</version>
 * </dependency>
 */
public class SM4Util {

    private static final String ALGORITHM = "SM4";
    private static final String TRANSFORMATION = "SM4/CBC/PKCS5Padding";
    private static final int KEY_SIZE = 128;
    private static final int IV_SIZE = 16;

    static {
        // 注册 BouncyCastle 提供者
        Security.addProvider(new BouncyCastleProvider());
    }

    /**
     * 生成 SM4 密钥
     */
    public static String generateKey() throws Exception {
        KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM, "BC");
        keyGenerator.init(KEY_SIZE, new SecureRandom());
        SecretKey secretKey = keyGenerator.generateKey();
        return Base64.getEncoder().encodeToString(secretKey.getEncoded());
    }

    /**
     * SM4 加密
     */
    public static String encrypt(String plainText, String base64Key) throws Exception {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }

        byte[] keyBytes = Base64.getDecoder().decode(base64Key);
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);

        // 生成随机 IV
        byte[] iv = new byte[IV_SIZE];
        new SecureRandom().nextBytes(iv);
        IvParameterSpec ivSpec = new IvParameterSpec(iv);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);

        byte[] cipherText = cipher.doFinal(plainText.getBytes("UTF-8"));

        // IV + 密文
        byte[] combined = new byte[iv.length + cipherText.length];
        System.arraycopy(iv, 0, combined, 0, iv.length);
        System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length);

        return Base64.getEncoder().encodeToString(combined);
    }

    /**
     * SM4 解密
     */
    public static String decrypt(String cipherText, String base64Key) throws Exception {
        if (cipherText == null || cipherText.isEmpty()) {
            return cipherText;
        }

        byte[] keyBytes = Base64.getDecoder().decode(base64Key);
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);

        byte[] combined = Base64.getDecoder().decode(cipherText);

        byte[] iv = new byte[IV_SIZE];
        byte[] encrypted = new byte[combined.length - IV_SIZE];
        System.arraycopy(combined, 0, iv, 0, iv.length);
        System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);

        IvParameterSpec ivSpec = new IvParameterSpec(iv);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);

        byte[] plainText = cipher.doFinal(encrypted);
        return new String(plainText, "UTF-8");
    }
}
使用示例
public class SymmetricEncryptDemo {

    public static void main(String[] args) throws Exception {
        // 1. 生成密钥(实际项目中应该安全存储)
        String aesKey = AESUtil.generateKey();
        String sm4Key = SM4Util.generateKey();

        String phone = "13812345678";

        // 2. AES 加密解密
        String aesEncrypted = AESUtil.encrypt(phone, aesKey);
        String aesDecrypted = AESUtil.decrypt(aesEncrypted, aesKey);

        System.out.println("=== AES ===");
        System.out.println("原文: " + phone);
        System.out.println("密文: " + aesEncrypted);
        System.out.println("解密: " + aesDecrypted);

        // 3. SM4 加密解密
        String sm4Encrypted = SM4Util.encrypt(phone, sm4Key);
        String sm4Decrypted = SM4Util.decrypt(sm4Encrypted, sm4Key);

        System.out.println("=== SM4 ===");
        System.out.println("原文: " + phone);
        System.out.println("密文: " + sm4Encrypted);
        System.out.println("解密: " + sm4Decrypted);
    }
}

方案二:非对称加密(RSA/SM2)

原理图解
7-Blog/后端与微服务/assets/image-d5bb4004
RSA 加密实现
import javax.crypto.Cipher;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

/**
 * RSA 非对称加密工具类
 */
public class RSAUtil {

    private static final String ALGORITHM = "RSA";
    private static final String TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
    private static final int KEY_SIZE = 2048;

    /**
     * 生成 RSA 密钥对
     */
    public static KeyPairResult generateKeyPair() throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM);
        keyPairGenerator.initialize(KEY_SIZE, new SecureRandom());
        KeyPair keyPair = keyPairGenerator.generateKeyPair();

        String publicKey = Base64.getEncoder().encodeToString(
            keyPair.getPublic().getEncoded());
        String privateKey = Base64.getEncoder().encodeToString(
            keyPair.getPrivate().getEncoded());

        return new KeyPairResult(publicKey, privateKey);
    }

    /**
     * 公钥加密
     */
    public static String encrypt(String plainText, String base64PublicKey) throws Exception {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }

        byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
        PublicKey publicKey = keyFactory.generatePublic(keySpec);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, publicKey);

        byte[] cipherText = cipher.doFinal(plainText.getBytes("UTF-8"));
        return Base64.getEncoder().encodeToString(cipherText);
    }

    /**
     * 私钥解密
     */
    public static String decrypt(String cipherText, String base64PrivateKey) throws Exception {
        if (cipherText == null || cipherText.isEmpty()) {
            return cipherText;
        }

        byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
        PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.DECRYPT_MODE, privateKey);

        byte[] plainText = cipher.doFinal(Base64.getDecoder().decode(cipherText));
        return new String(plainText, "UTF-8");
    }

    /**
     * 私钥签名
     */
    public static String sign(String data, String base64PrivateKey) throws Exception {
        byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
        PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initSign(privateKey);
        signature.update(data.getBytes("UTF-8"));

        return Base64.getEncoder().encodeToString(signature.sign());
    }

    /**
     * 公钥验签
     */
    public static boolean verify(String data, String sign, String base64PublicKey) throws Exception {
        byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
        PublicKey publicKey = keyFactory.generatePublic(keySpec);

        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initVerify(publicKey);
        signature.update(data.getBytes("UTF-8"));

        return signature.verify(Base64.getDecoder().decode(sign));
    }

    /**
     * 密钥对结果
     */
    public static class KeyPairResult {
        private final String publicKey;
        private final String privateKey;

        public KeyPairResult(String publicKey, String privateKey) {
            this.publicKey = publicKey;
            this.privateKey = privateKey;
        }

        public String getPublicKey() { return publicKey; }
        public String getPrivateKey() { return privateKey; }
    }
}
混合加密(推荐实践)
/**
 * 混合加密方案
 * RSA + AES 结合,兼顾安全性和性能
 *
 * 原理:
 * 1. 用 AES 加密大量数据(速度快)
 * 2. 用 RSA 加密 AES 密钥(安全传输)
 */
public class HybridEncryptUtil {

    /**
     * 混合加密
     * @return 格式: Base64(RSA加密的AES密钥) + "." + Base64(AES加密的数据)
     */
    public static String encrypt(String plainText, String rsaPublicKey) throws Exception {
        // 1. 生成随机 AES 密钥
        String aesKey = AESUtil.generateKey();

        // 2. 用 AES 加密数据
        String encryptedData = AESUtil.encrypt(plainText, aesKey);

        // 3. 用 RSA 公钥加密 AES 密钥
        String encryptedKey = RSAUtil.encrypt(aesKey, rsaPublicKey);

        // 4. 组合返回
        return encryptedKey + "." + encryptedData;
    }

    /**
     * 混合解密
     */
    public static String decrypt(String cipherText, String rsaPrivateKey) throws Exception {
        // 1. 分离密钥和数据
        String[] parts = cipherText.split("\\.");
        if (parts.length != 2) {
            throw new IllegalArgumentException("Invalid cipher text format");
        }

        String encryptedKey = parts[0];
        String encryptedData = parts[1];

        // 2. 用 RSA 私钥解密 AES 密钥
        String aesKey = RSAUtil.decrypt(encryptedKey, rsaPrivateKey);

        // 3. 用 AES 解密数据
        return AESUtil.decrypt(encryptedData, aesKey);
    }
}

方案三:哈希算法(密码存储)

原理图解
7-Blog/后端与微服务/assets/image-afd953a2
BCrypt 密码加密(推荐)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import org.springframework.security.crypto.password.PasswordEncoder;

/**

 * 密码加密工具类

 * 使用 BCrypt 算法

 */

public class PasswordUtil {

    // BCrypt 强度 (4-31),越大越安全但越慢,推荐 10-12

    private static final int STRENGTH = 12;

    private static final PasswordEncoder encoder = new BCryptPasswordEncoder(STRENGTH);

    /**

     * 加密密码

     * @param rawPassword 原始密码

     * @return BCrypt 哈希值(包含算法标识、强度、盐值)

     */

    public static String encode(String rawPassword) {

        return encoder.encode(rawPassword);

    }

    /**

     * 验证密码

     * @param rawPassword 用户输入的密码

     * @param encodedPassword 数据库存储的哈希值

     * @return 是否匹配

     */

    public static boolean matches(String rawPassword, String encodedPassword) {

        return encoder.matches(rawPassword, encodedPassword);

    }

}
SHA256 + 盐值实现
import java.security.MessageDigest;

import java.security.SecureRandom;

import java.util.Base64;

/**

 * SHA256 + 盐值 密码工具类

 * 适用于不想引入 Spring Security 的场景

 */

public class SHA256PasswordUtil {

    private static final int SALT_LENGTH = 16;

    private static final int ITERATIONS = 10000; // 迭代次数

    /**

     * 加密密码

     * @return 格式: Base64(盐值) + ":" + Base64(哈希值)

     */

    public static String encode(String rawPassword) throws Exception {

        // 生成随机盐值

        byte[] salt = new byte[SALT_LENGTH];

        new SecureRandom().nextBytes(salt);

        // 计算哈希

        byte[] hash = hash(rawPassword, salt);

        // 组合返回

        return Base64.getEncoder().encodeToString(salt) + ":" +

               Base64.getEncoder().encodeToString(hash);

    }

    /**

     * 验证密码

     */

    public static boolean matches(String rawPassword, String encodedPassword) throws Exception {

        String[] parts = encodedPassword.split(":");

        if (parts.length != 2) {

            return false;

        }

        byte[] salt = Base64.getDecoder().decode(parts[0]);

        byte[] expectedHash = Base64.getDecoder().decode(parts[1]);

        byte[] actualHash = hash(rawPassword, salt);

        // 时间恒定比较,防止时序攻击

        return MessageDigest.isEqual(expectedHash, actualHash);

    }

    private static byte[] hash(String password, byte[] salt) throws Exception {

        MessageDigest md = MessageDigest.getInstance("SHA-256");

        md.update(salt);

        byte[] hash = md.digest(password.getBytes("UTF-8"));

        // 多次迭代增加破解难度

        for (int i = 0; i < ITERATIONS; i++) {

            md.reset();

            hash = md.digest(hash);

        }

        return hash;

    }

}
使用示例
public class PasswordDemo {

    public static void main(String[] args) throws Exception {

        String password = "MySecretPassword123";

        // BCrypt

        String bcryptHash = PasswordUtil.encode(password);

        System.out.println("BCrypt哈希: " + bcryptHash);

        // 输出: $2a$12$xxxxx... (每次不同)

        System.out.println("验证正确密码: " + PasswordUtil.matches(password, bcryptHash));

        // 输出: true

        System.out.println("验证错误密码: " + PasswordUtil.matches("wrong", bcryptHash));

        // 输出: false

        // SHA256 + 盐值

        String sha256Hash = SHA256PasswordUtil.encode(password);

        System.out.println("SHA256哈希: " + sha256Hash);

        System.out.println("验证: " + SHA256PasswordUtil.matches(password, sha256Hash));

    }

}

方案四:ShardingSphere 透明加密(⭐推荐)

透明加密原理
7-Blog/后端与微服务/assets/image-bf7ce05c
数据库表结构
-- 原始表结构

CREATE TABLE t_user (

    id BIGINT PRIMARY KEY,

    username VARCHAR(50),

    phone VARCHAR(20),  -- 原字段

    id_card VARCHAR(30)

);

-- 改造后的表结构

CREATE TABLE t_user (

    id BIGINT PRIMARY KEY,

    username VARCHAR(50),

    phone_cipher VARCHAR(200),  -- 密文列(必需)

    phone_plain VARCHAR(20),    -- 明文列(可选,用于模糊查询)

    id_card_cipher VARCHAR(200),

    id_card_plain VARCHAR(30)

);
配置文件方式(推荐)
# application.yml

spring:

  shardingsphere:

    datasource:

      names: ds

      ds:

        type: com.zaxxer.hikari.HikariDataSource

        driver-class-name: com.mysql.cj.jdbc.Driver

        jdbc-url: jdbc:mysql://localhost:3306/test?useSSL=false

        username: root

        password: root

    rules:

      # 加密规则配置

      encrypt:

        # 加密器定义

        encryptors:

          # SM4 加密器

          sm4_encryptor:

            type: SM4

            props:

              sm4-key: 1234567890abcdef  # 16字节密钥

              sm4-mode: CBC

              sm4-iv: fedcba0987654321   # 16字节IV

              sm4-padding: PKCS5Padding

          # AES 加密器

          aes_encryptor:

            type: AES

            props:

              aes-key-value: my_aes_secret_key_32bytes!!!!  # 32字节密钥

        # 表加密规则

        tables:

          # t_user 表配置

          t_user:

            columns:

              # phone 字段加密配置

              phone:

                cipher-column: phone_cipher     # 密文列名

                plain-column: phone_plain       # 明文列名(可选)

                encryptor-name: sm4_encryptor   # 使用的加密器

              # id_card 字段加密配置

              id_card:

                cipher-column: id_card_cipher

                encryptor-name: sm4_encryptor

          # t_order 表配置

          t_order:

            columns:

              buyer_phone:

                cipher-column: buyer_phone_cipher

                encryptor-name: aes_encryptor

        # 查询时使用密文列还是明文列

        query-with-cipher-column: true

    props:

      sql-show: true  # 打印SQL,调试用
Java Config 方式
import org.apache.shardingsphere.encrypt.api.config.EncryptRuleConfiguration;

import org.apache.shardingsphere.encrypt.api.config.rule.EncryptColumnRuleConfiguration;

import org.apache.shardingsphere.encrypt.api.config.rule.EncryptTableRuleConfiguration;

import org.apache.shardingsphere.infra.config.algorithm.AlgorithmConfiguration;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import java.util.*;

@Configuration

public class ShardingSphereEncryptConfig {

    @Bean

    public EncryptRuleConfiguration encryptRuleConfiguration() {

        // 1. 定义加密器

        Map<String, AlgorithmConfiguration> encryptors = new HashMap<>();

        // SM4 加密器

        Properties sm4Props = new Properties();

        sm4Props.setProperty("sm4-key", "1234567890abcdef");

        sm4Props.setProperty("sm4-mode", "CBC");

        sm4Props.setProperty("sm4-iv", "fedcba0987654321");

        sm4Props.setProperty("sm4-padding", "PKCS5Padding");

        encryptors.put("sm4_encryptor", new AlgorithmConfiguration("SM4", sm4Props));

        // AES 加密器

        Properties aesProps = new Properties();

        aesProps.setProperty("aes-key-value", "my_aes_secret_key_32bytes!!!!");

        encryptors.put("aes_encryptor", new AlgorithmConfiguration("AES", aesProps));

        // 2. 定义表的加密规则

        List<EncryptTableRuleConfiguration> tables = new ArrayList<>();

        // t_user 表

        EncryptColumnRuleConfiguration phoneColumn = new EncryptColumnRuleConfiguration(

            "phone",              // 逻辑列名

            "phone_cipher",       // 密文列

            null,                 // 辅助查询列

            "phone_plain",        // 明文列

            "sm4_encryptor",      // 加密器名

            null

        );

        EncryptColumnRuleConfiguration idCardColumn = new EncryptColumnRuleConfiguration(

            "id_card",

            "id_card_cipher",

            null,

            null,                 // 不保留明文

            "sm4_encryptor",

            null

        );

        EncryptTableRuleConfiguration userTable = new EncryptTableRuleConfiguration(

            "t_user",

            Arrays.asList(phoneColumn, idCardColumn),

            true                  // 查询时使用密文列

        );

        tables.add(userTable);

        // 3. 创建加密规则配置

        return new EncryptRuleConfiguration(tables, encryptors);

    }

}
自定义加密器
import org.apache.shardingsphere.encrypt.spi.EncryptAlgorithm;

import org.apache.shardingsphere.encrypt.spi.context.EncryptContext;

import java.util.Properties;

/**

 * 自定义加密算法

 * 需要在 META-INF/services/ 下注册 SPI

 */

public class CustomEncryptAlgorithm implements EncryptAlgorithm<Object, String> {

    private Properties props;

    private String secretKey;

    @Override

    public void init(Properties props) {

        this.props = props;

        this.secretKey = props.getProperty("secret-key");

    }

    @Override

    public String encrypt(Object plainValue, EncryptContext encryptContext) {

        if (plainValue == null) {

            return null;

        }

        try {

            // 使用自定义加密逻辑

            return SM4Util.encrypt(plainValue.toString(), secretKey);

        } catch (Exception e) {

            throw new RuntimeException("Encrypt failed", e);

        }

    }

    @Override

    public Object decrypt(String cipherValue, EncryptContext encryptContext) {

        if (cipherValue == null || cipherValue.isEmpty()) {

            return null;

        }

        try {

            return SM4Util.decrypt(cipherValue, secretKey);

        } catch (Exception e) {

            throw new RuntimeException("Decrypt failed", e);

        }

    }

    @Override

    public String getType() {

        return "CUSTOM_SM4";  // 配置文件中使用的类型名

    }

    @Override

    public Properties getProps() {

        return props;

    }

}
SPI 注册文件:
# META-INF/services/org.apache.shardingsphere.encrypt.spi.EncryptAlgorithm

com.example.encrypt.CustomEncryptAlgorithm
业务代码(完全无感知)
@Data

@TableName("t_user")

public class User {

    @TableId(type = IdType.ASSIGN_ID)

    private Long id;

    private String username;

    // 正常写 phone,ShardingSphere 自动处理

    private String phone;

    private String idCard;

}

@Mapper

public interface UserMapper extends BaseMapper<User> {

    // 正常的 MyBatis 操作

    @Select("SELECT * FROM t_user WHERE phone = #{phone}")

    User selectByPhone(@Param("phone") String phone);

}

@Service

public class UserService {

    @Autowired

    private UserMapper userMapper;

    public void createUser() {

        User user = new User();

        user.setUsername("张三");

        user.setPhone("13812345678");  // 明文传入

        user.setIdCard("110101199003070312");

        userMapper.insert(user);  // 自动加密存储

    }

    public User getUser(Long id) {

        // 自动解密返回

        return userMapper.selectById(id);  // phone = "13812345678"

    }

    public User getByPhone(String phone) {

        // 查询条件也会自动加密

        return userMapper.selectByPhone(phone);

    }

}
重要注意事项

⚠️ 模糊查询限制

• 密文列无法直接 LIKE 查询

• 解决方案:保留明文列(plain-column) 用于模糊查询

• 或者使用辅助查询列(assisted-query-column)

⚠️ 存量数据迁移

• 需要编写迁移脚本,将明文加密后写入密文列

• 可以配置 query-with-cipher-column: false 过渡

⚠️ 密钥管理

• 密钥不要硬编码在配置文件

• 推荐使用配置中心或密钥管理服务(KMS)

⚠️ 索引问题

• 密文列上的索引仍然有效(精确查询)

• 范围查询、排序会失效

⚠️ 性能影响

• 加解密有 CPU 开销

• 密文比明文长,增加存储和带宽开销

分析

7-Blog/后端与微服务/assets/image-2c701b93 7-Blog/后端与微服务/assets/image-eb5b0dfc

1️⃣ 密码存储(BCrypt)

  • 专为密码设计,自带盐值
  • 故意设计得慢,抵抗暴力破解

2️⃣ 数据库字段加密

  • 已使用 ShardingSphere → 提供透明加密(业务无感知)
  • 未使用 ShardingSphere → 推荐 AES / SM4 + TypeHandler

3️⃣ 国内合规项目(SM4 国密算法)

  • 金融、政务项目通常要求使用国密算法

4️⃣ 数据传输加密(混合加密:RSA + AES)

  • RSA 用于交换密钥
  • AES 用于加密实际数据

5️⃣ 密钥管理是关键

  • 不要将密钥硬编码在代码中
  • 使用配置中心或 KMS(如 KMS、HashiCorp Vault 等)
  • 定期轮换密钥

最佳实践

/**

 * 统一加密服务接口

 * 方便切换加密实现

 */

public interface EncryptService {

    /**

     * 加密

     */

    String encrypt(String plainText);

    /**

     * 解密

     */

    String decrypt(String cipherText);

}

/**

 * SM4 实现

 */

@Service

@ConditionalOnProperty(name = "encrypt.type", havingValue = "SM4")

public class SM4EncryptService implements EncryptService {

    @Value("${encrypt.sm4.key}")

    private String key;

    @Override

    public String encrypt(String plainText) {

        return SM4Util.encrypt(plainText, key);

    }

    @Override

    public String decrypt(String cipherText) {

        return SM4Util.decrypt(cipherText, key);

    }

}

/**

 * AES 实现

 */

@Service

@ConditionalOnProperty(name = "encrypt.type", havingValue = "AES")

public class AESEncryptService implements EncryptService {

    @Value("${encrypt.aes.key}")

    private String key;

    @Override

    public String encrypt(String plainText) {

        return AESUtil.encrypt(plainText, key);

    }

    @Override

    public String decrypt(String cipherText) {

        return AESUtil.decrypt(cipherText, key);

    }

}
密钥管理
# ❌ 错误做法:硬编码密钥

encrypt:

  key: 1234567890abcdef

# ✅ 正确做法:使用环境变量

encrypt:

  key: ${ENCRYPT_KEY}

# ✅ 更好做法:使用配置中心

encrypt:

  key: ${nacos:encrypt.key}

# ✅ 最佳做法:使用密钥管理服务 (KMS)

encrypt:

  key-id: ${KMS_KEY_ID}

Step 3:实现方案

7-Blog/后端与微服务/assets/shardingSphere-6f95d7d2

依赖引入

<properties>
    <shardingsphere.version>5.3.2</shardingsphere.version>
</properties>

<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>shardingsphere-jdbc-core</artifactId>
    <version>${shardingsphere.version}</version>
</dependency>

数据源配置

spring:
  datasource:
    driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
    url: jdbc:shardingsphere:classpath:shardingsphere-user.yaml

加密规则配置

rules:
  # 加密规则
  - !ENCRYPT
    tables:
      # ========== d_user 表 ==========
      d_user:
        columns:
          # 手机号加密
          mobile:
            cipherColumn: mobile                    # 密文存储列
            encryptorName: user_encryption_algorithm # 使用的加密算法
          # 密码加密
          password:
            cipherColumn: password
            encryptorName: user_encryption_algorithm
          # 身份证号加密
          id_number:
            cipherColumn: id_number
            encryptorName: user_encryption_algorithm

      # ========== d_user_mobile 表 ==========
      d_user_mobile:
        columns:
          mobile:
            cipherColumn: mobile
            encryptorName: user_encryption_algorithm

      # ========== d_ticket_user 表 ==========
      d_ticket_user:
        columns:
          id_number:
            cipherColumn: id_number
            encryptorName: user_encryption_algorithm

    # ========== 加密算法配置 ==========
    encryptors:
      user_encryption_algorithm:
        type: SM4                                   # 国密算法 SM4
        props:
          sm4-key: d3ecdaa11d6ab89e1987870186073eaa # 密钥(16字节)
          sm4-mode: CBC                             # 加密模式
          sm4-iv: 1afc7fdce9ebc393f693cd3d23e35ed2  # 初始化向量
          sm4-padding: PKCS7Padding                 # 填充方式

props:
  sql-show: true  # 开发环境打印真实 SQL

关键设计决策:确定性加密

7-Blog/后端与微服务/assets/为什么固定IV-d2d02e3f

⚠️ ShardingSphere 加密的使用限制

7-Blog/后端与微服务/assets/shardingsphere加密限制-7bcfe51c

SM4 算法参数说明

参数 类型 说明
sm4-key String SM4 密钥,必须是 16 字节(128位)
sm4-mode String 加密模式:CBC 或 ECB
sm4-iv String 初始化向量,CBC 模式必须指定,16 字节
sm4-padding String 填充方式:PKCS5Padding 或 PKCS7Padding

完整数据流程图

7-Blog/后端与微服务/assets/敏感数据完整生命周期-4085027a

核心总结

两道防线对比

防线 目的 实现方式 作用时机
加密存储 防止数据泄露 ShardingSphere + SM4 数据写入数据库时
展示脱敏 防止信息暴露 VO 层重写 get 方法 数据返回前端时

设计亮点

设计点 说明
透明加密 业务代码无感知,框架自动处理
确定性加密 固定 IV,支持分片和索引
简单脱敏 重写 get 方法,无需额外依赖
分层处理 存储层加密,展示层脱敏,职责分离

需要注意的问题

1.密钥管理

  • 密钥不能硬编码在配置文件中(生产环境)
  • 使用密钥管理服务(KMS)
  • 定期轮换密钥
  1. 存量数据迁移
  • 启用加密前需要将存量数据加密
  • 建议停机迁移或双写过渡

3.性能影响

  • 加解密有一定性能开销
  • 热点数据考虑缓存明文(内存中)

4.备份恢复

  • 密钥丢失=数据永久丢失
  • 密钥需要独立备份

💡 设计思想:敏感数据保护是纵深防御的典型应用。加密存储是第一道防线,即使数据库被攻破,攻击者也只能看到密文;展示脱敏是第二道防线,即使数据被授权访问,用户也只能看到部分信息。两道防线相互独立,任何一道被突破都不会导致全面失守。

疑问与解答

但是shardingsphere貌似是分库分表的一个工具 适用于微服务项目 但这种对于业务透明的加密解密确实很方便 单体项目能用这个工具吗 或者有没有其他的类似的工具 在我之前的单体项目中 使用的是专为密码设计的BCrypt 为弥补其不足 加上了动态盐 存在用户表里面 实现动态盐+静态盐加密 我觉得还是挺好用的 另外我还察觉到一个问题 邮箱和电话都加密存储的话 索引还能生效吗?分片好像是做哈希确定的 这个shardingsphere里面应该是做好了 先加密 再哈希 从而确定存储位置的吧 不然先确定位置 加密一下 找不到了 失去了意义 模糊查询怎么办呢 框架也会帮忙做好处理吗 如果我自己想在单体项目中实现这样的使用无察觉的加密方式 好像很难处理这些问题 太多细节了

  1. ShardingSphere 是否适用于单体项目?
    • ShardingSphere 确实主要用于分库分表,但它的加密功能是独立的
    • 单体项目也可以只使用它的加密功能,不启用分片规则
    • 但这样确实有点"杀鸡用牛刀"的感觉
  2. 单体项目有没有类似的透明加密方案?
    • MyBatis 拦截器/TypeHandler
    • JPA/Hibernate 的 AttributeConverter
    • Spring Data 的自定义 Converter
    • 自定义注解 + AOP
  3. 用户提到的 BCrypt + 动态盐方案
    • BCrypt 是专门为密码设计的哈希算法
    • 它本身就有盐值机制
    • 用户又加了动态盐,这是一个加强方案
    • 但要注意:BCrypt 是单向哈希,不是加密,无法解密
    • 适合密码场景(只需验证,不需要获取原文)
    • 不适合手机号、身份证等需要查看原文的场景
  4. 加密后索引能否生效?
    • 关键点:确定性加密(同一明文 → 同一密文)
    • 如果使用确定性加密,索引可以生效
    • 因为查询时也会先加密查询条件,再去数据库匹配
    • ShardingSphere 的处理流程:
      • 写入时:明文 → 加密 → 存储密文
      • 查询时:WHERE mobile = '13800001234' → 先加密 '13800001234' → WHERE mobile = '加密后的值'
      • 7-Blog/后端与微服务/assets/加密后的索引问题-b0af746c
  5. 分片 + 加密的处理顺序
    • 用户的理解是对的

    • 流程应该是:先加密,再根据密文(或原文)做分片

    • 但实际上 ShardingSphere 可能是:先分片(用原文),再加密存储

    • 查询时:加密查询条件后,再根据分片键路由

      7-Blog/后端与微服务/assets/ShardingSphere_的处理顺序-0454d69a
  6. 模糊查询的问题
    • 加密存储天然无法支持模糊匹配
    • 可选方案包括:不加密模糊查询字段、采用可搜索加密技术、维护前缀hash辅助列,或依赖 ShardingSphere 的有限模糊查询支持
  7. 单体项目如何实现透明加密?
    • 这涉及不少实现细节。最实用的做法是通过 MyBatis TypeHandler 或 JPA AttributeConverter 来实现,这样能在数据层自动处理加密解密。

对于微服务架构 使用ShardingSphere确实是很方便 既解决了分库分表的问题 又能实现字段级加密解密 学习了解了这么多 我有设想过一套逻辑 因为不外乎是加密解密脱敏 实现自定义注解 封装加密工具类 对外预留接口 内部多种实现使用策略模式真实需求选择 编码时在需要加密的字段添加注解 使用枚举值指定加密算法 如密码使用PassWord标注 内部实现用BCrypt算法单向加密 手机、邮箱等字段用XX标准 内部实现用可逆的加密 结合MyBatis TypeHandler加密解密过程无感知 既优雅又复用性高 涉及到查询 通过拦截器 先加密 再查询 又保证了索引的生效 到了脱敏逻辑 也是同样的 使用枚举指定脱敏方式 使用权限校验加强脱敏 什么身份(如管理员)可以看到脱敏前的原信息 其他用户权限不足只能查看脱敏后的信息 既保证了安全 又提高了debug效率 我的想法是否健壮 能否实现这个思路

优秀的设计点

  • 注解驱动,声明式使用,优雅简洁
  • 策略模式,算法可扩展,符合开闭原则。
  • TypeHandler +拦截器,透明处理。
  • 权限控制脱敏,安全性和便利性兼顾

需要注意的细节

  • BCrypt 是单向的,不能用常规 TypeHandler 解密·拦截器需要识别哪些参数需要加密
  • 脱敏时需要获取当前用户上下文
  • 动态盐需要特殊处理(从同一行其他字段获取)

完全可以实现,下面给出完整方案

04-加密脱敏最佳实践