痛点暴击:手动脱敏?当代打工人的 "指尖酷刑"
前阵子公司搞活动,运营同学拿着 Excel 表格来找我:"哥,这 1000 个用户的手机号得打码,不然展示出来违规!" 我瞅了瞅表格里密密麻麻的138****5678,再想想他要逐个单元格替换的场景 —— 这不就是当代版 "手撕快递单" 吗?
手动改数的痛,谁懂啊:改漏了怕合规追责,改错了用户投诉,改多了眼花手抖。更惨的是下次活动又来了,重复劳动到怀疑人生。直到我写了这套 "数据自动打码机",才算把自己从 "脱敏流水线" 上解放出来。
脱敏工具の懒人黑科技:注解一贴,数据自动 "穿马甲"
这套工具的核心逻辑特简单:用注解标记要脱敏的字段,程序自动在返回给前端前 "打码"。再也不用写一堆replace逻辑,更不用盯着数据逐个改。
举个栗子:给手机号戴 "口罩" 只需 3 步
- 贴标签:在实体类的手机号字段上加@MaskValue注解,告诉程序 "这货要脱敏":
public class ActivityUser {
private String name;
@MaskValue // 注解一贴,自动脱敏
private String phone;
}
- 选配方:默认用手机号脱敏策略(中间 4 位变****),想换规则?自己写个MaskStrategy就行,比如身份证脱敏、邮箱脱敏。
- 躺平等结果:接口返回时自动拦截处理,前端拿到的手机号自动变成138****5678,后端代码零侵入。
实现
代码实现
MaskValue (注解)
package cn.ideamake.business.component.mask;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author Barcke
* @version 1.0
* @projectName ideamake-framework
* @className MaskPhone
* @date 2025/8/13 15:50
* @slogan: 源于生活 高于生活
* @description: 是否需要加密返回值
**/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MaskValue {
Class<? extends MaskStrategy> value() default DefaultMaskPhoneStrategy.class;
}
MaskStrategy (脱敏策略)
package cn.ideamake.business.component.mask;
/**
* @author Barcke
* @version 1.0
* @projectName ideamake-framework
* @className MaskStrategy
* @date 2025/8/13 15:55
* @slogan: 源于生活 高于生活
* @description:
**/
public interface MaskStrategy {
/**
* 数据脱敏
* @return
*/
Object mask(Object value);
/**
* @return true 表示需要脱敏
*/
boolean shouldMask(Object value);
}
MaskResponseAdvice (响应体脱敏)
package cn.ideamake.business.component.mask;
import cn.hutool.extra.spring.SpringUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.lang.reflect.Field;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.Deque;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author Barcke
* @version 优化版
* @projectName ideamake-framework
* @className MaskResponseAdvice
* @date 2025/8/13 15:51
* @slogan: 源于生活 高于生活
* @description: 响应体脱敏处理,优化实现
**/
@RestControllerAdvice
@Slf4j
public class MaskResponseAdvice implements ResponseBodyAdvice<Object> {
// 缓存方法是否需要脱敏,提升性能
private static final Map<MethodParameter, Boolean> METHOD_MASK_NEEDED_CACHE = new ConcurrentHashMap<>();
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 全部 Controller 返回都拦截
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
ServerHttpResponse response) {
if (body == null) {
return null;
}
// 优先从缓存读取是否需要脱敏
Boolean needMask = METHOD_MASK_NEEDED_CACHE.get(returnType);
if (Boolean.FALSE.equals(needMask)) {
return body;
}
// 检测并执行脱敏
MaskDetectResult detectResult = new MaskDetectResult();
maskObject(body, new IdentityHashMap<>(), detectResult);
// 首次检测后缓存结果
if (needMask == null) {
METHOD_MASK_NEEDED_CACHE.put(returnType, detectResult.needMask);
}
return body;
}
/**
* 脱敏处理,防止循环引用,提升性能和可读性
*/
private void maskObject(Object rootObj, Map<Object, Boolean> visited, MaskDetectResult detectResult) {
if (rootObj == null) {
return;
}
Deque<Object> stack = new ArrayDeque<>();
stack.push(rootObj);
while (!stack.isEmpty()) {
Object obj = stack.pop();
Class<?> objClass = obj.getClass();
if (isJavaBasicType(objClass) || visited.containsKey(obj)) {
continue;
}
visited.put(obj, Boolean.TRUE);
// 处理集合
if (obj instanceof Collection<?>) {
for (Object item : (Collection<?>) obj) {
if (item != null) {
stack.push(item);
}
}
continue;
}
// 处理Map
if (obj instanceof Map<?, ?>) {
for (Object value : ((Map<?, ?>) obj).values()) {
if (value != null) {
stack.push(value);
}
}
continue;
}
// 只处理有脱敏注解的字段
List<FieldCache.FieldMaskMeta> maskFields = FieldCache.getMaskFields(objClass);
if (!maskFields.isEmpty()) {
detectResult.needMask = true;
}
for (FieldCache.FieldMaskMeta meta : maskFields) {
Field field = meta.getField();
try {
Object value = field.get(obj);
MaskStrategy strategy = meta.getStrategy();
if (strategy.shouldMask(value)) {
Object maskedValue = strategy.mask(value);
field.set(obj, maskedValue);
}
} catch (Exception ignored) {
}
}
// 递归处理所有非基础类型字段
for (Field field : getAllFields(objClass)) {
Class<?> fieldType = field.getType();
if (isJavaBasicType(fieldType) || fieldType.isEnum()) {
continue;
}
try {
Object value = field.get(obj);
if (value != null) {
stack.push(value);
}
} catch (Exception ignored) {
}
}
}
}
/**
* 获取类及其所有父类的字段,避免同名字段混淆
*/
private static List<Field> getAllFields(Class<?> clazz) {
List<Field> fields = new ArrayList<>();
Set<String> fieldNames = new HashSet<>();
for (Class<?> c = clazz; c != null && c != Object.class; c = c.getSuperclass()) {
for (Field field : c.getDeclaredFields()) {
if (fieldNames.add(field.getName())) {
field.setAccessible(true);
fields.add(field);
}
}
}
return fields;
}
/**
* 判断是否为Java基础类型、包装类型、String、时间类型、枚举等
*/
private static boolean isJavaBasicType(Class<?> clazz) {
if (clazz == null) {
return false;
}
if (clazz.isPrimitive() || clazz.isEnum()) {
return true;
}
if (clazz == String.class || clazz == Boolean.class || clazz == Character.class) {
return true;
}
if (Number.class.isAssignableFrom(clazz)) {
return true;
}
if (Date.class.isAssignableFrom(clazz)) {
return true;
}
return clazz.getPackage() != null && clazz.getPackage().getName().startsWith("java.time");
}
/**
* 标记本次是否发现需要脱敏的字段
*/
private static class MaskDetectResult {
boolean needMask = false;
}
}
/**
* 字段脱敏元数据缓存
*/
@Slf4j
class FieldCache {
private static final Map<Class<?>, List<FieldMaskMeta>> CACHE = new ConcurrentHashMap<>();
public static List<FieldMaskMeta> getMaskFields(Class<?> clazz) {
return CACHE.computeIfAbsent(clazz, FieldCache::scanMaskFields);
}
private static List<FieldMaskMeta> scanMaskFields(Class<?> clazz) {
List<FieldMaskMeta> list = new ArrayList<>();
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(MaskValue.class)) {
field.setAccessible(true);
MaskValue maskValue = field.getAnnotation(MaskValue.class);
MaskStrategy strategy = null;
try {
strategy = SpringUtil.getBean(maskValue.value());
} catch (Exception e) {
log.error("获取bean失败:{}", maskValue, e);
}
if (strategy != null) {
list.add(new FieldMaskMeta(field, strategy));
}
}
// 可扩展更多注解与策略映射
}
return list;
}
@AllArgsConstructor
@Getter
public static class FieldMaskMeta {
private final Field field;
private final MaskStrategy strategy;
}
}
DefaultMaskPhoneStrategy 默认的加密策略(手机号加密)
package cn.ideamake.business.component.mask;
import cn.hutool.core.convert.Convert;
import cn.ideamake.auth.context.IdeamakeSubjectContext;
import cn.ideamake.auth.context.IdeamakeUserInfo;
import cn.ideamake.common.util.PhoneEncyUtil;
import java.util.Objects;
/**
* @author Barcke
* @version 1.0
* @projectName ideamake-framework
* @className DefaultMaskPhoneStrategy
* @date 2025/8/13 16:07
* @slogan: 源于生活 高于生活
* @description: 默认的手机号加密策略
**/
public class DefaultMaskPhoneStrategy implements MaskStrategy {
@Override
public Object mask(Object value) {
if (value == null) {
return null;
}
return PhoneEncyUtil.encryptPhoneAccordAreaCode(Convert.toStr(value));
}
@Override
public boolean shouldMask(Object value) {
IdeamakeUserInfo ideamakeUserInfo = IdeamakeSubjectContext.getInfo();
boolean ifMaskPhone = Convert.toStr(value) != null
&& Convert.toStr(value).matches(".*[a-zA-Z].*");
if (ideamakeUserInfo == null && !ifMaskPhone) {
return false;
}
return Objects.equals(IdeamakeSubjectContext.get().getUserBO().getIfPhoneEncrypt(), 0)
|| ifMaskPhone;
}
}
MaskConfiguration (配置)
package cn.ideamake.business.config;
import cn.ideamake.business.component.mask.DefaultMaskPhoneStrategy;
import cn.ideamake.business.component.mask.MaskResponseAdvice;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author Barcke
* @version 1.0
* @projectName ideamake-framework
* @className MaskConfiguration
* @date 2025/8/13 19:14
* @slogan: 源于生活 高于生活
* @description:
**/
@Slf4j
@Configuration
public class MaskConfiguration {
@Bean
@ConditionalOnMissingBean
public DefaultMaskPhoneStrategy defaultMaskPhoneStrategy(){
return new DefaultMaskPhoneStrategy();
}
@Bean
@ConditionalOnMissingBean
public MaskResponseAdvice maskResponseAdvice(){
return new MaskResponseAdvice();
}
}
干货拆解:这工具到底藏了多少 "小心机"?
别看用起来简单,内部设计全是 "懒人智慧"。咱们扒开代码看看它是怎么干活的:
- 注解:给数据贴 "需要打码" 的便利贴 @MaskValue注解就像个荧光笔,标记出需要脱敏的字段。你可以指定用哪个脱敏策略(比如@MaskValue(DefaultMaskPhoneStrategy.class)),也能默认用手机号策略,灵活得很。
- 拦截器:响应数据的 "安检员" MaskResponseAdvice实现了 Spring 的ResponseBodyAdvice,就像快递站的安检员 —— 所有接口返回的数据都会经过它检查。它会自动扫描返回对象里带@MaskValue的字段,悄悄完成脱敏再发给前端,业务代码完全感知不到。
- 缓存:避免 "重复搜身" 的性能优化 第一次扫描类字段后,FieldCache会把带注解的字段信息缓存起来,下次同一类数据过来直接用缓存,不用反复扫描类结构。就像安检员记熟了常来的包裹,不用每次都拆开查,性能直接 up up。
- 策略模式:脱敏规则的 "万能调料包" MaskStrategy接口是个 "万能配方本":mask()方法定义怎么脱敏(比如手机号中间插*),shouldMask()定义什么时候脱敏(比如根据用户配置决定是否加密)。想换规则?新建个类实现接口就行,不用改核心逻辑。
- 防坑设计:专治数据 "套娃" 和 "死循环" 用IdentityHashMap记录已处理的对象,避免循环引用导致的死循环(比如 A 包含 B,B 又包含 A);用栈替代递归遍历对象,处理集合、Map 时更高效,再复杂的对象结构都能 hold 住。
优势总结:为什么说它是脱敏界的 "六边形战士"?
- 懒人狂喜:注解驱动零侵入 不用改业务代码,加个注解就生效,告别 "逐个字段写 replace" 的苦差事,新手也能 5 分钟上手。
- 性能不翻车:缓存 + 高效遍历 字段信息缓存减少重复扫描,非递归栈遍历避免 OOM,哪怕返回 1000 条数据也不卡顿。
- 规则随便换:策略模式玩出花 手机号、身份证、邮箱脱敏?只需换个策略类。甚至能根据用户配置动态开关脱敏(比如DefaultMaskPhoneStrategy里根据ifPhoneEncrypt判断),灵活度拉满。
- 安全无死角:自动处理不遗漏 从对象到集合再到 Map,所有带注解的字段全扫描,再也不怕手动改漏导致数据裸奔,合规检查稳稳通过。