文章目录
-
- 一、设计背景与目标
-
- [1.1 业务背景](#1.1 业务背景)
- [1.2 总体目标](#1.2 总体目标)
- [1.3 技术架构约束](#1.3 技术架构约束)
- 二、核心设计理念
-
- [2.1 设计原则](#2.1 设计原则)
- [2.2 系统边界](#2.2 系统边界)
- 三、整体技术架构
-
- [3.1 组件划分](#3.1 组件划分)
- [3.2 核心流程时序图](#3.2 核心流程时序图)
- [3.3 三级缓存架构](#3.3 三级缓存架构)
- 四、核心模块设计
-
- [4.1 敏感词注解 `@SensitiveCheck`](#4.1 敏感词注解
@SensitiveCheck) - [4.2 DFA 算法实现](#4.2 DFA 算法实现)
-
- [4.2.1 Trie 树数据结构](#4.2.1 Trie 树数据结构)
- [4.2.2 敏感词过滤器实现](#4.2.2 敏感词过滤器实现)
- [4.3 缓存管理器](#4.3 缓存管理器)
- [4.4 AOP 切面实现](#4.4 AOP 切面实现)
- [4.1 敏感词注解 `@SensitiveCheck`](#4.1 敏感词注解
- 五、数据库设计
-
- [5.1 敏感词表](#5.1 敏感词表)
- [5.2 白名单表](#5.2 白名单表)
- [5.3 审计日志表](#5.3 审计日志表)
- 六、使用示例
-
- [6.1 评论发布场景](#6.1 评论发布场景)
- [6.2 私信发送场景](#6.2 私信发送场景)
- [6.3 剧情评论场景](#6.3 剧情评论场景)
- 七、敏感词管理接口
-
- [7.1 管理接口设计](#7.1 管理接口设计)
- 八、性能优化方案
-
- [8.1 性能设计考量](#8.1 性能设计考量)
- [8.2 压测方案](#8.2 压测方案)
-
- [8.2.1 压测场景](#8.2.1 压测场景)
- [8.2.2 性能指标](#8.2.2 性能指标)
- 九、监控与告警
-
- [9.1 监控指标](#9.1 监控指标)
- [9.2 告警规则](#9.2 告警规则)
- 十、安全设计
-
- [10.1 权限控制](#10.1 权限控制)
- [10.2 数据安全](#10.2 数据安全)
- [10.3 防刷机制](#10.3 防刷机制)
- 十一、部署方案
-
- [11.1 配置文件](#11.1 配置文件)
- [11.2 初始化脚本](#11.2 初始化脚本)
- 十二、总结与最佳实践
-
- [12.1 核心优势](#12.1 核心优势)
- [12.2 最佳实践](#12.2 最佳实践)
- [12.3 注意事项](#12.3 注意事项)
- [12.4 常见问题 FAQ](#12.4 常见问题 FAQ)
一、设计背景与目标
1.1 业务背景
在视频评论、剧情评论、私信等用户生成内容(UGC)场景中,需要对用户输入进行实时敏感词检测,保证内容合规性,防止违规内容传播,降低平台风险。
应用场景:
- 📝 评论系统:视频评论、剧情评论、回复内容过滤
- 💬 私信系统:用户间私信内容实时审核
- 📝 内容发布:帖子、动态、资料等UGC内容检测
- 👤 用户资料:昵称、个性签名等字段过滤
1.2 总体目标
- 高可用性:系统稳定性 ≥ 99.9%,避免因敏感词检测导致业务不可用
- 高性能:支持 15,000+ QPS,单次检测耗时 < 5ms,不影响业务响应时间
- 低侵入性:通过注解方式集成,业务代码改动最小化
- 灵活配置:支持多种处理策略(拦截、替换、标记审核)、热更新敏感词库
- 精准匹配:支持精确匹配、模糊匹配(变体、拆分、谐音)、白名单机制
- 可观测性:完善的日志审计、监控告警、统计分析能力
1.3 技术架构约束
- 运行时框架:Spring Boot 3.5.6 + Java 17
- 持久层:MyBatis Plus 3.5.9 + MySQL 8.0
- 缓存层:Redis(分布式缓存) + Caffeine(本地缓存)
- 分布式锁:Redisson 3.37.0
- 消息队列:RabbitMQ(异步审核通知)
- 鉴权认证:JWT + Spring Security
二、核心设计理念
2.1 设计原则
- 性能优先:采用高效的 DFA(Deterministic Finite Automaton)算法 + Trie 树数据结构
- 降级保护:检测失败时不阻塞业务,允许配置降级策略
- 缓存分层:本地缓存(Caffeine)+ 分布式缓存(Redis)+ 数据库(MySQL)三级存储
- 异步处理:敏感内容标记审核场景使用 MQ 异步通知
- 可插拔策略:支持多种处理策略,通过配置动态切换
2.2 系统边界
包含功能:
- 敏感词检测(精确匹配、模糊匹配)
- 多种处理策略(拦截、替换、标记审核)
- 敏感词库管理(增删改查、热更新)
- 白名单机制
- 日志审计与监控
不包含功能:
- 人工审核流程(可通过 MQ 对接)
- 图片/视频内容识别(需对接 AI 服务)
- 自然语言理解(语义分析)
三、整体技术架构
3.1 组件划分
com.video.sensitive/
├── annotation/ # 注解层
│ └── SensitiveCheck.java # 敏感词检测注解
├── aspect/ # AOP切面层
│ └── SensitiveCheckAspect.java
├── core/ # 核心引擎
│ ├── SensitiveWordFilter.java # 敏感词过滤器接口
│ ├── DfaSensitiveWordFilter.java # DFA算法实现
│ ├── SensitiveWordTrie.java # Trie树结构
│ └── SensitiveWordMatcher.java # 匹配器
├── strategy/ # 处理策略
│ ├── SensitiveStrategy.java # 策略接口
│ ├── RejectStrategy.java # 拒绝策略
│ ├── ReplaceStrategy.java # 替换策略
│ └── ReviewStrategy.java # 人工审核标记策略
├── cache/ # 缓存管理
│ ├── SensitiveWordCacheManager.java
│ └── WordCacheWarmer.java # 缓存预热
├── repository/ # 敏感词库管理
│ ├── SensitiveWordRepository.java
│ └── WhitelistRepository.java
├── entity/ # 实体类
│ ├── SensitiveWord.java # 敏感词实体
│ ├── SensitiveLog.java # 审计日志实体
│ └── Whitelist.java # 白名单实体
├── mapper/ # MyBatis Mapper
│ ├── SensitiveWordMapper.java
│ ├── SensitiveLogMapper.java
│ └── WhitelistMapper.java
├── service/ # 业务服务
│ ├── SensitiveWordService.java # 敏感词管理服务
│ └── SensitiveCheckService.java # 检测服务
├── controller/ # 管理接口
│ └── SensitiveWordController.java
├── config/ # 配置类
│ ├── SensitiveConfig.java
│ └── SensitiveProperties.java
├── enums/ # 枚举类
│ ├── SensitiveStrategy.java # 处理策略枚举
│ ├── SensitiveLevel.java # 敏感等级枚举
│ └── MatchType.java # 匹配类型枚举
├── exception/ # 异常类
│ ├── SensitiveWordException.java
│ └── SensitiveContentException.java
└── monitor/ # 监控与日志
├── SensitiveMonitor.java # 监控指标
└── SensitiveAuditLogger.java # 审计日志
3.2 核心流程时序图
用户 -> Controller -> @SensitiveCheck -> SensitiveCheckAspect
↓
[1] 获取待检测文本 + 注解配置
↓
[2] SensitiveWordCacheManager.getWordTrie()
↓ (缓存未命中)
[3] Redis 获取敏感词列表
↓ (Redis未命中)
[4] MySQL 查询敏感词表
↓
[5] 构建 Trie 树并缓存到 Caffeine + Redis
↓
[6] DfaSensitiveWordFilter.filter(text, wordTrie)
↓
[7] 返回检测结果(匹配词列表 + 位置)
↓
根据策略处理:
- REJECT: 抛出异常,拦截请求
- REPLACE: 替换为 *** 并继续执行
- REVIEW: 标记待审核,发送 MQ 消息
↓
[8] 记录审计日志(异步)
↓
[9] 更新监控指标
↓
返回结果给业务层
3.3 三级缓存架构
┌─────────────────────────────────────────────────────────┐
│ 检测请求 │
└────────────────────────┬────────────────────────────────┘
│
│ L1: Caffeine 本地缓存
│ - 过期时间:10分钟
│ - 命中率:> 95%
│ - RT:< 1ms
│
[命中] │ [未命中]
↓
│ L2: Redis 分布式缓存
│ - 过期时间:1小时
│ - 命中率:> 99%
│ - RT:< 3ms
│
[命中] │ [未命中]
↓
│ L3: MySQL 数据库
│ - 持久化存储
│ - 用于缓存重建
│ - RT:< 10ms
│
↓
返回 Trie 树
四、核心模块设计
4.1 敏感词注解 @SensitiveCheck
java
package com.video.sensitive.annotation;
import com.video.sensitive.enums.MatchType;
import com.video.sensitive.enums.SensitiveStrategyType;
import java.lang.annotation.*;
/**
* 敏感词检测注解
* 用于标记需要进行敏感词检测的方法参数或返回值
*/
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveCheck {
/**
* 需要检测的参数名称(支持SpEL表达式)
* 示例:"#dto.content" 或 "#comment.content"
*/
String[] fields() default {};
/**
* 处理策略
* - REJECT: 直接拒绝请求(默认)
* - REPLACE: 替换敏感词为 ***
* - REVIEW: 标记为待人工审核
*/
SensitiveStrategyType strategy() default SensitiveStrategyType.REJECT;
/**
* 匹配类型
* - EXACT: 精确匹配(默认)
* - FUZZY: 模糊匹配(支持变体、拆分、谐音)
*/
MatchType matchType() default MatchType.EXACT;
/**
* 是否启用白名单
*/
boolean useWhitelist() default true;
/**
* 业务场景描述(用于日志和监控)
*/
String scene() default "";
/**
* 是否启用降级保护
* 当检测服务异常时,是否允许请求通过
*/
boolean enableFallback() default true;
/**
* 超时时间(毫秒)
* 超过此时间未完成检测,触发降级
*/
long timeout() default 100;
}
4.2 DFA 算法实现
4.2.1 Trie 树数据结构
java
package com.video.sensitive.core;
import java.util.HashMap;
import java.util.Map;
/**
* 敏感词 Trie 树节点
*/
public class SensitiveWordTrie {
/**
* 根节点
*/
private final TrieNode root = new TrieNode();
/**
* Trie 树节点
*/
private static class TrieNode {
/**
* 子节点 Map(字符 -> 子节点)
*/
private Map<Character, TrieNode> children = new HashMap<>();
/**
* 是否为敏感词结尾
*/
private boolean isEnd = false;
/**
* 敏感词等级(1-低,2-中,3-高)
*/
private int level = 1;
/**
* 敏感词原始文本(用于日志记录)
*/
private String word;
}
/**
* 添加敏感词到 Trie 树
*
* @param word 敏感词
* @param level 敏感等级
*/
public void addWord(String word, int level) {
if (word == null || word.isEmpty()) {
return;
}
TrieNode node = root;
for (char c : word.toCharArray()) {
// 转为小写统一处理
c = Character.toLowerCase(c);
node = node.children.computeIfAbsent(c, k -> new TrieNode());
}
node.isEnd = true;
node.level = level;
node.word = word;
}
/**
* 检测文本中的敏感词(DFA算法)
*
* @param text 待检测文本
* @return 匹配结果列表
*/
public List<MatchResult> match(String text) {
if (text == null || text.isEmpty()) {
return Collections.emptyList();
}
List<MatchResult> results = new ArrayList<>();
int length = text.length();
for (int i = 0; i < length; i++) {
int matchLength = 0;
TrieNode node = root;
// DFA 状态机匹配
for (int j = i; j < length && node != null; j++) {
char c = Character.toLowerCase(text.charAt(j));
node = node.children.get(c);
if (node != null) {
matchLength++;
// 找到完整敏感词
if (node.isEnd) {
MatchResult result = new MatchResult();
result.setWord(node.word);
result.setStartIndex(i);
result.setEndIndex(j + 1);
result.setLevel(node.level);
result.setMatchedText(text.substring(i, j + 1));
results.add(result);
break;
}
}
}
}
return results;
}
/**
* 匹配结果
*/
@Data
public static class MatchResult {
private String word; // 原始敏感词
private String matchedText; // 匹配到的文本
private int startIndex; // 开始位置
private int endIndex; // 结束位置
private int level; // 敏感等级
}
}
4.2.2 敏感词过滤器实现
java
package com.video.sensitive.core;
import com.video.sensitive.enums.MatchType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
/**
* DFA 算法实现的敏感词过滤器
*/
@Slf4j
@Component
public class DfaSensitiveWordFilter implements SensitiveWordFilter {
@Override
public FilterResult filter(String text, SensitiveWordTrie wordTrie,
MatchType matchType, Set<String> whitelist) {
if (text == null || text.isEmpty()) {
return FilterResult.clean();
}
// 1. 执行敏感词匹配
List<SensitiveWordTrie.MatchResult> matches = wordTrie.match(text);
// 2. 过滤白名单
if (whitelist != null && !whitelist.isEmpty()) {
matches = matches.stream()
.filter(m -> !whitelist.contains(m.getWord()))
.collect(Collectors.toList());
}
// 3. 模糊匹配增强(可选)
if (matchType == MatchType.FUZZY && !matches.isEmpty()) {
matches = enhanceFuzzyMatch(text, matches);
}
// 4. 构建结果
FilterResult result = new FilterResult();
result.setHasSensitiveWord(!matches.isEmpty());
result.setMatches(matches);
result.setOriginalText(text);
if (!matches.isEmpty()) {
result.setFilteredText(replaceMatches(text, matches));
result.setHighestLevel(matches.stream()
.mapToInt(SensitiveWordTrie.MatchResult::getLevel)
.max()
.orElse(1));
} else {
result.setFilteredText(text);
result.setHighestLevel(0);
}
return result;
}
/**
* 替换敏感词为 ***
*/
private String replaceMatches(String text, List<SensitiveWordTrie.MatchResult> matches) {
StringBuilder sb = new StringBuilder(text);
// 从后往前替换,避免索引位移
matches.stream()
.sorted((a, b) -> Integer.compare(b.getStartIndex(), a.getStartIndex()))
.forEach(match -> {
int length = match.getEndIndex() - match.getStartIndex();
String replacement = "*".repeat(length);
sb.replace(match.getStartIndex(), match.getEndIndex(), replacement);
});
return sb.toString();
}
/**
* 模糊匹配增强
* 处理变体词(如:法轮功 -> 法_轮_功、falungong)
*/
private List<SensitiveWordTrie.MatchResult> enhanceFuzzyMatch(
String text, List<SensitiveWordTrie.MatchResult> exactMatches) {
// TODO: 实现模糊匹配逻辑
// 1. 去除特殊符号后重新匹配
// 2. 谐音转换后匹配
// 3. 拼音匹配
return exactMatches;
}
/**
* 过滤结果
*/
@Data
public static class FilterResult {
private boolean hasSensitiveWord; // 是否包含敏感词
private String originalText; // 原始文本
private String filteredText; // 过滤后文本
private List<SensitiveWordTrie.MatchResult> matches; // 匹配结果列表
private int highestLevel; // 最高敏感等级
public static FilterResult clean() {
FilterResult result = new FilterResult();
result.setHasSensitiveWord(false);
result.setMatches(Collections.emptyList());
result.setHighestLevel(0);
return result;
}
}
}
4.3 缓存管理器
java
package com.video.sensitive.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.video.sensitive.core.SensitiveWordTrie;
import com.video.sensitive.entity.SensitiveWord;
import com.video.sensitive.mapper.SensitiveWordMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 敏感词缓存管理器(三级缓存)
*/
@Slf4j
@Component
public class SensitiveWordCacheManager {
private static final String REDIS_KEY_WORDS = "sensitive:words:all";
private static final String REDIS_KEY_WHITELIST = "sensitive:whitelist:all";
private static final String REDIS_KEY_VERSION = "sensitive:version";
@Resource
private SensitiveWordMapper sensitiveWordMapper;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* L1: Caffeine 本地缓存 Trie 树
* 缓存时间:10分钟,最大 1000 个 key
*/
private final Cache<String, SensitiveWordTrie> trieCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
/**
* L1: 白名单本地缓存
*/
private final Cache<String, Set<String>> whitelistCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(100)
.build();
/**
* 应用启动时预热缓存
*/
@PostConstruct
public void warmUp() {
log.info("开始预热敏感词缓存...");
try {
// 预加载敏感词 Trie 树
getWordTrie();
// 预加载白名单
getWhitelist();
log.info("敏感词缓存预热完成");
} catch (Exception e) {
log.error("敏感词缓存预热失败", e);
}
}
/**
* 获取敏感词 Trie 树(三级缓存)
*/
public SensitiveWordTrie getWordTrie() {
String cacheKey = "default";
// L1: 本地缓存
SensitiveWordTrie trie = trieCache.getIfPresent(cacheKey);
if (trie != null) {
log.debug("命中 Caffeine 缓存");
return trie;
}
// L2: Redis 缓存
try {
Set<String> redisWords = stringRedisTemplate.opsForSet().members(REDIS_KEY_WORDS);
if (redisWords != null && !redisWords.isEmpty()) {
log.debug("命中 Redis 缓存,词库大小: {}", redisWords.size());
trie = buildTrieFromWords(redisWords);
trieCache.put(cacheKey, trie);
return trie;
}
} catch (Exception e) {
log.warn("从 Redis 加载敏感词失败,降级到数据库", e);
}
// L3: MySQL 数据库
List<SensitiveWord> words = sensitiveWordMapper.selectList(
new LambdaQueryWrapper<SensitiveWord>()
.eq(SensitiveWord::getStatus, 1)
.eq(SensitiveWord::getIsDeleted, 0)
);
log.info("从数据库加载敏感词,数量: {}", words.size());
// 构建 Trie 树
trie = new SensitiveWordTrie();
for (SensitiveWord word : words) {
trie.addWord(word.getWord(), word.getLevel());
}
// 回写缓存
trieCache.put(cacheKey, trie);
// 回写 Redis
try {
Set<String> wordSet = words.stream()
.map(SensitiveWord::getWord)
.collect(Collectors.toSet());
stringRedisTemplate.opsForSet().add(REDIS_KEY_WORDS, wordSet.toArray(new String[0]));
stringRedisTemplate.expire(REDIS_KEY_WORDS, 1, TimeUnit.HOURS);
} catch (Exception e) {
log.warn("回写 Redis 缓存失败", e);
}
return trie;
}
/**
* 获取白名单(三级缓存)
*/
public Set<String> getWhitelist() {
String cacheKey = "default";
// L1: 本地缓存
Set<String> whitelist = whitelistCache.getIfPresent(cacheKey);
if (whitelist != null) {
return whitelist;
}
// L2: Redis 缓存
try {
Set<String> redisWhitelist = stringRedisTemplate.opsForSet().members(REDIS_KEY_WHITELIST);
if (redisWhitelist != null && !redisWhitelist.isEmpty()) {
whitelistCache.put(cacheKey, redisWhitelist);
return redisWhitelist;
}
} catch (Exception e) {
log.warn("从 Redis 加载白名单失败", e);
}
// L3: 数据库
List<Whitelist> list = whitelistMapper.selectList(
new LambdaQueryWrapper<Whitelist>()
.eq(Whitelist::getStatus, 1)
);
whitelist = list.stream()
.map(Whitelist::getWord)
.collect(Collectors.toSet());
// 回写缓存
whitelistCache.put(cacheKey, whitelist);
try {
stringRedisTemplate.opsForSet().add(REDIS_KEY_WHITELIST, whitelist.toArray(new String[0]));
stringRedisTemplate.expire(REDIS_KEY_WHITELIST, 1, TimeUnit.HOURS);
} catch (Exception e) {
log.warn("回写白名单 Redis 缓存失败", e);
}
return whitelist;
}
/**
* 清除所有缓存(用于敏感词更新后刷新)
*/
public void clearCache() {
log.info("清除敏感词缓存");
trieCache.invalidateAll();
whitelistCache.invalidateAll();
try {
stringRedisTemplate.delete(REDIS_KEY_WORDS);
stringRedisTemplate.delete(REDIS_KEY_WHITELIST);
// 版本号+1,通知其他节点刷新
stringRedisTemplate.opsForValue().increment(REDIS_KEY_VERSION);
} catch (Exception e) {
log.error("清除 Redis 缓存失败", e);
}
}
/**
* 从词列表构建 Trie 树
*/
private SensitiveWordTrie buildTrieFromWords(Set<String> words) {
SensitiveWordTrie trie = new SensitiveWordTrie();
for (String word : words) {
trie.addWord(word, 1); // 从 Redis 加载默认等级为 1
}
return trie;
}
}
4.4 AOP 切面实现
java
package com.video.sensitive.aspect;
import com.video.common.Result;
import com.video.sensitive.annotation.SensitiveCheck;
import com.video.sensitive.cache.SensitiveWordCacheManager;
import com.video.sensitive.core.DfaSensitiveWordFilter;
import com.video.sensitive.core.SensitiveWordTrie;
import com.video.sensitive.enums.SensitiveStrategyType;
import com.video.sensitive.exception.SensitiveContentException;
import com.video.sensitive.monitor.SensitiveAuditLogger;
import com.video.sensitive.monitor.SensitiveMonitor;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 敏感词检测切面
*/
@Slf4j
@Aspect
@Component
public class SensitiveCheckAspect {
@Resource
private DfaSensitiveWordFilter sensitiveWordFilter;
@Resource
private SensitiveWordCacheManager cacheManager;
@Resource
private SensitiveAuditLogger auditLogger;
@Resource
private SensitiveMonitor monitor;
private final ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(com.video.sensitive.annotation.SensitiveCheck)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
SensitiveCheck annotation = method.getAnnotation(SensitiveCheck.class);
try {
// 1. 提取待检测文本
String[] fields = annotation.fields();
if (fields.length == 0) {
return joinPoint.proceed();
}
Object[] args = joinPoint.getArgs();
String[] paramNames = signature.getParameterNames();
// 2. 使用 SpEL 提取字段值
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
// 3. 检测每个字段
for (String field : fields) {
Expression expression = parser.parseExpression(field);
Object value = expression.getValue(context);
if (value instanceof String) {
String text = (String) value;
checkText(text, annotation, method.getName());
}
}
// 4. 执行业务逻辑
Object result = joinPoint.proceed();
// 5. 记录监控指标
long cost = System.currentTimeMillis() - startTime;
monitor.recordCheckSuccess(annotation.scene(), cost);
return result;
} catch (SensitiveContentException e) {
// 6. 敏感词检测失败
long cost = System.currentTimeMillis() - startTime;
monitor.recordCheckReject(annotation.scene(), cost);
throw e;
} catch (Exception e) {
// 7. 系统异常,触发降级
long cost = System.currentTimeMillis() - startTime;
monitor.recordCheckError(annotation.scene(), cost);
if (annotation.enableFallback()) {
log.warn("敏感词检测异常,触发降级保护,允许请求通过", e);
return joinPoint.proceed();
} else {
throw e;
}
}
}
/**
* 检测文本
*/
private void checkText(String text, SensitiveCheck annotation, String methodName) {
if (text == null || text.trim().isEmpty()) {
return;
}
// 1. 获取敏感词 Trie 树
SensitiveWordTrie wordTrie = cacheManager.getWordTrie();
// 2. 获取白名单
Set<String> whitelist = annotation.useWhitelist() ?
cacheManager.getWhitelist() : null;
// 3. 执行过滤
DfaSensitiveWordFilter.FilterResult result = sensitiveWordFilter.filter(
text, wordTrie, annotation.matchType(), whitelist);
// 4. 处理结果
if (result.isHasSensitiveWord()) {
// 记录审计日志
auditLogger.logSensitive(
annotation.scene(),
methodName,
text,
result.getMatches(),
annotation.strategy()
);
// 根据策略处理
handleByStrategy(annotation.strategy(), text, result);
}
}
/**
* 根据策略处理敏感内容
*/
private void handleByStrategy(SensitiveStrategyType strategy,
String text,
DfaSensitiveWordFilter.FilterResult result) {
switch (strategy) {
case REJECT:
// 直接拒绝
throw new SensitiveContentException(
"内容包含敏感词,禁止发布",
result.getMatches()
);
case REPLACE:
// 替换敏感词(修改原对象)
// 注意:需要通过反射修改原始对象的字段值
log.info("替换敏感词: {} -> {}", text, result.getFilteredText());
break;
case REVIEW:
// 标记待审核,发送 MQ 消息
log.info("标记内容待审核: {}", text);
// TODO: 发送 RabbitMQ 消息给审核系统
break;
default:
throw new IllegalArgumentException("未知策略: " + strategy);
}
}
}
五、数据库设计
5.1 敏感词表
sql
CREATE TABLE `a_sensitive_word` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`word` VARCHAR(200) NOT NULL COMMENT '敏感词',
`level` TINYINT NOT NULL DEFAULT 1 COMMENT '敏感等级:1-低,2-中,3-高',
`category` VARCHAR(50) DEFAULT NULL COMMENT '分类:政治、色情、暴力、广告等',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注说明',
`create_user` BIGINT DEFAULT NULL COMMENT '创建人',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_user` BIGINT DEFAULT NULL COMMENT '修改人',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_word` (`word`),
KEY `idx_level` (`level`),
KEY `idx_category` (`category`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='敏感词表';
5.2 白名单表
sql
CREATE TABLE `a_sensitive_whitelist` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`word` VARCHAR(200) NOT NULL COMMENT '白名单词汇',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注说明',
`create_user` BIGINT DEFAULT NULL COMMENT '创建人',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_user` BIGINT DEFAULT NULL COMMENT '修改人',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_word` (`word`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='敏感词白名单';
5.3 审计日志表
sql
CREATE TABLE `a_sensitive_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT DEFAULT NULL COMMENT '用户ID',
`scene` VARCHAR(50) NOT NULL COMMENT '业务场景',
`method_name` VARCHAR(200) DEFAULT NULL COMMENT '方法名',
`original_text` TEXT COMMENT '原始文本',
`matched_words` VARCHAR(500) DEFAULT NULL COMMENT '匹配的敏感词(JSON数组)',
`strategy` VARCHAR(20) NOT NULL COMMENT '处理策略',
`result` VARCHAR(20) NOT NULL COMMENT '处理结果:PASS-通过,REJECT-拒绝,REVIEW-审核',
`ip` VARCHAR(50) DEFAULT NULL COMMENT '客户端IP',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '客户端UA',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_scene` (`scene`),
KEY `idx_create_time` (`create_time`),
KEY `idx_result` (`result`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='敏感词审计日志';
-- 按月分区
ALTER TABLE `a_sensitive_log` PARTITION BY RANGE (TO_DAYS(`create_time`)) (
PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
PARTITION p202603 VALUES LESS THAN (TO_DAYS('2026-04-01')),
PARTITION p_max VALUES LESS THAN MAXVALUE
);
六、使用示例
6.1 评论发布场景
java
@PostMapping("/add")
@Operation(summary = "发表评论")
@SensitiveCheck(
fields = {"#dto.content"},
strategy = SensitiveStrategyType.REJECT,
matchType = MatchType.EXACT,
scene = "comment_publish",
enableFallback = true,
timeout = 100
)
public Result<CommentOperationVO> addComment(
@RequestHeader("userId") Long userId,
@RequestBody CommentDTO dto) {
VideoComment comment = videoCommentService.addComment(userId, dto);
return Result.success(CommentOperationVO.of(comment));
}
6.2 私信发送场景
java
@PostMapping("/send")
@SensitiveCheck(
fields = {"#message.content"},
strategy = SensitiveStrategyType.REVIEW, // 私信使用人工审核
matchType = MatchType.FUZZY, // 使用模糊匹配
scene = "private_message",
enableFallback = false // 不允许降级
)
public Result<Void> sendMessage(
@RequestHeader("userId") Long userId,
@RequestBody PrivateMessage message) {
privateMessageService.send(userId, message);
return Result.success();
}
6.3 剧情评论场景
java
@PostMapping("/add")
@Operation(summary = "发表剧情评论")
@SensitiveCheck(
fields = {"#dto.content"},
strategy = SensitiveStrategyType.REJECT,
scene = "story_comment_publish"
)
public Result<StoryComment> addComment(
@RequestParam Long userId,
@RequestBody CommentDTO dto) {
StoryComment comment = storyCommentService.addComment(userId, dto);
return Result.success(comment);
}
七、敏感词管理接口
7.1 管理接口设计
java
@RestController
@RequestMapping("/api/admin/sensitive")
@Tag(name = "敏感词管理", description = "敏感词库管理接口(需管理员权限)")
public class SensitiveWordController {
@Resource
private SensitiveWordService sensitiveWordService;
/**
* 添加敏感词
*/
@PostMapping("/word/add")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public Result<Void> addWord(@RequestBody SensitiveWordDTO dto) {
sensitiveWordService.addWord(dto);
return Result.success();
}
/**
* 批量导入敏感词
*/
@PostMapping("/word/batch/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public Result<ImportResult> batchImport(@RequestBody List<SensitiveWordDTO> words) {
ImportResult result = sensitiveWordService.batchImport(words);
return Result.success(result);
}
/**
* 删除敏感词
*/
@DeleteMapping("/word/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public Result<Void> deleteWord(@PathVariable Long id) {
sensitiveWordService.deleteWord(id);
return Result.success();
}
/**
* 更新敏感词
*/
@PutMapping("/word/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public Result<Void> updateWord(@PathVariable Long id, @RequestBody SensitiveWordDTO dto) {
sensitiveWordService.updateWord(id, dto);
return Result.success();
}
/**
* 刷新缓存(热更新)
*/
@PostMapping("/cache/refresh")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public Result<Void> refreshCache() {
sensitiveWordService.refreshCache();
return Result.success();
}
/**
* 分页查询敏感词
*/
@GetMapping("/word/page")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public Result<IPage<SensitiveWord>> getWordPage(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword) {
IPage<SensitiveWord> page = sensitiveWordService.getWordPage(pageNum, pageSize, keyword);
return Result.success(page);
}
/**
* 测试文本检测
*/
@PostMapping("/test")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public Result<FilterResult> testCheck(@RequestBody TestRequest request) {
FilterResult result = sensitiveWordService.testCheck(request.getText());
return Result.success(result);
}
}
八、性能优化方案
8.1 性能设计考量
| 优化点 | 具体措施 | 预期效果 |
|---|---|---|
| 算法优化 | 使用 DFA + Trie 树,时间复杂度 O(n) | 单次检测 < 3ms |
| 缓存分层 | Caffeine(L1)+ Redis(L2)+ MySQL(L3) | 缓存命中率 > 99% |
| 预热机制 | 应用启动时预加载敏感词库 | 首次请求无延迟 |
| 异步日志 | 审计日志异步写入,不阻塞主流程 | RT 降低 80% |
| 降级保护 | 检测超时/异常时允许请求通过 | 可用性 99.9% |
| 批量操作 | 批量导入/更新使用事务 | 导入速度提升 10 倍 |
8.2 压测方案
8.2.1 压测场景
-
场景 A:高并发评论发布
- 目标 QPS:15,000
- 敏感词比例:10%
- 期望 RT:< 10ms(P95)
-
场景 B:敏感词命中测试
- 目标 QPS:10,000
- 敏感词比例:100%
- 期望拒绝率:100%
8.2.2 性能指标
yaml
性能基线:
QPS: ≥ 15,000
响应时间:
P50: < 5ms
P95: < 10ms
P99: < 20ms
缓存命中率: ≥ 99%
错误率: < 0.1%
可用性: ≥ 99.9%
九、监控与告警
9.1 监控指标
java
@Component
public class SensitiveMonitor {
@Resource
private MeterRegistry meterRegistry;
/**
* 记录检测成功
*/
public void recordCheckSuccess(String scene, long costMs) {
Counter.builder("sensitive.check.success")
.tag("scene", scene)
.register(meterRegistry)
.increment();
Timer.builder("sensitive.check.latency")
.tag("scene", scene)
.register(meterRegistry)
.record(costMs, TimeUnit.MILLISECONDS);
}
/**
* 记录检测拒绝
*/
public void recordCheckReject(String scene, long costMs) {
Counter.builder("sensitive.check.reject")
.tag("scene", scene)
.register(meterRegistry)
.increment();
}
/**
* 记录检测异常
*/
public void recordCheckError(String scene, long costMs) {
Counter.builder("sensitive.check.error")
.tag("scene", scene)
.register(meterRegistry)
.increment();
}
}
9.2 告警规则
| 指标 | 阈值 | 级别 | 处理方式 |
|---|---|---|---|
| 检测错误率 | > 1% | P1 | 短信 + 电话 |
| 平均响应时间 | > 50ms | P2 | 短信 |
| 缓存命中率 | < 95% | P3 | 邮件 |
| 敏感词拦截数 | > 1000/min | P3 | 邮件 |
十、安全设计
10.1 权限控制
- 敏感词管理接口 :仅限
ROLE_ADMIN角色访问 - 日志查询接口 :仅限
ROLE_AUDITOR角色访问 - 配置变更:需二次确认 + 审计日志
10.2 数据安全
- 审计日志脱敏:原始文本存储时进行加密或脱敏
- 敏感词加密:特别敏感的词库可加密存储
- 访问控制:Redis/MySQL 访问需白名单 + 密码认证
10.3 防刷机制
- 接口限流 :管理接口使用
@RateLimiter注解 - 异常检测:短时间大量敏感内容触发时封禁用户
十一、部署方案
11.1 配置文件
yaml
# application.yml
sensitive:
enabled: true # 是否启用敏感词检测
fallback-enabled: true # 是否启用降级保护
default-strategy: REJECT # 默认处理策略
cache:
caffeine:
expire-minutes: 10 # 本地缓存过期时间
maximum-size: 1000 # 本地缓存最大数量
redis:
expire-hours: 1 # Redis 缓存过期时间
performance:
timeout-ms: 100 # 检测超时时间
async-log: true # 异步写入审计日志
match:
fuzzy-enabled: false # 是否启用模糊匹配
whitelist-enabled: true # 是否启用白名单
11.2 初始化脚本
sql
-- 插入测试敏感词
INSERT INTO `a_sensitive_word` (`word`, `level`, `category`, `status`) VALUES
('测试敏感词', 1, '测试', 1),
('法轮功', 3, '政治', 1),
('色情', 2, '色情', 1);
-- 插入白名单
INSERT INTO `a_sensitive_whitelist` (`word`, `status`) VALUES
('正常词汇', 1);
十二、总结与最佳实践
12.1 核心优势
- 高性能:DFA + Trie 树算法,O(n) 时间复杂度,支持 15,000+ QPS
- 高可用:三级缓存 + 降级保护,可用性 ≥ 99.9%
- 低侵入:注解驱动,业务代码改动最小
- 易维护:热更新敏感词库,无需重启服务
- 可扩展:支持多种策略、模糊匹配、白名单等扩展能力
12.2 最佳实践
-
敏感词库管理
- 定期更新敏感词库,及时响应热点事件
- 对敏感词按等级分类,区别对待
- 建立白名单机制,避免误杀正常内容
-
性能优化
- 预热缓存,避免冷启动影响
- 使用异步日志,避免阻塞主流程
- 合理设置缓存过期时间,平衡命中率与实时性
-
监控告警
- 建立完善的监控指标体系
- 设置合理的告警阈值
- 定期review审计日志,发现异常模式
-
安全防护
- 敏感词管理接口严格权限控制
- 审计日志定期归档,防止数据泄露
- 建立防刷机制,防止恶意攻击
12.3 注意事项
- 降级策略:务必配置降级保护,避免因检测服务异常影响核心业务
- 缓存一致性:多实例部署时,通过 Redis 版本号通知其他节点刷新缓存
- 日志存储:审计日志量大,建议按月分区或使用 ES 存储
- 性能测试:上线前必须进行压测,验证性能指标
12.4 常见问题 FAQ
Q1: DFA 算法的时间复杂度是多少?
A: DFA 算法的时间复杂度为 O(n),其中 n 是文本长度。无论敏感词库有多大,检测时间只与文本长度相关。
Q2: 如何处理敏感词的变体(如拆分、谐音)?
A: 可以启用模糊匹配模式(matchType = MatchType.FUZZY),在过滤器中实现:
- 去除特殊符号后重新匹配
- 谐音转换后匹配
- 拼音匹配
Q3: 如何保证多节点缓存一致性?
A: 通过 Redis 版本号机制:
- 敏感词更新时,版本号+1
- 各节点定时检查版本号
- 发现版本不一致时,清空本地缓存
Q4: REPLACE 策略如何修改原始对象?
A: 需要通过反射修改原始对象的字段值,示例代码:
java
Field field = dto.getClass().getDeclaredField("content");
field.setAccessible(true);
field.set(dto, result.getFilteredText());
Q5: 如何防止敏感词库被恶意获取?
A:
- 管理接口严格权限控制
- 不提供批量导出功能
- 测试接口加频率限制
- 审计日志记录所有访问