Spring AOP场景2——数据脱敏(附带源码)

在白嫖之前,希望你会内疚,一键三连OK?完整版源码在最后,CV微改即可;

在白嫖之前,希望你会内疚,一键三连OK?完整版源码在最后,CV微改即可;

在白嫖之前,希望你会内疚,一键三连OK?完整版源码在最后,CV微改即可;

一、整体架构与设计思路

核心目标

实现「无侵入、可扩展、多类型」的数据脱敏,支持字符串/日期/数值等类型,适配Spring Boot接口返回场景,满足合规要求(如手机号、身份证、地址脱敏)。

技术选型

  • AOP:拦截方法返回值,处理字符串类型字段脱敏;
  • Jackson序列化器:处理Date类型字段脱敏(避免类型赋值冲突);
  • 注解驱动:通过自定义注解标记需要脱敏的方法/字段,灵活配置规则;
  • 工具类解耦:将脱敏规则封装为工具方法,便于扩展和复用。

核心流程

复制代码
接口请求 → 执行@Sensitive注解方法 → AOP拦截返回值 → 
递归处理对象/集合 → 字符串字段通过工具类脱敏 → 
Date字段通过Jackson序列化器脱敏 → 返回脱敏后数据

二、代码模块逐行解析

1. 枚举类:SensitiveType(脱敏类型定义)

复制代码
public enum SensitiveType {
    PHONE,        // 手机号:138****1234
    ID_CARD,      // 身份证:110101********1234
    NAME,         // 姓名:张*
    PASSWORD,     // 密码:******
    CUSTOM,       // 自定义(保留前后N位)
    ADDRESS,      // 地址:仅保留省市区
    AMOUNT,       // 金额:全*
    TIME,         // 时间:全*
    ORDER_NO      // 订单号:最后6位*
}

作用 :标准化脱敏类型,避免硬编码,配合注解使用,让脱敏规则可配置。
扩展点:新增脱敏类型时,只需在枚举中添加,再补充工具类方法即可。

2. 注解类:Sensitive(方法级标记)

复制代码
@Target(ElementType.METHOD)  // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,AOP可反射获取
@Documented
public @interface Sensitive {}

作用 :标记需要脱敏的接口方法,AOP通过该注解作为切入点,无需修改业务代码。
使用场景 :Controller层接口方法上添加@Sensitive,即可自动脱敏返回值。

3. 注解类:SensitiveField(字段级标记)

复制代码
@Target(ElementType.FIELD) // 仅作用于字段
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SensitiveField {
    SensitiveType type(); // 脱敏类型(必填)
    int prefixLen() default 2; // 自定义脱敏-前缀长度
    int suffixLen() default 2; // 自定义脱敏-后缀长度
}

作用 :标记实体类中需要脱敏的字段,并指定脱敏规则(类型+自定义参数)。
使用示例

复制代码
// 手机号脱敏
@SensitiveField(type = SensitiveType.PHONE)
private String phone;

// 自定义脱敏(保留前3后2)
@SensitiveField(type = SensitiveType.CUSTOM, prefixLen = 3, suffixLen = 2)
private String bankCard;

4. AOP切面:SensitiveAspect(核心处理逻辑)

核心属性
复制代码
// 递归深度限制(避免无限递归,如对象循环引用)
private static final int MAX_RECURSION_DEPTH = 10;
// 已脱敏对象缓存(避免重复处理同一对象,提升性能)
private final ThreadLocal<Set<Object>> desensitizedCache = ThreadLocal.withInitial(ConcurrentHashMap::newKeySet);
// 字段缓存(缓存类的字段信息,避免重复反射,提升性能)
private final Map<Class<?>, Field[]> fieldCache = new ConcurrentHashMap<>();

重点

  • ThreadLocal:保证多线程安全,每个线程独立缓存;
  • 递归深度限制:防止对象循环引用导致栈溢出;
  • 字段缓存:反射获取字段是耗时操作,缓存后提升性能。
切入点方法
复制代码
@Pointcut("@annotation(org.springblade.business.aspect.annotation.Sensitive)")
public void sensitivePointcut() {}

作用 :匹配所有添加@Sensitive注解的方法,作为AOP拦截入口。

返回通知:desensitize(入口方法)
复制代码
@AfterReturning(value = "sensitivePointcut()", returning = "result")
public void desensitize(JoinPoint joinPoint, Object result) {
    try {
        desensitizeObject(result, 0); // 递归处理返回值
    } catch (Exception e) {
        log.error("脱敏切面:脱敏失败", e);
    } finally {
        // 清空缓存,避免内存泄漏
        desensitizedCache.get().clear();
        desensitizedCache.remove();
    }
}

作用:方法执行完成后,对返回值进行脱敏处理,最终清空缓存。

核心递归方法:desensitizeObject
复制代码
private void desensitizeObject(Object obj, int depth) {
    // 终止条件1:对象为空
    if (obj == null) return;
    // 终止条件2:递归深度超限
    if (depth >= MAX_RECURSION_DEPTH) {
        log.warn("递归深度超过{},终止脱敏", MAX_RECURSION_DEPTH);
        return;
    }
    // 终止条件3:对象已脱敏(避免重复处理)
    if (desensitizedCache.get().contains(obj)) return;
    desensitizedCache.get().add(obj); // 标记已脱敏

    // 适配R<T>返回格式(SpringBlade通用返回对象)
    if (obj.getClass().getSimpleName().equals("R")) {
        Field dataField = obj.getClass().getDeclaredField("data");
        dataField.setAccessible(true);
        desensitizeObject(dataField.get(obj), depth + 1);
        return;
    }

    // 处理集合(List/Set)
    if (obj instanceof Collection<?> collection) {
        for (Object item : collection) desensitizeObject(item, depth + 1);
        return;
    }

    // 处理数组
    if (obj.getClass().isArray()) {
        Object[] array = (Object[]) obj;
        for (Object item : array) desensitizeObject(item, depth + 1);
        return;
    }

    // 处理单个业务对象
    desensitizeSingleObject(obj, depth + 1);
}

核心逻辑

  1. 终止条件:空对象、递归超限、已脱敏对象,避免无效处理;
  2. 适配通用返回对象R<T>:仅处理data属性中的业务数据;
  3. 兼容集合/数组:遍历元素逐个脱敏;
  4. 单个对象:交给desensitizeSingleObject处理字段级脱敏。
字段级脱敏:desensitizeSingleObject
复制代码
private void desensitizeSingleObject(Object obj, int depth) {
    Class<?> clazz = obj.getClass();
    // 排除基础类型/String/Date(Date交给序列化器处理)
    if (clazz.isPrimitive() || obj instanceof String || obj instanceof Date) return;

    Field[] fields = getCachedFields(clazz); // 获取缓存的字段
    for (Field field : fields) {
        field.setAccessible(true);
        Object fieldValue = field.get(obj);
        Class<?> fieldType = field.getType();

        // 跳过Date类型(序列化器处理)
        if (fieldType == Date.class) continue;

        // 无脱敏注解:递归处理子对象
        if (!field.isAnnotationPresent(SensitiveField.class)) {
            if (fieldValue != null && !isExcludeType(fieldType)) {
                desensitizeObject(fieldValue, depth + 1);
            }
            continue;
        }

        // 有注解:仅处理字符串类型字段
        if (fieldValue instanceof String strValue) {
            SensitiveField annotation = field.getAnnotation(SensitiveField.class);
            String desensitizedStr = getDesensitizedString(strValue, annotation.type(), annotation.prefixLen(), annotation.suffixLen());
            field.set(obj, desensitizedStr); // 替换为脱敏后的值
        }
    }
}

重点

  1. 排除基础类型/Date:基础类型无需脱敏,Date交给Jackson序列化器;
  2. 跳过框架类型:通过isExcludeType排除Spring/MyBatis等框架类,避免反射异常;
  3. 仅处理字符串字段:避免类型赋值冲突(如数值类型直接脱敏会报错);
  4. 反射修改字段值:通过field.set(obj, desensitizedStr)替换原始值。
工具类调用:getDesensitizedString
复制代码
private String getDesensitizedString(String fieldValue, SensitiveType type, int prefixLen, int suffixLen) {
    if (fieldValue.isBlank()) return fieldValue;
    return switch (type) {
        case PHONE -> DesensitizeUtil.desensitizePhone(fieldValue);
        case ID_CARD -> DesensitizeUtil.desensitizeIdCard(fieldValue);
        case NAME -> DesensitizeUtil.desensitizeName(fieldValue);
        case PASSWORD -> DesensitizeUtil.desensitizePassword(fieldValue);
        case CUSTOM -> DesensitizeUtil.desensitizeCustom(fieldValue, prefixLen, suffixLen);
        case ADDRESS -> DesensitizeUtil.desensitizeAddress(fieldValue);
        case AMOUNT -> DesensitizeUtil.desensitizeAmount(fieldValue);
        case TIME -> DesensitizeUtil.desensitizeTime(fieldValue);
        case ORDER_NO -> DesensitizeUtil.desensitizeOrderNo(fieldValue);
        default -> fieldValue;
    };
}

作用:根据脱敏类型,调用工具类对应的方法,解耦AOP和脱敏规则。

辅助方法:getCachedFields(字段缓存)
复制代码
private Field[] getCachedFields(Class<?> clazz) {
    if (fieldCache.containsKey(clazz)) return fieldCache.get(clazz);
    List<Field> fieldList = new ArrayList<>();
    Class<?> currentClazz = clazz;
    // 递归获取父类字段(处理继承场景)
    while (currentClazz != null && currentClazz != Object.class) {
        fieldList.addAll(Arrays.asList(currentClazz.getDeclaredFields()));
        currentClazz = currentClazz.getSuperclass();
    }
    Field[] fields = fieldList.toArray(new Field[0]);
    fieldCache.put(clazz, fields);
    return fields;
}

作用:缓存类的所有字段(包括父类),避免重复反射,提升性能。

辅助方法:isExcludeType(排除框架类型)
复制代码
private boolean isExcludeType(Class<?> clazz) {
    // 排除基础类型包装类
    Set<Class<?>> basicTypes = Set.of(Integer.class, Long.class, Double.class, Float.class,
            Boolean.class, Byte.class, Short.class, Character.class);
    if (basicTypes.contains(clazz)) return true;
    // 排除Date/数值类型
    if (clazz == Date.class || clazz == BigDecimal.class || Number.class.isAssignableFrom(clazz)) return true;
    // 排除框架类(避免反射处理Spring/MyBatis等对象)
    String className = clazz.getName();
    return className.startsWith("java.util.") && !className.startsWith("java.util.List") && !className.startsWith("java.util.Set") ||
            className.startsWith("org.springblade.") && !className.startsWith("org.springblade.business.entity") ||
            className.startsWith("com.baomidou.mybatisplus.") ||
            className.startsWith("jakarta.") ||
            className.startsWith("org.springframework.");
}

作用 :避免对框架类(如Spring的HashMap、MyBatis的Page)进行无效反射,防止报错。

5. Date类型序列化器:SensitiveDateSerializer

复制代码
public class SensitiveDateSerializer extends JsonSerializer<Date> {
    private static final SimpleDateFormat JSON_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA);

    @Override
    public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value == null) {
            gen.writeNull();
            return;
        }
        // 获取当前序列化的对象和JSON字段名
        Object currentObj = gen.getCurrentValue();
        String jsonFieldName = gen.getOutputContext().getCurrentName();
        // 查找字段的脱敏注解(兼容驼峰/下划线、父类字段)
        SensitiveField sensitiveField = findSensitiveField(currentObj.getClass(), jsonFieldName);
        
        // 无TIME注解:正常序列化
        if (sensitiveField == null || sensitiveField.type() != SensitiveType.TIME) {
            gen.writeString(JSON_FORMAT.format(value));
            return;
        }
        // 有TIME注解:全*脱敏
        String dateStr = JSON_FORMAT.format(value);
        gen.writeString("*".repeat(dateStr.length()));
    }

    // 递归查找字段注解(兼容父类)
    private SensitiveField findSensitiveField(Class<?> clazz, String jsonFieldName) {
        if (clazz == Object.class) return null;
        for (Field field : clazz.getDeclaredFields()) {
            String fieldName = field.getName();
            String jsonNameToCamel = underlineToCamel(jsonFieldName); // 下划线转驼峰
            if (fieldName.equals(jsonNameToCamel) || fieldName.equals(jsonFieldName)) {
                field.setAccessible(true);
                return field.getAnnotation(SensitiveField.class);
            }
        }
        return findSensitiveField(clazz.getSuperclass(), jsonFieldName);
    }

    // 下划线转驼峰(适配JSON字段名)
    private String underlineToCamel(String str) {
        if (str == null || str.isEmpty()) return str;
        StringBuilder sb = new StringBuilder();
        boolean nextUpper = false;
        for (char c : str.toCharArray()) {
            if (c == '_') {
                nextUpper = true;
            } else {
                sb.append(nextUpper ? Character.toUpperCase(c) : c);
                nextUpper = false;
            }
        }
        return sb.toString();
    }
}

核心解决的问题

  1. Date类型无法通过AOP直接脱敏(字符串赋值给Date会报错);
  2. JSON字段名可能是下划线(如submit_time),实体字段是驼峰(submitTime),需要格式转换;
  3. 兼容父类字段:递归查找父类中的字段注解;
  4. 仅对标记TIME类型的Date字段脱敏,其他Date字段正常序列化。

6. 脱敏工具类:DesensitizeUtil(规则实现)

手机号脱敏:desensitizePhone
复制代码
public static String desensitizePhone(String phone) {
    if (StringUtils.isBlank(phone) || phone.length() != 11) return phone;
    // 正则替换:保留前3后4,中间4位*
    return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}

规则:11位手机号才脱敏,避免非手机号字符串被错误处理。

身份证脱敏:desensitizeIdCard
复制代码
public static String desensitizeIdCard(String idCard) {
    if (StringUtils.isBlank(idCard)) return idCard;
    int length = idCard.length();
    if (length == 18) {
        // 18位:保留前6后4,中间8位*
        return idCard.replaceAll("(\\d{6})\\d{8}(\\d{4})", "$1********$2");
    } else if (length == 15) {
        // 15位:保留前6后3,中间6位*
        return idCard.replaceAll("(\\d{6})\\d{6}(\\d{3})", "$1******$2");
    }
    return idCard;
}

规则:区分15/18位身份证,适配不同长度的脱敏规则。

姓名脱敏:desensitizeName
复制代码
public static String desensitizeName(String name) {
    if (StringUtils.isBlank(name)) return name;
    int length = name.length();
    if (length == 1) return name; // 单字名不脱敏
    // 处理复姓(如欧阳、司马)
    String[] compoundSurnames = {"欧阳", "司马", "上官", "司徒", "夏侯", "诸葛", "闻人", "南宫", "万俟", "闻人", "赫连", "皇甫", "尉迟", "公羊"};
    String surname = "";
    if (length >= 2) {
        String twoChar = name.substring(0, 2);
        for (String cs : compoundSurnames) {
            if (cs.equals(twoChar)) {
                surname = twoChar;
                break;
            }
        }
    }
    // 非复姓取单字
    if (StringUtils.isBlank(surname)) surname = name.substring(0, 1);
    // 无论剩余多少字,只加1个*(如张三丰→张*,欧阳娜娜→欧阳*)
    return surname + "*";
}

难点:兼容复姓,避免复姓被错误截断(如"欧阳娜娜"脱敏为"欧阳*"而非"欧*")。

地址脱敏:desensitizeAddress
复制代码
public static String desensitizeAddress(String address) {
    if (StringUtils.isBlank(address)) return address;
    // 地址层级关键词(优先级:区/县 > 市 > 省)
    String[] levelKeywords = {
        "区", "县", "旗", "自治县", "自治旗", "林区", "特区",
        "市", "自治州", "地区", "盟",
        "省", "自治区", "直辖市", "特别行政区"
    };
    // 找到第一个层级关键词,截断后续内容
    for (String keyword : levelKeywords) {
        int keywordIndex = address.indexOf(keyword);
        if (keywordIndex != -1) {
            return StringUtils.trim(address.substring(0, keywordIndex + keyword.length()));
        }
    }
    return address;
}

规则:仅保留省/市/县(区),剔除街道、门牌号等敏感信息,适配不同地区的地址格式(如直辖市、自治区)。

其他工具方法
  • 密码脱敏:固定返回6个*,无论原密码长度;
  • 订单号脱敏:最后6位替换为*,长度≤6则全*;
  • 金额脱敏:全*,支持字符串和数值类型(重载方法);
  • 自定义脱敏:保留指定前缀/后缀,中间4个*,避免前缀+后缀长度超过字符串长度。

三、重点难点总结(面试高频)

1. 核心难点:Date类型脱敏

  • 问题:AOP中直接修改Date字段值会导致类型赋值冲突(字符串→Date);
  • 解决方案:通过Jackson序列化器在JSON输出阶段脱敏,不修改实体字段原值;
  • 关键细节 :兼容JSON字段名(驼峰/下划线)、父类字段、@JsonFormat格式。

2. 性能优化点

  • 字段缓存:反射获取字段是耗时操作,缓存类的字段信息;
  • 已脱敏对象缓存:避免重复处理同一对象(如集合中重复元素);
  • 递归深度限制:防止对象循环引用导致栈溢出;
  • ThreadLocal缓存:保证多线程安全,避免缓存污染。

3. 兼容性设计

  • 适配通用返回对象 :支持R<T>、集合、数组、单个对象;
  • 排除框架类型:避免反射处理Spring/MyBatis等框架类,防止报错;
  • 父类字段兼容:递归获取父类字段,支持继承场景;
  • 空值/异常处理:所有脱敏方法都做了空值判断,避免NPE。

4. 扩展性设计

  • 枚举驱动 :新增脱敏类型只需在SensitiveType中添加,补充工具类方法;
  • 注解参数化:自定义脱敏支持前缀/后缀长度配置;
  • 工具类解耦:脱敏规则与AOP/序列化器解耦,便于修改规则。

5. 面试高频问题

Q1:为什么Date类型不能通过AOP直接脱敏?

A:AOP中通过反射修改字段值时,若字段是Date类型,脱敏后的字符串无法赋值给Date字段,会抛出IllegalArgumentException;因此选择在Jackson序列化阶段脱敏,仅修改JSON输出内容,不修改实体字段原值。

Q2:如何避免递归处理对象时的栈溢出?

A:

  1. 设置递归深度限制(如MAX_RECURSION_DEPTH = 10);
  2. 缓存已脱敏对象,避免重复处理;
  3. 排除框架类型和基础类型,减少递归次数。
Q3:如何提升脱敏框架的性能?

A:

  1. 缓存类的字段信息,避免重复反射;
  2. 缓存已脱敏对象,避免重复处理;
  3. 排除无需脱敏的类型(基础类型、框架类型);
  4. 仅处理有@SensitiveField注解的字段,减少无效遍历。
Q4:如何扩展新的脱敏类型?

A:

  1. SensitiveType枚举中添加新类型(如BANK_CARD);
  2. DesensitizeUtil中实现对应的脱敏方法(如desensitizeBankCard);
  3. SensitiveAspectgetDesensitizedString方法中添加枚举分支;
  4. 在实体字段上添加@SensitiveField(type = SensitiveType.BANK_CARD)
Q5:为什么要排除框架类型?

A:框架类型(如Spring的HashMap、MyBatis的Page)无需脱敏,且反射处理这些类可能会抛出权限异常,因此通过isExcludeType方法排除,只处理业务实体类。

四、开箱即用使用指南

1. 快速集成

  1. 复制所有类到项目中(枚举、注解、切面、序列化器、工具类);
  2. 确保依赖齐全(Spring AOP、Jackson、Apache Commons Lang);
  3. 在Controller层接口方法上添加@Sensitive注解;
  4. 在实体类敏感字段上添加@SensitiveField注解(指定脱敏类型);
  5. Date类型字段添加@JsonSerialize(using = SensitiveDateSerializer.class)

2. 使用示例

实体类
复制代码
public class User {
    @SensitiveField(type = SensitiveType.PHONE)
    private String phone;

    @SensitiveField(type = SensitiveType.ID_CARD)
    private String idCard;

    @SensitiveField(type = SensitiveType.NAME)
    private String realName;

    @SensitiveField(type = SensitiveType.TIME)
    @JsonSerialize(using = SensitiveDateSerializer.class)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date submitTime;

    @SensitiveField(type = SensitiveType.ADDRESS)
    private String address;
}
Controller层
复制代码
@RestController
@RequestMapping("/user")
public class UserController {
    @PostMapping("/get")
    @Sensitive // 标记该方法返回值需要脱敏
    public R<User> getUser() {
        User user = new User();
        user.setPhone("13812345678");
        user.setIdCard("110101199001011234");
        user.setRealName("张三丰");
        user.setSubmitTime(new Date());
        user.setAddress("北京市朝阳区建国路88号");
        return R.ok(user);
    }
}
返回结果
复制代码
{
    "code": 200,
    "success": true,
    "data": {
        "phone": "138****5678",
        "idCard": "110101********1234",
        "realName": "张*",
        "submitTime": "******************",
        "address": "北京市朝阳区"
    },
    "msg": "操作成功"
}

五、扩展建议

  1. 支持更多类型:如BigDecimal、LocalDateTime(参考Date序列化器实现);
  2. 配置化脱敏规则:将脱敏规则(如手机号保留位数)配置在yml文件中,无需修改代码;
  3. 全局序列化器配置 :通过Jackson全局配置注册序列化器,无需在每个Date字段添加@JsonSerialize
  4. 脱敏日志:添加脱敏审计日志,记录脱敏的字段、原值(脱敏后)、操作时间等;
  5. 自定义序列化器注解 :封装@SensitiveDate注解,简化Date字段的注解配置。

完整版源码

复制代码
package org.springblade.business.enums;

/**
 * 脱敏类型枚举
 */
public enum SensitiveType {
    /** 手机号:138****1234 */
    PHONE,
    /** 身份证号:110101********1234 */
    ID_CARD,
    /** 姓名:张*、张三丰→张* */
    NAME,
    /** 密码:全部打码 ****** */
    PASSWORD,
    /** 自定义脱敏(保留前2位、后2位) */
    CUSTOM,
    /* 地址脱敏,仅保留省市区级,如江西省赣州市于都县 */
    ADDRESS,
    /* 金额脱敏,全部打码 */
    AMOUNT,
    /* 时间脱敏,全部打码 */
    TIME,
    /** 订单编号,最后6位打码 */
    ORDER_NO
}

package org.springblade.business.aspect.annotation;

import java.lang.annotation.*;

/**
 * 方法级注解:标记该方法的返回值需要进行数据脱敏
 */
@Target(ElementType.METHOD) // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,AOP可反射获取
@Documented
public @interface Sensitive {
}

package org.springblade.business.aspect.annotation;

import org.springblade.business.enums.SensitiveType;

import java.lang.annotation.*;

/**
 * 字段级注解:标记该字段需要脱敏,并指定脱敏类型
 */
@Target(ElementType.FIELD) // 仅作用于字段
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,AOP可反射获取
@Documented
public @interface SensitiveField {
    /**
     * 脱敏类型(必填)
     */
    SensitiveType type();

    /**
     * 自定义脱敏:保留前缀长度(默认2)
     */
    int prefixLen() default 2;

    /**
     * 自定义脱敏:保留后缀长度(默认2)
     */
    int suffixLen() default 2;
}

package org.springblade.business.aspect;

import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springblade.business.aspect.annotation.SensitiveField;
import org.springblade.business.enums.SensitiveType;
import org.springblade.business.util.DesensitizeUtil;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 数据脱敏AOP切面
 * 拦截带@Sensitive注解的方法,自动脱敏返回值中的敏感字段
 */
@Slf4j
@Aspect
@Component
public class SensitiveAspect {

    // 递归深度限制(避免无限递归)
    private static final int MAX_RECURSION_DEPTH = 10;
    // 已脱敏对象缓存(避免重复处理同一对象)
    private final ThreadLocal<Set<Object>> desensitizedCache = ThreadLocal.withInitial(ConcurrentHashMap::newKeySet);
    // 字段缓存(优化性能)
    private final Map<Class<?>, Field[]> fieldCache = new ConcurrentHashMap<>();

    /**
	 * 切入点:拦截所有带@Sensitive注解的方法
	 */
    @Pointcut("@annotation(org.springblade.business.aspect.annotation.Sensitive)")
    public void sensitivePointcut() {
    }

    /**
	 * 返回通知:方法执行成功后,对返回值进行脱敏
	 */
    @AfterReturning(value = "sensitivePointcut()", returning = "result")
    public void desensitize(JoinPoint joinPoint, Object result) {
        log.info("脱敏切面:进入脱敏方法,返回值类型:{}", result.getClass().getName());

        try {
            log.info("脱敏切面:开始脱敏,原始数据:{}", JSONUtil.toJsonStr(result));
            // 核心脱敏逻辑(初始化递归深度为0,清空缓存)
            desensitizeObject(result, 0);
            log.info("脱敏切面:脱敏完成,脱敏后数据:{}", JSONUtil.toJsonStr(result));
        } catch (Exception e) {
            log.error("脱敏切面:脱敏失败", e);
        } finally {
            // 清空线程缓存,避免内存泄漏
            desensitizedCache.get().clear();
            desensitizedCache.remove();
        }
        log.info("脱敏切面:脱敏完成,正在退出脱敏方法");
    }

    /**
	 * 递归处理对象脱敏(增加递归深度限制,避免无限递归)
	 *
	 * @param obj   待脱敏对象
	 * @param depth 当前递归深度
	 */
    private void desensitizeObject(Object obj, int depth) {
        // 终止条件1:对象为空
        if (obj == null) {
            return;
        }
        // 终止条件2:递归深度超过限制
        if (depth >= MAX_RECURSION_DEPTH) {
            log.warn("【脱敏切面】递归深度超过{},终止脱敏:{}", MAX_RECURSION_DEPTH, obj.getClass().getName());
            return;
        }
        // 终止条件3:对象已脱敏(避免重复处理)
        if (desensitizedCache.get().contains(obj)) {
            return;
        }
        // 标记对象为已脱敏
        desensitizedCache.get().add(obj);

        // 适配R<T>返回格式:先获取data属性
        if (obj.getClass().getSimpleName().equals("R")) {
            try {
                Field dataField = obj.getClass().getDeclaredField("data");
                dataField.setAccessible(true);
                Object data = dataField.get(obj);
                // 递归处理data,深度+1
                desensitizeObject(data, depth + 1);
                return;
            } catch (NoSuchFieldException | IllegalAccessException e) {
                log.warn("【脱敏切面】未找到R对象的data字段,直接脱敏原对象");
            }
        }

        // 场景1:集合(List/Set)→ 遍历元素脱敏
        if (obj instanceof Collection<?> collection) {
            for (Object item : collection) {
                desensitizeObject(item, depth + 1);
            }
            return;
        }

        // 场景2:数组 → 遍历元素脱敏
        if (obj.getClass().isArray()) {
            Object[] array = (Object[]) obj;
			for (Object item : array) {
				desensitizeObject(item, depth + 1);
			}
			return;
		}

		// 场景3:单个业务对象 → 脱敏字段
		desensitizeSingleObject(obj, depth + 1);
	}

	/**
	 * 处理单个对象的脱敏(仅处理业务实体字段)
	 */
	private void desensitizeSingleObject(Object obj, int depth) {
		if (obj == null) {
			return;
		}

		Class<?> clazz = obj.getClass();
		// 排除基础类型/String/Date(Date交给序列化器处理)
		if (clazz.isPrimitive() || obj instanceof String || obj instanceof Date) {
			return;
		}

		try {
			Field[] fields = getCachedFields(clazz);
			for (Field field : fields) {
				field.setAccessible(true);
				Object fieldValue = field.get(obj);
				Class<?> fieldType = field.getType();

				// 核心:跳过Date类型字段(无论是否有注解)
				if (fieldType == Date.class) {
					continue;
				}

				// 1. 无脱敏注解的字段:按原有逻辑过滤
				if (!field.isAnnotationPresent(SensitiveField.class)) {
					if (fieldValue != null && !isExcludeType(fieldType)) {
						desensitizeObject(fieldValue, depth + 1);
					}
					continue;
				}

				// 2. 仅处理字符串类型的注解字段
				if (fieldValue instanceof String strValue) {
					SensitiveField annotation = field.getAnnotation(SensitiveField.class);
					SensitiveType type = annotation.type();
					int prefixLen = annotation.prefixLen();
					int suffixLen = annotation.suffixLen();
					String desensitizedStr = getDesensitizedString(strValue, type, prefixLen, suffixLen);
					field.set(obj, desensitizedStr);
				}
			}
		} catch (IllegalAccessException e) {
			log.error("【脱敏切面】反射处理字段失败", e);
		}
	}

	/**
	 * 仅处理字符串类型的脱敏(移除类型转回逻辑,避免赋值异常)
	 */
	private String getDesensitizedString(String fieldValue, SensitiveType type, int prefixLen, int suffixLen) {
		if (fieldValue.isBlank()) {
			return fieldValue;
		}

		return switch (type) {
			case PHONE -> DesensitizeUtil.desensitizePhone(fieldValue);
			case ID_CARD -> DesensitizeUtil.desensitizeIdCard(fieldValue);
			case NAME -> DesensitizeUtil.desensitizeName(fieldValue);
			case PASSWORD -> DesensitizeUtil.desensitizePassword(fieldValue);
			case CUSTOM -> DesensitizeUtil.desensitizeCustom(fieldValue, prefixLen, suffixLen);
			case ADDRESS -> DesensitizeUtil.desensitizeAddress(fieldValue);
			case AMOUNT -> DesensitizeUtil.desensitizeAmount(fieldValue);
			case TIME -> DesensitizeUtil.desensitizeTime(fieldValue);
			case ORDER_NO -> DesensitizeUtil.desensitizeOrderNo(fieldValue);
			default -> fieldValue;
		};
	}

	/**
	 * 缓存获取类的所有字段(包括父类)
	 */
	private Field[] getCachedFields(Class<?> clazz) {
		if (fieldCache.containsKey(clazz)) {
			return fieldCache.get(clazz);
		}

		List<Field> fieldList = new ArrayList<>();
		Class<?> currentClazz = clazz;
		// 遍历所有父类(直到Object),不限制TenantEntity
		while (currentClazz != null && currentClazz != Object.class) {
			fieldList.addAll(Arrays.asList(currentClazz.getDeclaredFields()));
			currentClazz = currentClazz.getSuperclass();
		}

		Field[] fields = fieldList.toArray(new Field[0]);
		fieldCache.put(clazz, fields);
		return fields;
	}

	/**
	 * 判断是否为需要排除的类型(核心:避免递归处理框架/基础类型)
	 */
	private boolean isExcludeType(Class<?> clazz) {
		// 基础类型包装类
		Set<Class<?>> basicTypes = Set.of(Integer.class, Long.class, Double.class, Float.class,
			Boolean.class, Byte.class, Short.class, Character.class);
		if (basicTypes.contains(clazz)) {
			return true;
		}
		// 核心:明确排除Date/数值类型
		if (clazz == Date.class || clazz == BigDecimal.class || Number.class.isAssignableFrom(clazz)) {
			return true;
		}
		// 框架类型排除
		String className = clazz.getName();
		return className.startsWith("java.util.") && !className.startsWith("java.util.List") && !className.startsWith("java.util.Set") ||
			className.startsWith("org.springblade.") && !className.startsWith("org.springblade.business.entity") ||
			className.startsWith("com.baomidou.mybatisplus.") ||
			className.startsWith("jakarta.") ||
			className.startsWith("org.springframework.");
	}
}

package org.springblade.business.util;

import org.apache.commons.lang3.StringUtils;

/**
 * 数据脱敏工具类
 * 脱敏规则:
 * 1. 姓名:仅保留姓氏,其余打*(如刘德华→刘*)
 * 2. 订单编号:最后6位固定为******
 * 3. 放款时间/放款金额:全部替换为*
 * 4. 手机号/身份证/密码:通用脱敏逻辑
 * 5. 自定义脱敏:保留指定前缀+后缀,中间打*
 */
public class DesensitizeUtil {

    /**
	 * 手机号脱敏:保留前3位、后4位,中间4位打码
	 * 示例:13812345678 → 138****5678
	 */
    public static String desensitizePhone(String phone) {
        if (StringUtils.isBlank(phone) || phone.length() != 11) {
            return phone;
        }
        return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }

    /**
	 * 身份证号脱敏:保留前6位、后4位,中间打码
	 * 示例:110101199001011234 → 110101********1234
	 */
    public static String desensitizeIdCard(String idCard) {
        if (StringUtils.isBlank(idCard)) {
            return idCard;
        }
        int length = idCard.length();
        if (length == 18) {
            return idCard.replaceAll("(\\d{6})\\d{8}(\\d{4})", "$1********$2");
        } else if (length == 15) {
            return idCard.replaceAll("(\\d{6})\\d{6}(\\d{3})", "$1******$2");
        }
        return idCard;
    }

    /**
	 * 姓名脱敏:仅保留姓氏(单姓/复姓),其余字符统一只打1个*
	 * 示例:
	 * 单字名 → 张(不变)
	 * 单姓多字 → 张三→张*、刘德华→刘*
	 * 复姓 → 欧阳娜娜→欧阳*、司马相如→司马*
	 */
    public static String desensitizeName(String name) {
        if (StringUtils.isBlank(name)) {
            return name;
        }
        int length = name.length();
        // 1. 单字名直接返回
        if (length == 1) {
            return name;
        }

        // 2. 定义常见复姓(可根据业务扩展)
        String[] compoundSurnames = {"欧阳", "司马", "上官", "司徒", "夏侯", "诸葛", "闻人", "南宫", "万俟", "闻人", "赫连", "皇甫", "尉迟", "公羊"};
        String surname = "";

        // 3. 判断是否为复姓(优先匹配复姓,避免单姓误判)
        if (length >= 2) {
            String twoChar = name.substring(0, 2);
            for (String cs : compoundSurnames) {
                if (cs.equals(twoChar)) {
                    surname = twoChar;
                    break;
                }
            }
        }

        // 4. 非复姓则取单字为姓
        if (StringUtils.isBlank(surname)) {
            surname = name.substring(0, 1);
        }

        // 5. 无论剩余字符多少,只加1个*
        return surname + "*";
    }

    /**
	 * 密码脱敏:全部打码
	 * 示例:123456 → ******
	 */
    public static String desensitizePassword(String password) {
        if (StringUtils.isBlank(password)) {
            return password;
        }
        return "******";
    }

    /**
	 * 订单编号脱敏:最后6位固定替换为******
	 * 示例:ORDER123456789 → ORDER123******、123456 → ******
	 */
    public static String desensitizeOrderNo(String orderNo) {
        if (StringUtils.isBlank(orderNo)) {
            return orderNo;
        }
        int length = orderNo.length();
        // 长度≤6时,全部替换为******
        if (length <= 6) {
            return "******";
        }
        // 长度>6时,保留前面部分,最后6位替换为******
        String prefix = orderNo.substring(0, length - 6);
        return prefix + "******";
    }

    /**
	 * 时间脱敏:全部替换为*(长度与原字符串一致)
	 * 示例:2025-12-09 10:00:00 → ******************
	 */
    public static String desensitizeTime(String time) {
        if (time == null || time.isBlank()) {
            return time;
        }
        return "*".repeat(time.length());
    }

    /**
	 * 放款金额脱敏:全部替换为*(长度与原字符串一致)
	 * 示例:10000.00 → *******
	 */
    public static String desensitizeAmount(String amount) {
        if (StringUtils.isBlank(amount)) {
            return amount;
        }
        return "*".repeat(amount.length());
    }

    /**
	 * 自定义脱敏:保留指定前缀和后缀长度,中间打码
	 */
    public static String desensitizeCustom(String str, int prefixLen, int suffixLen) {
		if (StringUtils.isBlank(str) || prefixLen + suffixLen >= str.length()) {
			return str;
		}
		String prefix = str.substring(0, prefixLen);
		String suffix = str.substring(str.length() - suffixLen);
		return prefix + "****" + suffix;
	}

	// 重载:适配数值类型的放款金额(如BigDecimal/Integer/Double)
	public static String desensitizeAmount(Number amount) {
		if (amount == null) {
			return null;
		}
		String amountStr = amount.toString();
		return "*".repeat(amountStr.length());
	}

	/**
	 * 详细地址脱敏:仅保留省/市/县(区)层级,剔除街道、道路、门牌号等详细信息
	 * 适配场景:
	 * 1. 普通地址:湖北省神农架林区347国道北侧 → 湖北省神农架林区
	 * 2. 直辖市:北京市朝阳区建国路88号 → 北京市朝阳区
	 * 3. 无省信息:杭州市西湖区文三路 → 杭州市西湖区
	 * 4. 仅省/市:广东省深圳市 → 广东省深圳市
	 */
	public static String desensitizeAddress(String address) {
		// 空值/空白串直接返回
		if (StringUtils.isBlank(address)) {
			return address;
		}

		// 定义地址层级关键词(优先级:区/县 > 市 > 省,匹配到则截断后续内容)
		String[] levelKeywords = {
			"区", "县", "旗", "自治县", "自治旗", "林区", "特区", // 县级关键词(核心截断标识)
			"市", "自治州", "地区", "盟",                       // 市级关键词(无县级时截断)
			"省", "自治区", "直辖市", "特别行政区"              // 省级关键词(仅保留省)
		};

		// 遍历关键词,找到第一个匹配的层级末尾,截断后续内容
		for (String keyword : levelKeywords) {
			int keywordIndex = address.indexOf(keyword);
			if (keywordIndex != -1) {
				// 截取到关键词末尾(如"神农架林区"中"区"在最后,截取到"区")
				String result = address.substring(0, keywordIndex + keyword.length());
				// 去除截断后末尾的多余空格/特殊字符
				return StringUtils.trim(result);
			}
		}

		// 无匹配层级关键词时,返回原地址(避免误截断)
		return address;
	}

}

package org.springblade.business.config;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springblade.business.aspect.annotation.SensitiveField;
import org.springblade.business.enums.SensitiveType;

import java.io.IOException;
import java.lang.reflect.Field;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

/**
 * 需要在date格式的字段上填下如下注解,日期才能实现脱敏,指定脱敏序列化器
 * //@JsonSerialize(using = SensitiveDateSerializer.class)
 */
public class SensitiveDateSerializer extends JsonSerializer<Date> {

    // 适配@JsonFormat的日期格式
    private static final SimpleDateFormat JSON_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA);

    @Override
    public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value == null) {
            gen.writeNull();
            return;
        }

        // 1. 获取当前序列化的实体对象和字段名(JSON字段名)
        Object currentObj = gen.getCurrentValue();
        String jsonFieldName = gen.getOutputContext().getCurrentName();
        SensitiveField sensitiveField = null;

        // 2. 遍历实体类+父类字段,匹配字段(兼容驼峰/下划线)
        sensitiveField = findSensitiveField(currentObj.getClass(), jsonFieldName);

        // 3. 无TIME脱敏注解 → 正常序列化(按@JsonFormat格式)
        if (sensitiveField == null || sensitiveField.type() != SensitiveType.TIME) {
            gen.writeString(JSON_FORMAT.format(value));
            return;
        }

        // 4. 有TIME注解 → 按yyyy-MM-dd HH:mm:ss格式脱敏
        String dateStr = JSON_FORMAT.format(value);
        gen.writeString("*".repeat(dateStr.length()));
    }

    /**
	 * 递归查找字段的@SensitiveField注解(兼容驼峰/下划线、父类字段)
	 */
    private SensitiveField findSensitiveField(Class<?> clazz, String jsonFieldName) {
        // 终止条件:已到Object类
        if (clazz == Object.class) {
            return null;
        }

        // 遍历当前类所有字段
        for (Field field : clazz.getDeclaredFields()) {
            // 匹配规则:实体字段名(驼峰)= JSON字段名(下划线转驼峰)
            String fieldName = field.getName();
            String jsonNameToCamel = underlineToCamel(jsonFieldName);
            if (fieldName.equals(jsonNameToCamel) || fieldName.equals(jsonFieldName)) {
                field.setAccessible(true);
                return field.getAnnotation(SensitiveField.class);
            }
        }

        // 递归查找父类
        return findSensitiveField(clazz.getSuperclass(), jsonFieldName);
    }

    /**
	 * 下划线转驼峰(适配JSON字段名)
	 */
    private String underlineToCamel(String str) {
        if (str == null || str.isEmpty()) {
            return str;
        }
        StringBuilder sb = new StringBuilder();
        boolean nextUpper = false;
        for (char c : str.toCharArray()) {
            if (c == '_') {
                nextUpper = true;
            } else {
                sb.append(nextUpper ? Character.toUpperCase(c) : c);
                nextUpper = false;
            }
        }
        return sb.toString();
    }
}

注释:

要使用这个框架的话,只需要在接口处添加如下注解:

然后,需要在你的VO中添加对应注解,如果是date数据类型,还需要添加日期序列化注解:

对于日期格式,还需添加如下注解:

测试结果,如下所示:

相关推荐
JavaEdge.3 小时前
Spring数据源配置
java·后端·spring
sunly_3 小时前
Flutter:showModalBottomSheet底部弹出完整页面
开发语言·javascript·flutter
铭毅天下3 小时前
Spring Boot + Easy-ES 3.0 + Easyearch 实战:从 CRUD 到“避坑”指南
java·spring boot·后端·spring·elasticsearch
李慕婉学姐3 小时前
【开题答辩过程】以《基于Springboot的惠美乡村助农系统的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
java·spring boot·后端
Cricyta Sevina3 小时前
Java Map 集合深度笔记(理论篇)
java·笔记·哈希算法·map集合
似霰4 小时前
传统 Hal 开发笔记2----传统 HAL 整体架构
java·架构·framework·hal
漏洞文库-Web安全4 小时前
AWD比赛随笔
开发语言·python·安全·web安全·网络安全·ctf·awd
源码获取_wx:Fegn08954 小时前
基于springboot + vue停车场管理系统
java·vue.js·spring boot·后端·spring·课程设计
张人玉4 小时前
C#通信精讲系列——C# 通讯编程基础(含代码实例)
开发语言·c#·c#通信