企业级敏感词拦截检查系统设计方案(Spring Boot)

文章目录

    • 一、设计背景与目标
      • [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 切面实现)
    • 五、数据库设计
      • [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 设计原则

  1. 性能优先:采用高效的 DFA(Deterministic Finite Automaton)算法 + Trie 树数据结构
  2. 降级保护:检测失败时不阻塞业务,允许配置降级策略
  3. 缓存分层:本地缓存(Caffeine)+ 分布式缓存(Redis)+ 数据库(MySQL)三级存储
  4. 异步处理:敏感内容标记审核场景使用 MQ 异步通知
  5. 可插拔策略:支持多种处理策略,通过配置动态切换

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 核心优势

  1. 高性能:DFA + Trie 树算法,O(n) 时间复杂度,支持 15,000+ QPS
  2. 高可用:三级缓存 + 降级保护,可用性 ≥ 99.9%
  3. 低侵入:注解驱动,业务代码改动最小
  4. 易维护:热更新敏感词库,无需重启服务
  5. 可扩展:支持多种策略、模糊匹配、白名单等扩展能力

12.2 最佳实践

  1. 敏感词库管理

    • 定期更新敏感词库,及时响应热点事件
    • 对敏感词按等级分类,区别对待
    • 建立白名单机制,避免误杀正常内容
  2. 性能优化

    • 预热缓存,避免冷启动影响
    • 使用异步日志,避免阻塞主流程
    • 合理设置缓存过期时间,平衡命中率与实时性
  3. 监控告警

    • 建立完善的监控指标体系
    • 设置合理的告警阈值
    • 定期review审计日志,发现异常模式
  4. 安全防护

    • 敏感词管理接口严格权限控制
    • 审计日志定期归档,防止数据泄露
    • 建立防刷机制,防止恶意攻击

12.3 注意事项

  1. 降级策略:务必配置降级保护,避免因检测服务异常影响核心业务
  2. 缓存一致性:多实例部署时,通过 Redis 版本号通知其他节点刷新缓存
  3. 日志存储:审计日志量大,建议按月分区或使用 ES 存储
  4. 性能测试:上线前必须进行压测,验证性能指标

12.4 常见问题 FAQ

Q1: DFA 算法的时间复杂度是多少?

A: DFA 算法的时间复杂度为 O(n),其中 n 是文本长度。无论敏感词库有多大,检测时间只与文本长度相关。

Q2: 如何处理敏感词的变体(如拆分、谐音)?

A: 可以启用模糊匹配模式(matchType = MatchType.FUZZY),在过滤器中实现:

  • 去除特殊符号后重新匹配
  • 谐音转换后匹配
  • 拼音匹配

Q3: 如何保证多节点缓存一致性?

A: 通过 Redis 版本号机制:

  1. 敏感词更新时,版本号+1
  2. 各节点定时检查版本号
  3. 发现版本不一致时,清空本地缓存

Q4: REPLACE 策略如何修改原始对象?

A: 需要通过反射修改原始对象的字段值,示例代码:

java 复制代码
Field field = dto.getClass().getDeclaredField("content");
field.setAccessible(true);
field.set(dto, result.getFilteredText());

Q5: 如何防止敏感词库被恶意获取?

A:

  • 管理接口严格权限控制
  • 不提供批量导出功能
  • 测试接口加频率限制
  • 审计日志记录所有访问

相关推荐
Honmaple2 小时前
DeepSeek-OCR + AgentScope:打造私有化智能文档处理智能体
后端
野犬寒鸦2 小时前
从零起步学习RabbitMQ || 第一章:认识消息队列及项目实战中的技术选型
java·数据库·后端
老毛肚2 小时前
Spring源码探究1.0
java·后端·spring
源代码•宸2 小时前
Golang原理剖析(程序初始化、数据结构string)
开发语言·数据结构·经验分享·后端·golang·string·init
小鸡脚来咯2 小时前
RESTful API 面试详解
后端·面试·restful
吴巴格2 小时前
springboot引用其他中间件,如何确定版本
spring boot·后端·中间件
IT_陈寒2 小时前
Vue3性能优化实战:5个被低估的API让我减少了40%的代码量
前端·人工智能·后端
vx_bisheyuange2 小时前
基于SpringBoot的旅游管理系统
前端·javascript·vue.js·spring boot·毕业设计
岁岁种桃花儿2 小时前
Spring Boot项目核心配置:parent父项目详解(附实操指南)
java·spring boot·spring