数据脱敏实战:手机号/身份证/银行卡——三行代码搞定,不侵入业务逻辑
引言
上个月安全审计,我们被点名了三个问题:
- 订单导出接口把用户手机号明文吐给了客服系统,客服离职后导出名单卖给竞品
- 前端页面直接展示身份证号全串,截图发朋友圈就是一次个人信息泄露
- 日志里打印了完整的银行卡号,等保测评直接不给过
整改要求:所有出参中的敏感字段必须脱敏。第一反应是写个工具类 DesensitizeUtil.maskPhone(...),然后在几十个 Controller 里逐个调用——改了三天,漏了两处,还把一个不需要脱敏的字段误伤了(测试同事的订单列表手机号变成 138****5678 无法回显)。
后来换成注解 + Jackson 序列化器方案:在 DTO 字段上标注解 @Sensitive(type = PHONE),序列化时自动脱敏,业务代码零改动。原来的三天工作量,现在每个字段只需加一行注解,而且脱敏规则集中在枚举里统一维护。
这篇文章把我们踩过的坑和最终方案完整讲一遍:
- 三种脱敏方案的对比(Jackson 序列化器 / MyBatis TypeHandler / AOP),各自的适用边界
@Sensitive注解 + 自定义 Jackson 序列化器的完整实现(支持手机号/身份证/银行卡/姓名/邮箱等 8 种类型,支持按角色动态决定是否脱敏)- 数据库层脱敏(MyBatis TypeHandler)和存储加密的边界——为什么"查询要用原文"的字段不能在 DB 层脱敏
一、先搞清楚:脱敏脱的是什么
1.1 脱敏的本质:最小可用原则
脱敏不是把数据打码就完事,而是在"业务可用"和"数据安全"之间找平衡:
| 场景 | 需要什么 | 脱敏策略 |
|---|---|---|
| 订单列表展示手机号 | 能认出是自己的号 | 保留前 3 后 4:138****5678 |
| 客服核对身份 | 验证后四位即可 | 保留后 4:***********5678 |
| 风控分析 | 完整号码做关联 | 不脱敏(但走内部权限) |
| 日志打印 | 排查问题能对上号 | 保留前 3 后 4 |
| 数据导出给第三方 | 不可还原 | 全脱敏或哈希 |
脱敏的核心问题不是"怎么打码",而是"每个场景需要看到多少"。同一个手机号,在客服系统、风控系统、数据仓库里的脱敏规则可能完全不同。
1.2 常见敏感数据类型与默认脱敏规则
| 类型 | 示例原文 | 脱敏后 | 规则 |
|---|---|---|---|
| 手机号 | 13812345678 | 138****5678 | 保留前 3 后 4 |
| 身份证 | 110101199001011234 | 110***********1234 | 保留前 3 后 4 |
| 银行卡 | 6222020200112233445 | 6222 ***********3445 | 保留前 4 后 4 |
| 姓名 | 张三丰 | 张** | 保留首字(少数民族姓名特殊处理) |
| 邮箱 | zhangsan@example.com | z******@example.com | 保留首字符和域名 |
| 地址 | 北京市海淀区xx路xx号 | 北京市海淀区****** | 保留到区级 |
| IP | 192.168.1.100 | 192.168.. | 保留前两段 |
| 自定义 | 任意字符串 | 按正则/前后保留位数 | 支持扩展 |
1.3 脱敏的三个层次
按数据流转的环节,脱敏可以在三个层次做:
| 层次 | 时机 | 优点 | 缺点 |
|---|---|---|---|
| 序列化层(出参脱敏) | Java 对象 → JSON 时 | 零业务入侵、注解声明式、按接口灵活控制 | 数据库和内存里仍是明文 |
| 持久层(DB 层脱敏) | 查询结果映射时 | 所有下游统一脱敏、防止误用 | 影响所有查询方,需要原文的场景失效 |
| 存储层(加密存储) | 写入数据库时 | 拖库也安全 | 需要密钥管理,模糊查询困难 |
这三个层次不互斥,而是组合使用:日志和出参用序列化层脱敏;导出给低信任方的数据在 DB 层脱敏;核心敏感字段(密码、密钥)加密存储。本文重点讲序列化层(最常用、侵入最小),同时给出持久层方案作对比。
二、三种方案对比:为什么推荐 Jackson 序列化器
2.1 方案一:工具类手动调用(反面教材)
// ❌ 业务代码里到处是脱敏调用
@GetMapping("/orders")
public List<OrderVO> listOrders() {
List<OrderVO> orders = orderService.listOrders();
for (OrderVO order : orders) {
order.setUserPhone(DesensitizeUtil.maskPhone(order.getUserPhone()));
order.setIdCard(DesensitizeUtil.maskIdCard(order.getIdCard()));
order.setBankCard(DesensitizeUtil.maskBankCard(order.getBankCard()));
// 每个接口都要写一遍,漏一处就是事故
}
return orders;
}
问题:侵入业务逻辑、容易遗漏、脱敏规则散落各处。新同事加接口忘了调工具类,就是一次数据泄露。这是我们要淘汰的方案。
2.2 方案二:AOP 切面(方法返回值脱敏)
// 思路:AOP 拦截 Controller 方法,对返回值里的注解字段统一脱敏
@Aspect
@Component
public class SensitiveAspect {
@Around("@annotation(com.example.annotation.SensitiveApi)")
public Object desensitize(ProceedingJoinPoint pjp) throws Throwable {
Object result = pjp.proceed();
// 反射遍历返回值对象树,找到 @Sensitive 字段,改写值
SensitiveFieldProcessor.process(result);
return result;
}
}
| 优点 | 缺点 |
|---|---|
| 业务代码只加注解,不用调工具类 | 反射改写对象,性能开销大(每次请求遍历对象树) |
| 与 JSON 序列化框架无关 | 污染原对象:脱敏后的对象如果被复用(缓存、二次序列化),数据已被破坏 |
| 嵌套对象/集合/Map 里的字段处理复杂,边界情况多 | |
| 拦截点在 Controller 层,内部服务间 Feign 调用不走 AOP,漏网 |
最大的坑是"污染原对象":如果返回值来自本地缓存,AOP 脱敏后缓存里存的就是打码数据,下一次请求返回的数据全坏了。这种 bug 非常隐蔽。
2.3 方案三:MyBatis TypeHandler(持久层脱敏)
// 思路:查询结果从 DB 映射到 Java 对象时脱敏
@MappedTypes(String.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class PhoneDesensitizeHandler extends BaseTypeHandler<String> {
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return DesensitizeUtil.maskPhone(rs.getString(columnName));
}
// setNonNullParameter / 其他 getNullableResult 重载略
}
// Mapper XML 里指定
<result column="user_phone" property="userPhone" typeHandler="com.example.handler.PhoneDesensitizeHandler"/>
| 优点 | 缺点 |
|---|---|
| 所有下游统一脱敏,覆盖面最大 | 需要原文的场景全部失效:发短信、客服回显、风控关联都拿不到原文 |
| DB 层兜底,不怕漏 | 每个字段都要配置 TypeHandler,配置繁琐 |
| 写库仍是明文:只防"读",不防"拖库" | |
| Java 对象里是脱敏值,业务逻辑里再做校验/更新会出错(把打码值写回 DB) |
TypeHandler 适合数据仓库、导出服务这类"只需要脱敏值"的场景。业务库慎用——业务逻辑经常需要原文(发短信、修改手机号前的验证)。
2.4 方案四:Jackson 序列化器(推荐)
// 思路:自定义 Jackson 序列化器,序列化时读取字段上的 @Sensitive 注解并打码
// DTO 定义
public class OrderVO {
@Sensitive(type = SensitiveType.PHONE)
private String userPhone; // 序列化时自动变成 138****5678
@Sensitive(type = SensitiveType.ID_CARD)
private String idCard;
// 非敏感字段不受影响
private String orderId;
}
| 优点 | 缺点 |
|---|---|
| 零业务入侵:字段加注解即可,一行代码 | 依赖 Jackson(Spring Boot 默认集成,微服务场景基本无缺点) |
| 不改原对象:只在 JSON 输出时打码,内存对象完好 | 内部 RPC 如果用非 JSON 序列化(如 Protobuf),需要另配 |
| 天然覆盖所有出参:Controller、Feign、消息序列化统一生效 | |
| 支持嵌套对象、集合、Map(Jackson 递归处理) | |
| 性能开销极小:序列化本来就要做,只多一次字符串处理 |
为什么"不改原对象"如此关键:Jackson 是在"对象 → JSON"的输出阶段做转换,Java 对象本身不变。缓存里存的是明文对象,缓存复用、二次序列化、对象回传内部服务都不受影响。而 AOP 是直接改对象,坑就在这里。
结论:出参脱敏首选 Jackson 序列化器;数据仓库/导出场景加 TypeHandler 兜底;AOP 方案不推荐。下面重点展开 Jackson 方案的完整实现。
三、@Sensitive 注解 + Jackson 序列化器完整实现
3.1 第一步:定义敏感类型枚举
/**
* 敏感数据类型:统一维护所有脱敏规则
* 新增类型只需加枚举 + 对应的 mask 实现
*/
public enum SensitiveType {
/** 手机号:保留前 3 后 4 → 138****5678 */
PHONE {
@Override
public String mask(String value) {
return SensitiveUtil.mask(value, 3, 4);
}
},
/** 身份证:保留前 3 后 4 → 110***********1234 */
ID_CARD {
@Override
public String mask(String value) {
return SensitiveUtil.mask(value, 3, 4);
}
},
/** 银行卡:保留前 4 后 4 → 6222***********3445 */
BANK_CARD {
@Override
public String mask(String value) {
return SensitiveUtil.mask(value, 4, 4);
}
},
/** 姓名:保留首字 → 张** */
NAME {
@Override
public String mask(String value) {
if (value == null || value.length() <= 1) {
return value;
}
return value.charAt(0) + "*".repeat(value.length() - 1);
}
},
/** 邮箱:保留首字符和域名 → z******@example.com */
EMAIL {
@Override
public String mask(String value) {
if (value == null || !value.contains("@")) {
return value;
}
int at = value.indexOf('@');
return value.charAt(0) + "******" + value.substring(at);
}
},
/** 地址:保留前 9 字符(到区级) */
ADDRESS {
@Override
public String mask(String value) {
if (value == null || value.length() <= 9) {
return value;
}
return value.substring(0, 9) + "******";
}
};
/** 每个类型实现自己的脱敏逻辑 */
public abstract String mask(String value);
}
3.2 第二步:定义 @Sensitive 注解
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.lang.annotation.*;
/**
* 敏感数据脱敏注解:标注在 DTO 字段上,序列化时自动脱敏
*
* @JacksonAnnotationsInside:把本注解"打包"成 Jackson 元注解,
* 使 @Sensitive 可以直接挂载 JsonSerialize,业务侧一个注解搞定
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {
/** 敏感类型(决定脱敏规则) */
SensitiveType type();
/**
* 角色:可选。配置后只有拥有该角色的用户能看到明文
* 留空表示所有出参一律脱敏
*/
String role() default "";
}
@JacksonAnnotationsInside 是这个方案的关键——它把 @Sensitive 和 @JsonSerialize 合成一个注解,业务字段上只需要写一个 @Sensitive,不需要同时写两个注解。
3.3 第三步:实现 Jackson 序列化器
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import java.io.IOException;
import java.util.Objects;
/**
* 脱敏序列化器:序列化时读取字段的 @Sensitive 注解并按规则打码
*
* 实现 ContextualSerializer 是关键:
* Jackson 为每个字段创建序列化器实例时会调用 createContextual,
* 在这里把字段上的 @Sensitive 注解信息"注入"当前序列化器实例
*/
public class SensitiveJsonSerializer extends JsonSerializer<String>
implements ContextualSerializer {
private SensitiveType sensitiveType;
/** 脱敏豁免角色:当前用户拥有该角色时输出明文(动态脱敏) */
private String exemptRole;
/** Jackson 要求的无参构造 */
public SensitiveJsonSerializer() {
}
public SensitiveJsonSerializer(SensitiveType type, String exemptRole) {
this.sensitiveType = type;
this.exemptRole = exemptRole;
}
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
if (value == null) {
gen.writeNull();
return;
}
// 1. 当前用户是否豁免(如客服管理员角色可看明文)
if (shouldExempt()) {
gen.writeString(value);
return;
}
// 2. 按类型脱敏后输出
gen.writeString(sensitiveType.mask(value));
}
/**
* 上下文创建:Jackson 为每个字段初始化序列化器时回调
* 从 BeanProperty 中取出字段上的 @Sensitive 注解
*/
@Override
public JsonSerializer<?> createContextual(SerializerProvider provider, BeanProperty property) {
if (property == null) {
return provider.findNullValueSerializer(null);
}
Sensitive annotation = property.getAnnotation(Sensitive.class);
if (annotation != null && Objects.equals(String.class, property.getType().getRawClass())) {
return new SensitiveJsonSerializer(annotation.type(), annotation.role());
}
// 字段没有 @Sensitive 注解:退回默认的 String 序列化器
return provider.findValueSerializer(property.getType(), property);
}
/** 动态脱敏:检查当前登录用户是否持有豁免角色 */
private boolean shouldExempt() {
if (exemptRole == null || exemptRole.isEmpty()) {
return false; // 未配置豁免角色:一律脱敏
}
// 从 SecurityContext / ThreadLocal 取当前用户角色(按项目实际实现)
Set<String> currentRoles = CurrentUserHolder.getCurrentRoles();
return currentRoles.contains(exemptRole);
}
}
ContextualSerializer 的作用必须理解:Jackson 的序列化器实例是按字段上下文复用的,createContextual 在初始化时把"这个字段是什么敏感类型、豁免角色是什么"注入当前实例。没有它,序列化器拿不到字段上的注解信息。
3.4 第四步:通用打码工具
/**
* 通用打码:保留前 prefix 后 suffix 位,中间用 * 填充
* 做长度和边界保护:短字符串不脱(避免把 "138" 脱成 "***")
*/
public class SensitiveUtil {
public static String mask(String value, int prefix, int suffix) {
if (value == null || value.isEmpty()) {
return value;
}
int length = value.length();
// 长度不足以保留前后缀:全打码
if (length <= prefix + suffix) {
return "*".repeat(length);
}
String head = value.substring(0, prefix);
String tail = value.substring(length - suffix);
String middle = "*".repeat(Math.max(length - prefix - suffix, 4));
return head + middle + tail;
}
}
3.5 使用效果:业务代码只需加注解
/**
* 用户订单 VO:敏感字段加 @Sensitive,其余零改动
*/
public class OrderVO {
private String orderId; // 非敏感,正常输出
@Sensitive(type = SensitiveType.PHONE)
private String userPhone; // → 138****5678
@Sensitive(type = SensitiveType.ID_CARD)
private String idCard; // → 110***********1234
@Sensitive(type = SensitiveType.NAME)
private String userName; // → 张**
@Sensitive(type = SensitiveType.BANK_CARD)
private String bankCard; // → 6222***********3445
/** 客服主管角色可看明文手机号(动态脱敏) */
@Sensitive(type = SensitiveType.PHONE, role = "ROLE_CS_ADMIN")
private String contactPhone;
}
// Controller:不用改任何代码!
@GetMapping("/orders/{id}")
public OrderVO getOrder(@PathVariable String id) {
return orderService.getOrder(id); // 序列化时自动脱敏
}
实际输出对比:
// 脱敏前(原始对象)
{
"orderId": "ORD20240822001",
"userPhone": "13812345678",
"idCard": "110101199001011234",
"userName": "张三丰",
"bankCard": "6222020200112233445"
}
// 脱敏后(接口返回)
{
"orderId": "ORD20240822001",
"userPhone": "138****5678",
"idCard": "110***********1234",
"userName": "张**",
"bankCard": "6222***********3445"
}
四、进阶:日志脱敏与 DB 层兜底
4.1 日志脱敏:Logback 的替换规则
序列化层管住了出参,但日志是另一个泄漏面。两种手段:
手段一:业务侧规范——日志参数用脱敏后的值:
// ❌ 危险:明文入日志
log.info("发送短信 phone={}", user.getPhone());
// ✅ 安全:日志用脱敏值
log.info("发送短信 phone={}", SensitiveType.PHONE.mask(user.getPhone()));
手段二:Logback 正则替换——兜底拦截,不依赖业务自觉:
<!-- logback.xml:用 replace 对特定 pattern 的日志内容替换 -->
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<filter class="ch.qos.logback.classic.boolex.OnMarkerEvaluator"/>
<!-- 消息 pattern 替换:11 位手机号打码 -->
<layout>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger - %replace(%msg){'(\d{3})\d{4}(\d{4})','$1****$2'}%n</pattern>
</layout>
</appender>
%replace(%msg){'正则','替换'} 会对每行日志内容做正则替换——11 位手机号自动打码,业务代码忘了脱敏也有兜底。身份证、银行卡同理配置多组替换规则。注意正则替换有性能成本,日志量大时只在关键 appender 上开启。
4.2 MyBatis TypeHandler:导出场景的 DB 层兜底
数据导出、报表服务这类"拿到的数据还要再加工/存储"的场景,对象层面的脱敏值可能被下游二次序列化"洗掉"。这时在持久层直接脱敏:
/**
* 手机号脱敏 TypeHandler:查询映射时打码
* 适用于数据导出服务 / 报表库 / 给低信任下游的查询
*/
@MappedTypes(String.class)
public class PhoneMaskTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
throws SQLException {
// 写库不脱敏(业务库原文写入,加密存储另配)
ps.setString(i, parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return SensitiveUtil.mask(rs.getString(columnName), 3, 4);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return SensitiveUtil.mask(rs.getString(columnIndex), 3, 4);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return SensitiveUtil.mask(cs.getString(columnIndex), 3, 4);
}
}
<!-- Mapper 配置:只对导出查询的映射启用 -->
<resultMap id="ExportOrderMap" type="com.example.dto.ExportOrderDTO">
<result column="user_phone" property="userPhone"
typeHandler="com.example.handler.PhoneMaskTypeHandler"/>
<result column="id_card" property="idCard"
typeHandler="com.example.handler.IdCardMaskTypeHandler"/>
</resultMap>
TypeHandler 的使用边界再强调一次:业务服务慎用,因为发短信、验证码校验、手机号修改等逻辑需要原文。它适合只消费脱敏值的下游——导出、报表、开放给第三方的数据同步。
4.3 存储加密:脱敏解决不了的"拖库"问题
要分清脱敏和加密的边界:
| 威胁 | 脱敏能否防护 | 需要的手段 |
|---|---|---|
| 出参泄露(接口/前端) | ✅ 序列化层脱敏 | — |
| 日志泄露 | ✅ 日志替换兜底 | — |
| 拖库(DB 文件被盗) | ❌ 数据库里是明文 | 加密存储 + 密钥管理 |
| DBA 直接查库 | ❌ | 加密存储 + 列权限 |
银行卡号这类高敏字段,在脱敏之上还要加密存储:
/**
* 敏感字段加密存储:AES-GCM,密钥从 KMS/Vault 获取
* 常用实现:MyBatis Interceptor 对指定列加解密,或 JPA AttributeConverter
*/
@Converter
public class AesEncryptConverter implements AttributeConverter<String, String> {
private final AesCipher cipher; // 密钥来自 Vault/KMS,绝不硬编码
@Override
public String convertToDatabaseColumn(String attribute) {
return attribute == null ? null : cipher.encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
return dbData == null ? null : cipher.decrypt(dbData);
}
}
三层组合拳:加密存储(防拖库)→ 持久层脱敏(防下游滥用)→ 序列化层脱敏(防出参泄露)。大多数团队先做第三层(成本最低见效最快),再逐步补齐前两层。
五、常见问题
5.1 @Sensitive 能处理嵌套对象和 List 吗?
能。Jackson 序列化是递归的:List<OrderVO>、Map<String, OrderVO>、OrderVO.AddressVO 里嵌套的 @Sensitive 字段都会被各自的序列化器处理,不需要额外配置。唯一的注意点是循环引用——被双向引用的对象配合 @JsonManagedReference/@JsonBackReference 处理,与脱敏无关。
5.2 同一个字段不同接口要不同脱敏规则怎么办?
三种做法:① 动态脱敏(role 属性):@Sensitive(type=PHONE, role="ROLE_CS"),客服角色看明文,普通用户看打码;② 多视图(JsonView):不同接口用不同的 View 序列化,敏感字段在受限 View 里配置更严格的脱敏类型;③ 拆 VO:风控专用的 VO 不加注解走内部权限,对外的 VO 加注解。方案①最简单,优先考虑。
5.3 序列化器会影响性能吗?
开销极小。Jackson 序列化本来就要执行,@Sensitive 只是在写 String 前多做一次 substring + 拼接,单次纳秒级。我们的订单列表接口(单次 100 条记录 × 5 个敏感字段)压测 P99 增幅 < 0.5ms。真正要注意的反而是 AOP 方案——反射遍历对象树的开销是它的几十倍。
5.4 入参需要脱敏吗?
入参"脱敏"通常指两类需求:① 日志脱敏:入参打印日志时打码,用 logback replace 或手动 mask(见 4.1);② 入参回显:表单校验失败后回显打码值,同样走出参序列化。入参本身不能改——业务逻辑需要原文(发短信、存库),改了入参业务就错了。序列化器只动出参,天然安全。
5.5 为什么不直接在 Service 层把数据改成脱敏值?
因为内存里的明文对象还有别的用途:本地缓存复用、发短信、跨服务传输、二次计算。Service 层改值会把"展示层的脱敏需求"泄漏进业务层,导致所有下游都只能拿到打码数据,还可能把打码值写回数据库造成数据污染。脱敏是展示关切,不是业务关切,应该在序列化边界处理。
5.6 微服务间 Feign 调用会被脱敏吗?
会被脱敏——Feign 默认用 Jackson 编码请求体,带 @Sensitive 的字段在调用方出参时已打码,下游收到的就是脱敏值。这通常是期望行为(最小数据原则),但如果有内部服务需要原文,用 @JsonView 区分:对外接口挂脱敏 View,内部 Feign 接口挂原文 View。
六、总结
三种方案速查卡
┌─────────────────┬──────────────┬────────────────────────────────┐
│ 方案 │ 侵入性 │ 适用场景 │
├─────────────────┼──────────────┼────────────────────────────────┤
│ Jackson 序列化器 │ 零(加注解) │ 出参脱敏首选,覆盖所有 JSON 序列化│
│ MyBatis TypeHandler│ 配置繁琐 │ 数据导出/报表/给低信任下游的查询 │
│ AOP 切面 │ 中(注解) │ 不推荐:污染原对象 + 反射性能差 │
│ 工具类手动调用 │ 高(改业务码) │ 淘汰:易遗漏,规则散落 │
└─────────────────┴──────────────┴────────────────────────────────┘
实施三步走
① 出参脱敏:@Sensitive 注解 + SensitiveJsonSerializer(本文第三、四章)
② 日志兜底:logback %replace 正则替换手机号/身份证/银行卡
③ 高敏字段:加密存储(AES + KMS 密钥管理),防拖库
关键数据
- 改造成本:每个敏感字段 1 行注解,原 3 天的手动改造 → 半天完成
- 性能开销:订单列表 100 条 × 5 敏感字段,P99 增幅 < 0.5ms
- 覆盖范围:Controller 出参、Feign 编码、消息序列化统一生效
- 动态脱敏:role 属性按角色豁免,客服主管看明文,普通用户看打码
一句话
脱敏的本质不是"把数据打码",而是"每个场景只看到它该看的部分"。Jackson 序列化器方案赢在三点:注解声明式零入侵、只在输出边界转换不改原对象、所有 JSON 出参统一兜底——把安全关切从业务代码里剥离出来,才不会出现"新接口忘了脱敏"这种防不住的事故。
给团队的建议
| 阶段 | 建议 |
|---|---|
| 零脱敏 | 先上 @Sensitive + Jackson 方案,覆盖出参,成本最低 |
| 出参已脱敏 | 加 logback replace 日志兜底,防业务侧遗漏 |
| 有导出场景 | 导出服务补 TypeHandler,低信任下游只见脱敏值 |
| 高敏数据 | 银行卡/密钥类字段加密存储 + KMS,防拖库 |
| 长期治理 | 建立敏感字段清单 + CI 扫描(出参 DTO 未加注解则告警) |
互动话题:你们项目的脱敏是在哪一层做的?有没有踩过"脱敏值被写回数据库"或者"缓存里的数据被脱敏污染"的坑?动态脱敏(按角色看明文)你们是怎么实现的?评论区聊聊。
参考资料
- Jackson 注解官方文档
- Jackson ContextualSerializer 机制
- MyBatis TypeHandler 官方文档
- Logback replace 转换符
- GB/T 35273《个人信息安全规范》脱敏要求
- Spring Data JPA AttributeConverter
标题:数据脱敏实战:手机号/身份证/银行卡——三行代码搞定,不侵入业务逻辑
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/03/1788097377007.html
公众号:服务端技术精选
- 引言
- 一、先搞清楚:脱敏脱的是什么
- 1.1 脱敏的本质:最小可用原则
- 1.2 常见敏感数据类型与默认脱敏规则
- 1.3 脱敏的三个层次
- 二、三种方案对比:为什么推荐 Jackson 序列化器
- 2.1 方案一:工具类手动调用(反面教材)
- 2.2 方案二:AOP 切面(方法返回值脱敏)
- 2.3 方案三:MyBatis TypeHandler(持久层脱敏)
- 2.4 方案四:Jackson 序列化器(推荐)
- 三、@Sensitive 注解 + Jackson 序列化器完整实现
- 3.1 第一步:定义敏感类型枚举
- 3.2 第二步:定义 @Sensitive 注解
- 3.3 第三步:实现 Jackson 序列化器
- 3.4 第四步:通用打码工具
- 3.5 使用效果:业务代码只需加注解
- 四、进阶:日志脱敏与 DB 层兜底
- 4.1 日志脱敏:Logback 的替换规则
- 4.2 MyBatis TypeHandler:导出场景的 DB 层兜底
- 4.3 存储加密:脱敏解决不了的"拖库"问题
- 五、常见问题
- 5.1 @Sensitive 能处理嵌套对象和 List 吗?
- 5.2 同一个字段不同接口要不同脱敏规则怎么办?
- 5.3 序列化器会影响性能吗?
- 5.4 入参需要脱敏吗?
- 5.5 为什么不直接在 Service 层把数据改成脱敏值?
- 5.6 微服务间 Feign 调用会被脱敏吗?
- 六、总结
- 三种方案速查卡
- 实施三步走
- 关键数据
- 一句话
- 给团队的建议
- 参考资料
评论