Featured image of post 用 Jackson 和 AOP 优雅地处理接口数据脱敏

用 Jackson 和 AOP 优雅地处理接口数据脱敏

手机号身份证号邮箱地址要脱敏?写注解 + 序列化器,业务代码无感——把脱敏从重复劳动变成声明式配置

这件事每个面向用户的接口都遇到

合规要求越来越严,业务接口里要返回手机号、身份证、邮箱、银行卡——但生产环境必须脱敏

1
2
3
4
138****8000     原: 13800138000
510123****0023  原: 510123199001020023
z****@163.com   原: zhangsan@163.com
6228**********0023  原: 6228480000010990023

一个朴素的实现是在 Service 里手动转换:

1
2
3
4
5
6
7
8
9
public UserVO getUser(Long id) {
    User user = userRepo.findById(id).orElseThrow();
    UserVO vo = new UserVO();
    vo.setName(user.getName());
    vo.setPhone(MaskUtil.maskPhone(user.getPhone()));        // 手动脱敏
    vo.setIdCard(MaskUtil.maskIdCard(user.getIdCard()));     // 手动脱敏
    vo.setEmail(MaskUtil.maskEmail(user.getEmail()));        // 手动脱敏
    return vo;
}

写一两次不烦——写到一百次必出错

  • 新人写新接口忘了脱敏 → 信息泄漏
  • 字段重命名时 Util 的调用没改全 → 漏脱敏
  • 测试环境不希望脱敏 → 各处 if 判断又加 bug
  • 一个字段多个接口都返回 → 改脱敏规则要改一堆地方

正确姿势是——让脱敏变成"声明式"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class UserVO {
    private String name;

    @Sensitive(type = PHONE)
    private String phone;

    @Sensitive(type = ID_CARD)
    private String idCard;

    @Sensitive(type = EMAIL)
    private String email;
}

写一次注解,所有接口自动生效。本文讲清楚怎么用 Jackson 自定义序列化 + AOP 实现这套机制,以及生产环境的细节。


一、最朴素:用 Jackson 自定义序列化器

Spring Boot 默认用 Jackson 序列化 JSON——拦截这个过程是最干净的姿势。

1. 定义脱敏类型

1
2
3
4
5
6
7
8
9
public enum SensitiveType {
    PHONE,
    ID_CARD,
    EMAIL,
    BANK_CARD,
    NAME,
    ADDRESS,
    CUSTOM;
}

2. 定义注解

1
2
3
4
5
6
7
8
9
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside     // 让自定义注解能携带其他 Jackson 注解
@JsonSerialize(using = SensitiveSerializer.class)
public @interface Sensitive {
    SensitiveType type();
    int prefix() default -1;
    int suffix() default -1;
}

@JacksonAnnotationsInside 是一个非常关键的"元注解"——它让 @Sensitive 自带 @JsonSerialize 的能力。

3. 写自定义序列化器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class SensitiveSerializer extends JsonSerializer<String>
        implements ContextualSerializer {

    private SensitiveType type = SensitiveType.PHONE;
    private int prefix = -1;
    private int suffix = -1;

    public SensitiveSerializer() {}
    public SensitiveSerializer(SensitiveType type, int prefix, int suffix) {
        this.type = type;
        this.prefix = prefix;
        this.suffix = suffix;
    }

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider sp)
            throws IOException {
        if (value == null) { gen.writeNull(); return; }
        gen.writeString(SensitiveUtil.mask(value, type, prefix, suffix));
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov,
                                              BeanProperty property) {
        if (property == null) return this;
        Sensitive ann = property.getAnnotation(Sensitive.class);
        if (ann != null) {
            return new SensitiveSerializer(ann.type(), ann.prefix(), ann.suffix());
        }
        return this;
    }
}

ContextualSerializer 让序列化器能在每次使用时拿到字段上的注解参数——这是注解能"传参"的关键

4. 实现脱敏工具

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class SensitiveUtil {
    public static String mask(String src, SensitiveType type, int prefix, int suffix) {
        if (src == null || src.isEmpty()) return src;
        switch (type) {
            case PHONE:
                return src.length() == 11 ? src.substring(0, 3) + "****" + src.substring(7) : src;
            case ID_CARD:
                return src.length() == 18 ? src.substring(0, 6) + "********" + src.substring(14) : src;
            case EMAIL:
                int at = src.indexOf('@');
                if (at <= 1) return src;
                String name = src.substring(0, at);
                String mask = name.charAt(0) + "****";
                return mask + src.substring(at);
            case BANK_CARD:
                return src.length() < 4 ? src : "****" + src.substring(src.length() - 4);
            case NAME:
                return src.charAt(0) + "*";
            case ADDRESS:
                return src.length() <= 10 ? src : src.substring(0, 6) + "****";
            case CUSTOM:
                return maskCustom(src, prefix, suffix);
            default:
                return src;
        }
    }

    private static String maskCustom(String src, int prefix, int suffix) {
        if (src.length() <= prefix + suffix) return src;
        return src.substring(0, prefix)
                + "*".repeat(src.length() - prefix - suffix)
                + src.substring(src.length() - suffix);
    }
}

5. 实战使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class UserVO {
    private String name;

    @Sensitive(type = PHONE)
    private String phone;

    @Sensitive(type = ID_CARD)
    private String idCard;

    @Sensitive(type = EMAIL)
    private String email;

    @Sensitive(type = CUSTOM, prefix = 4, suffix = 4)
    private String bankCard;

    // getters / setters
}

业务代码原汁原味:

1
2
3
4
5
6
7
@GetMapping("/users/{id}")
public UserVO getUser(@PathVariable Long id) {
    User user = userRepo.findById(id).orElseThrow();
    UserVO vo = new UserVO();
    BeanUtils.copyProperties(user, vo);     // 直接拷贝
    return vo;                              // 返回时 Jackson 自动脱敏
}

二、按角色控制是否脱敏

需求经常是这样:

普通用户调接口看到 138****8000;管理员调接口看到完整 13800138000

注解里加一个"绕过角色":

1
2
3
4
public @interface Sensitive {
    SensitiveType type();
    String[] bypassRoles() default {};   // 这些角色不脱敏
}

修改序列化器(在原有字段基础上加 bypassRoles,并在 createContextual 里读注解传进新实例):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class SensitiveSerializer extends JsonSerializer<String> implements ContextualSerializer {

    private SensitiveType type = SensitiveType.PHONE;
    private int prefix = -1;
    private int suffix = -1;
    private String[] bypassRoles = new String[0];   // ★ 新增

    public SensitiveSerializer() {}
    public SensitiveSerializer(SensitiveType type, int prefix, int suffix, String[] bypassRoles) {
        this.type = type;
        this.prefix = prefix;
        this.suffix = suffix;
        this.bypassRoles = bypassRoles;
    }

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider sp)
            throws IOException {
        if (value == null) { gen.writeNull(); return; }
        Set<String> myRoles = UserContextHolder.current().getRoles();
        if (Arrays.stream(bypassRoles).anyMatch(myRoles::contains)) {
            gen.writeString(value);   // 不脱敏
            return;
        }
        gen.writeString(SensitiveUtil.mask(value, type, prefix, suffix));
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
        if (property == null) return this;
        Sensitive ann = property.getAnnotation(Sensitive.class);
        if (ann != null) {
            return new SensitiveSerializer(ann.type(), ann.prefix(), ann.suffix(), ann.bypassRoles());
        }
        return this;
    }
}

业务里:

1
2
@Sensitive(type = PHONE, bypassRoles = {"ADMIN", "RISK_OPS"})
private String phone;

三、Jackson 方案的限制

Jackson 序列化是 JSON 输出阶段 才介入——它解决了"返回给前端时脱敏"。但有些场景它不能覆盖:

  • 入参脱敏(用户上传含敏感数据,落库前要脱敏)
  • 写日志脱敏(日志里也不能完整打印身份证)
  • 第三方接口调用脱敏(你不直接控制 JSON 序列化)

这些场景需要 AOP 配合。


四、AOP 方案:方法级数据转换

AOP 拦截 Controller / Service 方法,在方法返回后遍历返回对象,手动改写带 @Sensitive 的字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Aspect
@Component
public class SensitiveAspect {

    @AfterReturning(value = "@annotation(SensitiveResult)", returning = "result")
    public void after(JoinPoint pjp, Object result) {
        maskRecursively(result);
    }

    private void maskRecursively(Object obj) {
        if (obj == null) return;
        if (obj instanceof Collection) {
            ((Collection<?>) obj).forEach(this::maskRecursively);
            return;
        }
        for (Field f : obj.getClass().getDeclaredFields()) {
            Sensitive ann = f.getAnnotation(Sensitive.class);
            if (ann != null && f.getType() == String.class) {
                f.setAccessible(true);
                try {
                    String v = (String) f.get(obj);
                    f.set(obj, SensitiveUtil.mask(v, ann.type(), ann.prefix(), ann.suffix()));
                } catch (IllegalAccessException ignored) {}
            } else if (!f.getType().isPrimitive() && !f.getType().getName().startsWith("java.")) {
                f.setAccessible(true);
                try {
                    maskRecursively(f.get(obj));
                } catch (IllegalAccessException ignored) {}
            }
        }
    }
}

调用方加注解:

1
2
@SensitiveResult
public List<UserVO> listUsers() { ... }

Jackson vs AOP

JacksonAOP
触发时机JSON 序列化时方法返回时
改的对象输出流Java 对象本身
后续可读输出流写完即结束对象被改了,后续逻辑读到脱敏值
性能中(反射遍历)
控制粒度字段级方法级 + 字段级
适用接口返回入参、日志、复杂场景

生产推荐组合:Jackson 处理 99% 的接口返回,AOP 处理少数特殊场景。


五、踩坑提醒

1. 不要原地修改 Entity

AOP 方案里有个隐含风险——直接修改了 User 实体的字段

1
2
List<User> users = userRepo.findAll();
maskRecursively(users);   // 改了实体!

如果接下来还要把这些 users 写回 DB,会写入脱敏后的值——数据被破坏。

正确做法:只对 VO 脱敏,永不对 Entity。VO 是临时对象,Entity 是数据。

2. 注解要打在 String 上

LongInteger 这些类型上加 @Sensitive 不会生效(序列化器只处理 String)——身份证存 String 不要存 Long。

3. 集合 + 嵌套对象

JSON 序列化器自动递归处理嵌套;AOP 方案要手动写递归,容易栈溢出——加个深度限制:

1
2
3
4
private void maskRecursively(Object obj, int depth) {
    if (depth > 10) return;
    // ...
}

4. 缓存的对象

如果对象被脱敏后又被放进缓存,缓存里的值就是脱敏的——下次拿出来给管理员也是脱敏的。永远只在最后一刻(接口返回)脱敏。

5. 日志里也要脱敏

1
log.info("user info: {}", user);   // ❌ user.toString() 含完整手机号

可以为 Lombok 的 @ToString@ToString.Exclude,或在 Logback 配 PatternMaskingFilter。

6. 测试环境不脱敏

通过 Profile 切换:

1
2
3
4
5
@Profile({"!test"})
@Configuration
public class SensitiveConfig {
    // 注册序列化器
}

测试环境关闭脱敏,方便调试。

7. 某些字段不脱敏 = 数据合规风险

不要让"业务方便"凌驾于合规之上——有人为了排查问题要"管理员能看完整信息",要做:

  • 操作日志(谁在什么时候查了谁的完整数据)
  • 二次确认(点"查看完整"才显示)
  • 限频(每天最多查 N 个)

六、生产推荐架构

每一层都有自己的脱敏机制,VO 上的注解是核心声明,序列化器/AOP/日志过滤器各自落实。


小结

把全文压一句:

数据脱敏不该写在业务代码里——它属于『数据展示层』的事。用注解声明字段类型,用 Jackson 序列化器或 AOP 做执行——业务代码只关心业务。

工程上几条铁律:

  1. 永远脱敏 VO,不脱敏 Entity
  2. 接口返回用 Jackson 序列化器,特殊场景用 AOP 兜底
  3. 日志脱敏单独走 Logback Filter
  4. 角色绕过要严格审计
  5. 测试环境通过 Profile 关闭

把这套做对,业务代码再也不用满地 MaskUtil.maskPhone(...),新接口加字段也不会漏脱敏。

使用 Hugo 构建
主题 StackJimmy 设计