数据脱敏

数据脱敏将敏感字段(手机号、身份证、银行卡等)以部分隐藏的形式展示,在不影响可读性的前提下保护用户隐私,满足 GDPR、个人信息保护法等合规要求。

常见脱敏规则

数据类型原始值脱敏后规则
手机号13812345678138****5678保留前3后4
身份证1101011990010112341101********1234保留前4后4
银行卡62220202000000012346222*******1234保留前4后4
邮箱user@example.comus**@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 SerializerAPI 响应序列化无侵入,注解声明API 响应脱敏(首选)
AOP 切面方法执行后统一处理日志输出日志保护
MyBatis 拦截器查询结果映射数据库层统一服务内部数据脱敏
Canal + 审计数据变更事件完整变更历史数据审计合规
数据库视图SQL 查询最底层保障多应用共享数据库

相关链接

  • 密码加密 — 密码字段的 @JsonIgnoreBCrypt 存储
  • AOP — 日志脱敏的切面实现
  • MyBatis — MyBatis 拦截器机制
  • 安全 — Spring Security 角色权限控制
  • 方法级安全@PreAuthorize 控制敏感接口访问
  • Canal — binlog 监听实现数据变更审计
  • 日志 — MDC 与日志输出中敏感数据的保护