《从零开发校园墙微信小程序》第 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;
}三个好处:
- 不漏 :「敏敏感词」里,起点 0 失配,起点 1 完整匹配「敏感词」,命中;
- 支持嵌套 / 重叠 :词库同时有
ab和abcd,文本abcd会同时产生两个命中; - 复杂度仍然很低 :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 篇。
- 命中要记日志 。敏感词日志表是运营发现「谁在反复试探」的唯一依据。
小结
- 多模式匹配用 Trie ,复杂度和词库大小无关;
- 「失配就重置到根」的单指针写法有首字重复绕过 漏洞;每个位置都从 root 重新尝试 才稳;
- 检测、取命中、替换共用同一次扫描,避免不一致;
- 替换用「最左最长」;
- 自建词库只是第一道防线。
下一篇讲投稿里的图片:怎么缩放、压缩、存到对象存储,以及一个内外网地址的细节。