Featured image of post 模型安全检测 06:敏感词服务深入(AC 自动机 + 正则倒排加速)

模型安全检测 06:敏感词服务深入(AC 自动机 + 正则倒排加速)

敏感词匹配源码走读:AC 自动机原理、黑白灰名单短路逻辑、正则倒排索引与词库 60s 热更新。

状态:✅ 已完成 前置阅读:03-技术架构与业务模块分析(模块全景与两个时期划分);02-Mac本地部署实操(实验环境可复现) 素材来源:safety/safety-skills/safety-keywords 源码逐行核实 + safety-api 侧 KeywordFunction + admin 表结构与 API 文档 读者定位:想搞懂"敏感词检测为什么能做到毫秒级、万级词库"的读者;准备维护词库、写自定义检测能力的读者


〇、先建立全局认识

0.1 服务定位:第一道安检

safety-keywords 是整个审核链路里最便宜也最快的一环,类比机场安检的"第一道闸机"——X 光粗筛,成本低、速度快,拦下明显违禁品,拿不准的交给后面的精检仪器(BERT)。

属性值出处
技术栈Java / Spring Boot(@EnableScheduling 开启定时任务)LlmSecSensitiveApplication.java:14
容器内端口8005(根 compose 里 ports 被注释,仅容器网络可达)docker-compose.yaml:145
对外接口仅一个:POST /keyword/querycontoller/SensitiveWordsController.java:24(包名 contoller 是仓库原样的拼写)
数据源MySQL sensitive_words 表,只加载 status=onlineSensitiveWordsManageServiceImpl.java:359
匹配引擎AC 自动机(org.ahocorasick 0.4.0)+ 正则倒排加速pom.xml:73

它在全链路中的位置(配置期与执行期分开,和 03 篇的框架一致):

1
2
3
4
5
6
配置期:  运维 ──► safety-admin(8006) /data/sensitive_words/upsert ──► MySQL sensitive_words
                                                                        │ 每 60s 轮询,有变更则全量重建 Trie
执行期:                                                                ▼
  业务方 ──► safety-api(8007) ──DAG──► KeywordFunction ──HTTP──► safety-keywords(8005) /keyword/query
                                            ▲                            │
                                            └──── KVResponse(bwgLabel) ──┘

0.2 数据模型:一张表讲清四个维度

safety/safety-admin/docs/tables.sql:1 的 sensitive_words 表,核心字段:

字段作用取值
word词本体(普通词或正则)varchar(1000)
match_type匹配方式contain(包含)/ equal(完全相等)/ regex(正则)
handle_strategy命中后处置black(拦截)/ white(放行)/ grey(观察)/ exclude(豁免)
business_scene生效业务范围all 或空 = 全局;否则逗号分隔的业务名列表
status上线状态online 才会被检测服务加载
first_class_no/name风险分类(如 1006/辱骂)命中后透传给 DAG 做 riskCode

表上有 ON UPDATE CURRENT_TIMESTAMP 的 update_time 字段(tables.sql:22)——这是热更新机制的关键伏笔,见第五章。

0.3 ⚠️ 文档/提纲与源码的出入(本篇一律以源码为准)

核实源码时发现几处"传说中的设计"与实际实现不符,先立此存照:

常见说法源码实际
match_type 有 compound(组合)只出现在 tables.sql:9 的注释里;MatchType.java:12 枚举只有 contain/equal/regex/exclude,全仓库无 compound 实现
handle_strategy 有 gray代码里拼写是 grey(WordsUtils.java:29、BwgLabelUtils.java:17)。词库里写 “gray” 会落到 default 分支:bwgLabel=0、排序优先级最低,等于不生效
exclude 是一种 match_type枚举里确实声明了(MatchType.java:31),但 doContainTask 把它当 handleStrategy 用(SensitiveWordsManageServiceImpl.java:124);match_type=exclude 的词根本不会被加载(contains 引擎只查 contain/equal,见 :282)
双引擎各有一个线程池实际是一个 200 线程的共享池,提交两个任务(:51、:65-66)

一、匹配算法核心:AC 自动机

1.1 为什么不用笨办法

词库 1 万个词、文本 1000 字,逐个词 String.contains() 是 O(词库规模 × 文本长 × 词长),千万级字符操作,单次请求就要几百毫秒。AC 自动机(Aho-Corasick)把所有模式串先编译成一棵树,文本只扫一遍,复杂度 O(文本长 + 命中数),与词库规模基本无关。

1.2 三件套:goto / fail / output

以词库 {王八蛋, 八蛋, 坏蛋} 为例:

1
2
3
4
5
6
7
8
9
        goto(实线,建树时确定)          fail(虚线,匹配失败时跳转)
        (root)
         ┃王              ┃八              ┃坏
         ▼                ▼               ▼
        (王)──八──▶(王八)  (八)──蛋──▶(八蛋)★   (坏)──蛋──▶(坏蛋)★
                    │蛋                   ▲
                    ▼                     ┆
                 (王八蛋)★ ─ ─ fail ─ ─ ─ ┘
        ★ = output(到此节点说明命中了一个完整的词)

fail 指针的含义:“当前匹配走不下去时,已匹配串的最长后缀在哪里”。比如在 (王八) 节点读到"蛋"走到 (王八蛋),它的 fail 指向 (八蛋)——因为"王八蛋"的最长后缀"八蛋"本身也是模式串。扫文本"老王八蛋"的过程:

  1. 老:root 没有这条边,留在 root(没有任何词被"部分命中")
  2. 王→(王);八→(王八);蛋→(王八蛋),output:命中"王八蛋"
  3. 沿 output 链接补报 fail 链上的 ★:同时命中"八蛋"

一句话理解:AC 自动机 = Trie 树 + KMP 的 fail 思想推广到多模式串。命中可重叠(“王八蛋"和"八蛋"都会报),这正是敏感词场景想要的。

1.3 为什么不选 DFA

把上万条规则编译成一个确定性自动机(DFA),理论匹配也是 O(n),但状态数会爆炸、构建慢、内存大,词库每分钟都要热重建的场景下不可行。AC 建树是 O(总词长),万级词库重建只需毫秒级,重建完整体替换引用即可(见第五章)。

1.4 代码:一个 34 行的薄封装

service/data/SensitiveWordDetector.java:14-33 全部职责就是把词库交给 ahocorasick 库建树、查询透传:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public SensitiveWordDetector(Map<String, List<MatchType.SensitiveWordsVO>> sensitiveWordsMap) {
    Trie.TrieBuilder builder = Trie.builder();
    for (String word : sensitiveWordsMap.keySet()) {
        builder.addKeyword(word);          // 逐词加入 Trie
    }
    trie = builder.build();                // 编译:建 goto + fail + output
}
public List<Emit> detectSensitiveWords(String text) {
    return (List<Emit>) trie.parseText(text);   // 一遍扫描,返回全部命中(含重叠)
}

两个使用注意:builder 没有调 ignoreCase(),匹配大小写敏感;parseText 返回所有重叠命中(“王八蛋"会同时报出"八蛋”),后续靠排序归约(见第三章)。


二、请求处理全流程:双引擎并行 + 150ms 预算

2.1 入口与预处理

POST /keyword/query → SensitiveWordsController.java:24 → KeywordApiServiceImpl.invoke()(rpc/KeywordApiServiceImpl.java:30)。预处理有两个值得知道的点:

  • 超长截断:文本超过 MAX_LEN = 10000 字时,取前 5000 字 + 后 5000 字拼接(:52)——中间部分直接不检。写超长文本绕过检测在理论上是可能的,别把它当唯一防线。
  • 全程 fail-open:任何异常都返回 statusCode=500 但文案"正常文本"的响应(:69-70)。上游 KeywordFunction 发现 !result.success() 后返回 null(KeywordFunction.java:86-89),该节点等于没结果。敏感词服务挂了不会阻断业务,这是内容安全"宁可漏过不可误杀"哲学的体现——但也意味着监控告警很重要。

2.2 双引擎并行与短路逻辑(全篇最重要的一段代码)

SensitiveWordsManageServiceImpl.parallelDetection()(:63-96),逐行批注:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Future<...> regexTask   = pool.submit(() -> doRegexTask(content, bizName));   // ① 正则引擎
Future<...> containTask = pool.submit(() -> doContainTask(content, bizName)); // ② AC 引擎,同一个 200 线程池
ret1 = containTask.get(waitMillis, MILLISECONDS);   // ③ AC 最多等 150ms(MAX_PROCESS_MILLISECONDS,:37)
//    超时 → log warn 并 return null(fail-open,:71-74)
if (ret1 != null && Objects.equals("black", ret1.getHandleStrategy())) {
    regexTask.cancel(true);                          // ④ AC 命中黑词:掐掉正则任务,直接返回(短路)
    return ret1;
}
ret2 = regexTask.get(剩余预算, MILLISECONDS);         // ⑤ 否则用 150ms 减去已耗时间再等正则
//    ret2 为 black → 返回 ret2(:85);否则优先返回 AC 结果 ret1(:88);正则超时也 return null

决策逻辑画成表:

AC 结果正则结果最终返回
black任意(被取消)AC 结果(短路,不等正则)
非 black / nullblack正则结果
非 black / null非 black优先 AC,其次正则
超时—null(放行)
任意超时null(放行,连 AC 的非黑命中也丢弃!)

设计解读:

  • 150ms 是这个服务对自己的 SLA 承诺。关键词是链路第一道,不能拖慢整条 DAG(对比:KeywordFunction 注册超时 1000ms,见第六章)。
  • black 短路是性能优化:黑词已是终局,正则算完也改变不了结果,cancel 掉省 CPU。
  • 正则超时会连累 AC 的非黑命中(最后一行)——白/灰词在正则引擎卡死时会被一起丢弃。这是代码的真实行为,用词时心里有数。

2.3 AC 引擎内部:命中后的四道过滤

doContainTask()(:98-135)在 AC 扫出命中词后还要过四关:

1
2
3
4
5
AC parseText 命中词列表
  → ① 查 wordsMap 拿到该词的所有 VO(同一个词可配多条规则,如不同业务不同处置)
  → ② isSceneMatch 业务场景过滤(all/空=全局;否则逗号列表包含当前业务名,:180-191)
  → ③ equal 类型二次确认:要求 content 完全等于 word(:111-115)
  → ④ WORDS_Comparator 排序取第 0 名;若其 handleStrategy=exclude → return null(豁免,:124-126)

注意 ③:equal 词也进了 AC 树,AC 粗筛出"包含"之后再用字符串相等做精确确认——复用一套索引,省一棵树。


三、match_type × handle_strategy:名单体系

3.1 三种生效的 match_type

match_type加载进哪个引擎语义典型场景
containAC 树(:282)文本包含该词即命中“王八蛋”(演示词,example/safety-admin/data/sensitive_words.sql:18)
equalAC 树 + 相等二次确认整条文本恰好等于该词验证码类垃圾"加V:xxxx"整句
regex正则引擎(倒排加速,第四章)正则 find() 命中手机号、身份证号、变体广告

3.2 四种 handle_strategy 与 bwgLabel

handle_strategybwgLabel(BwgLabelUtils.java:7)语义排序优先级(WordsUtils.java:20-34)
exclude0豁免:该词在本业务不算敏感,压制其他命中-4(最高)
black1黑名单,命中即拦-3
white2白名单,明确放行(策略里可跳过下游模型,省 BERT 开销)-2
grey3灰名单,观察记录不拦截-1

优先级排序器的逐行解读(WordsUtils.java:20-34):先按处置策略升序(exclude 最前),同策略词越长越靠前(thenComparing(-word.length()))。所以"自慰”(black)和"自慰高潮X"(white)同时命中时,返回的是更长的白词——更长更具体的规则优先,这是词库设计的常识:具体规则应当覆盖笼统规则。

exclude 的正确用法:它是 handleStrategy(不是 matchType!见 0.3 节)。比如全局词库有"枪"(black),军事业务的正常讨论就会被误伤;给军事业务配一条 word=“枪”、handleStrategy=exclude、business_scene=military 的记录,该业务命中"枪"时排序器把它排第一,doContainTask 看到 exclude 直接 return null——为特定业务在全局词库上"打孔"。

3.3 业务场景过滤的细节

isSceneMatch()(:180-191):businessScene 为 "all" 或空串 → 对所有业务生效;否则按逗号 split 后精确包含。两个坑:

  1. 方法第一行是 businessScene.equals("all"),businessScene 为 null 会 NPE(null 检查写在后半句,顺序反了)。upsert 时 businessScene 必填,正常不会触发,但直接往 MySQL 灌数据时注意。
  2. 匹配是包含语义:业务名是 test 时,business_scene="test2" 不会命中(用的是列表 contains,精确相等)。

四、正则倒排加速(亮点设计)

4.1 问题:为什么不能全量正则扫文本

假设词库有 5000 条正则,每来一次文本就 for (rule : rules) rule.pattern.matcher(text).find():

  • 复杂度 O(规则数 × 文本长),单次请求几十上百毫秒起步,150ms 预算必爆;
  • 正则引擎是 NFA 回溯,恶意构造的正则(嵌套量词)有灾难性回溯风险,一条烂规则就能打满 CPU。

4.2 解法:借搜索引擎的倒排索引思想

核心洞察:正则再花,里面总有字面量片段。1[3-9]\d{9} 必须包含 “1”,(赌博|博彩)网站 必须包含"赌博"或"博彩"或"网站"。文本连这些字面量都不含,正则绝不可能命中。于是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
构建期(updateRegex,:205-272):
  每条 regex 规则 ──regexTokenize(:275)──► 按元字符切开取字面量 token
       │  切分正则 splitRgex(:274):( ) . | * + ? [] {} \d{n} 等全部当分隔符
       ▼
  token ──► ① 全量 token 建一棵 AC Trie(regexDetector)
       └──► ② regexInvertMap: token → {规则id 集合}   ← 这就是"倒排索引"
       └──► ③ id2WordsMap: 规则id → 编译好的 Pattern

查询期(doRegexTask,:137-161):
  文本 ──AC 粗筛──► 命中哪些 token?──► 倒排表合并出候选规则 id 集合
       ──► 只对候选规则跑真正的 Pattern.matcher(content).find()(:153)

类比:AC 粗筛是搜索引擎用倒排索引找"可能含这些词的文档",Pattern 精匹配是打开文档确认。绝大多数文本一个 token 都命中不了,候选集为空直接返回——把"5000 条正则全扫"变成"几条候选正则精验"。

4.3 代码批注

1
2
3
4
5
private Set<String> regexTokenize(String word) {   // :275
    List<String> split = Splitter.onPattern(splitRgex)   // Guava Splitter,按元字符正则切
            .omitEmptyStrings().trimResults().splitToList(word);
    return new HashSet<>(split);                   // token 去重
}
1
2
3
4
5
6
7
8
Set<Long> docIds = Sets.newHashSet();              // queryPattern,:163
for (String keyword : matchedKeywords) {
    Set<Long> temp = regexInvertMap.get(keyword);  // token → 候选规则 id
    if (CollectionUtils.isNotEmpty(temp)) docIds.addAll(temp);
}
return docIds.stream().map(x -> id2WordsMap.get(x))
        .filter(x -> isSceneMatch(bizName, x.getBusinessScene()))  // 同样过业务场景
        .collect(Collectors.toList());

4.4 这个设计的边界(如实记录)

  • 纯元字符/无字面量的正则会失效:如 ^\d+$ 切完没有 token,updateRegex 会 warn “正则表达式分词结果为空"并跳过该规则(:252-255)——这条规则永远不会命中。配 regex 词时必须保证有可切的字面量片段。
  • token 太常见则粗筛失效:如 “1” 这种 token 几乎每条文本都命中,挂在上面的规则每次都要精匹配。切分粒度无法控制,这是简易实现的代价。
  • 正则侧没有 exclude 短路(doContainTask 才有),exclude 策略的 regex 命中后照常返回 VO,靠 bwgLabel=0 自然放行。

五、词库热更新:60s 轮询 + 全量重建

5.1 机制全貌

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
启动:@PostConstruct loadSensitiveWords()(:48)
       ├── updateAll():首载词库,建 containDetector + regexDetector
       └── 建 200 线程共享池(queue=10,CallerRunsPolicy 满了自己跑,:51)

运行:@Scheduled(fixedRate = 60_000) checkForUpdates()(:194,每分钟一次)
       └── updateAll() = updateContains() + updateRegex(),各自独立判断:
            ① 变更探测:SELECT ... WHERE match_type IN (...) ORDER BY update_time DESC LIMIT 1
               拿到最新 update_time,与"上次刷新时间"比较,没变 → return(:216-232 / :283-299)
            ② 有变 → 全量分页拉取(id >= startId ORDER BY id ASC LIMIT 5000 循环,:356-363)
               只拉 status=online
            ③ 在局部变量里建全新 wordsMap/Trie/倒排索引
            ④ 整体替换成员变量引用(:321-322 / :267-269),旧对象交给 GC

5.2 设计要点与启示

  • “先探测、再重建”:每分钟一次的变更探测只是一条 LIMIT 1 的轻查询,没变更时开销忽略不计;有变更才付出全量重建成本。这是轮询系统的标准省流姿势。
  • 无锁热切换:读请求直接读成员变量,更新在后台另起炉灶建完再换引用——靠 JVM 引用赋值的原子性保证读到的是完整快照。没有加 volatile,严格说有可见性瑕疵,工程上可接受。
  • 与 03 篇 api 侧 60s 配置轮询异曲同工:JoySafety 的配置下发哲学统一是"pull + 全量替换”,不依赖推送,admin 挂了执行层照跑。
  • 时延特性:词变更最长约 60s 生效(探测周期)+ 重建耗时。变更探测用 update_time(MySQL 服务器时钟)对比 lastRefreshTime(应用 System.currentTimeMillis()),两台机器时钟有偏移时可能漏判——生产部署注意 NTP 对时。

5.3 ⚠️ 两个真实的坑

  1. 清空词库不会生效:updateContains 里 if (newWordsMap.size() != 0) 才替换(:319)——把词全下线后查询结果为空,老 Trie 继续服役。想清空只能重启或留一条废词。
  2. contain 与 regex 的刷新时间戳互相独立(containsLastRefreshTime / regexLastRefreshTime),一类失败不影响另一类,但也意味着两类词库可能短暂不一致。

六、业务链路串联:一个词从录入到拦截的完整旅程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
① admin 写词:POST :8006/data/sensitive_words/upsert(status=online 立即落库)
        │
        ▼  MySQL sensitive_words(update_time 自动刷新)
② keywords 服务 60s 内探测到变更 → 全量重建 Trie + 倒排索引(日志见 "contain数据有更新")
        │
        ▼
③ safety-api DAG 执行到 keyword 节点 → KeywordFunction.doRun()(safety-api/.../functions/KeywordFunction.java:69)
        │  POST conf.url(注册时配置,演示环境 http://safety-keywords:8005/keyword/query,
        │  见 example/safety-skills/safety-knowledge/skills_init.py:14)
        │  body: {content, businessName}
        ▼
④ keywords 返回 KVResponse{hitWord, firstClassNo/Name, bwgLabel, latency}
        │
        ▼
⑤ KeywordFunction 包装成 KeywordCheck 结果(riskCode=firstClassNo,:95-103)
        │
        ▼
⑥ DAG groovy 路由裁决(strategy_example.yml:42):
     if (kw.bwgLabel == 1 || kw.bwgLabel == 2) { ctx.curResult = kw; return null }
     → 黑词直接终结链路返回拦截;白词也终结链路(放行且不送下游 BERT,省钱)
     → 灰词(3)/未命中 → return next,继续走 BERT 等精检

KeywordFunction 还有两个工程细节值得抄作业:

  • OkHttpClient 全局复用:FunctionResourcePool.getFunc(type, url) 按 url 缓存连接池(:58-65),多个 DAG 节点调同一个 keywords 服务共享连接,配 Keep-Alive timeout=20, max=5000(:82)。
  • 默认超时 200ms(:51),演示环境注册时改成了 1000ms(skills_init.py:10)——与 keywords 服务自己的 150ms 预算形成"外层兜底"。

七、动手实验

前提:按 02 篇部署完成,容器网络内 keywords 可达。根 compose 里 keywords 的 ports 被注释(docker-compose.yaml:145),直连验证用 docker exec 进容器 curl,或解注释后重建容器。

实验 1:加词 → 观察 60s 生效

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# ① 加一条黑词(upsert 即 online)
curl -X POST http://localhost:8006/data/sensitive_words/upsert \
  -H 'Content-Type: application/json' \
  -d '{"businessScene":"all","group":"default","word":"快乐星球","className":"实验",
       "classNo":9999,"matchType":"contain","handleStrategy":"black",
       "source":"manual","status":"online","desc":"06篇实验"}'

# ② 立即直连 keywords 验证(大概率还没生效,返回"正常文本")
docker exec safety-keywords curl -s -X POST http://localhost:8005/keyword/query \
  -H 'Content-Type: application/json' \
  -d '{"content":"欢迎来到快乐星球","businessName":"test"}'

# ③ 蹲日志看热更新发生(约 60s 内出现"contain数据有更新")
docker logs -f safety-keywords 2>&1 | grep -E "数据有更新|命中敏感词"

# ④ 再次验证 → hitWord="快乐星球", bwgLabel=1, firstClassNo=9999

实验 2:端到端走 DAG

1
2
docker exec safety-knowledge python /work/api_test.py '欢迎来到快乐星球'
# 预期:riskCode=9999, riskCheckType=keyword, hitWord=快乐星球(与 02 篇案例 2 同构)

实验 3:exclude 豁免与业务场景过滤

1
2
3
4
5
6
7
8
# 给 test 业务对"快乐星球"打孔(同一个词再录一条 exclude,businessScene 换成 test)
curl -X POST http://localhost:8006/data/sensitive_words/upsert \
  -H 'Content-Type: application/json' \
  -d '{"businessScene":"test","group":"default","word":"快乐星球","className":"实验",
       "classNo":9999,"matchType":"contain","handleStrategy":"exclude",
       "source":"manual","status":"online","desc":"豁免实验"}'
# 约 60s 后:businessName=test 的查询不再命中(exclude 排序第一,压制 black);
#           其他业务仍命中(exclude 那条的 scene 不含它们)

实验 4:压测词库规模 vs 匹配耗时

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ① 批量造词(1000 条,按需调整上界;admin 单线程插入,耐心等)
for i in $(seq 1 1000); do
  curl -s -X POST http://localhost:8006/data/sensitive_words/upsert \
    -H 'Content-Type: application/json' \
    -d "{\"businessScene\":\"all\",\"group\":\"default\",\"word\":\"压测词$i\",
         \"className\":\"perf\",\"classNo\":0,\"matchType\":\"contain\",
         \"handleStrategy\":\"black\",\"source\":\"perf\",\"status\":\"online\",\"desc\":\"perf\"}" > /dev/null
done

# ② 等一轮热更新(看日志 count 涨到 1000+)
docker logs safety-keywords 2>&1 | grep "分类别获取数据结束"

# ③ 未命中场景压 100 次(AC 一遍扫,开销≈纯扫描)
time for i in $(seq 1 100); do
  docker exec safety-keywords curl -s -X POST http://localhost:8005/keyword/query \
    -H 'Content-Type: application/json' \
    -d '{"content":"一段不含任何敏感词的普通文本","businessName":"test"}' > /dev/null
done

预期观察:词库从 10 涨到 1000、10000,未命中查询的耗时应基本不变(AC 复杂度与词库规模无关,只与建树的内存和文本长有关)——这就是 AC 自动机的价值,可以亲手验证。KVResponse 里的 latency 字段是服务内部耗时,比 curl 总耗时(含 docker exec 开销)更准。


八、速查附录

8.1 关键源码坐标

内容坐标
HTTP 入口 POST /keyword/querysafety/safety-skills/safety-keywords/.../contoller/SensitiveWordsController.java:24
预处理/截断/fail-open.../rpc/KeywordApiServiceImpl.java:30(MAX_LEN :24,截断 :52)
AC 薄封装.../service/data/SensitiveWordDetector.java:14
150ms 预算 / 双任务并行 / black 短路.../service/data/SensitiveWordsManageServiceImpl.java:37 / :63-96
contain 引擎四道过滤同上 :98-135
正则倒排(构建 / 查询 / 切分)同上 :205-272 / :137-178 / :274-278
60s 轮询 / 分页全量拉取 / 只取 online同上 :194 / :356-363(:359)
处置优先级排序器.../utils/WordsUtils.java:20-34
bwgLabel 映射(black1/white2/grey3).../utils/BwgLabelUtils.java:7-25
api 侧调用方safety/safety-api/.../functions/KeywordFunction.java:69
管理接口safety/safety-admin/.../contoller/data/SensitiveWordsController.java:26(upsert/updateWithId/getById/queryByWord/upgrade/online/offline)

8.2 管理 API(:8006,文档见 safety-admin/docs/api/sensitive_words_api.md)

/data/sensitive_words/upsert(新增/更新)· /queryByWord(模糊查)· /getById · /online /offline(上下线)· /updateWithId · /upgrade

8.3 本篇要点回顾

  1. AC 自动机让匹配耗时与词库规模解耦;正则靠"字面量 token 倒排"避免全量扫描——一个服务里用了两遍倒排/树思想。
  2. black 短路、exclude 豁免、白词跳过下游模型,处置策略是排序器+短路共同实现的。
  3. 150ms 预算 + 全程 fail-open:安全组件自我定位为"不能拖累业务"。
  4. 60s 轮询 = 轻探测(update_time DESC LIMIT 1)+ 全量重建 + 引用替换,时延上限约一分钟。
  5. 用词规范提醒:handle_strategy 用 grey 不是 gray;regex 词必须含字面量片段;exclude 当 handleStrategy 用。
使用 Hugo 构建
主题 Stack 由 Jimmy 设计