【技术派后端篇】技术派通用敏感词替换:原理、实现与应用

发布于:2025-04-21 ⋅ 阅读:(13) ⋅ 点赞:(0)

在当今互联网环境下,数据脱敏对于国内的互联网企业而言已经成为一项标配。这不仅是为了满足合规性要求,更是保障用户信息安全和企业声誉的重要举措。本文将深入探讨技术派中实现数据脱敏的关键技术——通用敏感词替换,从算法原理到具体实现,为你呈现一个全面的技术视角。

1 敏感词校验算法:DFA 算法

敏感词校验算法在数据脱敏中起着至关重要的作用。目前,相对成熟的算法有很多,其中 DFA(Deterministic Finite Automaton,确定有限自动机)算法是一种在敏感词校验等领域常用且高效的算法,其基本原理如下:

  1. 构建树状查找结构(森林)

    • 首先,基于给定的敏感词库来构建一个特殊的树状结构(严格来说,对于更完整的敏感词库构建出的结构,若不考虑根节点,是由多个树结构组成的森林)。
    • 例如,若敏感词库包含“我爱你”“我爱他”“我爱她”“我爱你呀”“我爱他呀”“我爱她呀”“我爱她啊”这些词汇。在构建树状结构时,以这些词汇的字符序列为路径来构建节点关系。从根节点开始,第一个字符作为第一层节点的分支依据,第二个字符作为下一层节点的分支依据,以此类推,直到完整的敏感词路径构建完成。这样,相同前缀的敏感词会共享前面的节点路径,形成一种层次化的树状结构。
      在这里插入图片描述
  2. 字符串遍历与匹配

    • 当有需要校验的字符串输入时,从字符串的第一个字符开始进行遍历。
    • 同时,设置一个指针指向树状结构的根节点。对于字符串中的每个字符,在树状结构中查找对应的子节点。如果能找到匹配的子节点,则将指针移动到该子节点;如果找不到,则表示从当前位置开始的字符序列不是敏感词的一部分,继续处理字符串的下一个字符,指针保持在当前位置(根节点或之前匹配到的节点)。
    • 例如,对于输入字符串“一灰我爱你呀哈哈哈”,从“一”开始遍历,在树状结构中找不到匹配的以“一”开头的敏感词分支,指针保持在根节点;接着处理“灰”,同样找不到匹配分支;当处理到“我”时,在树状结构中找到以“我”开头的敏感词分支,指针移动到对应的子节点;然后处理“爱”,继续沿着匹配的路径移动指针;当处理到“你”时,指针移动到相应节点,此时发现该节点是一个敏感词“我爱你”的结束节点,就表示找到了一个敏感词。然后继续从下一个字符“呀”开始重复上述过程,直到遍历完整个字符串。
  3. 高效性体现

    • DFA 算法的高效性在于,它通过预先构建好的树状结构,在对输入字符串进行一次遍历的过程中,就能快速确定其中是否包含敏感词以及具体的敏感词内容。相比于一些简单的逐字符匹配算法(如暴力匹配算法),不需要对每个位置都进行大量的比较操作,大大减少了时间复杂度,提高了敏感词校验的效率,尤其适用于敏感词库较大、输入字符串较长的场景。

通过以上步骤,DFA 算法实现了对输入字符串中敏感词的快速、准确校验。

2 敏感词服务类

为了在项目中更方便地使用敏感词校验功能,我们可以借助开源库。这里从 GitHub 中选取了一个 star 较多的库:https://github.com/houbb/sensitive-word

在技术派的项目中,使用该库的方式如下:

  1. pom.xml文件引入依赖
<dependency>
   <groupId>com.github.houbb</groupId>
   <artifactId>sensitive-word</artifactId>
   <version>${sensitive.version}</version>
</dependency>
  1. 新增一个敏感词配置类,用于处理自定义的敏感词以及白名单。
/**
 * 敏感词相关配置,db配置表中的配置优先级更高,支持动态刷新
 */
@Data
@Component
@ConfigurationProperties(prefix = "paicoding.sensitive")
public class SensitiveProperty {
    /**
     * true 表示开启敏感词校验
     */
    private Boolean enable;

    /**
     * 自定义的敏感词
     */
    private List<String> deny;

    /**
     * 自定义的非敏感词
     */
    private List<String> allow;
}
  1. 结合技术派实现的配置动态变更刷新机制([✅ 技术派实现自定义配置注入与动态刷新] ),封装了支持敏感词动态变更的服务类。
/**
 * 敏感词服务类
 *
 * @author YiHui
 * @date 2023/8/9
 */
@Slf4j
@Service
public class SensitiveService {
    private SensitiveProperty sensitiveConfig;

    private SensitiveWordBs sensitiveWordBs;

    public SensitiveService(DynamicConfigContainer dynamicConfigContainer, SensitiveProperty sensitiveConfig) {
        this.sensitiveConfig = sensitiveConfig;
        dynamicConfigContainer.registerRefreshCallback(sensitiveConfig, this::refresh);
    }

    @PostConstruct
    public void refresh() {
        IWordDeny deny = () -> {
            List<String> sub = WordDenySystem.getInstance().deny();
            sub.addAll(sensitiveConfig.getDeny());
            return sub;
        };

        IWordAllow allow = () -> {
            List<String> sub = WordAllowSystem.getInstance().allow();
            sub.addAll(sensitiveConfig.getAllow());
            return sub;
        };
        sensitiveWordBs = SensitiveWordBs.newInstance()
                .wordDeny(deny)
                .wordAllow(allow)
                .init();
        log.info("敏感词初始化完成!");
    }

    /**
     * 判断是否包含敏感词
     *
     * @param txt
     * @return
     */
    public boolean contains(String txt) {
        if (BooleanUtils.isTrue(sensitiveConfig.getEnable())) {
            return sensitiveWordBs.contains(txt);
        }
        return false;
    }


    /**
     * 敏感词替换
     *
     * @param txt
     * @return
     */
    public String replace(String txt) {
        if (BooleanUtils.isTrue(sensitiveConfig.getEnable())) {
            return sensitiveWordBs.replace(txt);
        }
        return txt;
    }

}

目前,敏感词校验主要应用在两个部分:

  • 派聪明的提问:对用户输入的提问内容进行敏感词检测和替换。
    在这里插入图片描述

  • 评价的敏感词替换:基于 Mybatis 的插件机制,直接针对从数据库查询出来的评价字段进行敏感词替换。

3 自定义的数据库敏感词替换方案

在实际生产项目中,为了安全和合规性,数据库中有很多信息不能存储明文,例如身份证、银行卡等敏感信息,需要加密后存储,读取时再解密返回明文。实现这一过程有两种常见方式:

  1. 直接编码实现,每次写和读取数据时手动进行加解密操作。这种方式虽然简单直接,但在实际应用中,尤其是在数据量较大、业务逻辑复杂的情况下,手动加解密容易出错且维护成本高。
  2. 实现一个通用的解决方案,在需要脱敏的字段上加一个标识,然后在实际写入数据库或从数据库读取时,自动实现加解密。我们这里重点介绍的就是第二种方案。

4 基于 mybatis 拦截器的敏感词替换实现方案

整体方案的思路较为清晰,具体步骤如下:

  1. 实现一个自定义注解,将其放置在需要脱敏的数据库实体对象的成员上。通过这个注解来标识哪些字段需要进行敏感词替换或数据脱敏处理。
  2. 实现查询拦截器,当从数据库返回内容到数据库实体对象上时,判断成员上是否有对应注解。如果有,则将该成员的值替换为敏感词替换之后的内容。

在具体实现层面,为了提高性能,我们增加了缓存机制,减少每次都对实体对象的成员进行是否需要脱敏的判定。所有相关代码可以在 com.github.paicoding.forum.core.senstive 下查看。

下面对几个关键的实现进行详细说明:

  1. 自定义注解com.github.paicoding.forum.core.senstive.ano.SensitiveField。通过这个注解来标识哪些字段需要进行敏感词替换或数据脱敏处理。
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface SensitiveField {
    /**
     * 绑定的db中的哪个字段
     *
     * @return
     */
    String bind() default "";

}
  1. 拦截器实现com.github.paicoding.forum.core.senstive.ibatis.SensitiveReadInterceptor 。核心步骤包括根据返回结果找到对应的实体类,并确定需要进行替换的成员;然后执行具体的敏感词替换操作。
/**
 * 敏感词替换拦截器,这里主要是针对从db中读取的数据进行敏感词处理 (如果需要在写入db时,进行脱敏如加密,也可以使用类似的方式来实现)

 */
@Intercepts({
        @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {java.sql.Statement.class})
})
@Component
@Slf4j
public class SensitiveReadInterceptor implements Interceptor {

    private static final String MAPPED_STATEMENT = "mappedStatement";

    @Autowired
    private SensitiveService sensitiveService;

    @SuppressWarnings("unchecked")
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        final List<Object> results = (List<Object>) invocation.proceed();

        if (results.isEmpty()) {
            return results;
        }

        final ResultSetHandler statementHandler = realTarget(invocation.getTarget());
        final MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
        final MappedStatement mappedStatement = (MappedStatement) metaObject.getValue(MAPPED_STATEMENT);

        Optional firstOpt = results.stream().filter(Objects::nonNull).findFirst();
        if (!firstOpt.isPresent()) {
            return results;
        }
        Object firstObject = firstOpt.get();

        SensitiveObjectMeta sensitiveObjectMeta = findSensitiveObjectMeta(firstObject);
        replaceSensitiveResults(results, mappedStatement, sensitiveObjectMeta);
        return results;
    }

    /**
     * 执行具体的敏感词替换
     *
     * @param results
     * @param mappedStatement
     * @param sensitiveObjectMeta
     */
    private void replaceSensitiveResults(Collection<Object> results, MappedStatement mappedStatement, SensitiveObjectMeta sensitiveObjectMeta) {
        for (Object obj : results) {
            if (sensitiveObjectMeta.getSensitiveFieldMetaList() == null) {
                continue;
            }

            final MetaObject objMetaObject = mappedStatement.getConfiguration().newMetaObject(obj);
            sensitiveObjectMeta.getSensitiveFieldMetaList().forEach(i -> {
                Object value = objMetaObject.getValue(StringUtils.isBlank(i.getBindField()) ? i.getName() : i.getBindField());
                if (value == null) {
                    return;
                } else if (value instanceof String) {
                    String strValue = (String) value;
                    String processVal = sensitiveService.replace(strValue);
                    objMetaObject.setValue(i.getName(), processVal);
                } else if (value instanceof Collection) {
                    Collection listValue = (Collection) value;
                    if (CollectionUtils.isNotEmpty(listValue)) {
                        Optional firstValOpt = listValue.stream().filter(Objects::nonNull).findFirst();
                        if (firstValOpt.isPresent()) {
                            SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(firstValOpt.get());
                            if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {
                                replaceSensitiveResults(listValue, mappedStatement, valSensitiveObjectMeta);
                            }
                        }
                    }
                } else if (!ClassUtils.isPrimitiveOrWrapper(value.getClass())) {
                    // 对于非基本类型的,需要对其内部进行敏感词替换
                    SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(value);
                    if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {
                        replaceSensitiveResults(newArrayList(value), mappedStatement, valSensitiveObjectMeta);
                    }
                }
            });
        }
    }

    /**
     * 查询对象中,携带有 @SensitiveField 的成员,进行敏感词替换
     *
     * @param firstObject 待查询的对象
     * @return 返回对象的敏感词元数据
     */
    private SensitiveObjectMeta findSensitiveObjectMeta(Object firstObject) {
        SensitiveMetaCache.computeIfAbsent(firstObject.getClass().getName(), s -> {
            Optional<SensitiveObjectMeta> sensitiveObjectMetaOpt = SensitiveObjectMeta.buildSensitiveObjectMeta(firstObject);
            return sensitiveObjectMetaOpt.orElse(null);
        });

        return SensitiveMetaCache.get(firstObject.getClass().getName());
    }

    @Override
    public Object plugin(Object o) {
        return Plugin.wrap(o, this);
    }

    @Override
    public void setProperties(Properties properties) {
    }

    public static <T> T realTarget(Object target) {
        if (Proxy.isProxyClass(target.getClass())) {
            MetaObject metaObject = SystemMetaObject.forObject(target);
            return realTarget(metaObject.getValue("h.target"));
        }
        return (T) target;
    }
}
  1. 敏感词替换元数据信息com.github.paicoding.forum.core.senstive.ibatis.SensitiveObjectMeta 。通过反射获取数据库实体对象的所有成员,判断是否有自定义注解 SensitiveField ,如果有则记录相关信息,以便后续进行替换操作。
/**
 * 敏感词相关配置,db配置表中的配置优先级更高,支持动态刷新

 */
@Data
public class SensitiveObjectMeta {
    private static final String JAVA_LANG_OBJECT = "java.lang.object";
    /**
     * 是否启用脱敏
     */
    private Boolean enabledSensitiveReplace;

    /**
     * 类名
     */
    private String className;

    /**
     * 标注 SensitiveField 的成员
     */
    private List<SensitiveFieldMeta> sensitiveFieldMetaList;

    public static Optional<SensitiveObjectMeta> buildSensitiveObjectMeta(Object param) {
        if (isNull(param)) {
            return Optional.empty();
        }

        Class<?> clazz = param.getClass();
        SensitiveObjectMeta sensitiveObjectMeta = new SensitiveObjectMeta();
        sensitiveObjectMeta.setClassName(clazz.getName());

        List<SensitiveFieldMeta> sensitiveFieldMetaList = newArrayList();
        sensitiveObjectMeta.setSensitiveFieldMetaList(sensitiveFieldMetaList);
        boolean sensitiveField = parseAllSensitiveFields(clazz, sensitiveFieldMetaList);
        sensitiveObjectMeta.setEnabledSensitiveReplace(sensitiveField);
        return Optional.of(sensitiveObjectMeta);
    }


    private static boolean parseAllSensitiveFields(Class<?> clazz, List<SensitiveFieldMeta> sensitiveFieldMetaList) {
        Class<?> tempClazz = clazz;
        boolean hasSensitiveField = false;
        while (nonNull(tempClazz) && !JAVA_LANG_OBJECT.equalsIgnoreCase(tempClazz.getName())) {
            for (Field field : tempClazz.getDeclaredFields()) {
                SensitiveField sensitiveField = field.getAnnotation(SensitiveField.class);
                if (nonNull(sensitiveField)) {
                    SensitiveFieldMeta sensitiveFieldMeta = new SensitiveFieldMeta();
                    sensitiveFieldMeta.setName(field.getName());
                    sensitiveFieldMeta.setBindField(sensitiveField.bind());
                    sensitiveFieldMetaList.add(sensitiveFieldMeta);
                    hasSensitiveField = true;
                }
            }
            tempClazz = tempClazz.getSuperclass();
        }
        return hasSensitiveField;
    }


    @Data
    public static class SensitiveFieldMeta {
        /**
         * 默认根据字段名,找db中同名的字段
         */
        private String name;

        /**
         * 绑定的数据库字段别名
         */
        private String bindField;
    }
}
  1. 敏感词替换com.github.paicoding.forum.core.senstive.ibatis.SensitiveReadInterceptor#replaceSensitiveResults ,这是具体执行敏感词替换的方法。
/**
 * 执行具体的敏感词替换
 *
 * @param results
 * @param mappedStatement
 * @param sensitiveObjectMeta
 */
private void replaceSensitiveResults(Collection<Object> results, MappedStatement mappedStatement, SensitiveObjectMeta sensitiveObjectMeta) {
    for (Object obj : results) {
        if (sensitiveObjectMeta.getSensitiveFieldMetaList() == null) {
            continue;
        }

        final MetaObject objMetaObject = mappedStatement.getConfiguration().newMetaObject(obj);
        sensitiveObjectMeta.getSensitiveFieldMetaList().forEach(i -> {
            Object value = objMetaObject.getValue(StringUtils.isBlank(i.getBindField()) ? i.getName() : i.getBindField());
            if (value == null) {
                return;
            } else if (value instanceof String) { 
                // 字符串类型,直接进行替换
                String strValue = (String) value;
                String processVal = sensitiveService.replace(strValue);
                objMetaObject.setValue(i.getName(), processVal);
            } else if (value instanceof Collection) { 
                // 集合类型,需要对集合中的每个元素进行替换
                Collection listValue = (Collection) value;
                if (CollectionUtils.isNotEmpty(listValue)) {
                    Optional firstValOpt = listValue.stream().filter(Objects::nonNull).findFirst();
                    if (firstValOpt.isPresent()) {
                        SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(firstValOpt.get());
                        if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {
                            replaceSensitiveResults(listValue, mappedStatement, valSensitiveObjectMeta);
                        }
                    }
                }
            } else if (!ClassUtils.isPrimitiveOrWrapper(value.getClass())) {
                // 对于非基本类型的,需要对其内部进行敏感词替换
                SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(value);
                if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {
                    replaceSensitiveResults(newArrayList(value), mappedStatement, valSensitiveObjectMeta);
                }
            }
        });
    }
  1. 敏感词缓存com.github.paicoding.forum.core.senstive.ibatis.SensitiveMetaCache,为了提高性能,增加了缓存机制,减少每次都对实体对象的成员进行是否需要脱敏的判定。
/**
 * 敏感词缓存
 */
public class SensitiveMetaCache {
    private static ConcurrentHashMap<String, SensitiveObjectMeta> CACHE = new ConcurrentHashMap<>();

    public static SensitiveObjectMeta get(String key) {
        return CACHE.get(key);
    }

    public static void put(String key, SensitiveObjectMeta meta) {
        CACHE.put(key, meta);
    }

    public static void remove(String key) {
        CACHE.remove(key);
    }

    public static boolean contains(String key) {
        return CACHE.containsKey(key);
    }

    public static SensitiveObjectMeta putIfAbsent(String key, SensitiveObjectMeta meta) {
        return CACHE.putIfAbsent(key, meta);
    }

    public static SensitiveObjectMeta computeIfAbsent(String key, Function<String, SensitiveObjectMeta> function) {
        return CACHE.computeIfAbsent(key, function);
    }
}

5 实际效果与白名单机制

在实际生产环境中,敏感词会被替换为 * 号。例如,当数据库中存储的敏感词被检测到时,会进行相应的替换。同时,我们还实现了敏感词白名单机制,添加白名单中的词汇不会被当作敏感词处理。白名单的动态维护可以在后台进行全局配置(只有管理员有权限操作),添加白名单后会立即生效,实际效果符合预期。
在这里插入图片描述
在这里插入图片描述

6 小结

本文详细介绍了技术派中通用敏感词替换的相关技术,包括敏感词校验的 DFA 算法、敏感词服务类的使用、基于 Mybatis 拦截器的自定义数据库脱敏方案等。这些知识点在实际项目中具有很高的实用价值,虽然通常由公司的基础部门负责支撑,且只有新开项目才有机会亲自实践,但作为合格的研发人员,我们应该多思考如何将一些直接代码实现的场景抽象为通用的基础服务能力,同时要时刻牢记安全与合规的重要性,确保自己的系统具备足够的安全性,防止被恶意攻击。

7 参考链接

  1. 技术派通用敏感词替换
  2. 技术派自定义配置注入&动态刷新
  3. 项目仓库(GitHub)
  4. 项目仓库(码云)
  5. 分支:origin/feature/sensitive_word

网站公告

今日签到

点亮在社区的每一天
去签到