后端_看不见的隐私盾牌:一文读懂数据脱敏

数据脱敏技术详解与实战

前言

在 API 接口开发中,用户手机号、身份证号、银行卡号等敏感数据的传输与展示需严格遵循隐私保护规范。若直接返回原始数据,可能引发信息泄露风险。web 组件的 desensitize 包提供了注解驱动的脱敏方案,同时支持基于角色/权限的字段权限控制,可灵活实现“敏感数据按需展示”。

本文结合脱敏组件实践,从「脱敏原理-内置能力-自定义扩展-权限控制」四个维度,提供可直接落地的敏感数据保护指南。

1. 环境准备与依赖说明

1.1 核心依赖

可以将脱敏功能集成在 web Starter 中,后续使用则无需额外引入独立依赖,仅需确保项目已引入核心 web 组件。
组件参考目录实现如下:

desensize
├── core
│   ├── base
│   │   ├── annotation
│   │   │   └── DesensitizeBy
│   │   ├── handler
│   │   │   └── DesensitizationHandler
│   │   └── serializer
│   │       └── StringDesensitizeSerializer
│   ├── regex
│   │   ├── annotation
│   │   │   └── RegexDesensitize
│   │   └── handler
│   │       ├── AbstractRegexDesensitizationHandler
│   │       ├── DefaultRegexDesensitizationHandler
│   └── slider
│       ├── annotation
│       │   ├── BankCardDesensitize
│       │   ├── IdCardDesensitize
│       │   ├── MobileDesensitize
│       │   ├── PasswordDesensitize
│       └── handler
│           ├── AbstractSliderDesensitizationHandler
│           ├── BankCardDesensitization
│           ├── DefaultDesensitizationHandler
│           ├── IdCardDesensitization
│           ├── MobileDesensitization
│           └── PasswordDesensitization

1.2 实现原理说明

脱敏组件基于 Jackson 框架扩展,通过自定义注解与序列化处理器实现数据脱敏。核心流程如下:

  1. 在 VO/DTO 的敏感字段上添加脱敏注解(如 @MobileDesensitize
  2. 接口返回数据时,Jackson 序列化器触发注解对应的脱敏处理器
  3. 处理器按预设规则对原始数据进行脱敏处理(如替换中间字符为 *
  4. 最终返回脱敏后的安全数据,原始数据在服务端保持完整

核心实现类参考如下

/**
 * 脱敏注解,自定义注解需要使用此注解
 */
@Documented
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside // 此注解是其他所有 jackson 注解的元注解,打上了此注解的注解表明是 jackson 注解的一部分
@JsonSerialize(using = StringDesensitizeSerializer.class) // 指定序列化器
public @interface DesensitizeBy {
    /**
     * 脱敏处理器
     */
    @SuppressWarnings("rawtypes")
    Class<? extends DesensitizationHandler> handler();

}

/**
 * 脱敏序列化器
 *
 * 实现 JSON 返回数据时,使用 {@link DesensitizationHandler} 对声明脱敏注解的字段,进行脱敏处理。
 */
@SuppressWarnings("rawtypes")
public class StringDesensitizeSerializer extends StdSerializer<String> implements ContextualSerializer {

    @Getter
    @Setter
    private DesensitizationHandler desensitizationHandler;

    protected StringDesensitizeSerializer() {
        super(String.class);
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) {
        DesensitizeBy annotation = beanProperty.getAnnotation(DesensitizeBy.class);
        if (annotation == null) {
            return this;
        }
        // 创建一个 StringDesensitizeSerializer 对象,使用 DesensitizeBy 对应的处理器
        StringDesensitizeSerializer serializer = new StringDesensitizeSerializer();
        serializer.setDesensitizationHandler(Singleton.get(annotation.handler()));
        return serializer;
    }

    @Override
    @SuppressWarnings("unchecked")
    public void serialize(String value, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
        if (StrUtil.isBlank(value)) {
            gen.writeNull();
            return;
        }
        // 获取序列化字段
        Field field = getField(gen);

        // 自定义处理器
        DesensitizeBy[] annotations = AnnotationUtil.getCombinationAnnotations(field, DesensitizeBy.class);
        if (ArrayUtil.isEmpty(annotations)) {
            gen.writeString(value);
            return;
        }
        for (Annotation annotation : field.getAnnotations()) {
            if (AnnotationUtil.hasAnnotation(annotation.annotationType(), DesensitizeBy.class)) {
                value = this.desensitizationHandler.desensitize(value, annotation);
                gen.writeString(value);
                return;
            }
        }
        gen.writeString(value);
    }

    /**
     * 获取字段
     *
     * @param generator JsonGenerator
     * @return 字段
     */
    private Field getField(JsonGenerator generator) {
        String currentName = generator.getOutputContext().getCurrentName();
        Object currentValue = generator.currentValue();
        Class<?> currentValueClass = currentValue.getClass();
        return ReflectUtil.getField(currentValueClass, currentName);
    }

}

/**
 * 脱敏处理器接口
 */
public interface DesensitizationHandler<T extends Annotation> {

    /**
     * 脱敏
     *
     * @param origin     原始字符串
     * @param annotation 注解信息
     * @return 脱敏后的字符串
     */
    String desensitize(String origin, T annotation);

    /**
     * 是否禁用脱敏的 Spring EL 表达式
     *
     * 如果返回 true 则跳过脱敏
     *
     * @param annotation 注解信息
     * @return 是否禁用脱敏的 Spring EL 表达式
     */
    default String getDisable(T annotation) {
        // 约定:默认就是 enable() 属性。如果不符合,子类重写
        try {
            return (String) ReflectUtil.invoke(annotation, "disable");
        } catch (Exception ex) {
            return "";
        }
    }

}


/**
 * 正则表达式脱敏处理器抽象类,已实现通用的方法
 */
public abstract class AbstractRegexDesensitizationHandler<T extends Annotation>
        implements DesensitizationHandler<T> {

    @Override
    public String desensitize(String origin, T annotation) {
        // 1. 判断是否禁用脱敏
        Object disable = SpringExpressionUtils.parseExpression(getDisable(annotation));
        if (Boolean.TRUE.equals(disable)) {
            return origin;
        }

        // 2. 执行脱敏
        String regex = getRegex(annotation);
        String replacer = getReplacer(annotation);
        return origin.replaceAll(regex, replacer);
    }

    /**
     * 获取注解上的 regex 参数
     *
     * @param annotation 注解信息
     * @return 正则表达式
     */
    abstract String getRegex(T annotation);

    /**
     * 获取注解上的 replacer 参数
     *
     * @param annotation 注解信息
     * @return 待替换的字符串
     */
    abstract String getReplacer(T annotation);

}
/**
 * 滑动脱敏处理器抽象类,已实现通用的方法
 */
public abstract class AbstractSliderDesensitizationHandler<T extends Annotation>
        implements DesensitizationHandler<T> {

    @Override
    public String desensitize(String origin, T annotation) {
        // 1. 判断是否禁用脱敏
        Object disable = SpringExpressionUtils.parseExpression(getDisable(annotation));
        if (Boolean.TRUE.equals(disable)) {
            return origin;
        }

        // 2. 执行脱敏
        int prefixKeep = getPrefixKeep(annotation);
        int suffixKeep = getSuffixKeep(annotation);
        String replacer = getReplacer(annotation);
        int length = origin.length();
        int interval = length - prefixKeep - suffixKeep;

        // 情况一:原始字符串长度小于等于前后缀保留字符串长度,则原始字符串全部替换
        if (interval <= 0) {
            return buildReplacerByLength(replacer, length);
        }

        // 情况二:原始字符串长度大于前后缀保留字符串长度,则替换中间字符串
        return origin.substring(0, prefixKeep) +
                buildReplacerByLength(replacer, interval) +
                origin.substring(prefixKeep + interval);
    }

    /**
     * 根据长度循环构建替换符
     *
     * @param replacer 替换符
     * @param length   长度
     * @return 构建后的替换符
     */
    private String buildReplacerByLength(String replacer, int length) {
        return replacer.repeat(length);
    }

    /**
     * 前缀保留长度
     *
     * @param annotation 注解信息
     * @return 前缀保留长度
     */
    abstract Integer getPrefixKeep(T annotation);

    /**
     * 后缀保留长度
     *
     * @param annotation 注解信息
     * @return 后缀保留长度
     */
    abstract Integer getSuffixKeep(T annotation);

    /**
     * 替换符
     *
     * @param annotation 注解信息
     * @return 替换符
     */
    abstract String getReplacer(T annotation);

}

/**
 * 手机号 脱敏注解
 */
@Documented
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@DesensitizeBy(handler = MobileDesensitization.class)
public @interface MobileDesensitize {

    /**
     * 前缀保留长度
     */
    int prefixKeep() default 3;

    /**
     * 后缀保留长度
     */
    int suffixKeep() default 4;

    /**
     * 替换规则,手机号;比如:13248765917 脱敏之后为 132****5917
     */
    String replacer() default "*";

    /**
     * 是否禁用脱敏
     *
     * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏
     */
    String disable() default "";

}

/**
 * {@link MobileDesensitize} 的脱敏处理器
 */
public class MobileDesensitization extends AbstractSliderDesensitizationHandler<MobileDesensitize> {

    @Override
    Integer getPrefixKeep(MobileDesensitize annotation) {
        return annotation.prefixKeep();
    }

    @Override
    Integer getSuffixKeep(MobileDesensitize annotation) {
        return annotation.suffixKeep();
    }

    @Override
    String getReplacer(MobileDesensitize annotation) {
        return annotation.replacer();
    }

}


2. 核心脱敏能力:内置注解与使用

组件内置两类脱敏注解——正则脱敏滑块脱敏,覆盖 90% 以上的日常脱敏场景,无需手动开发即可直接使用。

2.1 滑块脱敏:按位置保留明文(最常用)

滑块脱敏通过设置“前缀保留长度”和“后缀保留长度”,将中间部分替换为 *,适用于手机号、身份证号、银行卡号等格式固定的字段。

2.1.1 核心注解:@SliderDesensitize

可自定义前缀保留位数、后缀保留位数及替换字符,灵活性高。

属性名类型默认值作用说明
prefixKeepint0前缀保留的明文长度(从左向右数)
suffixKeepint0后缀保留的明文长度(从右向左数)
replacerString“*”中间隐藏部分的替换字符(支持多字符,如 “****”)

使用示例

@Data
public class UserRespVO {
    // 手机号:保留前3位、后4位,中间用4个*替换(效果:132****5917)
    @SliderDesensitize(prefixKeep = 3, suffixKeep = 4, replacer = "****")
    private String mobile;
}
2.1.2 预置滑块脱敏注解

为简化开发,组件内置了常用场景的滑块脱敏注解,无需手动配置参数,直接添加即可使用。

注解适用场景脱敏规则原始数据示例脱敏结果示例
@MobileDesensitize手机号保留前3位、后4位,中间4个*13248765917132****5917
@IdCardDesensitize身份证号保留前6位、后2位,中间10个*530321199204074611530321**********11
@BankCardDesensitize银行卡号保留前6位、后4位,中间8个*9988002866797031998800********31
@ChineseNameDesensitize中文名保留姓,名替换为2个*刘子豪刘**
@CarLicenseDesensitize车牌号保留前3位、后1位,中间3个*粤A66666粤A6***6
@PasswordDesensitize密码全部替换为6个*123456******

使用示例

@Data
public class UserRespVO {
    private Long id;
    private String username;
    
    // 直接使用预置注解,自动按手机号规则脱敏
    @MobileDesensitize
    private String mobile;
    
    // 直接使用预置注解,自动按身份证号规则脱敏
    @IdCardDesensitize
    private String idCard;
}

2.2 正则脱敏:按规则匹配替换

正则脱敏通过自定义正则表达式匹配目标字符,将匹配部分替换为指定字符,适用于邮箱、自定义编码等格式灵活的字段。

2.2.1 核心注解:@RegexDesensitize

通过正则表达式精准匹配需要隐藏的部分,支持复杂脱敏规则。

属性名类型默认值作用说明
regexString"^[\\s\\S]*$" 匹配需要替换的正则表达式(默认匹配全部字符)
replacerString"******" 匹配部分的替换字符

使用示例

@Data
public class UserRespVO {
    // 邮箱脱敏:匹配@前第一个字符后的内容,替换为4个*(效果:e****@gmail.com)
    @RegexDesensitize(regex = "(?<=^.).*(?=@)", replacer = "****")
    private String email;
}
2.2.2 预置正则脱敏注解

组件内置了邮箱脱敏的预置注解,直接适配常见邮箱格式。

注解适用场景脱敏规则原始数据示例脱敏结果示例
@EmailDesensitize电子邮箱保留@前第一个字符,中间替换为4个*example@gmail.come****@gmail.com

使用示例

@Data
public class UserRespVO {
    // 直接使用预置注解,自动按邮箱规则脱敏
    @EmailDesensitize
    private String email;
}

3. 实战落地:脱敏功能使用三步法

基于 web 组件实现数据脱敏仅需“定义VO-添加注解-返回数据”三步,全程无侵入业务逻辑。

3.1 第一步:定义数据传输对象(VO/DTO)

在需要返回给前端的 VO 类中,标识敏感字段并添加对应脱敏注解。

@Data
public class UserDetailRespVO {
    /** 用户ID */
    private Long id;
    
    /** 用户名 */
    private String username;
    
    /** 手机号:使用预置滑块脱敏注解 */
    @MobileDesensitize
    private String mobile;
    
    /** 身份证号:使用预置滑块脱敏注解 */
    @IdCardDesensitize
    private String idCard;
    
    /** 银行卡号:使用预置滑块脱敏注解 */
    @BankCardDesensitize
    private String bankCard;
    
    /** 邮箱:使用预置正则脱敏注解 */
    @EmailDesensitize
    private String email;
    
    /** 自定义编码:使用自定义正则脱敏(保留前2位,后3位,中间替换为***) */
    @RegexDesensitize(regex = "(?<=^..).*(?=...$)", replacer = "***")
    private String customCode; // 原始:AB1234567 → 脱敏:AB***567
}

3.2 第二步:Controller 层返回数据

接口正常查询数据并返回 VO 对象,Jackson 会自动触发脱敏处理,无需额外代码。

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/detail")
    public CommonResult<UserDetailRespVO> getUserDetail(@RequestParam Long id) {
        // 业务逻辑:查询用户原始数据
        UserDetailRespVO userDetail = userService.getDetailById(id);
        // 直接返回,自动脱敏
        return CommonResult.success(userDetail);
    }
}

3.3 第三步:查看脱敏结果

前端调用接口后,接收的敏感字段已按注解规则完成脱敏,原始数据在服务端未被修改。

返回结果示例

{
  "code": 200,
  "data": {
    "id": 1,
    "username": "zhangsan",
    "mobile": "132****5917",
    "idCard": "530321**********11",
    "bankCard": "998800********31",
    "email": "z****@example.com",
    "customCode": "AB***567"
  },
  "msg": "success"
}

4. 进阶实践:自定义脱敏与工具类

针对特殊业务场景(如摘要脱敏、自定义编码脱敏),可通过“自定义注解+处理器”实现扩展;同时支持工具类手动脱敏,适配日志打印、数据库存储等场景。

4.1 自定义脱敏注解(以摘要脱敏为例)

当内置注解无法满足需求时,可通过“注解定义+处理器实现”两步开发自定义脱敏逻辑,例如将敏感字段转换为 MD5/SHA256 摘要。

4.1.1 第一步:定义自定义脱敏注解

使用 @DesensitizeBy 注解绑定对应的处理器,声明注解属性(如摘要算法类型)。

@Documented
@Target({ElementType.FIELD}) // 仅作用于字段
@Retention(RetentionPolicy.RUNTIME) // 运行时生效
@JacksonAnnotationsInside // 标识为Jackson复合注解
// 绑定处理器:指定使用DigestHandler处理脱敏逻辑
@DesensitizeBy(handler = DigestHandler.class)
public @interface DigestDesensitize {

    /** 摘要算法(默认MD5,支持SHA256等) */
    String algorithm() default "MD5";
}
4.1.2 第二步:实现脱敏处理器

处理器需实现 DesensitizationHandler 接口,编写核心脱敏逻辑(如调用 Hutool 工具类生成摘要)。

// 泛型指定:自定义注解类型
public class DigestHandler implements DesensitizationHandler<DigestDesensitize> {

    /**
     * 核心脱敏逻辑
     * @param origin 原始数据
     * @param annotation 自定义注解(可获取注解属性)
     * @return 脱敏后的数据(摘要字符串)
     */
    @Override
    public String desensitize(String origin, DigestDesensitize annotation) {
        // 空值处理:避免空指针异常
        if (origin == null) {
            return null;
        }
        // 获取注解中的算法类型
        String algorithm = annotation.algorithm();
        // 调用Hutool工具类生成摘要
        return DigestUtil.digester(algorithm).digestHex(origin);
    }
}
4.1.3 第三步:使用自定义注解

在 VO 字段上添加自定义注解,即可自动触发摘要脱敏逻辑。

@Data
public class UserDetailRespVO {
    // 自定义摘要脱敏:将原始编码转换为MD5摘要
    @DigestDesensitize(algorithm = "MD5")
    private String secretCode; // 原始:123456 → 脱敏:e10adc3949ba59abbe56e057f20f883e
}

4.2 手动脱敏:Hutool 工具类使用

当需要在业务逻辑中直接脱敏(如打印日志、存储中间表)时,可使用 Hutool 的 DesensitizedUtil 工具类,无需依赖注解。

核心方法与示例

DesensitizedUtil.desensitized(原始数据, 脱敏类型):按预设类型直接返回脱敏结果。

脱敏类型适用场景使用示例
USER_ID用户IDDesensitizedUtil.desensitized("100", USER_ID) → “0”
MOBILE_PHONE手机号DesensitizedUtil.desensitized("18049531999", MOBILE_PHONE) → “180****1999”
ID_CARD身份证号DesensitizedUtil.desensitized("51343620000320711X", ID_CARD) → “5***************1X”
EMAIL邮箱DesensitizedUtil.desensitized("test@gmail.com", EMAIL) → “t****@gmail.com”
BANK_CARD银行卡号DesensitizedUtil.desensitized("11011111222233333256", BANK_CARD) → “1101 **** **** **** 3256”

业务场景示例(日志打印)

@Service
public class UserService {

    @Slf4j
    public UserDetailRespVO getDetailById(Long id) {
        // 查询原始数据
        User user = userMapper.selectById(id);
        // 手动脱敏敏感字段后打印日志(避免日志泄露隐私)
        log.info("查询用户详情:id={}, mobile={}, idCard={}",
                user.getId(),
                DesensitizedUtil.desensitized(user.getMobile(), DesensitizedUtil.DesensitizedType.MOBILE_PHONE),
                DesensitizedUtil.desensitized(user.getIdCard(), DesensitizedUtil.DesensitizedType.ID_CARD));
        // 转换为VO返回(自动脱敏)
        return ConvertUtils.convert(user, UserDetailRespVO.class);
    }
}

5. 精细化控制:基于权限的字段脱敏

同一敏感字段需根据用户角色/权限动态展示(如超管看完整手机号,普通用户看脱敏手机号),组件支持通过注解的 disable 属性实现权限控制。

5.1 核心原理

disable 属性支持 Spring EL 表达式,可调用权限工具类(如 @ss 对应的 SecurityUtils)判断当前用户权限:

  • 表达式结果为 true:禁用脱敏(返回原始数据);
  • 表达式结果为 false:启用脱敏(返回脱敏数据)。

5.2 实战场景示例

场景1:基于角色的控制(超管看完整数据)

仅“super_admin”角色可查看完整手机号,其他角色查看脱敏数据。

@Data
public class UserDetailRespVO {
    /**
     * disable = "@ss.hasRole('super_admin')":
     * 若当前用户是super_admin,禁用脱敏(返回原始手机号);
     * 否则启用脱敏(返回132****5917)
     */
    @MobileDesensitize(disable = "@ss.hasRole('super_admin')")
    private String mobile;
}
场景2:基于权限的控制(有特定权限看完整数据)

仅拥有“user:info:query-mobile”权限的用户可查看完整手机号,无权限则脱敏。

@Data
public class UserDetailRespVO {
    /**
     * 有权限 → disable=true
     */
      @MobileDesensitize(disable = "user:info:query-mobile')")
      private String mobile;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值