☰
敏感数据脱敏
2026/9/28 21:22:27 网站建设 项目流程

顺手再加个敏感数据脱敏

上一篇做完了字典翻译,这套 SPI 还剩一个很划算的用途:敏感数据脱敏。引擎是共用的,这篇只需要写 5 个文件。

没看过上一篇的话,先把那四个json包的文件(JsonValueEnhancer、JsonFieldProcessor、JsonFieldContext、JsonEnhancementContext)和TranslationConfig装进项目。这篇只讲脱敏部分。

问题

接口返回:

{"empName":"张三","phone":"13812345678","idCard":"310101199003071234"}

手机号、身份证直接裸露。放前端脱敏没用——接口一调全出来了,必须在后端处理。

常见做法是每个 VO 里手动调一遍StringUtils.mask(phone),跟字典翻译面临一样的问题:到处是重复代码,还容易漏。

我们想要的是:

{"empName":"张*","phone":"138****5678","idCard":"310101********1234"}

VO 上打个注解就行:

@Sensitive(type=SensitiveType.PHONE)privateStringphone;

依赖

不用加,一个都不用。完全复用上一篇的引擎,也没有 Hutool 依赖,脱敏算法自己实现。

和字典翻译的区别

搞清楚这三点,代码就很好懂:

字典翻译脱敏
要不要查外部数据要,查字典不要,纯字符串处理
要不要批量预取要,防 N+1不要,用不上 collect/prepare
读哪个字段的值一般用mapper读别的字段就读自己这个字段,不需要mapper

所以这边只需要实现supports()和process(),另外两个用接口的默认空实现。

设计:每个脱敏类型自带一个函数

不要写成一个大switch——那样每加一种脱敏方式都要回来改处理器。用策略模式:

SensitiveStrategy 接口,就一个方法:Function<String, String> desensitizer() ▲ │ implements SensitiveType 枚举,每个枚举项自带脱敏函数 SensitiveJsonFieldProcessor 处理器,只管取注解然后 apply,不管具体怎么脱

加一种脱敏方式 = 加一个枚举项。处理器一行都不用改。

代码

包名cn.prohup.xiao.crm.commons.sensitive:

commons/sensitive/ ├── annotation/ Sensitive.java ├── core/ │ ├── SensitiveStrategy.java 策略接口 │ ├── SensitiveType.java 枚举,实现策略 │ └── util/ │ └── SensitiveUtil.java 脱敏小工具 ├── handler/ SensitiveJsonFieldProcessor.java └── config/ SensitiveConfig.java

1. 注解

annotation/Sensitive.java

packagecn.prohup.xiao.crm.commons.sensitive.annotation;importcn.prohup.xiao.crm.commons.sensitive.core.SensitiveType;importjava.lang.annotation.Documented;importjava.lang.annotation.ElementType;importjava.lang.annotation.Retention;importjava.lang.annotation.RetentionPolicy;importjava.lang.annotation.Target;/** * type 脱敏类型 * beginIndex CUSTOM 模式保留的前缀位数 * endIndex CUSTOM 模式保留的后缀位数 */@Retention(RetentionPolicy.RUNTIME)@Target({ElementType.FIELD,ElementType.METHOD})@Documentedpublic@interfaceSensitive{SensitiveTypetype()defaultSensitiveType.CUSTOM;intbeginIndex()default0;intendIndex()default0;}

2. 策略接口和工具类

core/SensitiveStrategy.java

packagecn.prohup.xiao.crm.commons.sensitive.core;importjava.util.function.Function;/** * 脱敏策略:一个策略就是一个字符串转换函数,入参原文,出参脱敏后的文本。 * * 想自己扩展的话,实现这个接口即可,处理器不用动。 * 也可以自定义一个 Function 直接塞给 SensitiveType,见 SensitiveType.CUSTOM 的写法。 */publicinterfaceSensitiveStrategy{/** 返回该策略对应的脱敏函数 */Function<String,String>desensitizer();}

core/util/SensitiveUtil.java

packagecn.prohup.xiao.crm.commons.sensitive.core.util;importjava.util.Arrays;publicfinalclassSensitiveUtil{privatestaticfinalcharMASK='*';privateSensitiveUtil(){}/** * 保留前 prefix 位和后 suffix 位,中间全部填 *。 * 位数按实际长度算,不是固定几颗星。 */publicstaticStringkeep(Stringstr,intprefix,intsuffix){if(str==null||str.isEmpty()){returnstr;}intlen=str.length();// 短到前后都要塌陷的地步,直接全遮if(len<=prefix+suffix){returnrepeat(len);}StringBuildersb=newStringBuilder();sb.append(str.substring(0,prefix));sb.append(repeat(len-prefix-suffix));sb.append(str.substring(len-suffix));returnsb.toString();}/** 只留第一个字:张三 → 张* */publicstaticStringname(Stringstr){if(str==null||str.length()<=1){returnstr;}returnstr.substring(0,1)+repeat(str.length()-1);}/** 保留首位和 @ 之后的完整域名:zhangsan@163.com → z*******@163.com */publicstaticStringemail(Stringstr){if(str==null||str.isEmpty()){returnstr;}intat=str.indexOf('@');if(at<=0){returnkeep(str,1,0);}StringBuildersb=newStringBuilder();sb.append(str.substring(0,1));sb.append(repeat(at-1));sb.append(str.substring(at));returnsb.toString();}publicstaticStringrepeat(intcount){if(count<=0){return"";}char[]arr=newchar[count];Arrays.fill(arr,MASK);returnnewString(arr);}}

3. 枚举实现策略

core/SensitiveType.java

每个枚举项直接把自己对应的函数写进去。想加新的就加一行。

packagecn.prohup.xiao.crm.commons.sensitive.core;importcn.prohup.xiao.crm.commons.sensitive.core.util.SensitiveUtil;importjava.util.function.Function;/** * 内置脱敏类型,每个枚举项自带脱敏函数。 * 加一种脱敏方式 = 加一个枚举项,处理器完全不用动。 */publicenumSensitiveTypeimplementsSensitiveStrategy{/** 手机:138****5678,保留前 3 后 4 */PHONE(s->SensitiveUtil.keep(s,3,4)),/** 身份证:保留前 6 后 4 */ID_CARD(s->SensitiveUtil.keep(s,6,4)),/** 银行卡:只留后 4 位 */BANK_CARD(s->SensitiveUtil.keep(s,0,4)),/** 地址:保留前 6 个字 */ADDRESS(s->SensitiveUtil.keep(s,6,0)),/** 姓名:只留第一个字 */NAME(SensitiveUtil::name),/** 邮箱:保留首位和域名 */EMAIL(SensitiveUtil::email),/** 自定义:位数由注解上的 beginIndex / endIndex 决定,处理器里单独处理 */CUSTOM(s->s);/** 可自行添加其他脱敏策略,比如护照 s -> SensitiveUtil.keep(s, 2, 3) */privatefinalFunction<String,String>desensitizer;SensitiveType(Function<String,String>desensitizer){this.desensitizer=desensitizer;}@OverridepublicFunction<String,String>desensitizer(){returndesensitizer;}}

CUSTOM为什么是s -> s(原样返回)?因为它的位数来自注解参数,没法在枚举里预先定死,得在处理器里特判。下面会看到。

4. 处理器

handler/SensitiveJsonFieldProcessor.java

@Order(10)是执行顺序,数字越大越靠后。脱敏通常安排在翻译之后——万一某字段既翻译又要脱敏,先拿到可读文本再脱敏。

packagecn.prohup.xiao.crm.commons.sensitive.handler;importcn.prohup.xiao.crm.commons.sensitive.annotation.Sensitive;importcn.prohup.xiao.crm.commons.sensitive.core.SensitiveType;importcn.prohup.xiao.crm.commons.sensitive.core.util.SensitiveUtil;importcn.prohup.xiao.crm.commons.translation.json.JsonEnhancementContext;importcn.prohup.xiao.crm.commons.translation.json.JsonFieldContext;importcn.prohup.xiao.crm.commons.translation.json.JsonFieldProcessor;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.core.annotation.Order;/** * 敏感字段脱敏。不需要 collect / prepare,纯字符串处理没有批量查询的必要。 * 具体的脱敏逻辑都挂在 SensitiveType 上,这里只负责取注解、调函数。 */@Order(10)publicclassSensitiveJsonFieldProcessorimplementsJsonFieldProcessor{privatestaticfinalLoggerlog=LoggerFactory.getLogger(SensitiveJsonFieldProcessor.class);@Overridepublicbooleansupports(JsonFieldContextfieldContext){returnfieldContext.getAnnotation(Sensitive.class)!=null;}@OverridepublicObjectprocess(JsonFieldContextfieldContext,Objectvalue,JsonEnhancementContextcontext){Sensitivesensitive=fieldContext.getAnnotation(Sensitive.class);if(sensitive==null){returnvalue;}// 只处理字符串,数字、日期、对象一律放过if(!(valueinstanceofString)){returnvalue;}Stringstr=(String)value;if(str.isEmpty()){returnstr;}try{SensitiveTypetype=sensitive.type();// CUSTOM 的位数来自注解,不能预先定成一个函数,单独走这里if(type==SensitiveType.CUSTOM){returnSensitiveUtil.keep(str,sensitive.beginIndex(),sensitive.endIndex());}// 其余类型直接取枚举自带函数执行returntype.desensitizer().apply(str);}catch(Exceptione){// 脱敏出错宁可返回原文,也不能让整个接口挂掉log.error("脱敏异常 field={}",fieldContext.propertyName(),e);returnvalue;}}}

5. 装配

config/SensitiveConfig.java

只需要注册处理器 Bean。引擎不用再注册一遍——上一篇TranslationConfig里的JsonValueEnhancer会自动把容器里所有JsonFieldProcessor收集进去,脱敏这个自然挂上去了。

packagecn.prohup.xiao.crm.commons.sensitive.config;importcn.prohup.xiao.crm.commons.sensitive.handler.SensitiveJsonFieldProcessor;importorg.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;@ConfigurationpublicclassSensitiveConfig{/** * 引擎(JsonValueEnhancer)在上一篇的 TranslationConfig 里已经注册好了, * 它通过 List&lt;JsonFieldProcessor&gt; 自动收集所有处理器,这里只要把自己交给 Spring 即可。 */@Bean@ConditionalOnMissingBeanpublicSensitiveJsonFieldProcessorsensitiveJsonFieldProcessor(){returnnewSensitiveJsonFieldProcessor();}}

跑起来

VO 加注解:

@DatapublicclassEmployeeVo{privateLongempId;@Sensitive(type=SensitiveType.NAME)privateStringempName;@Sensitive(type=SensitiveType.PHONE)privateStringphone;@Sensitive(type=SensitiveType.ID_CARD)privateStringidCard;@Sensitive(type=SensitiveType.EMAIL)privateStringemail;@Sensitive(type=SensitiveType.BANK_CARD)privateStringbankCard;/** 保留前 2 后 2,中间全遮 */@Sensitive(type=SensitiveType.CUSTOM,beginIndex=2,endIndex=2)privateStringremark;}

拿这组数据跑:

张三/13812345678/310101199003071234zhangsan@163.com/6222020200011234/客户沟通很满意

实际返回(这组值是真跑出来的,不是手写示意):

{"empId":1,"empName":"张*","phone":"138****5678","idCard":"310101********1234","email":"z*******@163.com","bankCard":"************1234","remark":"客户***满意"}

注意remark是 7 个字客户沟通很满意,保留前 2 后 2,中间 3 个字全遮——keep()按实际长度算位数,不是固定几颗星。empId是 Long 没被处理,符合"只处理字符串"。

列表、嵌套对象、Map<String, Object>返回值统统生效,因为递归是引擎做的。

某个接口想看明文(比如管理员才可见的详情页),别用带注解的 VO,另定义一个不加密注解的 VO 返回即可。


加一种脱敏策略

只要两步,都在这个包里,处理器不用动。

第一步,SensitiveUtil里如果没有现成方法,加一个:

/** 护照:保留前 2 后 3 */publicstaticStringpassport(Stringstr){returnkeep(str,2,3);}

(keep已经能覆盖大部分情况,所以这步经常可以跳过。)

第二步,枚举里加一项:

/** 护照:保留前 2 后 3 */PASSPORT(s->SensitiveUtil.keep(s,2,3)),

完事。VO 里就能用@Sensitive(type = SensitiveType.PASSPORT)了。

一个容易踩的坑:策略类直接用不了

你可能会想单独写一个策略类:

publicclassPhoneTailStrategyimplementsSensitiveStrategy{@OverridepublicFunction<String,String>desensitizer(){// 长度够 4 位:4 颗星 + 后 4 位;不够就全遮(否则 substring 会越界)returns->s.length()>=4?"****"+s.substring(s.length()-4):SensitiveUtil.repeat(s.length());}}

它单独调用是没问题的:

newPhoneTailStrategy().desensitizer().apply("13812345678");// ****5678

但它接不到@Sensitive注解上——注解里type()的类型是SensitiveType枚举,填不进一个 class,
@Sensitive(type = PhoneTailStrategy.class)编译不过。

想让它生效,最省事的办法是把 lambda 直接写成枚举项:

/** 手机尾号:只显示后 4 位,前面补 **** */PHONE_TAIL(s->s.length()>=4?"****"+s.substring(s.length()-4):SensitiveUtil.repeat(s.length())),

然后 VO 里就能用:

@Sensitive(type=SensitiveType.PHONE_TAIL)privateStringphone;// 13812345678 → ****5678

结论:SensitiveStrategy接口的价值在于「枚举项里可以挂任意 lambda」,而不是让你在外面 new 一个策略类。
真要做到可插拔(比如让第三方 jar 也能加策略),注解得改成Class<? extends SensitiveStrategy> strategy()、
处理器里再反射实例化——复杂度上去了,一般业务用不上,枚举项足够。


坑

现象原因
脱敏没生效只处理 String。数字、日期、Long 不会脱敏,需要脱敏的类型得先转成 String
报非法 rangebeginIndex + endIndex超过字符串长度,keep()里已做全遮兜底
和字典翻译互相覆盖同一字段别同时打两个注解打架。真需要的话顺序是翻译先(@Order(0))、脱敏后(@Order(10))
加了注解但值是 null直接返回,不报错
导出 Excel 没脱敏本文只处理 JSON 响应,导出走的是另一条路,要在导出逻辑里单独处理

和上一篇共用的验收:列表 100 条,字典查询日志只出现 1 次。加了脱敏之后这条还得成立——脱敏本身没有任何 IO,如果变慢了说明处理器里混进了查询操作。


两套能力加起来二十来个文件,共用同一个引擎。想再加第三种(比如给 OSS 的 fileId 自动补成完整访问 URL)也是同一个套路:写个实现JsonFieldProcessor的类,supports()判断注解,process()做转换,注册成 Bean 就完事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询