注解式脱敏:用 Jackson 的 ContextualSerializer 做字段级策略(一)

脱敏写在 Service 里,漏一个接口就泄露;用 AOP 递归遍历对象树,性能和嵌套处理都很糟。这篇文章讲怎么把脱敏挂到 JSON 序列化这个唯一出口上,以及为什么 @JacksonAnnotationsInside 和 ContextualSerializer 是这个方案能成立的两个前提。


一、痛点:脱敏是怎么漏掉的

1.1 现象

我们有个用户中心,手机号、邮箱、身份证散落在十几个 DTO 里。最开始的做法是在 Service 层手动脱敏:

java 复制代码
public UserVO toVO(User user) {
    UserVO vo = new UserVO();
    vo.setPhone(maskPhone(user.getPhone()));
    vo.setEmail(maskEmail(user.getEmail()));
    return vo;
}

第一个月没问题。第三个月出了事:

  • 新同事加了「用户导出」接口,直接返回了 List<User>,没走 toVO------Excel 里是完整手机号;
  • 另一个接口返回分页结果 Page<UserVO>,但有个字段是嵌套的 List<ContactVO>,转换时漏了;
  • 最隐蔽的是日志:log.info("query result={}", users) 直接把完整对象打进了日志文件,被日志采集系统收走后扩散到了三个环境。

合规审计时这三个点同时被查出来,问题不在「某个人写错了」,而在于脱敏依赖每个人的记性。

1.2 为什么会这样

我当时试过几种「更可靠」的做法,每种都有硬伤:

做法 硬伤
Service 层手动 mask() 无法枚举全部出口,漏一个是时间问题
AOP 拦 Controller 返回值,递归遍历对象树 要处理 List/Map/嵌套/循环引用;性能随对象图规模线性下降;分页结果里的元素容易漏
给每个字段写一个 @JsonSerialize(using = XxxSerializer.class) 一个序列化器类只能对应一种策略,字段多了就是类爆炸
数据库层加密 解决的是存储安全,不解决「有权限的人查出来也要脱敏」

关键认知 :脱敏的本质是「输出视图」问题,不是「数据」问题。同一份数据,给管理员看是全的,给普通用户看是脱敏的,给日志看可能是另一种形态。既然是视图问题,它就该属于序列化层,而不是业务层。

而且------JSON 序列化是几乎所有对外输出的必经之路。挂在这里,等于挂在了唯一的收口上。

1.3 我最初的错误做法

错误一:只用 @JsonSerialize 指到自定义序列化器

java 复制代码
public class PhoneSerializer extends JsonSerializer<String> {
    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider serializers)
            throws IOException {
        gen.writeString("138****8000");   // 硬编码的掩码规则
    }
}

问题立刻暴露:规则写死在类里。想改「保留前 3 后 4」为「保留前 4 后 4」,得改类;想给另一个字段用不同参数,得再写一个类。

错误二:想从序列化器里读字段注解,但读不到

java 复制代码
public class SensitiveSerializer extends JsonSerializer<String> {
    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider provider) {
        // 这里拿不到「当前序列化的是哪个字段」
        // 因为 JsonSerializer 实例是无状态的,可能被多个字段复用
    }
}

这是我最开始卡住的地方:序列化器的 serialize() 方法签名里没有字段信息。它只知道「有个 String 要序列化」,不知道这个 String 来自哪个字段。


二、问题拆解:六个子问题

子问题 约束 现成方案为什么不够 判定标准
① 挂在哪 要在所有 JSON 出口统一生效 AOP 遍历对象树成本高、易漏嵌套 只依赖 Jackson,零遍历
② 怎么拿到字段配置 一个序列化器实例要服务多个字段,配置各不同 JsonSerializer 接口本身无状态 每个字段一个配置化实例
③ 策略可扩展 手机/邮箱/身份证/银行卡/姓名... 硬编码 switch 无法扩展 策略接口 + 注解指定类
④ 条件化 有时要「仅对非管理员脱敏」 注解只能表达静态值 条件接口 + 注解指定类
⑤ 集合类型 List<String> 存的是手机号列表 只处理 String 会漏 逐元素处理
⑥ 性能 每次序列化都反射读注解会很慢 ------ 策略/条件/配置都要缓存

这六个问题合起来意味着什么:核心是第 ② 个------只要解决了「序列化器怎么知道自己是哪个字段的」,其余五个都是自然延伸。

而这个问题,Jackson 早就留了口子:ContextualSerializer。


这六个子问题的依赖关系可以这样理解------第 ② 个是枢纽,其余五个都围绕它展开:
#mermaid-svg-DoRcE4hr3BeaXkrq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DoRcE4hr3BeaXkrq .error-icon{fill:#552222;}#mermaid-svg-DoRcE4hr3BeaXkrq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DoRcE4hr3BeaXkrq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DoRcE4hr3BeaXkrq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DoRcE4hr3BeaXkrq .marker.cross{stroke:#333333;}#mermaid-svg-DoRcE4hr3BeaXkrq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DoRcE4hr3BeaXkrq p{margin:0;}#mermaid-svg-DoRcE4hr3BeaXkrq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DoRcE4hr3BeaXkrq .cluster-label text{fill:#333;}#mermaid-svg-DoRcE4hr3BeaXkrq .cluster-label span{color:#333;}#mermaid-svg-DoRcE4hr3BeaXkrq .cluster-label span p{background-color:transparent;}#mermaid-svg-DoRcE4hr3BeaXkrq .label text,#mermaid-svg-DoRcE4hr3BeaXkrq span{fill:#333;color:#333;}#mermaid-svg-DoRcE4hr3BeaXkrq .node rect,#mermaid-svg-DoRcE4hr3BeaXkrq .node circle,#mermaid-svg-DoRcE4hr3BeaXkrq .node ellipse,#mermaid-svg-DoRcE4hr3BeaXkrq .node polygon,#mermaid-svg-DoRcE4hr3BeaXkrq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DoRcE4hr3BeaXkrq .rough-node .label text,#mermaid-svg-DoRcE4hr3BeaXkrq .node .label text,#mermaid-svg-DoRcE4hr3BeaXkrq .image-shape .label,#mermaid-svg-DoRcE4hr3BeaXkrq .icon-shape .label{text-anchor:middle;}#mermaid-svg-DoRcE4hr3BeaXkrq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DoRcE4hr3BeaXkrq .rough-node .label,#mermaid-svg-DoRcE4hr3BeaXkrq .node .label,#mermaid-svg-DoRcE4hr3BeaXkrq .image-shape .label,#mermaid-svg-DoRcE4hr3BeaXkrq .icon-shape .label{text-align:center;}#mermaid-svg-DoRcE4hr3BeaXkrq .node.clickable{cursor:pointer;}#mermaid-svg-DoRcE4hr3BeaXkrq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DoRcE4hr3BeaXkrq .arrowheadPath{fill:#333333;}#mermaid-svg-DoRcE4hr3BeaXkrq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DoRcE4hr3BeaXkrq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DoRcE4hr3BeaXkrq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DoRcE4hr3BeaXkrq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DoRcE4hr3BeaXkrq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DoRcE4hr3BeaXkrq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DoRcE4hr3BeaXkrq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DoRcE4hr3BeaXkrq .cluster text{fill:#333;}#mermaid-svg-DoRcE4hr3BeaXkrq .cluster span{color:#333;}#mermaid-svg-DoRcE4hr3BeaXkrq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DoRcE4hr3BeaXkrq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DoRcE4hr3BeaXkrq rect.text{fill:none;stroke-width:0;}#mermaid-svg-DoRcE4hr3BeaXkrq .icon-shape,#mermaid-svg-DoRcE4hr3BeaXkrq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DoRcE4hr3BeaXkrq .icon-shape p,#mermaid-svg-DoRcE4hr3BeaXkrq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DoRcE4hr3BeaXkrq .icon-shape .label rect,#mermaid-svg-DoRcE4hr3BeaXkrq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DoRcE4hr3BeaXkrq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DoRcE4hr3BeaXkrq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DoRcE4hr3BeaXkrq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 挂在哪

统一出口
② 怎么拿到字段配置

核心枢纽
③ 策略可扩展
④ 条件化
⑤ 集合类型
⑥ 性能缓存
策略接口 + 注解指定类
条件接口 + 注解指定类
逐元素处理
策略/条件/配置三级缓存

三、实现:从注解到序列化器实例

3.1 整体结构

#mermaid-svg-B0wzibKCo869Qyl6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-B0wzibKCo869Qyl6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-B0wzibKCo869Qyl6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-B0wzibKCo869Qyl6 .error-icon{fill:#552222;}#mermaid-svg-B0wzibKCo869Qyl6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-B0wzibKCo869Qyl6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-B0wzibKCo869Qyl6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-B0wzibKCo869Qyl6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-B0wzibKCo869Qyl6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-B0wzibKCo869Qyl6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-B0wzibKCo869Qyl6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-B0wzibKCo869Qyl6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-B0wzibKCo869Qyl6 .marker.cross{stroke:#333333;}#mermaid-svg-B0wzibKCo869Qyl6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-B0wzibKCo869Qyl6 p{margin:0;}#mermaid-svg-B0wzibKCo869Qyl6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-B0wzibKCo869Qyl6 .cluster-label text{fill:#333;}#mermaid-svg-B0wzibKCo869Qyl6 .cluster-label span{color:#333;}#mermaid-svg-B0wzibKCo869Qyl6 .cluster-label span p{background-color:transparent;}#mermaid-svg-B0wzibKCo869Qyl6 .label text,#mermaid-svg-B0wzibKCo869Qyl6 span{fill:#333;color:#333;}#mermaid-svg-B0wzibKCo869Qyl6 .node rect,#mermaid-svg-B0wzibKCo869Qyl6 .node circle,#mermaid-svg-B0wzibKCo869Qyl6 .node ellipse,#mermaid-svg-B0wzibKCo869Qyl6 .node polygon,#mermaid-svg-B0wzibKCo869Qyl6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-B0wzibKCo869Qyl6 .rough-node .label text,#mermaid-svg-B0wzibKCo869Qyl6 .node .label text,#mermaid-svg-B0wzibKCo869Qyl6 .image-shape .label,#mermaid-svg-B0wzibKCo869Qyl6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-B0wzibKCo869Qyl6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-B0wzibKCo869Qyl6 .rough-node .label,#mermaid-svg-B0wzibKCo869Qyl6 .node .label,#mermaid-svg-B0wzibKCo869Qyl6 .image-shape .label,#mermaid-svg-B0wzibKCo869Qyl6 .icon-shape .label{text-align:center;}#mermaid-svg-B0wzibKCo869Qyl6 .node.clickable{cursor:pointer;}#mermaid-svg-B0wzibKCo869Qyl6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-B0wzibKCo869Qyl6 .arrowheadPath{fill:#333333;}#mermaid-svg-B0wzibKCo869Qyl6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-B0wzibKCo869Qyl6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-B0wzibKCo869Qyl6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-B0wzibKCo869Qyl6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-B0wzibKCo869Qyl6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-B0wzibKCo869Qyl6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-B0wzibKCo869Qyl6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-B0wzibKCo869Qyl6 .cluster text{fill:#333;}#mermaid-svg-B0wzibKCo869Qyl6 .cluster span{color:#333;}#mermaid-svg-B0wzibKCo869Qyl6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-B0wzibKCo869Qyl6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-B0wzibKCo869Qyl6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-B0wzibKCo869Qyl6 .icon-shape,#mermaid-svg-B0wzibKCo869Qyl6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-B0wzibKCo869Qyl6 .icon-shape p,#mermaid-svg-B0wzibKCo869Qyl6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-B0wzibKCo869Qyl6 .icon-shape .label rect,#mermaid-svg-B0wzibKCo869Qyl6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-B0wzibKCo869Qyl6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-B0wzibKCo869Qyl6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-B0wzibKCo869Qyl6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} @JacksonAnnotationsInside

  • @JsonSerialize
    是
    否
    String
    Collection
    异常
    字段标注 @Sensitive
    SensitiveJsonSerializer
    createContextual(prov, property)
    从 BeanProperty 读注解

strategy / condition / begin / end
CLASS_FIELD_CACHE 命中?
createFromConfig 复用配置
getOrCreateStrategy

getOrCreateCondition
写入缓存
serialize(value, gen)
值类型
condition.valid? → strategy.desensitize
逐元素脱敏
写 MASK_ALL(严禁回退原值)

看图要点 :createContextual 是拿到字段注解的唯一入口,它每个字段只调一次;三个缓存在这里收口;异常路径必须回退全掩码而不是原值。

3.2 注解:两个元注解缺一不可

代码坐标:annotation/Sensitive.java

java 复制代码
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {
    Class<? extends DesensitizeStrategy> strategy() default CommonDesensitizeStrategy.class;
    Class<? extends DesensitizeCondition> condition() default AlwaysTrueDesensitizeCondition.class;
    String pattern() default "";
    String patternMask() default "";
    String replaceChar() default "*";
    int replaceSize() default 0;
    int begin() default 0;
    int end() default 0;
}

@JacksonAnnotationsInside 是整个方案能成立的前提,这点值得单独说。

Jackson 识别的是它自己认识的注解 (@JsonSerialize、@JsonFormat 等)。你自定义一个 @Sensitive,Jackson 默认完全看不见------它不会去读你的注解,更不会因为你的注解上组合了 @JsonSerialize 就自动生效。

@JacksonAnnotationsInside 就是告诉 Jackson:「这个注解内部组合的 Jackson 注解,应当在被标注的字段上生效」。

没有它会怎样:编译通过、启动正常、运行不报错,脱敏静默失效。我在第一次实现时就漏了这个注解,测试环境看了半天日志,最后发现字段是原样输出的。

这个坑之所以危险,是因为它完全静默------没有异常、没有警告。

3.3 ContextualSerializer:解决「序列化器不知道自己是哪个字段」

代码坐标:SensitiveJsonSerializer#createContextual

java 复制代码
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
    if (property == null) {
        return this;
    }

    Sensitive annotation = property.getAnnotation(Sensitive.class);
    if (annotation == null) {
        return this;
    }

    // 优先从缓存获取
    SensitiveFieldConfig cachedConfig = findCachedConfig(property);
    if (cachedConfig != null) {
        return createFromConfig(cachedConfig);
    }

    // 缓存未命中,构建新的序列化器
    SensitiveJsonSerializer serializer = new SensitiveJsonSerializer();
    serializer.strategy = getOrCreateStrategy(annotation.strategy());
    serializer.condition = getOrCreateCondition(annotation.condition());
    serializer.pattern = annotation.pattern();
    serializer.patternMask = annotation.patternMask();
    serializer.replaceChar = annotation.replaceChar();
    serializer.replaceSize = annotation.replaceSize();
    serializer.begin = annotation.begin();
    serializer.end = annotation.end();

    cacheFieldConfig(property, serializer);
    return serializer;
}

机制讲解:

  1. Jackson 在处理某个字段时,如果发现该字段声明了 @JsonSerialize(using = X),会先检查这个序列化器是否实现了 ContextualSerializer。
  2. 如果实现了,就调用 createContextual(prov, property),传入当前的 BeanProperty。
  3. BeanProperty 携带该字段的全部注解 ,所以能读到 @Sensitive 的 strategy、begin、end 等。
  4. 返回一个新的、已经配置好的序列化器实例。
  5. 之后该字段的每次序列化都走这个实例,不再需要反射读注解。

这一步是每个字段只执行一次(Jackson 会缓存上下文化后的序列化器),所以「反射读注解」的成本被摊薄到了几乎为零。

整个上下文化过程可以用一张时序图串起来:
BeanProperty CLASS_FIELD_CACHE SensitiveJsonSerializer Jackson BeanProperty CLASS_FIELD_CACHE SensitiveJsonSerializer Jackson #mermaid-svg-9NbfAmBFPTvnizB5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9NbfAmBFPTvnizB5 .error-icon{fill:#552222;}#mermaid-svg-9NbfAmBFPTvnizB5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9NbfAmBFPTvnizB5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9NbfAmBFPTvnizB5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9NbfAmBFPTvnizB5 .marker.cross{stroke:#333333;}#mermaid-svg-9NbfAmBFPTvnizB5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9NbfAmBFPTvnizB5 p{margin:0;}#mermaid-svg-9NbfAmBFPTvnizB5 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9NbfAmBFPTvnizB5 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9NbfAmBFPTvnizB5 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9NbfAmBFPTvnizB5 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9NbfAmBFPTvnizB5 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9NbfAmBFPTvnizB5 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9NbfAmBFPTvnizB5 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9NbfAmBFPTvnizB5 .sequenceNumber{fill:white;}#mermaid-svg-9NbfAmBFPTvnizB5 #sequencenumber{fill:#333;}#mermaid-svg-9NbfAmBFPTvnizB5 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9NbfAmBFPTvnizB5 .messageText{fill:#333;stroke:none;}#mermaid-svg-9NbfAmBFPTvnizB5 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9NbfAmBFPTvnizB5 .labelText,#mermaid-svg-9NbfAmBFPTvnizB5 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9NbfAmBFPTvnizB5 .loopText,#mermaid-svg-9NbfAmBFPTvnizB5 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9NbfAmBFPTvnizB5 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9NbfAmBFPTvnizB5 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9NbfAmBFPTvnizB5 .noteText,#mermaid-svg-9NbfAmBFPTvnizB5 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9NbfAmBFPTvnizB5 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9NbfAmBFPTvnizB5 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9NbfAmBFPTvnizB5 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9NbfAmBFPTvnizB5 .actorPopupMenu{position:absolute;}#mermaid-svg-9NbfAmBFPTvnizB5 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-9NbfAmBFPTvnizB5 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9NbfAmBFPTvnizB5 .actor-man circle,#mermaid-svg-9NbfAmBFPTvnizB5 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9NbfAmBFPTvnizB5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt缓存命中缓存未命中 createContextual(prov, property)getAnnotation(Sensitive.class)返回注解配置findCachedConfig(property)返回 SensitiveFieldConfigcreateFromConfig 复用配置nullgetOrCreateStrategy / getOrCreateConditioncacheFieldConfig 写入缓存返回已配置的序列化器实例serialize(value, gen)

3.4 三级缓存:性能的关键

代码坐标:SensitiveJsonSerializer 的静态字段

java 复制代码
/** 策略实例缓存,Key 为策略类,Value 为策略单例 */
private static final Map<Class<? extends DesensitizeStrategy>, DesensitizeStrategy> STRATEGY_CACHE
        = new ConcurrentHashMap<>(16);

/** 条件实例缓存 */
private static final Map<Class<? extends DesensitizeCondition>, DesensitizeCondition> CONDITION_CACHE
        = new ConcurrentHashMap<>(16);

/** 类级别的敏感字段配置缓存,外层 Key 为声明类,内层 Key 为字段名 */
private static final Map<Class<?>, Map<String, SensitiveFieldConfig>> CLASS_FIELD_CACHE
        = new ConcurrentHashMap<>(32);

三级缓存各自解决一个问题:

缓存 解决什么 命中率
STRATEGY_CACHE 避免每个字段都 newInstance() 一次策略类 高(策略类就那么几种)
CONDITION_CACHE 同上,针对条件类 高
CLASS_FIELD_CACHE 避免同一个字段被多次上下文化时重复读注解 高(字段数固定)

为什么第三级要按「声明类 + 字段名」两层组织:

java 复制代码
Class<?> declaringClass = property.getMember().getDeclaringClass();
String fieldName = property.getName();
Map<String, SensitiveFieldConfig> classFields = CLASS_FIELD_CACHE.get(declaringClass);
return (classFields != null) ? classFields.get(fieldName) : null;

如果只用字段名做 key,两个不同类里都叫 phone 的字段会互相污染------而它们的脱敏策略可能完全不同。

没有缓存会怎样 :在 100 条记录 × 5 个敏感字段的列表接口上,每次序列化都要 newInstance() 5 个策略对象 + 反射读 5 次注解。实测这个开销是肉眼可见的(P99 从 12ms 涨到 45ms,后来加缓存回到 13ms)。

3.5 策略与条件:两个接口,各管一件事

代码坐标:strategy/DesensitizeStrategy.java、condition/DesensitizeCondition.java

java 复制代码
public interface DesensitizeStrategy {
    String desensitize(String source, String replaceChar, int replaceSize,
                       String pattern, String patternMask, int begin, int end);
}

public interface DesensitizeCondition {
    boolean valid(String value);   // 返回 false 时不脱敏,原样输出
}

为什么必须拆成两个接口:

  • 策略 回答「怎么遮」:138****8000
  • 条件回答「遮不遮」:管理员看到完整号码

如果混在一起,策略类里就会出现「判断当前用户是否管理员」这种与脱敏无关的逻辑,而且每个策略都要重复写一遍。

拆开之后,组合是自由的:

java 复制代码
@Sensitive(
    strategy = PhoneDesensitizeStrategy.class,
    condition = NonAdminCondition.class
)
private String phone;

策略实现的一个细节 :PhoneDesensitizeStrategy 内部其实是委托给正则策略的:

java 复制代码
public class PhoneDesensitizeStrategy implements DesensitizeStrategy {
    private static final CommonPatternDesensitizeStrategy PATTERN_STRATEGY
            = new CommonPatternDesensitizeStrategy();

    private static final String DEFAULT_PATTERN = "(\\d{3})\\d*(\\d{4})";
    private static final String DEFAULT_PATTERN_MASK = "$1****$2";

    @Override
    public String desensitize(String source, String replaceChar, int replaceSize,
                              String pattern, String patternMask, int begin, int end) {
        if (StringUtils.isBlank(source)) {
            return source;
        }
        // 注意:忽略入参的 pattern/patternMask,用手机号自己的默认值
        return PATTERN_STRATEGY.desensitize(source, replaceChar, replaceSize,
                DEFAULT_PATTERN, DEFAULT_PATTERN_MASK, begin, end);
    }
}

这里有个容易误解的地方:PhoneDesensitizeStrategy 会忽略注解上传入的 pattern,强制使用手机号自己的正则。这是刻意的------既然选了「手机号策略」,就不该再让调用方覆盖正则,否则容易配出「看起来用了手机号策略、实际用了别的正则」这种坑。

内置的 8 种策略输出示例:

策略 输入 → 输出
PhoneDesensitizeStrategy 13800138000 → 138****8000
EmailDesensitizeStrategy example@mail.com → exa****@mail.com
IdCardDesensitizeStrategy 110101199001011234 → 110101********1234
BankCardDesensitizeStrategy 6222021234567890123 → 6222 **** **** 0123
NameDesensitizeStrategy 张三丰 → 张*丰
AccountDesensitizeStrategy 账号中间位遮蔽
PasswordDesensitizeStrategy 全部替换为 ******
CommonDesensitizeStrategy 按 begin/end 保留首尾

3.6 类型处理:String 与 Collection

代码坐标:SensitiveJsonSerializer#serialize

java 复制代码
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers)
        throws IOException {
    try {
        if (value == null) {
            gen.writeNull();
            return;
        }
        if (value instanceof String strValue) {
            serializeString(strValue, gen);
            return;
        }
        if (value instanceof Collection<?> collection) {
            serializeCollection(collection, gen);
            return;
        }
        // 其他类型直接输出
        gen.writeObject(value);
    } catch (Exception e) {
        // 异常回退:严禁返回原始敏感值!
        log.error("敏感数据序列化异常,已按全掩码降级处理(不返回原始值)。异常类型={}",
                e.getClass().getSimpleName(), e);
        gen.writeString(MASK_ALL);
    }
}

Collection 的处理是逐元素判断:

java 复制代码
private void serializeCollection(Collection<?> collection, JsonGenerator gen) throws IOException {
    gen.writeStartArray();
    for (Object element : collection) {
        if (element instanceof String strElement) {
            serializeString(strElement, gen);
        } else {
            gen.writeObject(element);   // 非 String 原样输出
        }
    }
    gen.writeEndArray();
}

这个异常兜底值得单独说 。脱敏代码抛异常时,最「自然」的写法是 gen.writeObject(value) 把原值写出去------但这等于泄露。所以代码里定了一个常量:

java 复制代码
/**
 * 异常兜底用的全掩码字符串。
 * 当脱敏策略执行过程中抛异常时,必须返回此固定字符串而**严禁返回原始值**,
 * 否则身份证/手机号/密码等敏感数据将绕过全部脱敏校验被原样写入响应。
 */
private static final String MASK_ALL = "********";

这是一个安全默认值的设计:宁可输出一串星号让用户投诉「数据显示异常」,也不能输出原始敏感数据。

3.7 什么时候不脱敏:条件的实际用法

java 复制代码
private void serializeString(String value, JsonGenerator gen) throws IOException {
    if (StringUtils.isBlank(value)) {
        gen.writeString("");
        return;
    }
    if (!condition.valid(value)) {
        gen.writeString(value);   // 条件不满足 → 原样输出
        return;
    }
    String desensitized = strategy.desensitize(value, replaceChar, replaceSize,
            pattern, patternMask, begin, end);
    gen.writeString(desensitized);
}

注意这里是 condition.valid(value) 而不是 condition.valid(config)------条件可以基于「值本身」判断。比如「只有长度足够的身份证才脱敏」这种规则就容易实现。

3.8 边界:只支持 String 和 Collection

这一点必须明确写进文档,因为它会影响数据结构设计:

java 复制代码
if (value instanceof String strValue) { ... }
if (value instanceof Collection<?> collection) { ... }
gen.writeObject(value);   // 其他类型:不脱敏

推论 :手机号如果存成 Long,这套方案对它无效。

这不是缺陷,是设计选择------Long 类型的手机号会丢掉前导零(013800138000 → 13800138000),本来就不该用数值类型存手机号。但如果项目里已经有这种脏数据,需要额外处理。


四、权衡、边界与坑

4.1 取舍表

决策 选择 备选 代价 / 边界
挂载点 Jackson 序列化 AOP 遍历对象树 只对 JSON 生效;导出 Excel 要另做
字段配置读取 ContextualSerializer 自定义 AnnotationIntrospector 后者更底层,但复杂得多
策略实例 全局缓存复用 每次 new 策略必须无状态
条件判定 每次调用 缓存结果 条件是运行时相关,不能缓存
异常处理 回退全掩码 回退原值 / 抛异常 选择可用性换安全;会让用户看到 ********
支持类型 String + Collection 全类型 数值型手机号需另处理

4.2 踩过的四个坑

坑 1:漏了 @JacksonAnnotationsInside,脱敏完全静默失效

  • 现象:接口返回的手机号是完整的,但没有任何异常或警告。
  • 定位 :Jackson 不认识自定义注解,所以 @JsonSerialize 根本没生效。
  • 修法 :补上 @JacksonAnnotationsInside。

这个坑最值得警惕的地方在于「静默」------没有报错,只有安全测试能发现。建议在 CI 里加一条断言:拿一个带敏感字段的 DTO 序列化,断言输出里不含原始手机号。

坑 2:策略类有状态,缓存后互相污染

  • 现象:A 字段配置的「保留前 3 后 4」,B 字段也变成了一样。
  • 定位 :策略类里存了 begin/end 字段,STRATEGY_CACHE 复用同一个实例导致互相覆盖。
  • 修法:策略类必须无状态,所有配置从方法入参传入。

这是我设计接口时的一个关键决定:

java 复制代码
// ✅ 无状态:配置全在入参
String desensitize(String source, String replaceChar, int replaceSize,
                   String pattern, String patternMask, int begin, int end);

参数确实多,但换来的是「实例可以安全共享」。

坑 3:@Sensitive 加在 DTO 上,但接口返回的是 Entity

  • 现象:注解明明加了,脱敏不生效。
  • 定位 :实际被序列化的是 User(Entity),而注解加在 UserVO 上。
  • 修法 :注解必须加在实际被序列化的类上。如果分层是 Entity → DTO,就要在 DTO 上加。

坑 4:日志打印绕过 Jackson

  • 现象:所有 JSON 接口都脱敏了,但日志文件里还是完整的手机号。
  • 定位 :log.info("{}", user) 走的是 toString(),不经过 Jackson。
  • 修法 :① toString() 里也用脱敏工具;② 或者干脆禁止直接打印含敏感字段的实体;③ 或者用 @ToString.Exclude 排除敏感字段。

这个坑揭示了方案的边界:它只保护 JSON 出口。

4.3 什么时候你不该用这套方案

第一,非 JSON 的输出场景。

Excel 导出、CSV、Protobuf、邮件模板------这些都要各自处理。如果项目里导出场景很多,建议在「导出」这一层再建一套脱敏(可以复用 DesensitizeStrategy 接口)。

第二,需要「同一个类在不同接口返回不同脱敏程度」时。

比如 /api/me 返回自己的完整号码,/api/users/{id} 对别人脱敏。注解是类级别的静态配置,无法表达「取决于调用方」。这种场景需要:

  • 要么用两个不同的 VO;
  • 要么用条件(DesensitizeCondition)读取当前登录用户------但要注意,条件里读 ThreadLocal 在异步场景会失效。

第三,脱敏规则需要动态下发时。

注解是编译期固定的。如果规则要支持运营配置(比如「这周开始身份证只保留后 2 位」),注解就无能为力了,需要改成配置驱动。

4.4 这套方案防不住什么

攻击/泄露路径 能否防住 说明
绕过 API 直接读库 ❌ 防不住 这是输出层防护,不是存储加密
接口返回了未标注的字段 ❌ 防不住 依赖开发者标注正确,没有兜底
日志/异常栈打印对象 ❌ 防不住 不走 Jackson
导出文件 ❌ 防不住 需要单独处理
JSON 接口泄露 ✅ 防住 只要标注了就对

一句话总结:它是「JSON 出口的最后一道闸」,不是「数据安全的全部」。


五、可以直接抄走的清单

  1. 自定义注解必须同时加 @JacksonAnnotationsInside 和 @JsonSerialize(using = ...),缺一个就静默失效。
  2. 序列化器实现 ContextualSerializer,从 BeanProperty#getAnnotation 读字段配置。
  3. createContextual 返回新的已配置实例 ,不要修改 this。
  4. 三级缓存:策略实例、条件实例、字段配置(按「声明类 + 字段名」两层)。
  5. 策略接口必须无状态,所有配置从方法入参传入。
  6. 策略(怎么遮)和条件(遮不遮)拆成两个接口。
  7. 异常兜底必须写全掩码常量,严禁回退原值。
  8. 支持 String 与 Collection<String>,其他类型明确不支持并写进文档。
  9. 在 CI 里加一条断言:序列化含敏感字段的 DTO,输出里不含原始值。
  10. 明确写下「不保护日志、导出、直连数据库」------别让人误以为加了注解就安全了。

附:一个最小可运行的验证

如果你要验证自己的实现是否正确,用这个最小例子:

java 复制代码
public class Demo {
    static class UserVO {
        @Sensitive(strategy = PhoneDesensitizeStrategy.class)
        private String phone = "13800138000";

        @Sensitive
        private String nickname = "张三丰";

        @Sensitive(strategy = PhoneDesensitizeStrategy.class)
        private List<String> contacts = List.of("13900139000", "13700137000");

        // getter 省略
    }

    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        System.out.println(mapper.writeValueAsString(new UserVO()));
    }
}

期望输出:

json 复制代码
{"phone":"138****8000","nickname":"张三*","contacts":["139****9000","137****7000"]}

如果 phone 仍是完整号码,第一个要检查的就是 @JacksonAnnotationsInside 有没有加。

相关推荐
独泪了无痕9 小时前
探索Pinyin4j-Java中的拼音转换利器
java·后端
码事漫谈9 小时前
从 Coze 居然要下线了说起
后端
想不到一个好的ID10 小时前
智能体开发
后端
皮皮林55110 小时前
为什么数据库连接池不采用 IO 多路复用?
后端
Dream it possible!13 小时前
LeetCode 面试经典 150_多维动态规划_交错字符串(97_C++_中等)
算法·leetcode·面试·动态规划
心抵鹊13 小时前
算法之双指针系列(一)
算法·leetcode
十二13 小时前
【洛谷题解】P1083 [NOIP 2012 提高组] 借教室(二分答案 + 差分)
数据结构·c++·算法·蓝桥杯
IT_陈寒14 小时前
Redis雪崩把我坑惨了,这是5年经验的教训
前端·人工智能·后端
量化分析码农14 小时前
【Python量化多因子实战 06】从因子到持仓:多因子选股实盘工程化落地
后端