Skip to content

优雅的实现数据的脱敏

约 7085 字大约 24 分钟

2025-09-18

优雅的实现数据安全

在应用程序的开发过程中,数据安全永远是无法绕过的话题。通常来讲,我们需要保证数据库中存储的数据是安全的,我们的日志中输出的内容是安全的,以及我们返回给前端的数据内容是安全的。

日志脱敏

日志通常是帮助我们定位Bug的重要文件,甚至在很多的时候都是属于公司的密级文件。在日志审计中有一项重要的标准,它就是要求我们的敏感信息在日志中需要进行脱敏。

一般来讲,日志脱敏会对手机号、身份证号、银行卡好、邮箱、地址或密码等,在展示、存储、传输和日志等场景中,通过一定的技术手段对其部分或全部的信息进行隐藏、替换或变形,以达到保护用户隐私、满足合规要求、防止数据泄露的目的。

数据脱敏的常规手段

数据遮挡

数据遮挡的意思就是将敏感信息中的一部分字符用固定的符号,例如*x#等替换,保留部分可以识别的信息,但隐藏关键内容。

13288552266  ==>  132****2266
13288552266  ==>  132xxxx2266

这种脱敏方式的优点在于简单、直观,适用展示类数据脱敏;缺点在于有可能被撞库或者结合其它信息才出原始数据。

数据加密

使用加密算法(如AES、RSA、MD5等)对敏感数据进行加密传输,只有在需要时使用秘钥解密还原

// 采用MD5加密的算法来实现的
telephone=18888888888  ==> telephone=CBD41C6103064D3F0AF848208C20ECE2

数据加密的方式来进行脱敏的话 ,通常会有三种策略:

  • 对称加密,例如RSA等;
  • 非对称加密,例如AES等;
  • 散列算法,例如MD5等

通常对于密码这种字段会采取非对称加密算法,而对于手机号这种会采用对称加密算法或者散列算法。

数据泛化

数据泛化的方式主要是针对要在界面上展示的数据,例如年龄、家庭住址等数据。如果用户年龄为12,我们可以通过少年、青少年、中年或老年的形式来展示,这样就避免展示用户的真实年龄了,类似的住址,我们可以仅仅展示到省或者市这个级别就可以了。

地址:北京市海淀区中关村南大街5号→ 北京市海淀区

数据泛化的优点在于保护隐私,适合宏观分析;缺点在于数据精度下降,不适合需要精准数据的业务场景。

动态脱敏

动态脱敏是指在访问敏感数据的时候动态的决定脱敏的策略,例如根据用户的角色,普通用户看到的是脱敏后的数据,管理员看到的是原始数据。

普通员工登录系统,看到用户手机号为:138****5678
管理员登录系统,可以看到完整手机号:13812345678

动态脱敏的好处在于灵活,按需脱敏;缺点在于实现较为复杂,需要权限系统的配合;

日志脱敏的实现步骤

本次日志脱敏使用的框架是:sensitive脱敏框架,是是一个比较优雅的日志脱敏框架。

提示

🔐Sensitive log tool for java, based on java annotation. (基于注解的 java 日志脱敏工具框架,更加优雅的日志打印。支持自定义哈希、支持基于 log4j2 插件的统一脱敏、支持 logback 插件统一脱敏)

我们直接用SpringBoot(3.2.0 + JDK 17)项目来进行演示,采取的默认的logback日志框架。

引入关键依赖

        <dependency>
            <groupId>com.github.houbb</groupId>
            <artifactId>sensitive-logback</artifactId>
            <version>1.7.0</version>
        </dependency>
        <dependency>
            <groupId>com.github.houbb</groupId>
            <artifactId>sensitive-core</artifactId>
            <version>1.7.0</version>
        </dependency>

编写logback的配置文件

<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="false">
    <!-- 基于 converter -->
    <conversionRule conversionWord="sensitive"            converterClass="com.github.houbb.sensitive.logback.converter.SensitiveLogbackConverter"/>

    <!-- 控制台输出 - 使用明确的格式 -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 使用 layout - 使用明确的格式 -->
    <appender name="STDOUTLayout" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="com.github.houbb.sensitive.logback.layout.SensitiveLogbackLayout">
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %sensitive%n</pattern>
        </layout>
    </appender>

    <!-- 为特定包设置日志级别 -->
    <logger name="com.work" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="STDOUTLayout"/>
    </logger>

    <!-- 禁用logback内部日志 -->
    <logger name="ch.qos.logback" level="INFO" additivity="false"/>
    <logger name="ch.qos.logback.core" level="INFO" additivity="false"/>
    <logger name="ch.qos.logback.classic" level="INFO" additivity="false"/>
    <logger name="org.springframework" level="INFO" additivity="false"/>

    <!-- 设置根日志级别为DEBUG,并将日志输出到控制台 -->
    <root level="WARN">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

在上面的配置文件中比较关键的是:

<!-- 这里配置的sensitive跟下面的pattern中的sensitive需要对应上,表示要进行脱敏的内容 -->
<conversionRule conversionWord="sensitive"                   converterClass="com.github.houbb.sensitive.logback.converter.SensitiveLogbackConverter"/>

<appender name="STDOUTLayout" class="ch.qos.logback.core.ConsoleAppender">
   <layout class="com.github.houbb.sensitive.logback.layout.SensitiveLogbackLayout">
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %sensitive%n</pattern>
   </layout>
</appender>

编写需要信息脱敏的类

@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {

    /**
     * 主键
     */
    private int id;
    /**
     * 姓名
     */
    private String name;
    /**
     * 年龄
     */
    private int age;
    /**
     * 手机号
     */
    private String telephone;
}

提示

这里基于注解的标注要脱敏的字段是需要使用的SensitiveUtil来进行脱敏的时候才是有效的,而日志中的脱敏都是通过字符扫描来实现的。比如我们有一个字段也是11为的数字,但是它是不需要脱敏的,但是还是会被误认为是手机号被脱敏掉了。

模拟日志输出

@Slf4j
@RestController
@RequestMapping("/test")
public class SensitiveController {

    @GetMapping("/hello")
    public String hello() {
        UserInfo userInfo = new UserInfo();
        userInfo.setAge(18);
        userInfo.setTelephone("18888888888"); // 修正手机号位数,使其符合11位标准格式
        userInfo.setName("panhao");
        userInfo.setId(1);
        // 方式1:使用工具类脱敏对象后再打印
        log.info("==========>>>>>>>>> userInfo:{}",SensitiveUtil.desJson(userInfo));
        // 方式2:直接打印对象,利用logback的converter机制
        log.info("==========>>>>>>>>>> userInfo:{}", userInfo);
        
        return "hello world";
    }
}

输出的结果为:

2025-09-18 23:45:14.785 [http-nio-8080-exec-4] INFO  com.work.sensitive.controller.SensitiveController - ==========>>>>>>>>>> userInfo:UserInfo(id=1, name=panhao, age=18, telephone=188****8888|CBD41C6103064D3F0AF848208C20ECE2)
2025-09-18 23:45:14.786 [http-nio-8080-exec-4] INFO  com.work.sensitive.controller.SensitiveController - ==========>>>>>>>>>> userInfo1111:{"age":18,"id":1,"name":"panhao","telephone":"1888****888"}

可以看到在实际的日志打印中,我们已经将手机号原始信息进行屏蔽了,它不会再展示原始数据了。接下来,我们再次测试下如果我们通过一些序列化框架对其进行序列化后的内容打印,它仍然是可以脱敏显示的。

    @GetMapping("/hello")
    public String hello() {
        UserInfo userInfo = new UserInfo();
        userInfo.setAge(18);
        userInfo.setTelephone("18888888888"); // 修正手机号位数,使其符合11位标准格式
        userInfo.setName("panhao");
        userInfo.setId(1);
        // 使用Hutool的JSON序列化的方式来进行序列化的
        log.info("==========>>>>>>>>>> userInfo1111:{}", JSONUtil.toJsonStr(userInfo));
        // 使用的fastJson2的方式来进行序列化的
        log.info("==========>>>>>>>>>> userInfo2222:{}", JSON.toJSONString(userInfo));
        return "hello world";
    }

基于注解的日志脱敏规则

在Sensitive中的有很多的内置的注解与映射,以下列举几个常用的:

注解等价备注
@SensitiveStrategyChineseName@Sensitive(startegy=StartegyChaineseName.class)中文名称脱敏
@SensitiveStrategyPassword@Sensitive(startegy=StartegyPassword.class)密码脱敏
@SensitiveStrategyEmail@Sensitive(strategy=StrategyEmail.class)邮箱
@SensitiveStrategyCardId@Sensitive(strategy = StrategyCardId.class)卡号脱敏
@SensitiveStrategyPhone@Sensitive(strategy = StrategyPhone.class)手机号脱敏
@SensitiveStrategyIdNo@Sensitive(strategy = StrategyIdNo.class)身份证脱敏
@SensitiveStrategyAddress@Sensitive(strategy = StrategyAddress.class)地址脱敏

其它还有很多注解可以参考:sensitive框架的项目地址

注解的使用方式需要搭配SensitiveUtil工具类实现,工具类在对对象进行脱敏的时候,会通过反射类获取字段上的注解来获取加密的规则。类似的,我们在UserInfo中对应的字段上添加上对应的注解:

@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {

    /**
     * 主键
     */
    private int id;
    /**
     * 日期
     */
    private String date;
    /**
     * 姓名
     */
    @SensitiveStrategyChineseName
    private String chinesName;
    /**
     * 年龄
     */
    private int age;
    /**
     * 手机号
     */
    @Sensitive(strategy = StrategyPhone.class) // 脱敏策略为手机号
    private String telephone;
}

我们可以通过SensitiveUtil的脱敏方法来对对应的关键属性进行脱敏展示:

 public static void main(String[] args) {
        UserInfo userInfo = new UserInfo();
        userInfo.setAge(18);
        userInfo.setTelephone("18888888888"); // 修正手机号位数,使其符合11位标准格式
        userInfo.setChinesName("张三");
        userInfo.setDate("13355559999");
        userInfo.setId(1);

        System.out.println(SensitiveUtil.desCopy(userInfo));
        // 输出结果:UserInfo(id=1, date=13355559999, chinesName=张*, age=18, telephone=1888****888)
        System.out.println(SensitiveUtil.desJson(userInfo));
        // 输出结果:{"age":18,"chinesName":"张*","date":"13355559999","id":1,"telephone":"1888****888"}
    }

根据输出结果可以看到对于关键属性已经进行了脱敏处理。使用SensitiveUtil工具类来进行脱敏的时候,可以用在向前端返回带有敏感信息的对象时,先进性脱敏的处理后再返回给前端。

除了SensitiveUtil以外还有SensitiveBS脱敏引导类来进行脱敏,它与SensitiveUtil之间的区别在于,SensitiveBS可以在脱敏的时候进行一些额外的配置。

    public static void main(String[] args) {
        UserInfo userInfo = new UserInfo();
        userInfo.setAge(18);
        userInfo.setTelephone("18888888888"); // 修正手机号位数,使其符合11位标准格式
        userInfo.setChinesName("张三");
        userInfo.setDate("13355559999");
        userInfo.setId(1);

        SensitiveBs sensitiveBs = SensitiveBs.newInstance()
                .deepCopy(FastJsonDeepCopy.getInstance())
                .hash(Hashes.md5());
        System.out.println(sensitiveBs.desCopy(userInfo));
        // 输出结果:UserInfo(id=1, date=13355559999, chinesName=张*|3A171D0048949318940BA53255B62F87, age=18, telephone=1888****888|5425DE6EC14A0722EC09A6C2E72AAE18)
        System.out.println(sensitiveBs.desJson(userInfo));
        // 输出结果:{"age":18,"chinesName":"张*|3A171D0048949318940BA53255B62F87","date":"13355559999","id":1,"telephone":"1888****888|5425DE6EC14A0722EC09A6C2E72AAE18"}
    }

使用SensitiveBS来对对象脱敏的时候,可以设置深克隆的方式以及对于敏感字段的脱敏算法。在某些时候我们需要在日志中查看脱敏字段的值,例如需要在日志中排查telephone字段值为18888888888值日志,我们可以通过脱敏的算法计算后的值来查找:

MD5(18888888888) =  5425DE6EC14A0722EC09A6C2E72AAE18

Sensitive框架使用的注意事项

序列化后再进行脱敏会导致脱敏失效

在使用SensitiveUtil来进行脱敏的时候,如果对需要脱敏的对象已经进行序列化过了,就会导致脱敏失效了。

    public static void main(String[] args) {
        UserInfo userInfo = new UserInfo();
        userInfo.setAge(18);
        userInfo.setTelephone("18888888888"); // 修正手机号位数,使其符合11位标准格式
        userInfo.setChinesName("张三");
        userInfo.setDate("13355559999");
        userInfo.setId(1);

        SensitiveBs sensitiveBs = SensitiveBs.newInstance()
                .deepCopy(FastJsonDeepCopy.getInstance())
                .hash(Hashes.md5());
        System.out.println(sensitiveBs.desJson(JSON.toJSONString(userInfo)));
        // 输出结果:"{\"age\":18,\"chinesName\":\"张三\",\"date\":\"13355559999\",\"id\":1,\"telephone\":\"18888888888\"}"
    }

其实这个结果比较好理解,因为sensitiveUtil是通过反射来获取字段上的注解,从而进行按照注解的脱敏规则对字段进行脱敏。但是序列化之后,类型编程了String,通过反射之后获取不到脱敏的规则注解了。

基于默认的脱敏规则的扫描存在误差

在上面我们对Sensitive的脱敏进行简单的例子,因为Sensitive它是基于对数据类型进行扫描来匹配敏感信息的,所有有些时候也有误匹配的时候。假如我们的UserInfo字段中有些字段需要脱敏,有些字段不需要脱敏:

@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {

    /**
     * 主键
     */
    private int id;
    /**
     * 编号
     */
    private String no;
    /**
     * 姓名
     */
    private String chinesName;
    /**
     * 年龄
     */
    private int age;
    /**
     * 手机号
     */
    private String telephone;
}

但是我们在日志打印的时候会发现,它仍然会对某些我们不想它脱敏的字段来进行脱敏了:

       @GetMapping("/hello")
    public String hello() {
        UserInfo userInfo = new UserInfo();
        userInfo.setAge(18);
        userInfo.setTelephone("18888888888"); // 修正手机号位数,使其符合11位标准格式
        userInfo.setChinesName("张三");
        userInfo.setNo("13355559999");
        userInfo.setId(1);
        log.info("==========>>>>>>>>>> 打印用户信息:{}", userInfo);
        log.info("==========>>>>>>>>>> 序列化后打印用户的信息:{}", JSON.toJSONString(userInfo));
        return "hello world";
    }

则它们的输出结果为:

==========>>>>>>>>>> 打印用户信息:UserInfo(id=1, date=133****9999|13176B368295864E984DE41334802AFA, chinesName=*|615DB57AA314529AAA0FBE95B3E95BD3, age=18, telephone=188****8888|CBD41C6103064D3F0AF848208C20ECE2)
==========>>>>>>>>>> 序列化后打印用户的信息:{"age":18,"chinesName":"张*|615DB57AA314529AAA0FBE95B3E95BD3","date":"133****9999|13176B368295864E984DE41334802AFA","id":1,"telephone":"188****8888|CBD41C6103064D3F0AF848208C20ECE2"}

仔细观察会发信啊,它把我们的no字段也进行了脱敏,其实我们只需要telephone和chineseName字段脱敏即可,但是它无法满足我们的需求。这样就会对我们日志排查带来一定的困难,以为no字段而言,它可能是11为的数字,也可能是11位的字母。如果它是11为的数字的时候,他会被识别成手机号而进行脱敏,反之,就不会。这就要求我们需要辨别字段会不会被脱敏。

提示

有些时候可以通过SensitiveUtil工具类来规避这些问题,但是坏处在于使用SensitiveUtil来进行打印时候要求对象不能进行序列化,否则就无法进行脱敏展示了。

前端数据返回脱敏

在我们的软件产品中,一些敏感的数据需要返回给前端是不可避免的。如果在前后端的传输过程中,我们使用明文传输的话就会容易导致敏感信息的泄露,例如可以通过抓包或者浏览器的开发者工具捕获到后端给前端的返回的真实数据。

前端数据脱敏的

采用遮盖的方式

我们可以直接对敏感数据进行遮盖,例如用户的手机号18888888888,我们返回给前端的时候可以变成188****8888。这种方式非常好实现,在我们返回数据的时候特殊处理下就可以了,但是缺点在于前端用户如果真的需要查看明文数据的时候,只能再次通过接口查询回来。

采用加密的方式

可以在后端将敏感数据进行加密,然后由前端接受到数据后进行解密。通常常用的算法有,对称加密算法(AESdeng)和非对称加密算法(RSA等)。采用加密算法的安全性较高,可以用户需要查询全部数据的时候再进行展示明文数据,其它的时候都可以展示位脱敏后的数据。但是加密算法引入了额外的复杂度,并且如果密钥被泄露了也会引入新的安全风险。

加密还有一种策略是利用散列的方式,相对于加密而言散列的性能更高,但是散列的算法都可以通过彩虹表的形式进行破解。对比加密的方式,复杂度下降,但是安全性也下降了。

前端数据脱敏的方式

主动硬编码的方式实现脱敏

直接硬编码的方式比较容易理解,就是我们在返回数据到前端之前主动的进行一个脱敏处理。

    @GetMapping("/hello")
    public String hello() {
        UserInfo userInfo = new UserInfo();
        userInfo.setAge(18);
        userInfo.setTelephone("18888888888"); 
        userInfo.setChinesName("张三");
        userInfo.setDate("13355559999");
        userInfo.setId(1);
        return SensitiveUtil.desJson(userInfo);
    }

这样前端接受到的就是脱敏之后的数据:

{"age":18,"chinesName":"张*","date":"13355559999","id":1,"telephone":"1888****888"}

提示

不一定非要使用SensitiveUtil来进行脱敏,可以使用其他的实现形式,但是大体逻辑上都是类似的。

直接硬编码的方式非常的容易理解,实现起来也比较的简单,但是它不够灵活对应业务有很强的侵入性。

基于ResponseBodyAdvice实现数据拦截脱敏

ResponseBodyAdvice是Spring MVC提供的一个AOP切面通知,它允许我们在响应体返回之前对响应体的内容进行处理。如下所示,就可以在beforeBodyWrite方法对要返回的响应体进行脱敏处理。

@ControllerAdvice
public class SensitiveResponseBodyAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // 只对特定类型的返回值执行处理逻辑,这里可以根据需要调整判断条件
        return Result.class.isAssignableFrom(returnType.getParameterType());
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        // 如果返回的对象是UserInfo/InviteRankInfo,进行脱敏处理
        if (body != null && body instanceof Result) {

            if (((Result<?>) body).getData() == null) {
                return body;
            }

            if (((Result<?>) body).getData() instanceof Collection<?>) {
                ((Result<Collection>) body).setData(SensitiveUtil.desCopyCollection((Collection) ((Result<?>) body).getData()));
                return body;
            }

            switch (((Result<?>) body).getData()) {
                case UserInfo userInfo:
                    ((Result<UserInfo>) body).setData(SensitiveUtil.desCopy(userInfo));
                    return body;
                case InviteRankInfo inviteRankInfo:
                    ((Result<InviteRankInfo>) body).setData(SensitiveUtil.desCopy(inviteRankInfo));
                    return body;
                default:
                    return body;
            }
        }
        return body;
    }
}

基于ResponseBodyAdvice的方式实现起来较为复杂,因为不是所有的场景下我们都是需要进行的脱敏的。所以我们需要有一套脱敏的规则,但是返回体的类型是不确定的,有可能是对应的脱敏对象的类型,有可能是集合类型,也极有可能出现嵌套的类型,有些时候我们需要编写一些复杂的逻辑来进行处理,才能保证所有的数据都被正确的脱敏。这种方式的优点在于对于业务而言,它是没有侵入性的,只需要我们在Spring中注入这个Bean,所有的接口就都能实现脱敏了。

自定义Jackson Serializer实现数据的脱敏

在数据返回给前端之前,会经过 JSON 序列化(比如使用 Jackson 或 Gson),我们可以通过 自定义序列化器(Serializer) 或 注解 + 反序列化规则,在序列化过程中对敏感字段进行脱敏。

自定义 Jackson Serializer

public class PhoneSerializer extends JsonSerializer<String> {
    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        gen.writeString(maskPhone(value));
    }
}

// 在字段上使用
public class UserVO {
    @JsonSerialize(using = PhoneSerializer.class)
    private String phone;
}

使用注解 + 自定义逻辑(推荐更灵活的方案)

我们希望做到:

  • 定义一个注解:@SensitiveField(type = SensitiveType.PHONE),用于标注需要脱敏的字段;
  • 支持多种脱敏类型:如手机号、身份证号、邮箱等;
  • JSON 序列化时自动脱敏,无需手动处理每个字段;
  • 实现方式基于 Jackson 的 JsonSerializer + 自定义 Module,对业务代码无侵入。

实现步骤:

  1. 定义枚举 SensitiveType,表示不同的脱敏类型;
  2. 定义注解 @SensitiveField,用于标注字段;
  3. 编写通用的脱敏工具类 DesensitizedUtil,实现各种类型的脱敏规则;
  4. 为每个需要脱敏的字段类型编写对应的 JsonSerializer,或者使用通用策略;
  5. 自定义一个 Jackson Module,在序列化时检测字段是否有 @SensitiveField注解,若有则使用自定义的序列化器替换默认的;
  6. 在 Spring Boot 项目中启用该 Module,测试效果。

1️⃣ 定义敏感类型枚举(SensitiveType.java)

public enum SensitiveType {
    PHONE,       // 手机号
    ID_CARD,     // 身份证
    EMAIL,       // 邮箱
    NAME,        // 姓名(如只显示第一个字)
    CUSTOM       // 可扩展
}

2️⃣ 定义注解(SensitiveField.java)

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD })
public @interface SensitiveField {
    SensitiveType type() default SensitiveType.PHONE;
}

3️⃣ 脱敏工具类(DesensitizedUtil.java)

public class DesensitizedUtil {

    /**
     * 手机号脱敏:138****5678
     */
    public static String desensitizePhone(String phone) {
        if (phone == null || phone.length() < 7) return phone;
        return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }

    /**
     * 身份证脱敏:110***********1234
     */
    public static String desensitizeIdCard(String idCard) {
        if (idCard == null || idCard.length() < 8) return idCard;
        return idCard.replaceAll("(\\d{4})\\d{10}(\\w{4})", "$1**********$2");
    }

    /**
     * 邮箱脱敏:t***@example.com
     */
    public static String desensitizeEmail(String email) {
        if (email == null || !email.contains("@")) return email;
        int atIndex = email.indexOf("@");
        if (atIndex <= 1) return email;
        return email.substring(0, 1) + "***" + email.substring(atIndex);
    }

    /**
     * 姓名脱敏:张*
     */
    public static String desensitizeName(String name) {
        if (name == null || name.isEmpty()) return name;
        return name.charAt(0) + "*";
    }
}

4️⃣ 自定义脱敏序列化器(SensitiveFieldSerializer.java)

我们将为所有被 @SensitiveField标记的字段,使用这一个通用的序列化器,根据不同的 type执行不同的脱敏策略。

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
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;

public class SensitiveFieldSerializer extends JsonSerializer<Object> implements ContextualSerializer {

    private SensitiveType sensitiveType;

    // 必须有无参构造,但实际通过 createContextual 设置
    public SensitiveFieldSerializer() {
    }

    public SensitiveFieldSerializer(SensitiveType sensitiveType) {
        this.sensitiveType = sensitiveType;
    }

    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value == null) {
            gen.writeNull();
            return;
        }

        String original = value.toString();
        String desensitized = "";

        if (sensitiveType == null) {
            desensitized = original;
        } else {
            switch (sensitiveType) {
                case PHONE:
                    desensitized = DesensitizedUtil.desensitizePhone(original);
                    break;
                case ID_CARD:
                    desensitized = DesensitizedUtil.desensitizeIdCard(original);
                    break;
                case EMAIL:
                    desensitized = DesensitizedUtil.desensitizeEmail(original);
                    break;
                case NAME:
                    desensitized = DesensitizedUtil.desensitizeName(original);
                    break;
                default:
                    desensitized = original;
            }
        }

        gen.writeString(desensitized);
    }

    // 关键:通过这个方法获取字段上的 @SensitiveField 注解信息
    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property)
            throws JsonMappingException {
        // 获取字段上的 SensitiveField 注解
        SensitiveField sensitiveField = property.getAnnotation(SensitiveField.class);
        if (sensitiveField != null) {
            return new SensitiveFieldSerializer(sensitiveField.type()); // 指定脱敏类型
        }
        return prov.findValueSerializer(property.getType(), property); // 默认序列化器
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        SensitiveFieldSerializer that = (SensitiveFieldSerializer) o;
        return sensitiveType == that.sensitiveType;
    }

    @Override
    public int hashCode() {
        return Objects.hash(sensitiveType);
    }
}

5️⃣ 自定义 Jackson Module(SensitiveModule.java)

该 Module 的作用是:扫描所有 Bean 的字段,如果发现字段上有 @SensitiveField注解,就使用我们自定义的 SensitiveFieldSerializer去序列化它。

import com.fasterxml.jackson.databind.module.SimpleModule;

public class SensitiveModule extends SimpleModule {

    public SensitiveModule() {
        super("SensitiveModule");
    }

    // 注意:这里不需要手动添加什么,因为我们在 SensitiveFieldSerializer 中
    // 通过 @ContextualSerializer 注解和 createContextual 方法动态绑定到字段上
}

实际上,关键逻辑不在 Module 的构造方法里手动注册什么,而是依托于 SensitiveFieldSerializer实现了 ContextualSerializer,它会针对每个字段动态判断是否存在 @SensitiveField注解,并决定使用哪个序列化器。

6️⃣ 在 Spring Boot 中注册该 Module(关键步骤)

如果你使用的是 Spring Boot,默认已经引入了 Jackson,我们只需要在启动时将 SensitiveModule注册到 ObjectMapper 中即可。创建一个配置类:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new SensitiveModule());
        return objectMapper;
    }
}

这样,Spring MVC 在序列化返回 JSON 时就会使用我们注册的 ObjectMapper,进而应用我们的脱敏逻辑。

实体类(UserVO.java)

public class UserVO {

    private String name;

    @SensitiveField(type = SensitiveType.PHONE)
    private String phone;

    @SensitiveField(type = SensitiveType.ID_CARD)
    private String idCard;

    @SensitiveField(type = SensitiveType.EMAIL)
    private String email;

    @SensitiveField(type = SensitiveType.NAME)
    private String realName;

    // 构造方法 / getter / setter
    public UserVO(String name, String phone, String idCard, String email, String realName) {
        this.name = name;
        this.phone = phone;
        this.idCard = idCard;
        this.email = email;
        this.realName = realName;
    }

    // getters and setters ...
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getPhone() { return phone; }
    public void setPhone(String phone) { this.phone = phone; }

    public String getIdCard() { return idCard; }
    public void setIdCard(String idCard) { this.idCard = idCard; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getRealName() { return realName; }
    public void setRealName(String realName) { this.realName = realName; }
}

数据库数据的加密

基于数据库的加密和解密通常会利用ORM框架的特性,例如Mybatis框架中的TypeHandler,它允许在向数据写入数据或从将数据读取到的数据映射到实体类时做一定的转换。

创建Typehandler

要创建一个TypeHandler只需要继承org.apache.ibatis.type.TypeHandler类即可,在该类中一共有以下几个方法:

public interface TypeHandler<T> {

  // 设置非空参数,在 PreparedStatement 中设置值
  void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;

  // 根据列名,从 ResultSet 中获取值并转换为 Java 类型
  T getResult(ResultSet rs, String columnName) throws SQLException;

  // 根据列的索引,从 ResultSet 中获取值并转换为 Java 类型
  T getResult(ResultSet rs, int columnIndex) throws SQLException;

  // 根据列名,从 CallableStatement(存储过程)中获取值
  T getResult(CallableStatement cs, String columnName) throws SQLException;

  // 根据列索引,从 CallableStatement 中获取值
  T getResult(CallableStatement cs, int columnIndex) throws SQLException;
}

方法详细说明

void setParameter(PreparedStatement ps,int i,T parameter,JdbcType jdbcType) throws SQLException

当MyBatis执行插入、更新等需要设计参数的SQL语句时,会调用此方法,将Java对象(参数值)设置到JDBC的PreparedStatement中。

  • PreparedStatement ps:JDBC的预编译语句对象,用于设置SQL参数;
  • int i:当要设置的参数位置(从1开始,不是0)
  • T parameter:要设置的Java对象值,也就是传入的参数值,比如String、Integer等;
  • JdbcType jdbcType:指定才参数对应的JDBC类型,比如VARCHAR、INTEGER等,这个参数可以是null,但建议在可能为null的字段中明确指定,以避免数据库兼容性问题。

T getResult(ResultSet rs,String columnName) throw SQLException

当MyBatis从数据库初选数据,通过列名获取某一列的值时,会调用此方法,将JDBC的返回值转换为需要的Java类型。

  • ResultSet rs:查询返回的结果集
  • String columnName:数据库结果集中对应的列表,比如user_name
  • 返回值T:就是你最终要得到的Java对象,比如String、自定义的User类型等;

T getResult(ResultSet rs,int columnIndex) throws SQLException

与上一个方法类似,只不过这里是通过列的索引,而不是通过列名,来获取值并转换为Java类型。

  • ResultSet rs:查询返回的结果集;
  • int columnIndex:列的索引号,例如第1列、第2列等;
  • 返回值T:转换后的Java对象

T getResult(CallableStatement cs,String columnName) throws SQLException

当调用存储过程,并且通过列名获取输出参数或结果集的值时,会调用此方法来进行类型转换。

  • CallableStatement cs:用于调用参数过程的JDBC对象;
  • String columnName:结果集中或输出参数的列名
  • 返回值T:转换后的Java类型

将Java中的枚举类型映射到数据库

在应用开发的过程中我们通常会有的很多种不同的User类型,我们可以用过自定义枚举类与数据库存储的值来进行映射。

实现映射的步骤如下:

1️⃣定义自定义的UserType枚举类选哪个

public enum UserType {
    NORMAL(1, "普通用户"),
    VIP(2, "VIP用户"),
    ADMIN(3, "管理员");

    private final int code;
    private final String desc;

    UserType(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public int getCode() {
        return code;
    }

    public String getDesc() {
        return desc;
    }

    // 根据 code 查找对应的枚举,数据库读出来的就是这个
    public static UserType fromCode(int code) {
        for (UserType type : UserType.values()) {
            if (type.getCode() == code) {
                return type;
            }
        }
        throw new IllegalArgumentException("Unknown UserType code: " + code);
    }

    // 可选:根据 code 安全返回 null 而不是抛异常
    public static UserType fromCodeOrNull(Integer code) {
        if (code == null) {
            return null;
        }
        for (UserType type : UserType.values()) {
            if (type.getCode() == code) {
                return type;
            }
        }
        return null;
    }
}

2️⃣创建自定义的TypeHandler

import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class UserTypeHandler extends BaseTypeHandler<UserType> {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, UserType parameter, JdbcType jdbcType) throws SQLException {
        // 将 Java 枚举 UserType 转为数据库 Integer
        if (parameter != null) {
            ps.setInt(i, parameter.getCode()); // 存的是 code,比如 1, 2, 3
        } else {
            ps.setNull(i, JdbcType.INTEGER.TYPE_CODE); // 如果允许为null,可以这样设置
        }
    }

    @Override
    public UserType getNullableResult(ResultSet rs, String columnName) throws SQLException {
        // 从数据库结果集中按列名读取 Integer,再转为 UserType
        int code = rs.getInt(columnName);
        return rs.wasNull() ? null : UserType.fromCode(code);
    }

    @Override
    public UserType getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        // 从数据库结果集中按索引读取 Integer,再转为 UserType
        int code = rs.getInt(columnIndex);
        return rs.wasNull() ? null : UserType.fromCode(code);
    }

    @Override
    public UserType getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        // 存储过程调用返回的 Integer 转 UserType
        int code = cs.getInt(columnIndex);
        return cs.wasNull() ? null : UserType.fromCode(code);
    }
}

3️⃣注册TypeHandler

方式一:在Mybatis配置文件中注册

<typeHandlers>
  <typeHandler handler="com.yourpackage.handler.UserTypeHandler" javaType="com.yourpackage.model.UserType" jdbcType="INTEGER"/>
</typeHandlers>

方式二:让TypeHandler在包路径下被自动扫描

<typeHandlers>
  <package name="com.yourpackage.handler"/>
</typeHandlers>

方式三:使用注解来简化配置

import org.apache.ibatis.type.MappedTypes;
import org.apache.ibatis.type.MappedJdbcTypes;

@MappedTypes(UserType.class)        // 表示这个 Handler 用于处理 Java 的 UserType 类型
@MappedJdbcTypes(JdbcType.INTEGER)  // 表示这个 Handler 用于处理 JDBC 的 INTEGER 类型
public class UserTypeHandler extends BaseTypeHandler<UserType> {
    // ... 上面的实现代码不变
}

4️⃣在Mapper中使用

如果TypeHandler注册成功,就不再需要在Mapper XML中显式指定TypeHandler,MyBatis会自动使用它来处理UserType与Integer的转换。

实体类展示:

public class User {
    private Long id;
    private String name;
    private UserType userType;  // 使用我们自定义的枚举类型

    // getter / setter 略
}

Mapper XML示例:

<resultMap id="userResultMap" type="com.yourpackage.model.User">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <!-- 显式注册TypeHandler 告诉Mybatis这个字段如何进行映射 --> 
    <result property="userType" column="user_type"
    	typeHandler="com.yourpackage.handler.UserTypeHandler"        
    /> 
    <!-- 自动使用 UserTypeHandler 
    <result property="userType" column="user_type"/>
    -->
</resultMap>

<select id="selectUserById" resultMap="userResultMap">
    SELECT id, name, user_type FROM user WHERE id = #{id}
</select>

<insert id="insertUser" parameterType="com.yourpackage.model.User">
    INSERT INTO user (name, user_type)
    VALUES (#{name}, #{userType})  <!-- 自动调用 setType,将 UserType 转为 Integer -->
</insert>

通过加解密来实现数据映射

经过了上面对TypeHandler的了解,其实数据库的加解密也是可以通过自定义TypeHandler来实现的。

public class AesEncryptTypeHandler extends BaseTypeHandler<String> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
        // 这里使用你的加密方法进行加密
        ps.setString(i, encrypt(parameter));
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String encrypted = rs.getString(columnName);
        return encrypted == null ? null : decrypt(encrypted);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String encrypted = rs.getString(columnIndex);
        return encrypted == null ? null : decrypt(encrypted);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String encrypted = cs.getString(columnIndex);
        return encrypted == null ? null : decrypt(encrypted);
    }

    /**
     * 加密方法
     * @param data
     * @return
     */
    private String encrypt(String data) {
        // 实现数据加密逻辑
        return AesUtil.encrypt(data);
    }

    /**
     * 解密方法
     * @param data
     * @return
     */
    private String decrypt(String data) {
        // 实现数据解密逻辑
        return AesUtil.decrypt(data);
    }
}

基于Mybatis-Plus使用自定义TypeHandler

Mybatis-Plus作为使用非常广泛的一个的框架,在项目开发的过程中也能提高我们很多的效率。在Mybatis-Plus中使用自定义的TypeHandler也是很简单的,只需要在@TableField注解中进行配置即可。

    /**
     * 真实姓名
     */
    @TableField(typeHandler = AesEncryptTypeHandler.class)
    private String realName;

    /**
     * 身份证hash
     */
    @TableField(typeHandler = AesEncryptTypeHandler.class)
    private String idCardNo;