Spring Boot数据脱敏实战:注解+Jackson序列化器实现接口与日志双层脱敏
2026/9/14 4:13:37 网站建设 项目流程

先说一个真实的场景:有一次我们在测试环境排查问题,运维从生产库拉了一份脱敏不彻底的数据到测试库,结果测试环境接口日志里,用户手机号、身份证号明文躺在Elasticsearch里,差点被安全团队通报。从那次之后,我彻底明白了一件事——Spring Boot项目里,数据脱敏不是“上线前加个过滤器”的锦上添花,而是从接口设计第一天就该考虑的基础能力。这个标题看着简单,真正落地的时候,涉及序列化机制、注解处理器、AOP切面、日志链路,每一个点都有坑。

这篇文章就是围绕Spring Boot项目实现数据脱敏,完整梳理我在实际项目里用过的方案和踩过的坑。核心思路是“接口返回脱敏 + 日志链路脱敏”双层方案,尽量不侵入业务代码。适合正在做接口开发的后端工程师、项目里被安全测试搞得头疼的团队,以及准备把脱敏能力沉淀成公共模块的架构师。无论你是刚接触Spring Boot还是已经写了好几年,这套设计都有可以直接抄作业的部分。

1. 数据泄露往往发生在“不起眼”的位置——先从场景说起

1.1 线上事故复盘:不是核心库泄露,而是日志和接口返回

那次问题的根因不是数据库被拖,而是接口返回给前端的报文里带了明文手机号,前端埋点上报又把这些字段打到了第三方统计平台。这事让我认识到一个关键规律:脱敏需求不是“核心业务库”的事,而是任何会离开服务边界的输出都要管控。包括:

  • Controller返回给前端的JSON响应体;
  • 服务间Feign调用、HTTP调用时传递的请求体和响应体;
  • 日志框架打印的入参、出参、SQL参数;
  • 异步消息队列发送的MQ消息体;
  • 导出Excel、生成PDF报表时的数据源。

这里面最容易被忽视的是日志。大家通常以为接口返回脱敏了就完事了,结果一翻日志,MyBatis打印的SQL参数里把身份证号、银行卡号全暴露了。排查问题的时候顺手贴一段日志到群里,等于把敏感信息群发了一遍。所以脱敏设计真正要覆盖的是“所有输出路径”,而不是某一个Controller。

1.2 哪些字段必须脱敏:先定义清楚再动手

不是所有字段都需要脱敏,也不是所有字段都有统一的脱敏规则。我在项目里做过一个敏感字段盘点,按泄露影响面分成了三个等级:

敏感等级字段类型示例脱敏要求
身份证号、银行卡号、支付账号110101199003071234只保留前6后4,中间打码
手机号13812345678保留前3后4,中间打码
姓名、家庭住址张三、北京市朝阳区xx路xx号姓名保留姓氏;地址保留到城市
邮箱、IP地址zhangsan@qq.com保留前缀首字母和域名
座机号、车牌号010-88888888保留区号,后几位打码

这里有一个判断原则:凡是能直接定位到具体个人的字段,都按“高”等级处理。姓名这种看起来不算太敏感,但如果和身份证号、手机号同时出现,组合起来就是完整的人格画像,所以一样要脱敏。

1.3 脱敏不是“把数据弄坏”:多个使用场景之间的平衡

这里要提一个很多人容易走进的误区:脱敏应该发生在“读出来给外部用”的阶段,而不是“写入数据库”的阶段。业务系统内部很多时候是需要完整数据的,比如发送短信验证码、做风控校验、财务对账。如果入库前就脱敏,那这些业务基本全废了。

所以我的设计原则是:数据库里存明文,输出边界做脱敏。具体来说:

  • 服务端内部方法调用、服务间可信调用,使用完整数据;
  • 对外接口返回、日志打印、前端展示,使用脱敏数据;
  • 内部运维查询、数据分析平台,通过独立的审批通道获取明文。

这样既满足了业务需要,又把泄露面限制在可控范围。后面要实现的方案,就是围绕“输出边界脱敏”这条主线展开的。

2. 脱敏方案选型:为什么最终落在“注解 + 序列化器”上

2.1 常见做法对比:过滤器、拦截器、AOP、序列化器各有什么问题

实现接口返回脱敏,通常有几种思路,我把它们放在一起对比过:

方案实现方式优点缺点
Filter过滤器在OncePerRequestFilter里包装响应体,对JSON字符串做正则替换不侵入业务代码正则容易误伤,JSON解析性能差,无法按字段精准控制
HandlerInterceptor拦截器在postHandle里修改ModelAndView和Spring MVC集成好只能处理ModelAndView,@ResponseBody直接序列化场景不生效
AOP切面拦截Controller方法,在返回前对结果对象做反射遍历脱敏灵活,能拿到方法注解要对每个脱敏字段写反射工具,嵌套对象处理麻烦
Jackson序列化器自定义JsonSerializer + 自定义注解精准到字段级,性能好,嵌套天然支持只能作用于Jackson序列化的场景,Fastjson等不生效

上述方案里,AOP切面和Jackson序列化器是我重点考虑的。AOP方案看起来灵活,实际做起来有个绕不开的麻烦:拿到返回对象之后做反射遍历,需要处理各种嵌套泛型、Map、List、Set,代码逻辑很容易变得又臭又硬。举个例子,一个分页对象PageResult<UserVO>,你要先解析出UserVO的泛型类型,再找到UserVO里的敏感字段,这个过程很容易漏。

2.2 借助Spring Boot默认的Jackson机制实现“字段级精准脱敏”

Spring Boot的HTTP消息转换器默认就是用Jackson完成对象到JSON的序列化。这意味着:只要在字段上标注自定义注解,并在Jackson里注册一个序列化器,所有走@ResponseBody的接口都会自动生效。这是最贴近Spring Boot原生机制、代码量最少、最好维护的方案。

自定义序列化器的好处在于:

  • 它工作在Jackson已经解析出字段值的阶段,不存在字符串替换误伤的问题;
  • 嵌套对象天然支持,因为Jackson会递归处理内部字段;
  • 性能开销非常低,只对带注解的字段多一次字符串处理;
  • 配合Jackson的ContextualSerializer,可以在序列化时读取字段上的注解元数据,一种Serializer处理多种脱敏类型。

最终我的选择就是“自定义注解 + Jackson序列化器 + ObjectMapper定制器”三件套,后面第3章会给出完整代码。

2.3 为什么没有选Fastjson或者非Spring生态的JSON库

有一个现实问题:如果一个项目里已经大量使用了Fastjson的@JSONField,换方案的工作量确实大。如果你用的是Fastjson,实现方式类似,但方向得换成Fastjson的ValueFilter或者自定义序列化器。

我的判断标准是这样的:

  • 新项目、可以控制技术栈的项目,优先用Jackson + 注解,因为Spring Boot默认就是Jackson,不需要额外依赖;
  • 遗留的Fastjson项目,可以在ObjectMapper层面做一次适配,或者使用Fastjson的ValueFilter统一拦截;
  • 如果项目里混用多个JSON库(Jackson、Fastjson、Gson),那就要考虑在AOP层统一处理,虽然麻烦,但能避免“漏网之鱼”。

我方案里选Jackson还有一个重要原因:日志脱敏时,Jackson的ObjectMapper可以直接复用,保证日志输出的字符串和接口返回的字符串格式完全一致。这个细节后面会再提到。

3. 核心实现:四个手工组件打通脱敏链路

3.1 步骤1:定义脱敏类型枚举,把规则集中管理

脱敏规则最好集中定义,不要散落在各个实体类里。我用一个枚举来管理所有脱敏类型和对应的正则规则:

public enum SensitiveType { // 手机号:保留前3后4,中间打码 MOBILE("mobile", "手机号", "(\\d{3})\\d{4}(\\d{4})", "$1****$2"), // 身份证号:支持15位和18位,保留前6后4 ID_CARD("idCard", "身份证号", "(\\d{6})\\d*(\\d{4})", "$1********$2"), // 银行卡号:保留前6后4 BANK_CARD("bankCard", "银行卡号", "(\\d{6})\\d*(\\d{4})", "$1****$2"), // 姓名:保留姓氏,名字打码 NAME("name", "姓名", "(.).*", "$1*"), // 邮箱:保留前缀第一个字符和@后面的内容 EMAIL("email", "邮箱", "(.)(.*)(@.*)", "$1****$3"), // 地址:保留前6个字符 ADDRESS("address", "地址", "(.{6}).*", "$1****"), // 自定义:什么都不处理,由具体场景传入自定义正则 CUSTOM("custom", "自定义", null, null); private final String code; private final String desc; private final String regex; private final String replacement; SensitiveType(String code, String desc, String regex, String replacement) { this.code = code; this.desc = desc; this.regex = regex; this.replacement = replacement; } public String desensitize(String value) { if (value == null || value.isEmpty() || regex == null) { return value; } return value.replaceAll(regex, replacement); } // getter... }

这里有几个细节注意一下:

  • 身份证号正则里的\\d*不能写成\\d{8},因为15位身份证中间只有6位数字,18位身份证中间有8位数字,用\\d*可以兼容;
  • 姓名脱敏用(.).*匹配,会把两个字的“张三”变成“张*”,三个字的“张小明”变成“张*”,四个字的“欧阳娜娜”变成“欧*”。如果业务要求保留前2后1,就得单独定义正则;
  • 邮箱脱敏用(.)(.*)(@.*),只保留第一个字符和@后面的域名。注意.*默认是贪婪匹配,不影响这里的结果,因为邮箱里只有一个@。

3.2 步骤2:自定义注解@SensitiveField,标记需要脱敏的字段

枚举定义好了,接下来要定义一个注解,用来标记实体类中的敏感字段:

@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotationsInside @JsonSerialize(using = SensitiveFieldSerializer.class) public @interface SensitiveField { // 脱敏类型,默认手机号 SensitiveType value() default SensitiveType.MOBILE; // 是否启用多级脱敏(按用户角色),默认不启用,后面扩展会讲 boolean dynamic() default false; }

注意一个关键设计:我在自定义注解上直接加上了@JsonSerialize(using = SensitiveFieldSerializer.class)。这样做的目的是让脱敏逻辑和注解绑定在一起,实体类上只需要标注@SensitiveField,不需要额外重复写@JsonSerialize。像这样:

public class UserVO { private Long id; @SensitiveField(SensitiveType.NAME) private String name; @SensitiveField(SensitiveType.MOBILE) private String mobile; @SensitiveField(SensitiveType.ID_CARD) private String idCard; @SensitiveField(SensitiveType.EMAIL) private String email; @SensitiveField(SensitiveType.BANK_CARD) private String bankCard; // getter/setter... }

这个注解就是整套方案的“开关”。业务人员看实体类的时候,一眼就能看出哪些字段是敏感的,代码自解释性很强。

3.3 步骤3:自定义Jackson序列化器,处理字段级别序列化

序列化器是整个方案的核心组件。它要实现JsonSerializer<String>ContextualSerializer两个接口。ContextualSerializercreateContextual方法会在Jackson初始化时被调用,我们在里面读取字段上的@SensitiveField注解,拿到脱敏类型,然后把序列化逻辑绑定到这个字段上:

public class SensitiveFieldSerializer extends JsonSerializer<String> implements ContextualSerializer { private SensitiveType type = SensitiveType.CUSTOM; @Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } gen.writeString(type.desensitize(value)); } @Override public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { if (property == null) { return prov.findNullValueSerializer(property); } // 判断字段类型,只对String类型的字段生效 if (property.getType().getRawClass() == String.class) { SensitiveField annotation = property.getAnnotation(SensitiveField.class); if (annotation == null) { annotation = property.getContextAnnotation(SensitiveField.class); } if (annotation != null) { this.type = annotation.value(); return this; } } return prov.findValueSerializer(property.getType(), property); } }

这里有一个很容易踩的坑:必须通过property.getAnnotation()拿到注解,而不是通过反射重新获取。因为createContextual方法在序列化器创建时执行,拿到的是当前正在处理的字段上下文。使用注解类型的value()必须赋值,否则默认就是CUSTOM类型,而CUSTOM的正则是null,会导致脱敏不生效。我建议枚举里CUSTOM类型的desensitize方法返回原值,避免出现NPE。

3.4 步骤4:注册到Spring Boot的ObjectMapper定制器

序列化器写好了,如果只是new ObjectMapper(),Spring Boot并不知道它的存在。需要通过Jackson2ObjectMapperBuilderCustomizer把它注册到Spring容器管理的主ObjectMapper中:

@Configuration public class SensitiveFieldAutoConfiguration { @Bean public Jackson2ObjectMapperBuilderCustomizer sensitiveFieldCustomizer() { return builder -> { // 注册自定义序列化器到默认上下文 builder.serializerByType(String.class, new SensitiveFieldSerializer()); // 或者通过注解模块注册,两种方式二选一 SimpleModule module = new SimpleModule("SensitiveFieldModule"); module.addSerializer(String.class, new SensitiveFieldSerializer()); builder.modules(module); }; } }

第一种方式serializerByType(String.class, ...)会把所有String类型的序列化都交给这个序列化器,通过createContextual方法判断字段上是否有注解,有注解才走脱敏,没有就回退到默认。这种方式简单直接,但要注意它会影响所有String序列化路径,如果项目中还有其他基于String类型的自定义序列化器,可能会冲突。

第二种方式通过SimpleModule注册,效果类似。实际项目中我建议用第一种,因为createContextual里已经做了大量的类型判断和注解判断,回调逻辑足够安全。

注册完之后,所有Controller返回的对象,只要字段上有@SensitiveField注解,输出的JSON里就会自动脱敏。测试一下:

{ "name": "张*", "mobile": "138****5678", "idCard": "110***********1234", "email": "z****@qq.com", "bankCard": "6222***********1234" }

3.5 关于Feign调用和HTTP调用的处理

默认情况下,Feign的编解码器使用的是容器里的HttpMessageConverters,也就是和Controller返回走同一套Jackson配置。所以只要注册了上面的ObjectMapper定制器,Feign请求体/响应体里带敏感注解的字段也会自动脱敏。这一点很关键,意味着服务间调用时不会把明文手机号传到下一个服务里。

如果你使用的是RestTemplate或WebClient,需要手动确保它们使用的ObjectMapper是同一个定制后的实例。我建议在项目里定义一个全局的ObjectMapper Bean,所有HTTP客户端都注入这个Bean,而不是各自new一个。

4. 独立于返回值的日志脱敏:一条切面织入所有Mapper

4.1 接口返回脱敏了,日志却又漏了

接口返回脱敏上线之后,我原本以为这事就完了。结果安全测试同事丢了一份报告过来:系统日志里打印了Service层入参和出参的完整对象,手机号是明文的。这才意识到,接口返回只是脱敏的一个出口,日志打印是另一个更大的泄露出口

当时项目里日志打印普遍是这么写的:

log.info("查询用户信息,请求参数:{}", JSON.toJSONString(userQueryDTO)); log.info("查询用户信息,返回结果:{}", JSON.toJSONString(userVO));

日志对象里包含完整的userVO结构,虽然Controller返回时脱敏了,但日志代码直接打印了Java对象,Jackson的序列化器不会介入JSON.toJSONString()这个过程。脱敏在这个路径上完全失效。

4.2 用AOP统一拦截Service出口,自动打印脱敏日志

我的解决思路是:不依赖业务代码主动打印脱敏日志,而是用一个AOP切面统一拦截Service方法,自动打印脱敏后的入参和出参

定义一个注解@LogDesensitize,标记需要打印脱敏日志的方法:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogDesensitize { // 是否打印入参 boolean printArgs() default true; // 是否打印出参 boolean printResult() default true; // 操作描述 String desc() default ""; }

然后定义一个切面:

@Aspect @Component public class DesensitizeLogAspect { private static final Logger log = LoggerFactory.getLogger(DesensitizeLogAspect.class); // 注入定制后的ObjectMapper @Resource private ObjectMapper objectMapper; @Around("@annotation(logDesensitize)") public Object around(ProceedingJoinPoint joinPoint, LogDesensitize logDesensitize) throws Throwable { String methodName = joinPoint.getSignature().toShortString(); if (logDesensitize.printArgs()) { Object[] args = joinPoint.getArgs(); // 注意:可能包含HttpServletRequest等无法序列化的类型,需要过滤 String argsJson = objectMapper.writeValueAsString(filterArgs(args)); log.info("[{}]入参: {}", methodName, argsJson); } Object result = null; try { result = joinPoint.proceed(); return result; } finally { if (logDesensitize.printResult()) { String resultJson = objectMapper.writeValueAsString(result); log.info("[{}]出参: {}", methodName, resultJson); } } } private Object[] filterArgs(Object[] args) { return Arrays.stream(args) .filter(arg -> !(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse) && !(arg instanceof BindingResult)) .toArray(); } }

这个切面有个核心技巧:它重用了定制后的ObjectMapper。因为我们的Jackson序列化器已经注册到这个ObjectMapper里,所以在打印日志时,实体类上标注了@SensitiveField的字段会自动脱敏。这就保证了日志输出和接口返回的脱敏效果一致。

使用的时候只需要在业务方法上标注:

@Service public class UserServiceImpl implements UserService { @LogDesensitize(desc = "查询用户信息") @Override public UserVO getUserById(Long id) { // 业务逻辑... } }

日志输出:

[查询用户信息]入参: {"id":1001} [查询用户信息]出参: {"name":"张*","mobile":"138****5678","idCard":"110***********1234"}

这样即使后来有人往实体类里加了新的敏感字段但忘了加脱敏注解,只要日志切面覆盖到这个方法,敏感信息不会因为日志输出而泄露。不过要补充一句:如果实体类字段没加@SensitiveField注解,Jackson不会脱敏,所以这个方案依赖前期的字段标注规范。

4.3 日志链路里的SQL参数脱敏:MyBatis日志如何收敛

除了业务日志,SQL日志也是一个风险点。MyBatis的MyBatis-Plus或者原生MyBatis打印SQL参数时,会把PreparedStatement的参数原样打印出来。比如where mobile = 13812345678这种,直接暴露了明文手机号。

解决思路有两个方向:

  • 调整日志级别,不打印SQL参数(最简单粗暴,但排查问题会不方便);
  • 实现自定义Interceptor拦截MyBatis的ParameterHandler,在日志输出前对参数脱敏。

我项目中采用的是第二种方向,拦截ParameterHandler.setParameters(),在参数真正被设置到PreparedStatement之前,不对参数做修改,但会对需要打印的日志内容做临时替换。这里摘录关键思路:

@Intercepts({ @Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class}) }) public class SensitiveParameterInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 这里可以在SQL日志输出前,通过反射读取ParameterHandler中的参数,对敏感类型做临时打码 return invocation.proceed(); } }

说实话,这个拦截器实现起来比预想中要麻烦,因为MyBatis不同版本对ParameterHandler内部结构的实现有差异,强依赖反射的方式在版本升级时很容易碎。所以我的建议是:如果项目框架允许,优先考虑把SQL日志级别调整到不打印参数值;如果确实需要打印,再考虑拦截器方案。

4.4 日志脱敏后的可排查性:不能脱敏到连问题都定位不了

日志脱敏有一个平衡问题:脱敏做过头了,线上排查问题会非常痛苦。比如手机号全变成****,那根据日志定位某个用户的问题就难了。

我的处理技巧是:脱敏日志里保留一个可关联的“业务跟踪ID”,比如用户ID、订单号。这样既保护了敏感信息,又保留了问题定位的线索。日志链路设计应该是:

请求ID: 8f0a2b3c-1234 用户ID: 1001 手机号: 138****5678

通过用户ID关联查询审计系统拿到完整数据,而不是直接在日志里打印明文。这个思路在分布式链路追踪里同样适用,TraceId、SpanId天然就是关联凭证。

5. 实测与踩坑:序列化时机、NPE与嵌套对象

5.1 坑1:写了序列化器但没生效——ObjectMapper实例不一致

这是最容易踩的坑。Spring Boot里ObjectMapper的实例来源有很多:WebMvcConfigurer里自定义的、@Bean手动创建的、Jackson2ObjectMapperBuilder构建的。如果你的项目里有人手动new ObjectMapper()并注册成了Bean,那么我们的Jackson2ObjectMapperBuilderCustomizer可能根本不会作用到那个实例上。

排查方法:在序列化器createContextualserialize方法里打日志,看看到底有没有被调用。如果完全没有调用,八成就是ObjectMapper实例不是同一个。

解决方法是统一ObjectMapper实例:

@Configuration public class ObjectMapperConfig { @Bean @Primary public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) { return builder.build(); } }

把自定义ObjectMapper定义为@Primary,所有注入ObjectMapper的地方都会拿到同一个实例。Spring Boot自动配置里的ObjectMapper也会被覆盖。

5.2 坑2:为空字段触发了脱敏逻辑导致NPE

如果一个敏感字段值为null,序列化器里如果直接调用type.desensitize(value),可能在正则匹配时触发空指针。很多人在字段为null的时候想的是“null不脱敏直接输出”,但Jackson的序列化器必须显式处理null情况:

@Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { // 注意这里不是writeNull(): 会绕过序列化器走默认null处理 gen.writeNull(); return; } // 空字符串也直接返回,避免正则处理空串 if (value.isEmpty()) { gen.writeString(value); return; } gen.writeString(type.desensitize(value)); }

这个坑之所以隐蔽,是因为在接口返回正常数据时根本不会触发,只有当数据库里的敏感字段本身就是NULL时,才会偶发出现。压测、联调阶段这类问题很难被发现,等到线上数据质量参差不齐时才会暴露。

5.3 坑3:嵌套对象的脱敏失效——List、Map、泛型对象

Jackson的序列化器支持嵌套调用,但要求嵌套对象里的字段也标注了@SensitiveField注解。例如一个分页结果:

public class PageResult<T> { private Long total; private List<T> records; // getter/setter... }

调用接口返回PageResult<UserVO>时,Jackson会递归解析records里的UserVO对象,UserVO里的sensitive注解字段会自动脱敏。这里不需要额外写代码。

但有一个特殊情况:Map类型。如果字段类型是Map<String, Object>,Jackson不会知道Map里的value是什么业务类型,自然也就无法自动脱敏。例如:

// 这种场景脱敏不会生效 private Map<String, Object> extraInfo;

这种场景下的处理方案是:在Service层组装Map时手动调用脱敏工具类,或者尽量使用强类型DTO,避免用Map传输业务数据。

5.4 坑4:字段类型不匹配导致序列化器不生效

SensitiveFieldSerializer实现的是JsonSerializer<String>,并通过property.getType().getRawClass() == String.class做了类型判断。如果字段类型是StringBuilder或者char[],这个序列化器根本不会绑定。所以脱敏字段必须是String类型,或者自己扩展其他类型的序列化器。

项目里有人遇到过把一个Long类型的字段加上@SensitiveField想让Jackson处理,比如把用户ID当成敏感字段,结果发现注解完全没有生效。原因就在这里,类型不匹配,序列化器直接被跳过了。

5.5 性能影响实测

脱敏操作的性能损耗主要是replaceAll正则在每次序列化时执行一次。我做了个简单压测,一万个包含手机号、姓名、身份证号、邮箱4个敏感字段的用户对象,序列化对比:

场景耗时说明
未启用脱敏序列化器约420ms直接走默认String序列化
启用脱敏序列化器约450ms每个字段多了一次正则替换
启用脱敏 + 日志AOP约480ms日志序列化了一次对象

性能开销在7%左右,对于绝大多数Web应用来说完全可接受。实际项目中,单次接口查询可能只有几十个对象,这个损耗基本可以忽略。如果字段数量巨大,可以考虑把正则Pattern预编译,代替String.replaceAll内部的每次编译。

所以我建议在项目里定义一个静态Pattern缓存:

public class SensitiveDataUtil { private static final Map<SensitiveType, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>(); public static String desensitize(String value, SensitiveType type) { if (value == null || value.isEmpty()) { return value; } Pattern pattern = PATTERN_CACHE.computeIfAbsent(type, t -> Pattern.compile(t.getRegex())); return pattern.matcher(value).replaceAll(type.getReplacement()); } }

这样正则只编译一次,后续全部复用,性能损耗能进一步降低。

6. 玩法升级:同一接口不同角色看到不同数据

6.1 需求场景:管理员和普通运营看到的手机号不一样

脱敏做到这一步,基本能满足绝大多数项目需求。但实际业务往往还有更细的要求:同一个用户列表接口,管理员能看到完整手机号,普通运营人员只能看到脱敏手机号。

这个需求如果靠“接口返回脱敏”这种静态规则实现会很难办,因为序列化器本身无状态,它不知道当前请求是谁。所以需要引入“动态脱敏”能力。

6.2 基于自定义策略注解实现动态脱敏

首先要给@SensitiveField注解增加一个可选的策略标识:

public @interface SensitiveField { SensitiveType value() default SensitiveType.MOBILE; // 脱敏策略bean名称,从Spring容器中动态获取 String strategy() default ""; }

在序列化器里,当strategy不为空时,通过Spring容器获取对应的DesensitizeStrategy实现类,由策略类决定当前用户应该看到明文还是脱敏文:

public class SensitiveFieldSerializer extends JsonSerializer<String> implements ContextualSerializer { private SensitiveType type; private String strategyName; @Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } String result = value; if (StringUtils.hasText(strategyName)) { // 从Spring容器中获取策略Bean DesensitizeStrategy strategy = SpringContextHolder.getBean(strategyName); result = strategy.desensitize(value, type); } else { result = type.desensitize(value); } gen.writeString(result); } @Override public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) { SensitiveField annotation = property.getAnnotation(SensitiveField.class); if (annotation != null) { this.type = annotation.value(); this.strategyName = annotation.strategy(); return this; } return prov.findNullValueSerializer(property); } }

DesensitizeStrategy是一个函数式接口:

public interface DesensitizeStrategy { String desensitize(String value, SensitiveType type); }

举个例子,判断当前用户是否有权限看到明文:

@Component("phoneAdminStrategy") public class PhoneAdminStrategy implements DesensitizeStrategy { @Override public String desensitize(String value, SensitiveType type) { // 判断当前登录用户角色 if (isAdmin()) { return value; // 管理员看完整数据 } return type.desensitize(value); } private boolean isAdmin() { // 从SecurityContext或ThreadLocal获取当前用户角色 return false; } }

实体类这样标注:

@SensitiveField(value = SensitiveType.MOBILE, strategy = "phoneAdminStrategy") private String mobile;

这属于进阶玩法,实现会稍微复杂一点,但思路是通的。核心还是把“脱敏规则”和“用户上下文”解耦,让脱敏逻辑具备感知当前请求的能力。

6.3 项目落地时的规范建议

最后根据我的项目经验,给几条实操层面的建议:

  1. 敏感字段盘点要做成表格,每一张表、每一个字段都过一遍,确定脱敏类型,这是所有工作的基础;
  2. 实体类分层之后统一管理注解,VO、DTO这些出参对象必须标注,Entity实体内部不标,避免内部调用受影响;
  3. 把脱敏能力封装成公共模块,打成starter给多个服务复用,不要每个服务各自实现一套;
  4. 上线前用安全测试工具扫一遍接口,重点看枚举、异常信息这些容易被忽略的地方;
  5. 定期检查日志平台,用一些简单的正则规则扫一下日志中是否出现手机号、身份证号明文。

数据脱敏这件事,技术上不难,难的是把它变成团队规范、落地到每个输出路径。Jackson序列化器这套方案,就是尽量让开发人员少操心、框架多承担。从实际运行效果看,稳定性和维护成本都让我比较满意。后续如果有精力,我可能会再扩展一个针对MQ消息体的脱敏拦截器,让整个数据出口的覆盖面更完整。

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

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

立即咨询