从 "手撕快递单" 到 "一键打码":我用注解给数据戴了个 "口罩"

68 阅读6分钟

痛点暴击:手动脱敏?当代打工人的 "指尖酷刑"

前阵子公司搞活动,运营同学拿着 Excel 表格来找我:"哥,这 1000 个用户的手机号得打码,不然展示出来违规!" 我瞅了瞅表格里密密麻麻的138****5678,再想想他要逐个单元格替换的场景 —— 这不就是当代版 "手撕快递单" 吗?

手动改数的痛,谁懂啊:改漏了怕合规追责,改错了用户投诉,改多了眼花手抖。更惨的是下次活动又来了,重复劳动到怀疑人生。直到我写了这套 "数据自动打码机",才算把自己从 "脱敏流水线" 上解放出来。

脱敏工具の懒人黑科技:注解一贴,数据自动 "穿马甲"

这套工具的核心逻辑特简单:用注解标记要脱敏的字段,程序自动在返回给前端前 "打码"。再也不用写一堆replace逻辑,更不用盯着数据逐个改。

举个栗子:给手机号戴 "口罩" 只需 3 步

  1. 贴标签:在实体类的手机号字段上加@MaskValue注解,告诉程序 "这货要脱敏":
public class ActivityUser {
    private String name;
    @MaskValue // 注解一贴,自动脱敏
    private String phone;
}
  1. 选配方:默认用手机号脱敏策略(中间 4 位变****),想换规则?自己写个MaskStrategy就行,比如身份证脱敏、邮箱脱敏。
  2. 躺平等结果:接口返回时自动拦截处理,前端拿到的手机号自动变成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();
    }

}

干货拆解:这工具到底藏了多少 "小心机"?

别看用起来简单,内部设计全是 "懒人智慧"。咱们扒开代码看看它是怎么干活的:

  1. 注解:给数据贴 "需要打码" 的便利贴 @MaskValue注解就像个荧光笔,标记出需要脱敏的字段。你可以指定用哪个脱敏策略(比如@MaskValue(DefaultMaskPhoneStrategy.class)),也能默认用手机号策略,灵活得很。
  2. 拦截器:响应数据的 "安检员" MaskResponseAdvice实现了 Spring 的ResponseBodyAdvice,就像快递站的安检员 —— 所有接口返回的数据都会经过它检查。它会自动扫描返回对象里带@MaskValue的字段,悄悄完成脱敏再发给前端,业务代码完全感知不到。
  3. 缓存:避免 "重复搜身" 的性能优化 第一次扫描类字段后,FieldCache会把带注解的字段信息缓存起来,下次同一类数据过来直接用缓存,不用反复扫描类结构。就像安检员记熟了常来的包裹,不用每次都拆开查,性能直接 up up。
  4. 策略模式:脱敏规则的 "万能调料包" MaskStrategy接口是个 "万能配方本":mask()方法定义怎么脱敏(比如手机号中间插*),shouldMask()定义什么时候脱敏(比如根据用户配置决定是否加密)。想换规则?新建个类实现接口就行,不用改核心逻辑。
  5. 防坑设计:专治数据 "套娃" 和 "死循环" 用IdentityHashMap记录已处理的对象,避免循环引用导致的死循环(比如 A 包含 B,B 又包含 A);用栈替代递归遍历对象,处理集合、Map 时更高效,再复杂的对象结构都能 hold 住。

优势总结:为什么说它是脱敏界的 "六边形战士"?

  • 懒人狂喜:注解驱动零侵入 不用改业务代码,加个注解就生效,告别 "逐个字段写 replace" 的苦差事,新手也能 5 分钟上手。
  • 性能不翻车:缓存 + 高效遍历 字段信息缓存减少重复扫描,非递归栈遍历避免 OOM,哪怕返回 1000 条数据也不卡顿。
  • 规则随便换:策略模式玩出花 手机号、身份证、邮箱脱敏?只需换个策略类。甚至能根据用户配置动态开关脱敏(比如DefaultMaskPhoneStrategy里根据ifPhoneEncrypt判断),灵活度拉满。
  • 安全无死角:自动处理不遗漏 从对象到集合再到 Map,所有带注解的字段全扫描,再也不怕手动改漏导致数据裸奔,合规检查稳稳通过。