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,221 @@
// ContentFilterService 单元测试
//
// 覆盖:精确匹配、谐音变体匹配、文本预处理、各分类检测、边界条件
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/data/services/content_filter_service.dart';
import 'package:nuanji_app/data/services/sensitive_words.dart';
void main() {
// ============================================================
// 精确匹配 — 各分类
// ============================================================
group('精确匹配', () {
test('暴力类词汇检测', () {
final matches = ContentFilterService.checkText('我要打死你');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.violence), isTrue);
expect(matches.any((m) => m.word == '打死你'), isTrue);
});
test('色情类词汇检测', () {
final matches = ContentFilterService.checkText('这个视频很色情');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.sexual), isTrue);
});
test('欺凌类词汇检测', () {
final matches = ContentFilterService.checkText('你是个废物');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.bullying), isTrue);
expect(matches.any((m) => m.word == '废物'), isTrue);
});
test('毒品类词汇检测', () {
final matches = ContentFilterService.checkText('他在吸毒');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.drugs), isTrue);
});
test('赌博类词汇检测', () {
final matches = ContentFilterService.checkText('我们去赌钱吧');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.gambling), isTrue);
});
test('粗口类词汇检测', () {
final matches = ContentFilterService.checkText('卧槽太厉害了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.profanity), isTrue);
});
test('诈骗类词汇检测', () {
final matches = ContentFilterService.checkText('恭喜中奖了,点击链接领奖');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.fraud), isTrue);
});
test('政治敏感类词汇检测', () {
final matches = ContentFilterService.checkText('要造反了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.politics), isTrue);
});
});
// ============================================================
// 安全内容
// ============================================================
group('安全内容', () {
test('正常日记文本不触发', () {
final text = '今天天气很好,我和小明一起去公园玩,非常开心。'
'我们玩了滑梯、秋千,还吃了冰淇淋。';
final matches = ContentFilterService.checkText(text);
expect(matches, isEmpty);
});
test('学习相关文本不触发', () {
final text = '今天数学课学了乘法,我觉得很有趣。'
'老师表扬了我,说我进步很大。';
final matches = ContentFilterService.checkText(text);
expect(matches, isEmpty);
});
});
// ============================================================
// 文本预处理 — 绕过手法
// ============================================================
group('文本预处理', () {
test('空格分隔不影响检测', () {
final matches = ContentFilterService.checkText('我 要 打 死 你');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '打死你'), isTrue);
});
test('特殊符号插入不影响检测', () {
final matches = ContentFilterService.checkText('废.物.垃.圾');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '废物'), isTrue);
});
test('零宽字符不影响检测', () {
// U+200B 零宽空格
final matches = ContentFilterService.checkText('废​物​');
expect(matches, isNotEmpty);
});
test('下划线连字符不影响检测', () {
final matches = ContentFilterService.checkText('废_物');
expect(matches, isNotEmpty);
});
});
// ============================================================
// 谐音/变体匹配
// ============================================================
group('谐音变体匹配', () {
test('数字谐音 "4" 匹配含 "死" 的词', () {
// "去死" 在词库中 → "去4" 应触发匹配
final matches = ContentFilterService.checkText('你怎么不去4');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '去死'), isTrue);
});
test('形近字 "草" 匹配含 "操" 的词', () {
// "操你" 在词库中 → "草你" 应触发匹配
final matches = ContentFilterService.checkText('我草你太牛了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '操你'), isTrue);
});
test('变体 "wc" 匹配 "卧槽"', () {
final matches = ContentFilterService.checkText('wc这个好厉害');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '卧槽'), isTrue);
});
test('变体 "莎" 匹配含 "杀" 的词', () {
// "杀人" 在词库中 → "莎人" 应触发匹配
final matches = ContentFilterService.checkText('我要莎人了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '杀人'), isTrue);
});
});
// ============================================================
// 边界条件
// ============================================================
group('边界条件', () {
test('空字符串返回空列表', () {
expect(ContentFilterService.checkText(''), isEmpty);
});
test('纯空格返回空列表', () {
expect(ContentFilterService.checkText(' '), isEmpty);
});
test('纯符号返回空列表', () {
expect(ContentFilterService.checkText('!@#\$%^&*'), isEmpty);
});
test('超长文本不崩溃', () {
final longText = '今天天气很好。' * 10000; // ~80,000 字符
final matches = ContentFilterService.checkText(longText);
expect(matches, isEmpty); // 正常内容
});
test('多次出现同一词精确匹配只报告一次', () {
final matches = ContentFilterService.checkText('白痴白痴');
final exactMatches = matches.where((m) => m.word == '白痴' && m.position >= 0).toList();
expect(exactMatches.length, 1);
});
});
// ============================================================
// 辅助方法
// ============================================================
group('辅助方法', () {
test('hasSensitiveContent 正确判断', () {
expect(ContentFilterService.hasSensitiveContent('你好世界'), isFalse);
expect(ContentFilterService.hasSensitiveContent('你是废物'), isTrue);
});
test('getMatchedCategories 返回分类标签', () {
final matches = ContentFilterService.checkText('废物你去死');
final categories = ContentFilterService.getMatchedCategories(matches);
expect(categories, isNotEmpty);
// 至少包含欺凌和暴力
expect(categories.any((c) => c == '欺凌'), isTrue);
expect(categories.any((c) => c == '暴力'), isTrue);
});
});
// ============================================================
// 词库完整性
// ============================================================
group('词库完整性', () {
test('8 个分类都有词', () {
expect(kSensitiveWords.length, 8);
for (final entry in kSensitiveWords.entries) {
expect(entry.value, isNotEmpty, reason: '${entry.key.label} 分类不应为空');
}
});
test('总词量 >= 100', () {
final total = kSensitiveWords.values.fold(0, (sum, list) => sum + list.length);
expect(total, greaterThanOrEqualTo(100));
});
test('谐音变体映射的 key 都在词库中', () {
final allWords = kSensitiveWords.values.expand((w) => w).toSet();
for (final key in kHomophoneVariants.keys) {
// 变体 key 应该在词库中存在(单字映射除外)
// 有些变体 key 是单字如 "死",对应词库中的 "去死" 等
expect(
allWords.any((w) => w.contains(key)),
isTrue,
reason: '变体 key "$key" 不在词库中',
);
}
});
});
}