《从零开发校园墙微信小程序》第 6 篇。校园墙必须有内容审核,否则一天就会变成垃圾场。这篇单独讲自建敏感词过滤的实现,以及我最初版本里一个很隐蔽的漏洞。

需求

  • 词库可由管理员在后台增删,改了即时生效;
  • 命中后分两种处理:
  • 一级(拦截) :直接拒绝投稿,并记录;
  • 二级(替换) :把敏感词换成指定文字(或 ***),放行;
  • 要快:每条投稿都要扫一遍,词库可能有几千个词。

为什么不用 String.contains 循环

最直观的写法:

for (String word : words) {
    if (content.contains(word)) { ... }
}

复杂度是 词数 × 文本长度 。词库 5000 个词、文本 500 字,就是 250 万次比较,每条投稿都来一遍,不划算。

Trie 树(前缀树)

把所有敏感词建成一棵树,每个字符一个节点,词的最后一个字符标记 isEnd。比如词库有 ab 和 abcd:

root
 └─ a
     └─ b   (isEnd: "ab")
         └─ c
             └─ d   (isEnd: "abcd")

扫描文本时,从每个位置出发,沿着树往下走 ,走到 isEnd 的节点就是一次命中。复杂度变成 文本长度 × 最长词长度 ,和词库大小无关。

节点定义

private static class TrieNode {
    private final Map<Character, TrieNode> children = new HashMap<>();
    private boolean isEnd;
    private int level;       // 1 = 拦截,2 = 替换
    private int wordId;
    private String word;
    private String replace;  // 替换词
}

建树

private void buildTrie(List<SensitiveWord> words) {
    for (SensitiveWord sw : words) {
        String word = sw.getWord();
        if (word == null || word.isEmpty()) continue;     // 空词跳过,防 NPE
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            node = node.children.computeIfAbsent(c, key -> new TrieNode());
        }
        node.isEnd = true;
        node.level = sw.getLevel() == null ? LEVEL_REPLACE : sw.getLevel();
        node.wordId = sw.getId() == null ? -1 : sw.getId();
        node.word = word;
        node.replace = (sw.getReplace() == null || sw.getReplace().isEmpty())
                ? DEFAULT_REPLACE : sw.getReplace();      // 没配替换词就用 ***
    }
}

computeIfAbsent 一行完成「有就取、没有就建」。

那个隐蔽的 bug

我最初的版本是这样扫描的(伪代码):

当前节点 = root
for 每个字符 c:
    if 当前节点有子节点 c:  当前节点 = 子节点
    else:                  当前节点 = root      ← 失配就回到根
    if 当前节点.isEnd: 命中

看起来没问题,对吧?但看这个例子:词库里有 「敏感词」 ,用户发的是 「敏敏感词」 。

位置0 '敏' → 进入「敏」节点
位置1 '敏' → 「敏」节点下没有子节点「敏」(它的子节点是「感」)→ 失配,回到 root
位置2 '感' → root 下没有「感」→ 保持 root
位置3 '词' → ……

问题出在失配时回到 root,但这个失配的字符本身被「吃掉」了 ——位置 1 的「敏」其实可以作为新一轮匹配的起点,却被跳过了。结果「敏敏感词」完整地绕过了检测。只要在敏感词前面多写一个和它首字相同的字符,就能绕过。
这类 bug 在很多网上抄来的「DFA 敏感词」代码里都有,因为它们都是单指针、失配即重置。

修复:每个位置都从 root 重新尝试

最稳妥的做法是放弃「单遍单指针」,对每个起点各自向下走一遍 :

private List<Hit> scan(String content) {
    List<Hit> hits = new ArrayList<>();
    int n = content.length();
    for (int i = 0; i < n; i++) {            // 每个位置都当一次起点
        TrieNode node = root;
        int j = i;
        while (j < n) {
            TrieNode next = node.children.get(content.charAt(j));
            if (next == null) break;          // 这条路走不通,换下一个起点
            node = next;
            if (node.isEnd) {
                hits.add(new Hit(i, j, node.wordId, node.level, node.replace));
            }
            j++;
        }
    }
    return hits;
}

三个好处:

  1. 不漏 :「敏敏感词」里,起点 0 失配,起点 1 完整匹配「敏感词」,命中;
  2. 支持嵌套 / 重叠 :词库同时有 ab 和 abcd,文本 abcd 会同时产生两个命中;
  3. 复杂度仍然很低 :O(n × 最长词长),对一条投稿完全够用。

最坏情况是文本全是重复的首字符、词又很长,但现实里的投稿不会成为问题。

一次扫描,多种用途

扫描结果 List<Hit> 被三个方法共用:

public boolean containsSensitiveWords(String content) {
    return content != null && !content.isEmpty() && !scan(content).isEmpty();
}

public Map<Integer, Integer> getTriggeredWordsAndLevels(String content) {
    Map<Integer, Integer> triggered = new LinkedHashMap<>();
    if (content == null || content.isEmpty()) return triggered;
    for (Hit hit : scan(content)) {
        triggered.putIfAbsent(hit.wordId, hit.level);   // 词 id → 等级
    }
    return triggered;
}

旧版本这三件事各扫一遍文本、甚至还有一个 indexOf 旁路,不仅慢,还会出现「检测说命中、替换却没替换」的不一致。共用同一份扫描结果,检测和处理就不会互相矛盾。

替换:最左最长

命中之后要替换,遇到重叠怎么办?比如 ab 和 abcd 都命中,该替换哪个?规则是最左优先,同一起点取最长 :

public String filterSensitiveWords(String content) {
    List<Hit> hits = scan(content);
    if (hits.isEmpty()) return content;

    StringBuilder result = new StringBuilder(content.length());
    int pos = 0;   // 已处理到的位置
    int i = 0;     // hits 按 start 升序
    while (pos < content.length()) {
        Hit best = null;
        while (i < hits.size() && hits.get(i).start < pos) i++;      // 跳过已被替换区间覆盖的命中
        while (i < hits.size() && hits.get(i).start == pos) {        // 同一起点取最长
            if (best == null || hits.get(i).end > best.end) best = hits.get(i);
            i++;
        }
        if (best != null) {
            int length = best.end - best.start + 1;
            result.append(best.level == LEVEL_BLOCK ? "*".repeat(length) : best.replace);
            pos = best.end + 1;
        } else {
            result.append(content.charAt(pos));
            pos++;
        }
    }
    return result.toString();
}

一级词按命中长度打 *,二级词用词库配置的替换文字。

使用时的注意点

  • 词库缓存与刷新 :sensitiveWordService.check() 返回的是构建好的 Trie。词库变更后要重建,否则管理员加了词不生效。可以在后台保存时触发重建,或设置较短的过期时间。
  • 这只是第一道防线 。自建词库挡得住常见词,挡不住谐音、拆字、加符号。所以项目里还接了微信的内容安全接口做第二道,见第 5 篇。
  • 命中要记日志 。敏感词日志表是运营发现「谁在反复试探」的唯一依据。

小结

  1. 多模式匹配用 Trie ,复杂度和词库大小无关;
  2. 「失配就重置到根」的单指针写法有首字重复绕过 漏洞;每个位置都从 root 重新尝试 才稳;
  3. 检测、取命中、替换共用同一次扫描,避免不一致;
  4. 替换用「最左最长」;
  5. 自建词库只是第一道防线。

下一篇讲投稿里的图片:怎么缩放、压缩、存到对象存储,以及一个内外网地址的细节。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏