敏感词过滤完整指南(DFA 字典树)
包路径:
com.example.word参考思路:CSDN DFA 敏感词过滤常见实现(HashMap/字典树 + 词尾标记)
目录
- 背景与目标
- 算法原理
- 目录结构
- 三大坑与修复
- 词库文件
- 完整源码(含注释)
- [HTTP 联调说明](#HTTP 联调说明)
- 联调结果(实测)
- [本地不启 Spring 的快速验证](#本地不启 Spring 的快速验证)
- 扩展建议
1. 背景与目标
敏感词过滤常见需求:
| 能力 | 说明 |
|---|---|
| 检测 | 文本是否包含敏感词 |
| 提取 | 返回命中了哪些词 |
| 替换 | 把命中片段替换成 * 等 |
| 长短匹配 | 最短命中 / 最长命中 |
| 抗干扰 | Abc、Abc、a*b 也能命中 abc/ab |
暴力做法:对词库每个词做 text.contains(word)。词库上万时,CPU 与延迟都不可接受。
DFA(确定有穷自动机)/ 字典树 :把词库预建成树,扫描文本近似 O(文本长度),与词库规模弱相关。
2. 算法原理
2.1 建树
把每个敏感词拆成字符挂到树上,词尾节点标记 end=true。
词库:ab、abc、广告、广告词
root
├─ a
│ └─ b (end) ← "ab"
│ └─ c (end) ← "abc"
└─ 广
└─ 告 (end) ← "广告"
└─ 词 (end) ← "广告词"
建树前会先做 归一化(小写、全角转半角、去掉词条里的干扰符),保证库与文本用同一套字符空间。
2.2 匹配
从文本每个起点沿树走:
- 干扰符(空格、
*、标点等)→ 跳过 ,不移动树指针(所以a*b≈ab) - 有效字符 →
normalize后再查子节点 - 走到
end=true:- MIN:立刻返回(最短)
- MAX:记下位置,继续尝试更长
2.3 替换(关键)
禁止 :先 getSensitiveWords 得到无序 Set,再 foreach String.replace。
正确 :单遍扫描,命中就遮罩原文区间 [start, end),再输出。
3. 目录结构
src/main/java/com/example/word/
├── SensitiveWordMatchType.java # MIN / MAX
├── SensitiveWordNode.java # 强类型树节点
├── SensitiveWordNormalizer.java # 大小写 / 全半角 / 跳过符号
├── SensitiveWordDfa.java # 核心算法
├── SensitiveWordService.java # Spring 服务,加载词库
├── SensitiveWordController.java # HTTP /word/**
├── SensitiveWordLocalDemo.java # 不启 Spring 的本地 Demo
├── README.md # 简版说明
└── SENSITIVE_WORD_GUIDE.md # 本完整文档
src/main/resources/
└── sensitive-words.txt # 词库(UTF-8)
4. 三大坑与修复
坑 1:短词先替换,长词失效
- 词库:
["ab", "abc"] - 文本:
abc - 错误流程:Set 无序可能先替换
ab→*c,再找abc失败 - 修复 :
replace单遍遮罩 + 默认MAX→ 结果***
坑 2:大小写 / 全半角 / 中间符号
| 原文 | 词库 | 无预处理 | 本实现 |
|---|---|---|---|
Abc |
abc |
漏 | 命中 |
Abc |
abc |
漏 | 命中 |
a*b / a b |
ab |
漏 | 命中 |
修复 :SensitiveWordNormalizer,建树与匹配共用。
坑 3:raw Map 无泛型
旧写法:Map + isEnd="0|1" + 强制转型 → 警告多、易错。
修复 :SensitiveWordNode(Map<Character, SensitiveWordNode> + boolean end)。
5. 词库文件
路径:src/main/resources/sensitive-words.txt
text
# 演示用敏感词库(每行一个,# 开头为注释)
# 长短词并存:用于验证「替换优先长词」(ab + abc)
ab
abc
广告
广告词
色情
赌博
暴力
违禁
骗子
刷单
加微信
日本人
中国人
大中华
大中华帝国
规则:
- UTF-8
- 一行一个词
#开头为注释- 空行忽略
- 启动时
SensitiveWordService@PostConstruct加载 POST /word/reload可热加载(仅内存/add的词会被丢掉,除非写入文件)
6. 完整源码(含注释)
6.1 SensitiveWordMatchType.java
java
package com.example.word;
/**
* 敏感词匹配规则(对应常见 DFA 文中的 minMatchType / maxMatchType)。
*
* <p>词库同时有「广告」「广告词」,文本为「我是广告词」时:</p>
* <ul>
* <li>{@link #MIN}:走到「广告」已是词尾 → 立刻返回,命中「广告」</li>
* <li>{@link #MAX}:继续往后看「词」也是词尾 → 命中「广告词」</li>
* </ul>
*
* <p><b>替换场景务必优先用 {@link #MAX}</b>,否则短词会先遮罩,长词再也匹配不上。</p>
*/
public enum SensitiveWordMatchType {
/** 最短匹配:第一次碰到词尾就停 */
MIN(1),
/** 最长匹配:在同一起点尽量吃到最长的词尾 */
MAX(2);
private final int code;
SensitiveWordMatchType(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}
6.2 SensitiveWordNode.java
java
package com.example.word;
import java.util.HashMap;
import java.util.Map;
/**
* DFA / 字典树的一个节点。
*
* <p>旧教程常用 {@code Map + isEnd="0|1"} 字符串标记,存在 raw 类型与转型警告。
* 本类用强类型表达同一结构:</p>
* <ul>
* <li>{@code children}:当前字符之后还能走到哪些下一字符</li>
* <li>{@code end=true}:从根走到此处恰好构成一个完整敏感词</li>
* </ul>
*
* <pre>
* 词库插入 "ab"、"abc" 后的树:
* root
* └─ a
* └─ b (end=true) ← 词 "ab"
* └─ c (end=true) ← 词 "abc"
* </pre>
*/
public class SensitiveWordNode {
/** 子节点:key 为「已归一化」的字符 */
private final Map<Character, SensitiveWordNode> children = new HashMap<>();
/** 是否为某个敏感词的结尾 */
private boolean end;
public Map<Character, SensitiveWordNode> getChildren() {
return children;
}
/** 按字符取子节点,没有则返回 null(匹配失败) */
public SensitiveWordNode getChild(char c) {
return children.get(c);
}
/** 建树时用:没有子节点就创建 */
public SensitiveWordNode getOrCreateChild(char c) {
return children.computeIfAbsent(c, k -> new SensitiveWordNode());
}
public boolean isEnd() {
return end;
}
public void setEnd(boolean end) {
this.end = end;
}
}
6.3 SensitiveWordNormalizer.java
java
package com.example.word;
/**
* 文本归一化工具:解决「大小写 / 全半角 / 中间插入符号」导致漏检的问题。
*
* <h3>为什么需要?</h3>
* <ul>
* <li>{@code Abc} 与词库 {@code abc} 不同 → 统一转小写</li>
* <li>{@code Abc}(全角)与 {@code abc}(半角)不同 → 全角转半角</li>
* <li>{@code a*b}、{@code a b} 想命中 {@code ab} → 匹配时跳过干扰符</li>
* </ul>
*
* <p>建树与匹配都必须走同一套规则,否则「库里是 ab、文本是 A*B」也对不上。</p>
*/
public final class SensitiveWordNormalizer {
private SensitiveWordNormalizer() {
}
/**
* 归一化单个字符:
* <ol>
* <li>全角空格 → 半角空格</li>
* <li>全角 ASCII 区(!~)→ 对应半角</li>
* <li>英文字母 → 小写</li>
* </ol>
*/
public static char normalize(char c) {
// Unicode 全角空格
if (c == 12288) {
return ' ';
}
// 全角可打印 ASCII:'!"# ... ~' 对应半角 '!' ... '~'
if (c >= 65281 && c <= 65374) {
c = (char) (c - 65248);
}
return Character.toLowerCase(c);
}
/**
* 是否为「干扰符」(匹配时跳过,不参与树行走)。
* <p>保留:字母、数字、CJK 汉字、假名、韩文音节等语义字符。</p>
* <p>跳过:空格、标点、{@code *}{@code _} 等常见插入符。</p>
*/
public static boolean isSkipSymbol(char c) {
char n = normalize(c);
if (Character.isLetterOrDigit(n)) {
return false;
}
Character.UnicodeBlock block = Character.UnicodeBlock.of(n);
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| block == Character.UnicodeBlock.HIRAGANA
|| block == Character.UnicodeBlock.KATAKANA
|| block == Character.UnicodeBlock.HANGUL_SYLLABLES) {
return false;
}
return true;
}
/**
* 把一条词库原文变成「建树用 key」:去掉干扰符 + 逐字 normalize。
* <pre>
* "A*B" → "ab"
* "Ab" → "ab"
* "广告" → "广告"
* </pre>
*/
public static String normalizeWord(String word) {
if (word == null || word.isBlank()) {
return "";
}
StringBuilder sb = new StringBuilder(word.length());
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
if (isSkipSymbol(c)) {
continue;
}
sb.append(normalize(c));
}
return sb.toString();
}
}
6.4 SensitiveWordDfa.java(核心)
java
package com.example.word;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 基于 DFA(确定有穷自动机 / 字典树)的敏感词过滤器 ------ 核心算法类。
*
* <h3>相对「旧教程」的改进</h3>
* <ul>
* <li>强类型 {@link SensitiveWordNode},无 raw {@code Map} 转型</li>
* <li>建词/匹配经 {@link SensitiveWordNormalizer}</li>
* <li>{@link #replace} 单遍遮罩,避免短词先替换破坏长词</li>
* </ul>
*
* <h3>经典坑(必须懂)</h3>
* 词库 {@code ["ab","abc"]},文本 {@code "abc"}:
* <ul>
* <li>错误做法:先取出 Set 再 foreach 替换 → 若先换 {@code ab} 得到 {@code "*c"},长词丢失</li>
* <li>正确做法:从左扫到右,在起点用 MAX 一次定长,整段遮罩为 {@code "***"}</li>
* </ul>
*/
public class SensitiveWordDfa {
/** 字典树根节点(不含字符,只作入口) */
private SensitiveWordNode root = new SensitiveWordNode();
public SensitiveWordNode getRoot() {
return root;
}
/**
* 用整库重建树(可重复调用,例如热更新词库文件后)。
* 使用临时根再整体替换,避免重建中途被查询读到半成品树。
*/
public synchronized void init(Set<String> words) {
SensitiveWordNode newRoot = new SensitiveWordNode();
if (words != null) {
for (String word : words) {
addWordToTree(newRoot, word);
}
}
this.root = newRoot;
}
/** 运行时动态追加一条敏感词(不重建整棵树) */
public synchronized void addWord(String word) {
addWordToTree(root, word);
}
/**
* 将一个词插入树中。
* 步骤:normalizeWord → 逐字符 getOrCreateChild → 末节点 setEnd(true)。
*/
private void addWordToTree(SensitiveWordNode rootNode, String word) {
String key = SensitiveWordNormalizer.normalizeWord(word);
if (key.isEmpty()) {
return;
}
SensitiveWordNode node = rootNode;
for (int i = 0; i < key.length(); i++) {
node = node.getOrCreateChild(key.charAt(i));
}
// 标记:从根走到这里是一个完整敏感词
node.setEnd(true);
}
/** 文本中是否至少命中一个敏感词 */
public boolean contains(String text, SensitiveWordMatchType matchType) {
return !findMatches(text, matchType).isEmpty();
}
/**
* 提取原文中的命中片段(保留原文形态,含中间被跳过的符号)。
* 例如词库 {@code ab},文本 {@code "A*b"} → 返回 {@code "A*b"}。
*/
public Set<String> getSensitiveWords(String text, SensitiveWordMatchType matchType) {
Set<String> result = new LinkedHashSet<>();
for (MatchSpan span : findMatches(text, matchType)) {
result.add(text.substring(span.start, span.end));
}
return result;
}
/**
* 用 {@code replaceChar} 遮罩敏感片段。
*
* <p><b>实现要点:</b>不先收集单词再 {@code String.replace},
* 而是对原文做一次扫描,命中则把区间打进 {@code mask[]},最后按 mask 输出。
* 这样在 MAX 下 {@code abc} 会整段变成 {@code ***},不会先变成 {@code *c}。</p>
*/
public String replace(String text, char replaceChar, SensitiveWordMatchType matchType) {
if (text == null || text.isEmpty()) {
return text;
}
SensitiveWordMatchType type = matchType == null ? SensitiveWordMatchType.MAX : matchType;
char[] chars = text.toCharArray();
// mask[i]=true 表示第 i 个字符要被替换
boolean[] mask = new boolean[chars.length];
int i = 0;
while (i < chars.length) {
MatchSpan span = matchAt(chars, i, type);
if (span != null) {
for (int p = span.start; p < span.end; p++) {
mask[p] = true;
}
// 跳过本段,避免短匹配在段内重复触发
i = span.end;
} else {
i++;
}
}
StringBuilder sb = new StringBuilder(chars.length);
for (int p = 0; p < chars.length; p++) {
sb.append(mask[p] ? replaceChar : chars[p]);
}
return sb.toString();
}
/**
* 命中词按「原文长度」从长到短排序。
* 若业务仍想「收集后再替换」,至少应按此顺序先换长词。
*/
public List<String> getSensitiveWordsLongestFirst(String text, SensitiveWordMatchType matchType) {
List<String> list = new ArrayList<>(getSensitiveWords(text, matchType));
list.sort(Comparator.comparingInt(String::length).reversed());
return list;
}
/** 从左到右找出所有互不重叠的命中区间 */
private List<MatchSpan> findMatches(String text, SensitiveWordMatchType matchType) {
List<MatchSpan> matches = new ArrayList<>();
if (text == null || text.isEmpty()) {
return matches;
}
char[] chars = text.toCharArray();
int i = 0;
while (i < chars.length) {
MatchSpan span = matchAt(chars, i, matchType);
if (span != null) {
matches.add(span);
i = span.end;
} else {
i++;
}
}
return matches;
}
/**
* 从 {@code index} 起尝试匹配一条敏感词。
*
* <ol>
* <li>起点若是干扰符 → 直接失败(由外层 i++ 推进)</li>
* <li>途中遇到干扰符 → 跳过,不移动树指针(实现 a*b ≈ ab)</li>
* <li>有效字符先 {@link SensitiveWordNormalizer#normalize} 再查子节点</li>
* <li>碰到 end:MIN 立即返回;MAX 继续尝试更长</li>
* </ol>
*
* @return 原文闭开区间 [start, end);未命中返回 null
*/
private MatchSpan matchAt(char[] chars, int index, SensitiveWordMatchType matchType) {
if (SensitiveWordNormalizer.isSkipSymbol(chars[index])) {
return null;
}
SensitiveWordNode node = root;
// 最近一次成功词尾在原文中的结束下标(开区间)
int matchEnd = -1;
int i = index;
while (i < chars.length) {
char raw = chars[i];
if (SensitiveWordNormalizer.isSkipSymbol(raw)) {
// 还在根上就遇到符号:说明还没吃进任何有效字符
if (node == root) {
break;
}
i++;
continue;
}
char key = SensitiveWordNormalizer.normalize(raw);
node = node.getChild(key);
if (node == null) {
break;
}
i++;
if (node.isEnd()) {
matchEnd = i;
if (matchType == SensitiveWordMatchType.MIN) {
break;
}
// MAX:不 break,继续看能否更长
}
}
if (matchEnd < 0) {
return null;
}
return new MatchSpan(index, matchEnd);
}
/** 一次命中在原文中的区间 [start, end) */
private record MatchSpan(int start, int end) {
int length() {
return end - start;
}
}
}
6.5 SensitiveWordService.java
java
package com.example.word;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* 敏感词业务门面:负责词库加载 / 热更新,并把能力暴露给 Controller。
*
* <p>词库文件默认:{@code classpath:sensitive-words.txt}(UTF-8,每行一词,{@code #} 开头为注释)。</p>
*/
@Service
public class SensitiveWordService {
private static final Logger log = LoggerFactory.getLogger(SensitiveWordService.class);
private final SensitiveWordDfa dfa = new SensitiveWordDfa();
/** Spring 注入的词库资源 */
@Value("classpath:sensitive-words.txt")
private Resource wordResource;
/** 内存中的原始词条(未归一化形态,便于 /word/dict 展示) */
private final Set<String> loadedWords = new HashSet<>();
/** 应用启动后自动建树 */
@PostConstruct
public void init() {
reload();
}
/** 从文件重新加载并重建整棵 DFA 树 */
public synchronized void reload() {
Set<String> words = loadWordsFromResource();
dfa.init(words);
loadedWords.clear();
loadedWords.addAll(words);
log.info("敏感词 DFA 初始化完成,词条数={}", words.size());
}
/**
* 动态加词:同时写入展示集合与 DFA 树。
* 注意:不会回写 sensitive-words.txt,进程重启后需重新 add 或改文件后 reload。
*/
public synchronized void addWord(String word) {
if (word == null || word.isBlank()) {
return;
}
String w = word.trim();
loadedWords.add(w);
dfa.addWord(w);
}
public boolean contains(String text) {
return contains(text, SensitiveWordMatchType.MAX);
}
public boolean contains(String text, SensitiveWordMatchType matchType) {
return dfa.contains(text, matchType);
}
public Set<String> find(String text) {
return find(text, SensitiveWordMatchType.MAX);
}
public Set<String> find(String text, SensitiveWordMatchType matchType) {
return dfa.getSensitiveWords(text, matchType);
}
/**
* 默认替换:固定 MAX,避免短词抢先。
*/
public String replace(String text) {
return replace(text, '*', SensitiveWordMatchType.MAX);
}
public String replace(String text, char replaceChar, SensitiveWordMatchType matchType) {
return dfa.replace(text, replaceChar, matchType);
}
/**
* 一次返回检测全貌,方便联调看 JSON。
*/
public Map<String, Object> analyze(String text, SensitiveWordMatchType matchType) {
Map<String, Object> result = new LinkedHashMap<>();
Set<String> words = find(text, matchType);
result.put("text", text);
result.put("matchType", matchType.name());
result.put("contains", !words.isEmpty());
result.put("sensitiveWords", words);
result.put("sensitiveWordsLongestFirst", dfa.getSensitiveWordsLongestFirst(text, matchType));
// filtered 跟随本次 matchType,便于对比 MIN/MAX 替换差异
result.put("filtered", replace(text, '*', matchType));
result.put("normalizeHint", "已忽略大小写/全半角;匹配时跳过标点空格等干扰符");
result.put("dictionarySize", loadedWords.size());
return result;
}
public Set<String> getLoadedWords() {
return Collections.unmodifiableSet(loadedWords);
}
private Set<String> loadWordsFromResource() {
Set<String> words = new HashSet<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(wordResource.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
// 空行、注释行跳过
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
words.add(line);
}
} catch (Exception e) {
log.error("加载敏感词库失败: {}", e.getMessage(), e);
}
return words;
}
}
6.6 SensitiveWordController.java
java
package com.example.word;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* 敏感词过滤 HTTP 联调入口,统一前缀 {@code /word}。
*/
@RestController
@RequestMapping("/word")
public class SensitiveWordController {
private final SensitiveWordService sensitiveWordService;
public SensitiveWordController(SensitiveWordService sensitiveWordService) {
this.sensitiveWordService = sensitiveWordService;
}
/** 接口清单,浏览器直接打开即可 */
@GetMapping("/help")
public Map<String, Object> help() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("algorithm", "DFA 强类型字典树 + 归一化(大小写/全半角) + 跳过干扰符");
m.put("check", "GET /word/check?text=abc&matchType=MAX");
m.put("replace", "GET /word/replace?text=abc (单遍最长匹配,避免 ab 抢先破坏 abc)");
m.put("normalizeDemo", "GET /word/check?text=A*bC");
m.put("add", "POST /word/add?word=新敏感词");
m.put("dict", "GET /word/dict");
m.put("reload", "POST /word/reload");
m.put("matchType", "MIN=最短, MAX=最长;替换建议始终用 MAX");
return m;
}
@GetMapping("/check")
public Map<String, Object> check(@RequestParam String text,
@RequestParam(defaultValue = "MAX") String matchType) {
return sensitiveWordService.analyze(text, parseMatchType(matchType));
}
@GetMapping("/replace")
public Map<String, Object> replace(@RequestParam String text,
@RequestParam(defaultValue = "*") String replaceChar,
@RequestParam(defaultValue = "MAX") String matchType) {
char c = (replaceChar == null || replaceChar.isEmpty()) ? '*' : replaceChar.charAt(0);
SensitiveWordMatchType type = parseMatchType(matchType);
Map<String, Object> result = new LinkedHashMap<>();
result.put("original", text);
result.put("matchType", type.name());
result.put("sensitiveWords", sensitiveWordService.find(text, type));
result.put("filtered", sensitiveWordService.replace(text, c, type));
return result;
}
@PostMapping("/add")
public Map<String, Object> add(@RequestParam String word) {
sensitiveWordService.addWord(word);
Map<String, Object> result = new LinkedHashMap<>();
result.put("added", word.trim());
result.put("dictionarySize", sensitiveWordService.getLoadedWords().size());
return result;
}
@GetMapping("/dict")
public Map<String, Object> dict() {
Set<String> words = sensitiveWordService.getLoadedWords();
Map<String, Object> result = new LinkedHashMap<>();
result.put("size", words.size());
result.put("words", words);
return result;
}
@PostMapping("/reload")
public Map<String, Object> reload() {
sensitiveWordService.reload();
Map<String, Object> result = new LinkedHashMap<>();
result.put("reloaded", true);
result.put("size", sensitiveWordService.getLoadedWords().size());
return result;
}
private SensitiveWordMatchType parseMatchType(String matchType) {
if (matchType != null && matchType.equalsIgnoreCase("MIN")) {
return SensitiveWordMatchType.MIN;
}
return SensitiveWordMatchType.MAX;
}
}
6.7 SensitiveWordLocalDemo.java
java
package com.example.word;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
* 本地快速验证(不启 Spring):
* {@code mvn -q exec:java -Dexec.mainClass=com.example.word.SensitiveWordLocalDemo}
*/
public class SensitiveWordLocalDemo {
public static void main(String[] args) {
SensitiveWordDfa dfa = new SensitiveWordDfa();
Set<String> dict = new HashSet<>(Arrays.asList(
"ab", "abc", "广告", "广告词", "违禁", "赌博"
));
dfa.init(dict);
demo(dfa, "abc");
demo(dfa, "A*bC");
demo(dfa, "Abc");
demo(dfa, "我是广告词");
demo(dfa, "这里有违禁和赌博内容");
}
private static void demo(SensitiveWordDfa dfa, String text) {
System.out.println("==== text=[" + text + "] ====");
System.out.println("MAX words = " + dfa.getSensitiveWords(text, SensitiveWordMatchType.MAX));
System.out.println("MAX filtered = " + dfa.replace(text, '*', SensitiveWordMatchType.MAX));
System.out.println("MIN words = " + dfa.getSensitiveWords(text, SensitiveWordMatchType.MIN));
System.out.println("MIN filtered = " + dfa.replace(text, '*', SensitiveWordMatchType.MIN));
System.out.println();
}
}
7. HTTP 联调说明
7.1 启动
确保 RabbitMQ / MySQL / Milvus 等依赖可用(或按你项目现状能正常启动 Spring Boot),然后:
bash
mvn spring-boot:run
浏览器先打开:
http
GET http://localhost:8080/word/help
7.2 接口一览
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /word/help |
接口说明 |
| GET | /word/check?text=&matchType= |
检测 + 替换预览 |
| GET | /word/replace?text=&replaceChar=&matchType= |
替换 |
| POST | /word/add?word= |
动态加词(内存) |
| GET | /word/dict |
查看词库 |
| POST | /word/reload |
从文件重载 |
中文参数请 URL 编码,例如「我是广告词」→ %E6%88%91%E6%98%AF%E5%B9%BF%E5%91%8A%E8%AF%8D。
8. 联调结果(实测)
以下结果来自 SensitiveWordLocalDemo 直接跑核心算法(与 HTTP 层结果一致)。
8.1 长短词替换坑:abc
请求
http
GET /word/check?text=abc&matchType=MAX
GET /word/check?text=abc&matchType=MIN
GET /word/replace?text=abc
结果
| matchType | sensitiveWords | filtered | 说明 |
|---|---|---|---|
| MAX | ["abc"] |
*** |
正确,整词遮罩 |
| MIN | ["ab"] |
**c |
最短命中,故意只遮两字 |
若用「Set 乱序先换短词」的旧写法,MAX 也可能变成
*c。本实现不会。
MAX 的 JSON 示例
json
{
"text": "abc",
"matchType": "MAX",
"contains": true,
"sensitiveWords": ["abc"],
"sensitiveWordsLongestFirst": ["abc"],
"filtered": "***",
"normalizeHint": "已忽略大小写/全半角;匹配时跳过标点空格等干扰符",
"dictionarySize": 15
}
8.2 大小写 + 干扰符:A*bC
请求
http
GET /word/check?text=A*bC&matchType=MAX
GET /word/check?text=A*bC&matchType=MIN
结果
| matchType | sensitiveWords | filtered |
|---|---|---|
| MAX | ["A*bC"] |
**** |
| MIN | ["A*b"] |
***C |
说明:* 被跳过,归一化后等价于匹配 abc / ab;返回片段保留原文形态。
8.3 全角:Abc
| matchType | sensitiveWords | filtered |
|---|---|---|
| MAX | ["Abc"] |
*** |
| MIN | ["Ab"] |
**c(末字保留全角 c) |
8.4 中文长短词:我是广告词
| matchType | sensitiveWords | filtered |
|---|---|---|
| MAX | ["广告词"] |
我是*** |
| MIN | ["广告"] |
我是**词 |
8.5 多词命中:这里有违禁和赌博内容
| matchType | sensitiveWords | filtered |
|---|---|---|
| MAX / MIN | ["违禁", "赌博"] |
这里有**和**内容 |
(两词等长,MIN/MAX 结果相同。)
8.6 动态加词
http
POST /word/add?word=测试敏感词
GET /word/check?text=这里有测试敏感词吗
期望:contains=true,filtered 中「测试敏感词」被 * 遮罩。
http
POST /word/reload
期望:仅文件内词条恢复;刚才 /add 且未写文件的词会消失。
9. 本地不启 Spring 的快速验证
不依赖 RabbitMQ / 数据库时,可只跑算法 Demo:
bash
mvn -q -DskipTests compile exec:java -Dexec.mainClass=com.example.word.SensitiveWordLocalDemo
控制台应看到与第 8 节一致的英文用例;中文请保证终端 UTF-8(Windows 可先 chcp 65001)。
10. 扩展建议
| 方向 | 说明 |
|---|---|
| 词库落库 | MySQL / Redis,管理台增删后通知 reload |
| 分类分级 | 节点上挂 tag(涉政 / 广告 / 辱骂),按场景启用 |
| 白名单 | 命中后若整词在白名单则放行 |
| 与 Agent 集成 | 把 SensitiveWordService 注入 ContentSafetyFilter,替换简易 contains |
| 性能 | 超大词库可考虑双数组 Trie;当前 HashMap 树对中小词库足够 |
| 单元测试 | 把第 8 节用例写成 JUnit,防止回归 |
附录:推荐联调顺序(Checklist)
-
GET /word/help能打开 -
GET /word/dict能看到ab/abc/广告/广告词 -
text=abc&matchType=MAX→filtered=*** -
text=abc&matchType=MIN→filtered=**c -
text=A*bC&matchType=MAX→ 命中且整段遮罩 -
text=我是广告词&matchType=MAX→我是*** -
text=我是广告词&matchType=MIN→我是**词 -
POST /word/add+check新词生效 -
POST /word/reload恢复文件词库
文档与源码同步维护于 com.example.word。若只改代码未改本文,以源码为准。