后端接口返回数据时,最常见的做法是直接把数据库查出来的对象序列化成JSON丢给前端。这种粗放模式在遇到用户手机号、身份证、银行卡号或者内部审计字段时,问题就暴露了。管理员看到的用户详情和普通用户看到的自己信息,返回字段的脱敏程度理应不同;同一个订单接口,客服角色能看到完整金额,而外部合作方只能看到脱敏后的价格区间。死板的静态脱敏或者一刀切的字段过滤根本满足不了这种动态需求,真正落地的方案必须把脱敏规则和权限控制从业务代码里抽离出来,做成可配置、可编排的切面层。

把脱敏策略抽象成注解与枚举的映射关系

不要在每个接口里写 if-else 判断角色然后手动替换字符串,那样维护成本太高。更合理的做法是定义一套脱敏类型枚举,比如 PHONE、ID_CARD、BANK_CARD、EMAIL、NAME、ADDRESS,每种类型对应固定的脱敏逻辑。然后自定义一个注解 @SensitiveField,里面包含脱敏类型和权限表达式两个核心属性。权限表达式可以用 SpEL 或者自己定义的简单表达式,例如 role:admin 表示管理员角色可见明文,role:cs:mask 表示客服角色看到脱敏后的值,*:mask 表示其他所有角色都脱敏。

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveField {
    SensitiveType type() default SensitiveType.DEFAULT;
    String permission() default "*:mask";
}

在返回的 VO 或者 DTO 类上,直接在字段上面打这个注解。手机号字段标上 type=PHONE,权限表达式写 role:admin:plain||role:self:plain||*:mask,意思就是管理员和用户本人看明文,其余角色看脱敏。这样业务代码完全不用动,返回对象构造好之后,交给统一的脱敏处理器去扫描注解并执行规则。

利用序列化层的扩展点植入脱敏逻辑

很多人第一反应是用 Jackson 的自定义序列化器,在每个需要脱敏的字段上加 @JsonSerialize。这种做法有两个硬伤:一是权限信息很难传递进序列化器,因为序列化器通常是单例,无法拿到请求上下文里的用户角色;二是字段一多,每个字段都要单独指定序列化器,配置量爆炸。更好的切入点是利用 Jackson 的 BeanSerializerModifier 或者 Fastjson 的 Filter 机制,在序列化过程中动态改变属性值。

以 Jackson 为例,注册一个自定义的 ContextualSerializer,在 createContextual 方法里拿到字段上的 SensitiveField 注解,然后根据当前请求上下文中的用户权限,决定返回一个直接输出明文的序列化器,还是返回一个先脱敏再输出的序列化器。关键点在于权限上下文的传递,一般用 ThreadLocal 或者 TransmittableThreadLocal 把当前请求的用户角色信息在过滤器或者拦截器里设置进去,序列化时直接从上下文中取。

public class SensitiveSerializer extends JsonSerializer implements ContextualSerializer {
    
    @Override
    public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) {
        SensitiveField annotation = property.getAnnotation(SensitiveField.class);
        if (annotation == null) {
            return this;
        }
        String permission = annotation.permission();
        SensitiveType type = annotation.type();
        // 从上下文获取当前用户角色
        Set roles = SecurityContextHolder.getRoles();
        boolean plain = evaluatePermission(permission, roles);
        return new DynamicSensitiveSerializer(type, plain);
    }
    
    // evaluatePermission 实现权限表达式解析逻辑
}

动态序列化器内部根据 plain 标志位决定是否执行脱敏。脱敏逻辑本身很简单,手机号保留前三后四中间星号,身份证保留前六后四,邮箱保留首字母和域名,姓名保留姓后面加星号。这些工具方法抽成一个 SensitiveUtils 类,全局复用。

权限表达式引擎的设计与实现

权限表达式是整个方案中最容易翻车的部分。简单的字符串 equals 比较根本不够用,实际业务中权限判断往往涉及角色、部门、数据归属、操作类型等多个维度。设计表达式语法时要兼顾表达能力和解析性能。建议采用类似 Shiro 或者 Spring Security 的权限字符串格式,用冒号分隔资源与操作,用竖线表示或,用与号表示且。比如 order:view:plain|role:admin 表示有订单查看明文权限或者是管理员角色。

解析器用递归下降或者栈的方式把表达式拆成最小判断单元,每个单元执行时去当前用户权限集合里匹配。为了性能,可以把解析后的表达式结构缓存起来,避免每次序列化都重新解析。缓存 key 用注解的 permission 字符串,value 是解析好的 AST 或者判断函数对象。权限集合在请求进入时一次性加载完毕,放到上下文里,整个请求生命周期内复用。

public class PermissionEvaluator {
    private static final Map>> CACHE = new ConcurrentHashMap<>();
    
    public static boolean evaluate(String expression, Set permissions) {
        Predicate> predicate = CACHE.computeIfAbsent(expression, PermissionEvaluator::parse);
        return predicate.test(permissions);
    }
    
    private static Predicate> parse(String expression) {
        // 解析表达式,返回一个 Predicate
        // 支持 || 和 && 操作符
    }
}

权限表达式的粒度可以细化到字段级别,也可以粗化到接口级别。字段级别的控制更灵活,但配置量稍大;接口级别的控制更粗暴,但实现简单。实际项目里建议两者结合:接口层面用 @PreAuthorize 或者自定义注解控制是否有访问该接口的权限,字段层面用脱敏注解控制返回内容的可见程度。这样即使攻击者绕过了前端校验直接调接口,也只能拿到脱敏后的数据,不会造成敏感信息泄露。

处理嵌套对象与集合中的脱敏

真实的返回结构很少是扁平对象,更多时候是嵌套对象或者集合。用户订单列表接口返回的是 List<OrderVO>,每个 OrderVO 里又包含 UserInfo 对象,UserInfo 里有手机号和邮箱。脱敏处理器必须具备递归扫描对象图的能力,否则嵌套对象里的敏感字段就会被漏掉。

在序列化器的处理逻辑里,判断属性值类型,如果是基本类型或者字符串,直接处理;如果是对象类型,递归进入该对象并对其字段再次执行脱敏扫描;如果是集合或者数组,遍历每个元素进行递归处理。递归过程中要注意循环引用的问题,用 IdentityHashMap 记录已经处理过的对象实例,避免死循环。

还有一个容易被忽略的场景是 Map 结构。有些接口为了灵活,返回的 data 字段直接就是一个 Map<String, Object>。这种情况下注解打不上去,只能靠配置来指定脱敏规则。可以在脱敏处理器里加一层针对 Map key 的匹配逻辑,比如配置一个规则表,key 匹配到 phone、mobile 等关键词的,自动按手机号脱敏;匹配到 idCard 的,按身份证脱敏。这种基于 key 名称的模糊匹配虽然不够精确,但在动态结构下是唯一可行的方案。

与数据权限层打通,实现字段级别的动态过滤

脱敏只是让敏感字段不可读,但有些场景下字段本身就不应该返回。比如内部审计字段 is_deleted、create_by、update_time 等,普通用户不仅不应该看到明文,连这个字段的存在都不应该知道。这时候就不是脱敏的问题,而是字段过滤的问题。字段过滤和脱敏可以共用同一套权限表达式体系,只是在处理方式上不同:脱敏是改变字段值,过滤是直接移除字段。

Jackson 里可以通过 PropertyFilter 或者 @JsonFilter 来实现动态字段过滤。在序列化时,根据权限表达式判断当前用户是否有权看到该字段,如果没有权限,直接跳过该字段不输出。这个判断逻辑可以和脱敏逻辑放在同一个处理器里,先判断是否可见,不可见直接过滤;可见再判断是否需要脱敏。

public class DynamicFieldFilter extends SimpleBeanPropertyFilter {
    
    @Override
    public void serializeAsField(Object pojo, JsonGenerator jgen, 
                                  SerializerProvider provider, 
                                  PropertyWriter writer) throws Exception {
        SensitiveField annotation = writer.getAnnotation(SensitiveField.class);
        if (annotation != null) {
            String permission = annotation.permission();
            Set roles = SecurityContextHolder.getRoles();
            if (!evaluateVisibility(permission, roles)) {
                return; // 字段不可见,直接跳过
            }
        }
        super.serializeAsField(pojo, jgen, provider, writer);
    }
}

字段过滤比脱敏更彻底,但也更容易引发前端报错。如果前端强依赖某个字段做渲染,突然字段消失了可能导致页面崩溃。所以字段过滤通常用于内部管理字段或者已废弃的字段,对于核心业务字段还是用脱敏更稳妥。团队内部需要约定好哪些字段走过滤,哪些字段走脱敏,避免线上事故。

性能优化与缓存策略

动态脱敏最大的性能开销在于反射和注解解析。每次序列化都去反射读取字段注解,再解析权限表达式,再判断角色匹配,这个链路在高并发下会成为瓶颈。优化手段主要有三个方向:一是注解元数据缓存,二是表达式解析缓存,三是权限判断结果缓存。

注解元数据缓存可以在应用启动时扫描所有 VO 类,把字段和注解信息的映射关系预先加载到一个 ConcurrentHashMap 里,key 是类的全限定名加字段名,value 是脱敏类型和权限表达式的封装对象。序列化时直接从缓存取,避免反射。表达式解析缓存前面已经提过,把权限表达式字符串解析成可执行的判断函数后缓存起来。权限判断结果缓存要谨慎使用,因为用户权限在请求生命周期内是不变的,可以在请求上下文里缓存单个字段的判断结果,避免同一个请求里对同一个字段反复判断。但跨请求的缓存不能做,因为不同用户的权限不同。

另外,序列化器本身也应该是无状态的,所有状态信息通过上下文传递。这样序列化器可以做成单例,减少对象创建开销。在高QPS场景下,这点优化能省出不少CPU时间。

日志脱敏与接口脱敏的统一

敏感数据不仅在接口返回时需要脱敏,在日志打印时同样需要。很多开发习惯在代码里用 log.info 直接打印入参和返回结果,如果返回对象已经在序列化层做了脱敏,但日志里打印的是脱敏前的对象,敏感信息还是会泄露到日志系统里。比较好的做法是把脱敏能力下沉到对象的 toString 方法或者日志框架的转换器里。

可以在 VO 基类里重写 toString,利用反射和脱敏注解,在拼接字符串时对敏感字段做脱敏处理。或者用 Logback 的 MessageConverter 机制,在日志输出前对消息体做正则替换。但这两种方案都有局限性,toString 方案要求所有 VO 都继承基类,MessageConverter 方案只能做简单的正则匹配,无法感知字段级别的权限控制。折中方案是规范日志打印行为,禁止直接打印整个返回对象,改为只打印关键业务标识字段,从源头减少敏感信息进入日志的可能性。

配置化与动态下发的演进方向

注解方案解决了硬编码问题,但脱敏规则和权限表达式仍然写在代码里,变更需要重新发布。对于频繁调整权限策略的业务,可以把脱敏配置外移到配置中心或者数据库。每个接口的返回字段脱敏规则以元数据的形式存储,应用启动时加载到内存,运行时定期刷新。配置结构可以设计成 JSON 格式,包含字段路径、脱敏类型、权限表达式三个要素。字段路径用点号分隔的字符串表示,比如 order.userInfo.mobile,支持通配符匹配嵌套集合内的字段。

这种配置化方案的好处是运营或者安全团队可以直接在管理后台调整脱敏策略,不需要开发介入。但代价是配置的维护成本上升,需要配套的配置校验和灰度发布机制,防止配错导致大面积数据泄露或者接口报错。对于大多数项目,注解方案已经足够灵活,只有权限策略极度复杂的大型系统才需要走向配置化。

动态脱敏与权限控制的本质是把安全策略从业务逻辑中解耦,让开发人员专注于业务实现,安全规则由统一的切面层保证。这套机制一旦建立,后续新增接口只需要在 VO 字段上打注解就能自动获得脱敏能力,大幅降低安全漏洞的出现概率,也让代码审查时有明确的检查点。后端开发中数据安全不是可选项,而是每个接口都必须考虑的基线要求。