这件事每个面向用户的接口都遇到
合规要求越来越严,业务接口里要返回手机号、身份证、邮箱、银行卡——但生产环境必须脱敏:
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
| Jackson | AOP |
|---|
| 触发时机 | 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 上
Long、Integer 这些类型上加 @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 个)
六、生产推荐架构
flowchart TD
Req[接口请求] --> Controller
Controller --> Service[业务逻辑]
Service --> DB[(DB 存原文)]
Service --> VO[转 VO]
VO --> Sensitive["@Sensitive 注解
(声明式)"]
Sensitive --> Jackson[Jackson 序列化
自动脱敏]
Jackson --> Resp[返回前端]
Logging[日志记录] --> LogMask[Logback PatternFilter
脱敏]每一层都有自己的脱敏机制,VO 上的注解是核心声明,序列化器/AOP/日志过滤器各自落实。
小结
把全文压一句:
数据脱敏不该写在业务代码里——它属于『数据展示层』的事。用注解声明字段类型,用 Jackson 序列化器或 AOP 做执行——业务代码只关心业务。
工程上几条铁律:
- 永远脱敏 VO,不脱敏 Entity
- 接口返回用 Jackson 序列化器,特殊场景用 AOP 兜底
- 日志脱敏单独走 Logback Filter
- 角色绕过要严格审计
- 测试环境通过 Profile 关闭
把这套做对,业务代码再也不用满地 MaskUtil.maskPhone(...),新接口加字段也不会漏脱敏。