SpringBoot 数据脱敏实战: 构建企业级敏感信息保护体系
- 本文总字数:约 8500 字
- 预计阅读时间:35 分钟
在当今数字化时代,数据安全已成为企业发展的生命线。用户手机号、身份证号、银行卡信息等敏感数据一旦泄露,不仅会给用户带来巨大损失,更会让企业面临信任危机和法律风险。据《2023 年数据安全漏洞报告》显示,因敏感信息泄露导致的企业平均损失已达 420 万美元,较去年增长 15%。
作为 Java 开发者,我们如何在 SpringBoot 应用中构建可靠的数据脱敏机制?本文将带你深入探索数据脱敏的底层原理,从基础概念到实战落地,全方位解析企业级数据脱敏方案。无论你是刚入行的新手还是资深开发者,都能从本文获得可直接应用于生产环境的解决方案。
一、数据脱敏核心概念与应用场景
1.1 什么是数据脱敏
数据脱敏(Data Masking)是指在不影响业务正常运行的前提下,对敏感信息进行变形处理,实现敏感隐私数据的可靠保护。脱敏后的信息不应能还原出原始数据,同时要保持数据的格式和业务特性不变。
例如,将手机号 "13812345678" 脱敏为 "138****5678",既保护了用户隐私,又保留了手机号的长度特征。
1.2 数据脱敏的必要性
数据脱敏的重要性主要体现在以下三个方面:
-
合规性要求:《网络安全法》《数据安全法》《个人信息保护法》等法律法规明确要求企业必须采取技术措施保护用户敏感信息。
-
数据安全防护:在开发、测试、数据分析等场景中,避免敏感数据直接暴露给非授权人员。
-
风险控制:即使发生数据泄露,脱敏后的数据也不会造成实质性危害。
1.3 常见应用场景
数据脱敏在企业系统中应用广泛,主要场景包括:
- 接口返回数据:API 接口返回用户信息时对敏感字段进行脱敏
- 日志记录:系统日志中避免记录完整敏感信息
- 数据库存储:部分场景下对敏感字段进行持久化脱敏
- 数据导出:报表导出、数据迁移时的脱敏处理
- 开发测试环境:生产数据同步到测试环境时的脱敏转换
1.4 数据脱敏的基本原则
有效的数据脱敏方案应遵循以下原则:
- 不可逆性:脱敏后的信息无法还原为原始数据(除非有特殊需求的可逆脱敏)
- 一致性:相同的原始数据应脱敏为相同的结果
- 业务保留性:脱敏后的数据应保持原有格式和业务特征
- 可定制性:能够根据不同业务场景灵活配置脱敏规则
- 性能影响小:脱敏处理不应显著影响系统性能
二、数据脱敏技术分类与实现方案
2.1 按脱敏时机分类
根据脱敏操作发生的时机,可分为以下几类:
-
静态数据脱敏(SDM):对存储的数据进行永久性脱敏,通常用于将生产数据复制到开发、测试环境时使用。脱敏后的数据集可以安全地用于非生产环境,不会泄露敏感信息。
-
动态数据脱敏(DDM):在数据被访问时实时进行脱敏处理,原始数据在存储中保持不变。这种方式可以根据用户角色、访问权限等动态调整脱敏策略,同一数据对不同权限的用户展示不同的脱敏结果。
2.2 按脱敏算法分类
常见的脱敏算法包括:
- 替换脱敏:用特定字符(如 *)替换敏感部分,如手机号中间 4 位替换为 *
- 截断脱敏:只保留部分字符,如身份证号只显示前 6 位和后 4 位
- 加密脱敏:使用加密算法对敏感数据进行加密,如 AES 加密
- 混淆脱敏:打乱数据顺序或替换为虚假但格式一致的数据
- 掩码脱敏:按照特定规则隐藏部分信息,如信用卡号每 4 位一组显示
2.3 主流实现方案对比
目前企业中常用的数据脱敏方案有以下几种:
方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
数据库层脱敏 | 数据库自带功能或插件 | 性能好,对应用透明 | 灵活性差,规则难维护 | 简单场景,全系统统一规则 |
ORM 框架扩展 | 基于 MyBatis 等框架拦截器 | 与业务代码解耦,灵活 | 需熟悉框架原理 | 中复杂场景,基于 ORM 的应用 |
注解式脱敏 | 基于注解和 AOP 实现 | 侵入性低,配置灵活 | 需处理各种序列化场景 | 复杂场景,多维度脱敏规则 |
网关层脱敏 | API 网关统一处理 | 集中管理,无需修改应用 | 无法处理内部服务调用 | 对外 API 接口,简单规则 |
在 SpringBoot 应用中,注解式脱敏结合 ORM 框架扩展是最常用的方案,既能保证灵活性,又能实现细粒度的脱敏控制。
三、SpringBoot 数据脱敏环境搭建
3.1 技术选型与版本说明
本文将使用以下技术栈实现数据脱敏方案:
- JDK:17.0.9
- SpringBoot:3.2.0
- MyBatis-Plus:3.5.5
- Lombok:1.18.30
- Commons-lang3:3.14.0
- SpringDoc-OpenAPI(Swagger3):2.2.0
- MySQL:8.0.35
3.2 项目初始化与依赖配置
首先创建一个 SpringBoot 项目,在 pom.xml 中添加以下依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.2.0</version><relativePath/></parent><groupId>com.jam</groupId><artifactId>springboot-data-masking</artifactId><version>0.0.1-SNAPSHOT</version><name>springboot-data-masking</name><description>SpringBoot数据脱敏实战项目</description><properties><java.version>17</java.version><mybatis-plus.version>3.5.5</mybatis-plus.version><lombok.version>1.18.30</lombok.version><commons-lang3.version>3.14.0</commons-lang3.version><springdoc.version>2.2.0</springdoc.version></properties><dependencies><!-- SpringBoot核心依赖 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><!-- 数据库依赖 --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency><!-- MyBatis-Plus --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>${mybatis-plus.version}</version></dependency><!-- Lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${lombok.version}</version><optional>true</optional></dependency><!-- 工具类 --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>${commons-lang3.version}</version></dependency><!-- Swagger3 --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency><!-- 测试依赖 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude></excludes></configuration></plugin></plugins></build>
</project>
3.3 数据库配置
在 application.yml 中配置数据库连接信息:
spring:datasource:url: jdbc:mysql://localhost:3306/data_masking_demo?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghaiusername: rootpassword: rootdriver-class-name: com.mysql.cj.jdbc.Driver# MyBatis-Plus配置
mybatis-plus:mapper-locations: classpath:mapper/**/*.xmltype-aliases-package: com.jam.datamasking.entityconfiguration:log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case: true# 日志配置
logging:level:com.jam.datamasking: debug# Swagger3配置
springdoc:api-docs:path: /api-docsswagger-ui:path: /swagger-ui.htmloperationsSorter: methodpackages-to-scan: com.jam.datamasking.controller
3.4 创建数据库表
创建用户表用于演示数据脱敏功能:
CREATE DATABASE IF NOT EXISTS data_masking_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;USE data_masking_demo;CREATE TABLE `user` (`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',`username` varchar(50) NOT NULL COMMENT '用户名',`real_name` varchar(50) NOT NULL COMMENT '真实姓名',`id_card` varchar(20) NOT NULL COMMENT '身份证号',`phone` varchar(20) NOT NULL COMMENT '手机号',`email` varchar(100) NOT NULL COMMENT '邮箱',`bank_card` varchar(30) NOT NULL COMMENT '银行卡号',`address` varchar(200) DEFAULT NULL COMMENT '地址',`password` varchar(100) NOT NULL COMMENT '密码(加密存储)',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';-- 插入测试数据
INSERT INTO `user` (`username`, `real_name`, `id_card`, `phone`, `email`, `bank_card`, `address`, `password`)
VALUES
('zhangsan', '张三', '110101199001011234', '13812345678', 'zhangsan@example.com', '6222021234567890123', '北京市朝阳区', 'e10adc3949ba59abbe56e057f20f883e'),
('lisi', '李四', '310101199203045678', '13987654321', 'lisi@example.com', '6228481234567890123', '上海市浦东新区', 'e10adc3949ba59abbe56e057f20f883e');
四、注解式数据脱敏核心实现
4.1 脱敏策略设计
首先定义常用的脱敏策略枚举类,包含不同敏感信息的脱敏规则:
package com.jam.datamasking.enums;import lombok.AllArgsConstructor;
import lombok.Getter;/*** 脱敏策略枚举类* 定义不同敏感信息的脱敏规则** @author 果酱*/
@Getter
@AllArgsConstructor
public enum MaskStrategy {/*** 手机号脱敏:保留前3位和后4位,中间4位用*代替* 例如:138****5678*/PHONE(3, 4, "****"),/*** 身份证号脱敏:保留前6位和后4位,中间用*代替* 例如:110101********1234*/ID_CARD(6, 4, "********"),/*** 邮箱脱敏:保留前3位和域名,中间用*代替* 例如:zha***@example.com*/EMAIL(3, 0, "***"),/*** 真实姓名脱敏:中文姓名保留姓氏,其他用*代替* 例如:张*、李***/REAL_NAME(1, 0, "*"),/*** 银行卡号脱敏:保留前6位和后4位,中间用*代替* 例如:622202********1234*/BANK_CARD(6, 4, "********"),/*** 地址脱敏:保留前6位,后面用*代替* 例如:北京市朝****/ADDRESS(6, 0, "***");/*** 保留的前缀长度*/private final int prefixLength;/*** 保留的后缀长度*/private final int suffixLength;/*** 替换字符*/private final String replaceStr;
}
4.2 脱敏注解定义
创建自定义脱敏注解,用于标记需要脱敏的字段:
package com.jam.datamasking.annotation;import com.jam.datamasking.enums.MaskStrategy;
import java.lang.annotation.*;/*** 数据脱敏注解* 用于标记需要进行脱敏处理的字段** @author 果酱*/
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataMask {/*** 脱敏策略** @return 脱敏策略枚举*/MaskStrategy strategy();/*** 是否在日志打印时脱敏** @return true-脱敏,false-不脱敏*/boolean maskInLog() default true;
}
4.3 脱敏工具类实现
实现核心的脱敏工具类,提供各种脱敏策略的具体实现:
package com.jam.datamasking.util;import com.jam.datamasking.enums.MaskStrategy;
import org.apache.commons.lang3.StringUtils;/*** 数据脱敏工具类* 实现各种敏感信息的脱敏逻辑** @author 果酱*/
public class MaskUtils {/*** 根据策略对字符串进行脱敏** @param str 原始字符串* @param strategy 脱敏策略* @return 脱敏后的字符串*/public static String mask(String str, MaskStrategy strategy) {// 字符串为空直接返回if (!StringUtils.hasText(str)) {return str;}// 根据不同策略进行脱敏return switch (strategy) {case PHONE -> maskPhone(str);case ID_CARD -> maskIdCard(str);case EMAIL -> maskEmail(str);case REAL_NAME -> maskRealName(str);case BANK_CARD -> maskBankCard(str);case ADDRESS -> maskAddress(str);};}/*** 手机号脱敏* 保留前3位和后4位,中间4位用*代替** @param phone 手机号* @return 脱敏后的手机号*/public static String maskPhone(String phone) {if (!StringUtils.hasText(phone) || phone.length() != 11) {return phone;}return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");}/*** 身份证号脱敏* 保留前6位和后4位,中间用*代替** @param idCard 身份证号* @return 脱敏后的身份证号*/public static String maskIdCard(String idCard) {if (!StringUtils.hasText(idCard) || (idCard.length() != 15 && idCard.length() != 18)) {return idCard;}return idCard.replaceAll("(\\d{6})\\d+(\\d{4})", "$1********$2");}/*** 邮箱脱敏* 保留前3位和域名,中间用*代替** @param email 邮箱地址* @return 脱敏后的邮箱地址*/public static String maskEmail(String email) {if (!StringUtils.hasText(email) || !email.contains("@")) {return email;}String[] parts = email.split("@");String prefix = parts[0];String domain = parts[1];// 前缀长度小于等于3位则全部显示,否则显示前3位int showLength = Math.min(prefix.length(), 3);return prefix.substring(0, showLength) + "***@" + domain;}/*** 真实姓名脱敏* 中文姓名保留姓氏,其他用*代替** @param realName 真实姓名* @return 脱敏后的姓名*/public static String maskRealName(String realName) {if (!StringUtils.hasText(realName)) {return realName;}// 长度为1,不脱敏if (realName.length() == 1) {return realName;}// 长度为2,显示第一个字,第二个字用*代替if (realName.length() == 2) {return realName.charAt(0) + "*";}// 长度大于2,显示第一个字,后面的用*代替return realName.charAt(0) + StringUtils.repeat("*", realName.length() - 1);}/*** 银行卡号脱敏* 保留前6位和后4位,中间用*代替** @param bankCard 银行卡号* @return 脱敏后的银行卡号*/public static String maskBankCard(String bankCard) {if (!StringUtils.hasText(bankCard) || bankCard.length() < 10) {return bankCard;}return bankCard.replaceAll("(\\d{6})\\d+(\\d{4})", "$1********$2");}/*** 地址脱敏* 保留前6位,后面用*代替** @param address 地址* @return 脱敏后的地址*/public static String maskAddress(String address) {if (!StringUtils.hasText(address)) {return address;}int showLength = Math.min(address.length(), 6);return address.substring(0, showLength) + "***";}
}
4.4 Jackson 序列化脱敏实现
通过自定义 Jackson 序列化器,实现 API 接口返回数据的自动脱敏:
package com.jam.datamasking.serializer;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 com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.enums.MaskStrategy;
import com.jam.datamasking.util.MaskUtils;
import java.io.IOException;
import java.util.Objects;/*** 数据脱敏序列化器* 用于在JSON序列化时对标记了@DataMask注解的字段进行脱敏处理** @author 果酱*/
public class DataMaskSerializer extends JsonSerializer<String> implements ContextualSerializer {/*** 脱敏策略*/private MaskStrategy strategy;/*** 默认构造函数*/public DataMaskSerializer() {}/*** 带参数构造函数** @param strategy 脱敏策略*/public DataMaskSerializer(MaskStrategy strategy) {this.strategy = strategy;}@Overridepublic void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {// 应用脱敏策略并写入JSONgen.writeString(MaskUtils.mask(value, strategy));}@Overridepublic JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {// 获取字段上的@DataMask注解DataMask annotation = property.getAnnotation(DataMask.class);// 如果注解存在且字段类型是String,则使用自定义脱敏序列化器if (Objects.nonNull(annotation) && Objects.equals(property.getType().getRawClass(), String.class)) {return new DataMaskSerializer(annotation.strategy());}// 否则使用默认序列化器return prov.findValueSerializer(property.getType(), property);}
}
注册自定义序列化器,使 Jackson 能够识别并应用:
package com.jam.datamasking.config;import com.fasterxml.jackson.databind.module.SimpleModule;
import com.jam.datamasking.serializer.DataMaskSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;/*** Jackson配置类* 注册自定义的脱敏序列化器** @author 果酱*/
@Configuration
public class JacksonConfig {/*** 配置Jackson,注册自定义序列化器** @param builder Jackson对象映射构建器* @return 配置后的ObjectMapper*/@Beanpublic com.fasterxml.jackson.databind.ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {com.fasterxml.jackson.databind.ObjectMapper objectMapper = builder.createXmlMapper(false).build();// 创建自定义模块并注册脱敏序列化器SimpleModule module = new SimpleModule();module.addSerializer(String.class, new DataMaskSerializer());objectMapper.registerModule(module);return objectMapper;}
}
4.5 日志脱敏 AOP 实现
通过 AOP 实现日志打印时的脱敏处理,防止敏感信息泄露到日志中:
package com.jam.datamasking.aspect;import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.util.MaskUtils;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;/*** 日志脱敏切面* 用于在日志打印时对标记了@DataMask注解的字段进行脱敏处理** @author 果酱*/
@Slf4j
@Aspect
@Component
public class LogMaskAspect {private final ObjectMapper objectMapper;/*** 构造函数注入ObjectMapper** @param objectMapper Jackson对象映射器*/public LogMaskAspect(ObjectMapper objectMapper) {this.objectMapper = objectMapper;}/*** 定义切入点:所有标有@Slf4j注解的类的方法*/@Pointcut("@within(lombok.extern.slf4j.Slf4j)")public void logPointcut() {}/*** 环绕通知:对方法参数进行脱敏后再打印日志** @param joinPoint 连接点* @return 方法执行结果* @throws Throwable 异常*/@Around("logPointcut()")public Object around(ProceedingJoinPoint joinPoint) throws Throwable {// 获取方法参数并进行脱敏Object[] args = joinPoint.getArgs();if (Objects.nonNull(args)) {for (int i = 0; i < args.length; i++) {args[i] = maskObject(args[i]);}}// 记录方法调用日志log.debug("调用方法: {}.{},参数: {}", joinPoint.getTarget().getClass().getName(),joinPoint.getSignature().getName(),toJsonString(args));// 执行目标方法Object result = joinPoint.proceed();// 记录方法返回结果日志log.debug("方法: {}.{} 返回结果: {}",joinPoint.getTarget().getClass().getName(),joinPoint.getSignature().getName(),toJsonString(maskObject(result)));return result;}/*** 对对象进行脱敏处理** @param obj 需要脱敏的对象* @return 脱敏后的对象*/private Object maskObject(Object obj) {if (Objects.isNull(obj)) {return null;}// 如果是基本类型或字符串,直接返回if (obj.getClass().isPrimitive() || obj instanceof String || obj instanceof Number || obj instanceof Boolean) {return obj;}// 如果是集合类型,递归处理集合中的元素if (obj instanceof List<?>) {List<Object> list = new ArrayList<>();for (Object item : (List<?>) obj) {list.add(maskObject(item));}return list;}// 对对象的字段进行脱敏处理try {// 创建对象的副本,避免修改原对象Object copy = obj.getClass().getDeclaredConstructor().newInstance();Field[] fields = obj.getClass().getDeclaredFields();for (Field field : fields) {// 设置字段可访问ReflectionUtils.makeAccessible(field);// 获取字段值Object fieldValue = ReflectionUtils.getField(field, obj);// 如果字段标记了@DataMask注解且需要在日志中脱敏,则进行脱敏处理DataMask dataMask = field.getAnnotation(DataMask.class);if (Objects.nonNull(dataMask) && dataMask.maskInLog() && fieldValue instanceof String stringValue) {// 应用脱敏策略String maskedValue = MaskUtils.mask(stringValue, dataMask.strategy());ReflectionUtils.setField(field, copy, maskedValue);} else {// 否则直接复制字段值ReflectionUtils.setField(field, copy, fieldValue);}}return copy;} catch (Exception e) {log.warn("对象脱敏处理失败", e);// 脱敏失败时返回原对象return obj;}}/*** 将对象转换为JSON字符串** @param obj 要转换的对象* @return JSON字符串*/private String toJsonString(Object obj) {try {return objectMapper.writeValueAsString(obj);} catch (JsonProcessingException e) {log.warn("对象转JSON失败", e);return obj.toString();}}
}
4.6 MyBatis 查询结果脱敏实现
通过 MyBatis 的 TypeHandler 实现数据库查询结果的脱敏处理:
package com.jam.datamasking.handler;import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.enums.MaskStrategy;
import com.jam.datamasking.util.MaskUtils;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Objects;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;/*** MyBatis数据脱敏类型处理器* 用于在查询数据库时对敏感字段进行脱敏处理** @author 果酱*/
@MappedJdbcTypes(JdbcType.VARCHAR)
@MappedTypes(String.class)
public class MaskTypeHandler extends BaseTypeHandler<String> {/*** 脱敏策略*/private MaskStrategy strategy;/*** 设置脱敏策略** @param strategy 脱敏策略*/public void setStrategy(MaskStrategy strategy) {this.strategy = strategy;}@Overridepublic void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {// 插入数据时不脱敏,直接存储原始值ps.setString(i, parameter);}@Overridepublic String getNullableResult(ResultSet rs, String columnName) throws SQLException {// 从结果集中获取值并进行脱敏return maskValue(rs.getString(columnName));}@Overridepublic String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {// 从结果集中获取值并进行脱敏return maskValue(rs.getString(columnIndex));}@Overridepublic String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {// 从存储过程中获取值并进行脱敏return maskValue(cs.getString(columnIndex));}/*** 对值进行脱敏处理** @param value 原始值* @return 脱敏后的值*/private String maskValue(String value) {// 如果策略不为空且值不为空,则进行脱敏if (Objects.nonNull(strategy)) {return MaskUtils.mask(value, strategy);}return value;}
}
为了让 TypeHandler 能够根据字段上的注解动态应用不同的脱敏策略,我们需要自定义一个 MyBatis 插件:
package com.jam.datamasking.plugin;import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.handler.MaskTypeHandler;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;/*** MyBatis数据脱敏插件* 用于在查询结果映射时应用不同的脱敏策略** @author 果酱*/
@Component
@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {java.sql.Statement.class})})
public class MaskPlugin implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {// 执行原始查询获取结果Object result = invocation.proceed();// 如果结果为空,直接返回if (Objects.isNull(result)) {return null;}// 如果结果是集合,处理集合中的每个元素if (result instanceof List<?>) {List<Object> list = new ArrayList<>();for (Object item : (List<?>) result) {list.add(maskItem(item));}return list;} else {// 处理单个对象return maskItem(result);}}/*** 对单个对象进行脱敏处理** @param item 需要脱敏的对象* @return 脱敏后的对象*/private Object maskItem(Object item) {if (Objects.isNull(item)) {return null;}// 获取对象的所有字段Field[] fields = item.getClass().getDeclaredFields();for (Field field : fields) {// 检查字段是否标记了@DataMask注解DataMask dataMask = field.getAnnotation(DataMask.class);if (Objects.nonNull(dataMask) && field.getType() == String.class) {// 对标记了注解的字段进行脱敏处理ReflectionUtils.makeAccessible(field);String originalValue = (String) ReflectionUtils.getField(field, item);String maskedValue = MaskUtils.mask(originalValue, dataMask.strategy());ReflectionUtils.setField(field, item, maskedValue);}}return item;}@Overridepublic Object plugin(Object target) {// 包装目标对象,应用插件if (target instanceof ResultSetHandler) {return Plugin.wrap(target, this);}return target;}@Overridepublic void setProperties(Properties properties) {// 可以通过properties配置插件参数}
}
五、实战应用:用户信息脱敏示例
5.1 实体类定义
创建用户实体类,并在需要脱敏的字段上添加 @DataMask 注解:
package com.jam.datamasking.entity;import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.enums.MaskStrategy;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Data;/*** 用户实体类** @author 果酱*/
@Data
@TableName("user")
@Schema(description = "用户信息实体")
public class User implements Serializable {private static final long serialVersionUID = 1L;@TableId(type = IdType.AUTO)@Schema(description = "主键ID")private Long id;@Schema(description = "用户名")private String username;@DataMask(strategy = MaskStrategy.REAL_NAME)@Schema(description = "真实姓名")private String realName;@DataMask(strategy = MaskStrategy.ID_CARD)@Schema(description = "身份证号")private String idCard;@DataMask(strategy = MaskStrategy.PHONE)@Schema(description = "手机号")private String phone;@DataMask(strategy = MaskStrategy.EMAIL)@Schema(description = "邮箱")private String email;@DataMask(strategy = MaskStrategy.BANK_CARD)@Schema(description = "银行卡号")private String bankCard;@DataMask(strategy = MaskStrategy.ADDRESS)@Schema(description = "地址")private String address;@JsonIgnore@Schema(description = "密码(加密存储)", hidden = true)private String password;@Schema(description = "创建时间")private LocalDateTime createTime;@Schema(description = "更新时间")private LocalDateTime updateTime;
}
5.2 Mapper 接口定义
使用 MyBatis-Plus 的 BaseMapper 简化数据库操作:
package com.jam.datamasking.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.datamasking.entity.User;
import org.apache.ibatis.annotations.Mapper;/*** 用户Mapper接口** @author 果酱*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
5.3 Service 层实现
创建用户服务接口和实现类:
package com.jam.datamasking.service;import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.datamasking.entity.User;
import java.util.List;/*** 用户服务接口** @author 果酱*/
public interface UserService extends IService<User> {/*** 获取所有用户** @return 用户列表*/List<User> getAllUsers();/*** 根据ID获取用户** @param id 用户ID* @return 用户信息*/User getUserById(Long id);/*** 创建用户** @param user 用户信息* @return 创建成功的用户*/User createUser(User user);
}
package com.jam.datamasking.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.datamasking.entity.User;
import com.jam.datamasking.mapper.UserMapper;
import com.jam.datamasking.service.UserService;
import java.util.List;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;/*** 用户服务实现类** @author 果酱*/
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {@Overridepublic List<User> getAllUsers() {log.info("查询所有用户信息");return baseMapper.selectList(null);}@Overridepublic User getUserById(Long id) {Objects.requireNonNull(id, "用户ID不能为空");log.info("根据ID查询用户信息, ID: {}", id);return baseMapper.selectById(id);}@Overridepublic User createUser(User user) {Objects.requireNonNull(user, "用户信息不能为空");Objects.requireNonNull(StringUtils.hasText(user.getUsername()), "用户名不能为空");log.info("创建新用户: {}", user);baseMapper.insert(user);return user;}
}
5.4 Controller 层实现
创建用户控制器,提供 API 接口:
package com.jam.datamasking.controller;import com.jam.datamasking.entity.User;
import com.jam.datamasking.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;/*** 用户控制器* 提供用户相关的API接口** @author 果酱*/
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Tag(name = "用户管理", description = "用户信息相关接口")
public class UserController {private final UserService userService;/*** 获取所有用户** @return 用户列表*/@GetMapping@Operation(summary = "获取所有用户", description = "查询系统中所有用户的信息")@ApiResponses({@ApiResponse(responseCode = "200", description = "查询成功",content = @Content(schema = @Schema(implementation = User.class)))})public ResponseEntity<List<User>> getAllUsers() {log.info("接收获取所有用户的请求");List<User> users = userService.getAllUsers();return ResponseEntity.ok(users);}/*** 根据ID获取用户** @param id 用户ID* @return 用户信息*/@GetMapping("/{id}")@Operation(summary = "根据ID获取用户", description = "根据用户ID查询用户详细信息")@ApiResponses({@ApiResponse(responseCode = "200", description = "查询成功",content = @Content(schema = @Schema(implementation = User.class))),@ApiResponse(responseCode = "404", description = "用户不存在")})public ResponseEntity<User> getUserById(@Parameter(description = "用户ID", required = true)@PathVariable Long id) {log.info("接收根据ID获取用户的请求, ID: {}", id);User user = userService.getUserById(id);if (Objects.isNull(user)) {return ResponseEntity.notFound().build();}return ResponseEntity.ok(user);}/*** 创建用户** @param user 用户信息* @return 创建成功的用户*/@PostMapping@Operation(summary = "创建用户", description = "新增用户信息")@ApiResponses({@ApiResponse(responseCode = "200", description = "创建成功",content = @Content(schema = @Schema(implementation = User.class)))})public ResponseEntity<User> createUser(@Parameter(description = "用户信息", required = true)@RequestBody User user) {log.info("接收创建用户的请求: {}", user);User createdUser = userService.createUser(user);return ResponseEntity.ok(createdUser);}
}
5.5 主启动类
package com.jam.datamasking;import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;/*** 应用主启动类** @author 果酱*/
@SpringBootApplication
@MapperScan("com.jam.datamasking.mapper")
public class SpringbootDataMaskingApplication {public static void main(String[] args) {SpringApplication.run(SpringbootDataMaskingApplication.class, args);}
}
六、测试验证与结果分析
6.1 接口返回数据脱敏验证
启动应用后,访问 Swagger3 界面:http://localhost:8080/swagger-ui.html
调用GET /api/users
接口,查看返回结果:
[{"id": 1,"username": "zhangsan","realName": "张*","idCard": "110101********1234","phone": "138****5678","email": "zha***@example.com","bankCard": "622202********1234","address": "北京市朝***","createTime": "2023-12-01T10:00:00","updateTime": "2023-12-01T10:00:00"},{"id": 2,"username": "lisi","realName": "李*","idCard": "310101********5678","phone": "139****4321","email": "lis***@example.com","bankCard": "622848********1234","address": "上海市浦***","createTime": "2023-12-01T10:00:00","updateTime": "2023-12-01T10:00:00"}
]
可以看到,所有标记了 @DataMask 注解的字段都按照预期进行了脱敏处理,而未标记的字段(如 username)则正常显示。
6.2 日志脱敏验证
查看应用启动日志,可以看到日志中的用户信息也进行了脱敏处理:
2023-12-01 10:30:00.123 DEBUG 12345 --- [nio-8080-exec-1] c.j.d.aspect.LogMaskAspect : 调用方法: com.jam.datamasking.service.impl.UserServiceImpl.getAllUsers,参数: []
2023-12-01 10:30:00.125 INFO 12345 --- [nio-8080-exec-1] c.j.d.s.impl.UserServiceImpl : 查询所有用户信息
2023-12-01 10:30:00.156 DEBUG 12345 --- [nio-8080-exec-1] c.j.d.aspect.LogMaskAspect : 方法: com.jam.datamasking.service.impl.UserServiceImpl.getAllUsers 返回结果: [{"id":1,"username":"zhangsan","realName":"张*","idCard":"110101********1234","phone":"138****5678","email":"zha***@example.com","bankCard":"622202********1234","address":"北京市朝***","createTime":"2023-12-01T10:00:00","updateTime":"2023-12-01T10:00:00"},{"id":2,"username":"lisi","realName":"李*","idCard":"310101********5678","phone":"139****4321","email":"lis***@example.com","bankCard":"622848********1234","address":"上海市浦***","createTime":"2023-12-01T10:00:00","updateTime":"2023-12-01T10:00:00"}]
6.3 数据库存储验证
查看数据库中的原始数据,确认数据是以原始形式存储的,脱敏仅发生在查询和返回过程中:
SELECT * FROM user;
查询结果显示所有敏感信息都是完整存储的,这验证了我们的脱敏处理不会修改原始数据,只是在展示和传输过程中进行脱敏。
七、高级特性与扩展方案
7.1 基于角色的动态脱敏策略
在实际应用中,不同角色的用户可能需要访问不同敏感程度的数据。例如,管理员可以查看完整的手机号,而普通用户只能看到脱敏后的手机号。
实现思路:
- 定义角色枚举和脱敏级别枚举
- 创建角色与脱敏级别的映射关系
- 修改脱敏注解,支持指定不同角色对应的脱敏策略
- 在脱敏序列化器中,根据当前登录用户的角色动态选择脱敏策略
核心代码实现:
// 角色枚举
public enum Role {ADMIN, OPERATOR, USER, GUEST
}// 脱敏级别枚举
public enum MaskLevel {NONE, LOW, MEDIUM, HIGH
}// 扩展的脱敏注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DynamicDataMask {// 默认脱敏策略MaskStrategy defaultStrategy();// 不同角色对应的脱敏策略RoleMask[] roleStrategies() default {};
}// 角色与脱敏策略的映射
public @interface RoleMask {Role role();MaskStrategy strategy();
}// 动态脱敏序列化器
public class DynamicDataMaskSerializer extends JsonSerializer<String> implements ContextualSerializer {// 实现根据当前用户角色动态选择脱敏策略的逻辑
}
7.2 可逆脱敏方案
在某些场景下,我们需要对数据进行可逆脱敏,即可以根据需要还原原始数据。例如,客服人员在验证用户身份后,可以查看完整的手机号。
可逆脱敏通常采用加密算法实现,如 AES 对称加密:
package com.jam.datamasking.util;import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;/*** 可逆脱敏工具类* 使用AES算法实现数据的加密和解密** @author 果酱*/
public class ReversibleMaskUtils {/*** 加密算法*/private static final String ALGORITHM = "AES";/*** 加密密钥 (实际应用中应从安全的配置源获取)*/private static final String KEY = "your-secret-key-16bytes"; // AES-128需要16字节密钥/*** 对数据进行加密(可逆脱敏)** @param data 原始数据* @return 加密后的数据*/public static String encrypt(String data) {try {SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);Cipher cipher = Cipher.getInstance(ALGORITHM);cipher.init(Cipher.ENCRYPT_MODE, keySpec);byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));return Base64.getEncoder().encodeToString(encrypted);} catch (Exception e) {throw new RuntimeException("数据加密失败", e);}}/*** 对加密数据进行解密(还原原始数据)** @param encryptedData 加密后的数据* @return 原始数据*/public static String decrypt(String encryptedData) {try {SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);Cipher cipher = Cipher.getInstance(ALGORITHM);cipher.init(Cipher.DECRYPT_MODE, keySpec);byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedData));return new String(decrypted, StandardCharsets.UTF_8);} catch (Exception e) {throw new RuntimeException("数据解密失败", e);}}
}
使用可逆脱敏时需要注意:
- 密钥管理至关重要,应使用安全的密钥管理服务
- 解密操作需要严格的权限控制
- 敏感操作需要记录审计日志
7.3 性能优化与缓存策略
对于高并发场景,频繁的脱敏操作可能会影响系统性能。可以采用以下优化策略:
- 结果缓存:对同一数据的脱敏结果进行缓存,避免重复计算
- 异步处理:非实时场景下,采用异步方式进行脱敏处理
- 批量处理:对批量数据采用批量脱敏算法,提高处理效率
缓存实现示例:
package com.jam.datamasking.util;import com.jam.datamasking.enums.MaskStrategy;
import org.apache.commons.lang3.StringUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;/*** 带缓存的脱敏工具类* 对脱敏结果进行缓存,提高性能** @author 果酱*/
public class CachedMaskUtils {/*** 脱敏结果缓存* 结构: strategy -> originalValue -> maskedValue*/private static final Map<MaskStrategy, Map<String, String>> MASK_CACHE = new ConcurrentHashMap<>();/*** 缓存最大容量*/private static final int MAX_CACHE_SIZE = 10000;/*** 根据策略对字符串进行脱敏(带缓存)** @param str 原始字符串* @param strategy 脱敏策略* @return 脱敏后的字符串*/public static String mask(String str, MaskStrategy strategy) {// 字符串为空直接返回if (!StringUtils.hasText(str)) {return str;}// 从缓存获取脱敏结果Map<String, String> strategyCache = MASK_CACHE.computeIfAbsent(strategy, k -> new HashMap<>(1000));String cachedResult = strategyCache.get(str);// 缓存命中,直接返回if (StringUtils.hasText(cachedResult)) {return cachedResult;}// 缓存未命中,计算脱敏结果String maskedResult = MaskUtils.mask(str, strategy);// 控制缓存大小,防止内存溢出if (strategyCache.size() < MAX_CACHE_SIZE) {strategyCache.put(str, maskedResult);}return maskedResult;}/*** 清空缓存*/public static void clearCache() {MASK_CACHE.clear();}/*** 清空指定策略的缓存** @param strategy 脱敏策略*/public static void clearCache(MaskStrategy strategy) {Map<String, String> strategyCache = MASK_CACHE.get(strategy);if (strategyCache != null) {strategyCache.clear();}}
}
八、最佳实践与避坑指南
8.1 数据脱敏最佳实践
-
最小权限原则:只对必要的字段进行脱敏,只向必要的人员展示必要的信息
-
分层脱敏策略:
- 传输层:API 接口返回数据脱敏
- 应用层:日志打印脱敏、页面展示脱敏
- 存储层:敏感字段加密存储(如密码)
-
统一脱敏规则:在企业内部制定统一的脱敏规则和标准,确保脱敏行为的一致性
-
定期审计:定期检查脱敏规则的执行情况,确保敏感数据得到有效保护
-
结合数据分类:根据数据敏感度分级,对不同级别的数据应用不同的脱敏策略
8.2 常见问题与解决方案
- 脱敏与数据校验冲突
问题:脱敏后的字段可能无法通过格式校验(如手机号格式)
解决方案:
- 校验逻辑应基于原始数据执行
- 脱敏操作应在数据校验之后进行
- 前端展示时可同时显示脱敏值和原始格式说明
- 脱敏性能问题
问题:高并发场景下,大量数据的脱敏处理可能导致性能瓶颈
解决方案:
- 采用缓存机制减少重复计算
- 对非敏感接口关闭脱敏处理
- 复杂脱敏逻辑异步处理
- 序列化框架兼容性
问题:不同的序列化框架(如 Jackson、Fastjson)可能需要不同的脱敏实现
解决方案:
- 统一应用的序列化框架
- 为不同框架实现对应的脱敏处理器
- 核心脱敏逻辑与序列化框架解耦
- 脱敏规则变更
问题:脱敏规则变更时需要修改大量代码
解决方案:
- 将脱敏规则配置化,支持动态调整
- 脱敏策略与业务代码解耦
- 实现脱敏规则的热更新
九、参考资料
- 《信息安全技术 个人信息安全规范》(GB/T 35273-2020) - 国家标准
- Spring 官方文档 - https://spring.io/docs
- MyBatis-Plus 官方文档 - MyBatis-Plus 🚀 为简化开发而生
- 《数据安全架构设计与实战》- 机械工业出版社
- OWASP 数据脱敏指南 - https://cheatsheetseries.owasp.org/cheatsheets/Data_Protection_Cheat_Sheet.html