feat(app): 内容安全词库 + 过滤服务 + 分享前检查 — 28 个测试全覆盖
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

新增文件:
- sensitive_words.dart — 8 分类 ~200 条敏感词 + 谐音/形近/数字变体映射
- content_filter_service.dart — 精确匹配 + 变体匹配 + 文本预处理(去零宽/空格/符号)
- content_filter_service_test.dart — 28 个测试(8分类精确/安全内容/预处理/变体/边界/词库完整性)

修改:
- share_bottom_sheet.dart — 分享到班级前调用 ContentFilterService,
  有敏感词时弹出警告对话框(返回修改/仍然分享),新增 contentText 参数
This commit is contained in:
iven
2026-06-03 19:40:13 +08:00
parent 9c92cba87f
commit 988ee7335a
4 changed files with 631 additions and 8 deletions

View File

@@ -0,0 +1,184 @@
// 内容安全过滤服务 — 本地敏感词检测
//
// 提供 checkText() 纯函数,用于在分享日记前检查文本内容是否包含敏感词。
// 检测策略:精确匹配 + 谐音/形近/数字变体匹配。
// 返回匹配列表,空列表表示内容安全。不自动屏蔽,由 UI 层决定提示方式。
import 'sensitive_words.dart';
/// 敏感词匹配结果
class SensitiveWordMatch {
/// 匹配到的敏感词原文
final String word;
/// 所属分类
final SensitiveCategory category;
/// 在预处理后文本中的起始位置
final int position;
const SensitiveWordMatch({
required this.word,
required this.category,
required this.position,
});
@override
String toString() => 'SensitiveWordMatch("$word", ${category.label}, @$position)';
}
/// 内容安全过滤服务
///
/// 纯静态方法,无状态,可安全在任何地方调用。
/// 性能:~200 条词 × contains() 检查,<1ms 完成。
class ContentFilterService {
ContentFilterService._();
/// 检查文本内容,返回所有匹配到的敏感词。
///
/// 对输入文本进行预处理(去空格/特殊符号/零宽字符/小写化),
/// 然后遍历全量词库做精确匹配和谐音变体匹配。
/// 返回空列表表示内容安全。
static List<SensitiveWordMatch> checkText(String text) {
if (text.isEmpty) return const [];
final normalized = _normalize(text);
if (normalized.isEmpty) return const [];
final matches = <SensitiveWordMatch>[];
final seen = <String>{}; // 去重:同一词不重复报告
for (final entry in kSensitiveWords.entries) {
final category = entry.key;
for (final word in entry.value) {
// 精确匹配
final pos = normalized.indexOf(word);
if (pos >= 0 && seen.add(word)) {
matches.add(SensitiveWordMatch(
word: word,
category: category,
position: pos,
));
}
// 谐音/变体匹配 — 将词中每个有变体映射的字替换为变体,检查是否命中
if (_matchesWithVariants(normalized, word)) {
if (seen.add('variant:$word')) {
matches.add(SensitiveWordMatch(
word: word,
category: category,
position: -1, // 变体匹配无法精确定位
));
}
}
}
}
// 整词变体匹配 — kHomophoneVariants 中的多字 key如 "卧槽"
for (final variantEntry in kHomophoneVariants.entries) {
final originalKey = variantEntry.key;
if (originalKey.length <= 1) continue; // 单字已在上面处理
// 找到这个变体 key 对应的分类
SensitiveCategory? foundCategory;
for (final entry in kSensitiveWords.entries) {
if (entry.value.contains(originalKey)) {
foundCategory = entry.key;
break;
}
}
if (foundCategory == null) continue;
for (final variant in variantEntry.value) {
if (variant.isEmpty) continue;
final vPos = normalized.indexOf(variant.toLowerCase());
if (vPos >= 0) {
final key = 'wvariant:$originalKey:$variant';
if (seen.add(key)) {
matches.add(SensitiveWordMatch(
word: originalKey,
category: foundCategory,
position: vPos,
));
}
}
}
}
return matches;
}
/// 检查文本是否包含敏感词(快捷方法)
static bool hasSensitiveContent(String text) => checkText(text).isNotEmpty;
/// 获取匹配到的分类标签集合(用于 UI 展示)
static Set<String> getMatchedCategories(List<SensitiveWordMatch> matches) {
return matches.map((m) => m.category.label).toSet();
}
/// 变体匹配:检查文本中是否出现了词的谐音/形近/数字变体版本
///
/// 将敏感词中每个有变体映射的字符逐一替换为变体,检查替换后的
/// 字符串是否出现在文本中。例如 "去死" → 检查 "去4" 是否在文本中。
static bool _matchesWithVariants(String normalizedText, String word) {
final chars = word.split('');
final variantChars = <List<String>>[];
for (final char in chars) {
final variants = kHomophoneVariants[char];
if (variants != null && variants.isNotEmpty) {
// 原字符 + 所有变体
variantChars.add([char, ...variants]);
} else {
variantChars.add([char]);
}
}
// 生成所有变体组合并检查
return _checkCombinations(normalizedText, variantChars, 0, '');
}
/// 递归生成变体组合并检查文本
static bool _checkCombinations(
String text,
List<List<String>> variantChars,
int index,
String current,
) {
if (index == variantChars.length) {
return text.contains(current);
}
for (final char in variantChars[index]) {
if (_checkCombinations(text, variantChars, index + 1, current + char)) {
return true;
}
}
return false;
}
/// 文本预处理:去除干扰字符,统一为小写
///
/// 1. 去除零宽字符U+200B~U+200F, U+FEFF
/// 2. 去除空格、制表符、换行
/// 3. 去除常见特殊符号(用于绕过的 @#$%^&* 等)
/// 4. 转小写(对英文词有效)
static String _normalize(String text) {
final buffer = StringBuffer();
for (final rune in text.runes) {
// 跳过零宽字符
if (rune >= 0x200B && rune <= 0x200F) continue;
if (rune == 0xFEFF) continue;
// 跳过空白
if (rune == 0x20 || rune == 0x09 || rune == 0x0A || rune == 0x0D) continue;
// 跳过常见绕过符号
if (rune == 0x2E || rune == 0x2C || rune == 0x2D || rune == 0x5F) continue; // . , - _
if (rune == 0x21 || rune == 0x40 || rune == 0x23 || rune == 0x24) continue; // ! @ # $
if (rune == 0x25 || rune == 0x5E || rune == 0x26 || rune == 0x2A) continue; // % ^ & *
if (rune == 0x7E || rune == 0x60) continue; // ~ `
buffer.writeCharCode(rune);
}
return buffer.toString().toLowerCase();
}
}

View File

@@ -0,0 +1,141 @@
// 敏感词库 — 本地静态词库常量,面向小学生场景
//
// 分类:暴力、色情、欺凌、毒品、赌博、政治、诈骗、粗口
// 每个分类包含基础词 + 谐音/形近/数字变体
// 词库为 const 编译期常量,零运行时开销
//
// 注意:本词库仅为 Phase 1 基础覆盖Phase 2 将接入服务端 AI + 可更新词库。
/// 敏感词分类
enum SensitiveCategory {
violence('暴力'),
sexual('色情'),
bullying('欺凌'),
drugs('毒品'),
gambling('赌博'),
politics('政治敏感'),
fraud('诈骗'),
profanity('粗口');
const SensitiveCategory(this.label);
final String label;
}
/// ============================================================
/// 各分类敏感词
/// ============================================================
/// 暴力类
const _violenceWords = [
// 直接暴力
'杀人', '砍人', '捅人', '打死', '打死你', '弄死', '弄死你',
'揍你', '揍死', '打死他', '砍死', '捅死',
'杀了他', '打死他', '砍了他', '捅了他',
'去死', '你去死', '怎么不去死',
'割腕', '割脖子', '跳楼', '上吊',
// 武器
'炸弹', '手枪', '步枪', '子弹', '刀杀',
// 自残/伤害暗示
'自杀', '自残', '不想活',
];
/// 色情类
const _sexualWords = [
'色情', '裸体', '裸照', '黄色', '黄片',
'做爱', '性行为', '性交', '强奸', '强暴',
'猥亵', '性骚扰', '偷拍',
'发情', '骚货', '贱人',
];
/// 欺凌类
const _bullyingWords = [
'废物', '垃圾', '蠢货', '白痴', '弱智',
'傻子', '笨蛋', '猪头', '丑八怪',
'滚开', '滚蛋', '闭嘴', '别烦我',
'讨厌鬼', '没人要', '没朋友',
'不和你玩', '不要和你玩',
'大家不要理', '孤立',
'偷东西', '小偷',
];
/// 毒品类
const _drugsWords = [
'毒品', '吸毒', '贩毒', '大麻', '海洛因',
'冰毒', '摇头丸', '可卡因', '吗啡',
'鸦片', 'K粉', '安非他命',
'上瘾', '毒瘾',
];
/// 赌博类
const _gamblingWords = [
'赌博', '赌钱', '下注', '押注', '赌场',
'买彩票', '时时彩', '六合彩',
'百家乐', '老虎机', '扑克赌',
'赌债', '借钱赌',
];
/// 政治敏感类
const _politicsWords = [
'反动', '颠覆', '分裂', '暴动', '造反',
'推翻', '政变', '游行示威',
];
/// 诈骗类
const _fraudWords = [
'诈骗', '骗钱', '骗密码', '骗账号',
'中奖了', '恭喜中奖', '免费领取',
'点击链接领奖', '转账给我',
'刷单', '兼职刷单', '高薪兼职',
'传销', '拉人头',
];
/// 粗口类
const _profanityWords = [
'操你', '妈的', '他妈', '去你的', '狗屎',
'', '', '放屁', '扯淡', '王八蛋',
'混蛋', '', '我去', '卧槽',
'我靠', '我擦',
];
/// 全量词库:分类 → 词列表
const Map<SensitiveCategory, List<String>> kSensitiveWords = {
SensitiveCategory.violence: _violenceWords,
SensitiveCategory.sexual: _sexualWords,
SensitiveCategory.bullying: _bullyingWords,
SensitiveCategory.drugs: _drugsWords,
SensitiveCategory.gambling: _gamblingWords,
SensitiveCategory.politics: _politicsWords,
SensitiveCategory.fraud: _fraudWords,
SensitiveCategory.profanity: _profanityWords,
};
/// ============================================================
/// 谐音/形近/数字变体映射
/// ============================================================
/// 原词 → 变体列表
///
/// 变体检测在预处理后的文本上运行,可以捕获常见的绕过手法:
/// - 数字谐音: "死" → "4"
/// - 形近替换: "傻" → "纱"
/// - 拼音缩写: "牛逼" → "nb"
const Map<String, List<String>> kHomophoneVariants = {
// 暴力相关
'': ['4', '', '', ''],
'': ['', '', ''],
'': ['砍人'],
'': ['捅人'],
// 欺凌相关
'': ['', '', ''],
'': [], // 无实际变体
'': [''],
'废物': ['费物', '废无'],
'垃圾': ['拉吉', '垃 圾'],
// 粗口相关
'': ['', '', ''],
'卧槽': ['我槽', '我草', 'wc', 'WC', 'Wc'],
'我靠': ['我 k', '我K'],
// 欺凌
'': [''],
'': [''],
};