前言
在 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 框架扩展,通过自定义注解与序列化处理器实现数据脱敏。核心流程如下:
- 在 VO/DTO 的敏感字段上添加脱敏注解(如
@MobileDesensitize) - 接口返回数据时,Jackson 序列化器触发注解对应的脱敏处理器
- 处理器按预设规则对原始数据进行脱敏处理(如替换中间字符为
*) - 最终返回脱敏后的安全数据,原始数据在服务端保持完整
核心实现类参考如下:
/**
* 脱敏注解,自定义注解需要使用此注解
*/
@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
可自定义前缀保留位数、后缀保留位数及替换字符,灵活性高。
| 属性名 | 类型 | 默认值 | 作用说明 |
|---|---|---|---|
prefixKeep | int | 0 | 前缀保留的明文长度(从左向右数) |
suffixKeep | int | 0 | 后缀保留的明文长度(从右向左数) |
replacer | String | “*” | 中间隐藏部分的替换字符(支持多字符,如 “****”) |
使用示例:
@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个* | 13248765917 | 132****5917 |
@IdCardDesensitize | 身份证号 | 保留前6位、后2位,中间10个* | 530321199204074611 | 530321**********11 |
@BankCardDesensitize | 银行卡号 | 保留前6位、后4位,中间8个* | 9988002866797031 | 998800********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
通过正则表达式精准匹配需要隐藏的部分,支持复杂脱敏规则。
| 属性名 | 类型 | 默认值 | 作用说明 |
|---|---|---|---|
regex | String | "^[\\s\\S]*$" | 匹配需要替换的正则表达式(默认匹配全部字符) |
replacer | String | "******" | 匹配部分的替换字符 |
使用示例:
@Data
public class UserRespVO {
// 邮箱脱敏:匹配@前第一个字符后的内容,替换为4个*(效果:e****@gmail.com)
@RegexDesensitize(regex = "(?<=^.).*(?=@)", replacer = "****")
private String email;
}
2.2.2 预置正则脱敏注解
组件内置了邮箱脱敏的预置注解,直接适配常见邮箱格式。
| 注解 | 适用场景 | 脱敏规则 | 原始数据示例 | 脱敏结果示例 |
|---|---|---|---|---|
@EmailDesensitize | 电子邮箱 | 保留@前第一个字符,中间替换为4个* | example@gmail.com | e****@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 | 用户ID | DesensitizedUtil.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;
}
数据脱敏技术详解与实战
652

被折叠的 条评论
为什么被折叠?



