数据脱敏
数据脱敏将敏感字段(手机号、身份证、银行卡等)以部分隐藏的形式展示,在不影响可读性的前提下保护用户隐私,满足 GDPR、个人信息保护法等合规要求。
常见脱敏规则
| 数据类型 | 原始值 | 脱敏后 | 规则 |
|---|---|---|---|
| 手机号 | 13812345678 | 138****5678 | 保留前3后4 |
| 身份证 | 110101199001011234 | 1101********1234 | 保留前4后4 |
| 银行卡 | 6222020200000001234 | 6222*******1234 | 保留前4后4 |
| 邮箱 | user@example.com | us**@example.com | 用户名保留前2 |
| 姓名 | 张三丰 | 张*丰 | 保留首尾字符 |
| 地址 | 北京市朝阳区xxx | 北京市朝阳区*** | 保留到区级 |
| 密码 | 任意 | ****** | 完全隐藏 |
方案一:Jackson 序列化脱敏(API 响应层)
最常用方案,在 JSON 序列化时自动脱敏,不影响数据库存储。
自定义注解 + Serializer
// 1. 脱敏类型枚举
public enum SensitiveType {
PHONE, ID_CARD, BANK_CARD, EMAIL, NAME, ADDRESS, PASSWORD
}
// 2. 自定义注解
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveFieldSerializer.class)
public @interface Sensitive {
SensitiveType type();
}
// 3. 脱敏工具类
public class SensitiveUtils {
public static String mask(String value, SensitiveType type) {
if (!StringUtils.hasText(value)) return value;
return switch (type) {
case PHONE -> maskPhone(value);
case ID_CARD -> maskIdCard(value);
case BANK_CARD -> maskBankCard(value);
case EMAIL -> maskEmail(value);
case NAME -> maskName(value);
case ADDRESS -> maskAddress(value);
case PASSWORD -> "******";
};
}
private static String maskPhone(String phone) {
if (phone.length() != 11) return phone;
return phone.substring(0, 3) + "****" + phone.substring(7);
}
private static String maskIdCard(String id) {
if (id.length() < 8) return id;
return id.substring(0, 4)
+ "*".repeat(id.length() - 8)
+ id.substring(id.length() - 4);
}
private static String maskBankCard(String card) {
if (card.length() < 8) return card;
return card.substring(0, 4)
+ "*".repeat(card.length() - 8)
+ card.substring(card.length() - 4);
}
private static String maskEmail(String email) {
int atIndex = email.indexOf('@');
if (atIndex <= 2) return email;
String name = email.substring(0, atIndex);
String domain = email.substring(atIndex);
return name.substring(0, 2) + "**" + domain;
}
private static String maskName(String name) {
if (name.length() == 1) return name;
if (name.length() == 2) return name.charAt(0) + "*";
return name.charAt(0)
+ "*".repeat(name.length() - 2)
+ name.charAt(name.length() - 1);
}
private static String maskAddress(String address) {
if (address.length() <= 6) return address;
return address.substring(0, 6) + "***";
}
}
// 4. Jackson Serializer
public class SensitiveFieldSerializer extends JsonSerializer<String>
implements ContextualSerializer {
private SensitiveType type;
public SensitiveFieldSerializer() {}
public SensitiveFieldSerializer(SensitiveType type) {
this.type = type;
}
@Override
public void serialize(String value, JsonGenerator gen,
SerializerProvider provider) throws IOException {
gen.writeString(SensitiveUtils.mask(value, type));
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov,
BeanProperty property) {
Sensitive annotation = property.getAnnotation(Sensitive.class);
if (annotation == null) {
annotation = property.getContextAnnotation(Sensitive.class);
}
if (annotation != null) {
return new SensitiveFieldSerializer(annotation.type());
}
return this;
}
}使用
@Data
public class UserVO {
private Long id;
private String username;
@Sensitive(type = SensitiveType.PHONE)
private String phone;
@Sensitive(type = SensitiveType.ID_CARD)
private String idCard;
@Sensitive(type = SensitiveType.EMAIL)
private String email;
@Sensitive(type = SensitiveType.NAME)
private String realName;
@JsonIgnore // 密码字段:序列化时完全不输出
private String password;
}响应结果:
{
"id": 1,
"username": "zhangsan",
"phone": "138****5678",
"idCard": "1101**********1234",
"email": "zh**@example.com",
"realName": "张*丰"
}密码序列化保护详见 密码加密。
方案二:AOP 脱敏(日志输出保护)
防止日志中打印敏感数据,在方法返回后对对象字段脱敏:
// 标记需要脱敏的方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveLog {}
// AOP 切面:对返回值的敏感字段脱敏再打印日志
@Aspect
@Component
@Slf4j
public class SensitiveLogAspect {
@Around("@annotation(SensitiveLog)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
Object result = pjp.proceed();
// 深拷贝 + 脱敏,不影响原始对象
Object masked = deepMask(result);
log.info("方法 {} 返回: {}", pjp.getSignature().getName(), masked);
return result; // 返回原始对象给调用方
}
private Object deepMask(Object obj) {
if (obj == null) return null;
// 反射遍历字段,找到 @Sensitive 注解的字段进行脱敏
// 实际使用建议用 BeanUtils 深拷贝后再处理
// 这里仅为示意
return obj;
}
}AOP 实现详见 AOP。
方案三:MyBatis 拦截器脱敏(查询结果层)
在 MyBatis 结果映射时自动脱敏,适合统一处理查询结果:
@Intercepts({
@Signature(type = ResultSetHandler.class,
method = "handleResultSets",
args = {Statement.class})
})
@Component
public class SensitiveInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object result = invocation.proceed();
if (result instanceof List<?> list) {
list.forEach(this::maskObject);
}
return result;
}
private void maskObject(Object obj) {
if (obj == null) return;
Class<?> clazz = obj.getClass();
for (Field field : clazz.getDeclaredFields()) {
Sensitive annotation = field.getAnnotation(Sensitive.class);
if (annotation == null) continue;
if (field.getType() != String.class) continue;
field.setAccessible(true);
try {
String value = (String) field.get(obj);
field.set(obj, SensitiveUtils.mask(value, annotation.type()));
} catch (IllegalAccessException e) {
log.warn("脱敏字段访问失败: {}", field.getName());
}
}
}
}MyBatis 拦截器详见 MyBatis。
方案四:Canal + 数据审计(变更追踪)
通过 Canal 监听 binlog,在数据入库时同步写入脱敏版本到审计库,实现数据变更全记录且不暴露原始值:
MySQL 主库(存储原始数据)
│ binlog
▼
Canal Server(解析 binlog)
│ RowChange 事件
▼
审计消费服务
├── 写审计日志(脱敏后的字段值)→ 审计库
└── 同步到 Elasticsearch(脱敏后)→ 全文检索
@Component
@Slf4j
public class UserAuditConsumer {
private final AuditLogRepository auditLogRepo;
// 消费 Canal 推送的 Kafka 消息(见 Canal MQ 模式)
@KafkaListener(topics = "canal-user-changes")
public void consume(CanalMessage message) {
if (!"users".equals(message.getTable())) return;
for (CanalMessage.RowData row : message.getData()) {
AuditLog log = new AuditLog();
log.setTable("users");
log.setEventType(message.getType());
log.setOperatedAt(LocalDateTime.now());
// 脱敏后写入审计日志
Map<String, String> maskedFields = new HashMap<>();
row.getAfter().forEach((field, value) -> {
String masked = switch (field) {
case "phone" -> SensitiveUtils.mask(value, SensitiveType.PHONE);
case "id_card" -> SensitiveUtils.mask(value, SensitiveType.ID_CARD);
case "email" -> SensitiveUtils.mask(value, SensitiveType.EMAIL);
default -> value;
};
maskedFields.put(field, masked);
});
log.setFields(maskedFields);
auditLogRepo.save(log);
}
}
}Canal 的部署与配置详见 Canal。
方案五:数据库视图脱敏
在数据库层面创建脱敏视图,只授权应用账号访问视图,禁止直接查询原表:
-- 创建脱敏视图
CREATE VIEW v_users_masked AS
SELECT
id,
username,
CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) AS phone,
CONCAT(LEFT(id_card, 4), '**********', RIGHT(id_card, 4)) AS id_card,
CONCAT(LEFT(email, 2), '**', SUBSTRING(email, LOCATE('@', email))) AS email,
status,
created_at
FROM users;
-- 只授权应用账号访问视图
GRANT SELECT ON mydb.v_users_masked TO 'app_user'@'%';
-- 禁止直接查询原表
REVOKE SELECT ON mydb.users FROM 'app_user'@'%';权限分级:不同角色看不同数据
@GetMapping("/users/{id}")
public UserVO getUser(@PathVariable Long id,
@AuthenticationPrincipal UserDetails principal) {
User user = userService.findById(id);
// 管理员看完整数据,普通用户看脱敏数据
if (hasRole(principal, "ADMIN")) {
return userMapper.toFullVO(user);
}
return userMapper.toMaskedVO(user);
}或通过 Spring Security 方法级权限控制:
// 管理员接口:返回原始数据
@GetMapping("/admin/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public UserFullVO getUserFull(@PathVariable Long id) { ... }
// 普通接口:返回脱敏数据
@GetMapping("/users/{id}/profile")
public UserVO getUserProfile(@PathVariable Long id) { ... }方法级权限详见 方法级安全,Spring Security 详见 安全。
各方案适用场景
| 方案 | 触发时机 | 优点 | 适用场景 |
|---|---|---|---|
| Jackson Serializer | API 响应序列化 | 无侵入,注解声明 | API 响应脱敏(首选) |
| AOP 切面 | 方法执行后 | 统一处理日志输出 | 日志保护 |
| MyBatis 拦截器 | 查询结果映射 | 数据库层统一 | 服务内部数据脱敏 |
| Canal + 审计 | 数据变更事件 | 完整变更历史 | 数据审计合规 |
| 数据库视图 | SQL 查询 | 最底层保障 | 多应用共享数据库 |