敏感词过滤完整指南(DFA 字典树)

敏感词过滤完整指南(DFA 字典树)

包路径:com.example.word

参考思路:CSDN DFA 敏感词过滤常见实现(HashMap/字典树 + 词尾标记)


目录

  1. 背景与目标
  2. 算法原理
  3. 目录结构
  4. 三大坑与修复
  5. 词库文件
  6. 完整源码(含注释)
  7. [HTTP 联调说明](#HTTP 联调说明)
  8. 联调结果(实测)
  9. [本地不启 Spring 的快速验证](#本地不启 Spring 的快速验证)
  10. 扩展建议

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 匹配

从文本每个起点沿树走:

  1. 干扰符(空格、*、标点等)→ 跳过 ,不移动树指针(所以 a*b ≈ ab)
  2. 有效字符 → normalize 后再查子节点
  3. 走到 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)

  1. GET /word/help 能打开
  2. GET /word/dict 能看到 ab/abc/广告/广告词
  3. text=abc&matchType=MAX → filtered=***
  4. text=abc&matchType=MIN → filtered=**c
  5. text=A*bC&matchType=MAX → 命中且整段遮罩
  6. text=我是广告词&matchType=MAX → 我是***
  7. text=我是广告词&matchType=MIN → 我是**词
  8. POST /word/add + check 新词生效
  9. POST /word/reload 恢复文件词库

文档与源码同步维护于 com.example.word。若只改代码未改本文,以源码为准。

相关推荐
专业程序开发源1 小时前
SSM笔记本在线销售系统32649-计算机课程设计、毕业设计
java·spring boot·后端·python·django·php·课程设计
(Charon)1 小时前
【C++面试】四种cast转换:static_cast、dynamic_cast、const_cast与reinterpret_cast
开发语言·c++·面试
脉动数据行情11 小时前
Python asyncio 异步实现比特币 BTC 实时行情监听 高并发版
开发语言·python·区块链
爱吃奥利奥_wen1 小时前
排序算法通关手册:从 O(n²) 到 O(n log n),用一篇文章打通算法内功心法
开发语言·经验分享
FYKJ_20101 小时前
express绿叶横店短剧推荐与影评分享平台34219-计算机课程设计、毕业设计
java·javascript·vue.js·spring boot·后端·课程设计·express
步行cgn1 小时前
Spring 事务属性详解
java·数据库·spring
外收内放1 小时前
Python基础语法练习题(55-56)
开发语言·python
西凉的悲伤1 小时前
MyBatis 中 association 与 collection 详解
java·mybatis