feat: add spam rule tester page

This commit is contained in:
ngfchl
2026-07-07 15:32:36 +08:00
parent f157b47678
commit 886cd0d301
9 changed files with 464 additions and 5 deletions
+1
View File
@@ -15,6 +15,7 @@ services:
KNOWLEDGE_SEARCH_MODE: lite
volumes:
- ./logs:/app/logs
- ./rules:/app/rules
- ./knowledge/index:/app/knowledge/index
- ./knowledge/models:/app/knowledge/models
- ./knowledge/qa_cache.json:/app/knowledge/qa_cache.json
+41 -3
View File
@@ -19,9 +19,15 @@ const shopItems = ref<ShopItem[]>([]);
const shopTransactions = ref<Record<string, any>[]>([]);
const lotteries = ref<LotteryItem[]>([]);
const lotteryParticipants = ref<Record<string, any>[]>([]);
const spamTestText = ref('');
const spamUseAi = ref(false);
const spamTesting = ref(false);
const spamTestResult = ref<Record<string, any> | null>(null);
const ruleForm = reactive({ type: 'keyword', pattern: '', score: 75 });
const ruleSaving = ref(false);
const shopForm = reactive({ item_key: '', name: '', cost: 0, desc: '', enabled: true });
const lotteryForm = reactive({ id: 0, title: '', prize: '', description: '', condition_type: 'all', condition_value: 0, draw_type: 'people', end_type: 'people', end_value: 3, max_participants: 3, draw_at: '', status: 'active' });
const activePage = ref<'dashboard' | 'shop' | 'lottery' | 'bans'>('dashboard');
const activePage = ref<'dashboard' | 'shop' | 'lottery' | 'bans' | 'rules'>('dashboard');
const activeTab = reactive({ shop: 'items', lottery: 'events' });
const toast = ref('');
const confirmState = reactive({ open: false, title: '', message: '', confirmText: '确认', danger: false, resolver: null as null | ((value: boolean) => void) });
@@ -95,10 +101,10 @@ function toDatetimeLocal(date: Date) {
}
const pageTitle = computed(() => ({
dashboard: '群组安全总览', shop: '商城管理', lottery: '抽奖管理', bans: '封禁记录'
dashboard: '群组安全总览', shop: '商城管理', lottery: '抽奖管理', bans: '封禁记录', rules: '规则测试'
}[activePage.value]));
const pageDesc = computed(() => ({
dashboard: '实时审计聊天、封禁、日志和运营活动', shop: '商品 CRUD 与兑换交易记录', lottery: '抽奖项目、开奖条件与参与记录', bans: '封禁审计与手动解封'
dashboard: '实时审计聊天、封禁、日志和运营活动', shop: '商品 CRUD 与兑换交易记录', lottery: '抽奖项目、开奖条件与参与记录', bans: '封禁审计与手动解封', rules: '检测广告/灰产文本并维护规则库'
}[activePage.value]));
function goPage(page: typeof activePage.value) {
activePage.value = page;
@@ -315,6 +321,37 @@ async function drawLottery(item: LotteryItem) {
catch (e: any) { showToast(`开奖失败:${e.message}`); }
finally { item._loading = false; }
}
async function testSpamText() {
if (!spamTestText.value.trim()) return showToast('请输入要检测的文本');
if (spamTesting.value) return;
spamTesting.value = true;
try {
spamTestResult.value = await fetchJson('/api/spam-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: spamTestText.value, use_ai: spamUseAi.value })
});
} catch (e: any) { showToast(`检测失败:${e.message}`); }
finally { spamTesting.value = false; }
}
async function saveSpamRule() {
if (!ruleForm.pattern.trim()) return showToast('请输入规则内容');
if (ruleSaving.value) return;
ruleSaving.value = true;
try {
const result = await fetchJson('/api/spam-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ruleForm)
});
showToast(`规则已写入,关键词 ${result.rule_counts?.keywords || '-'} / 正则 ${result.rule_counts?.regex || '-'}`);
ruleForm.pattern = '';
await testSpamText();
} catch (e: any) { showToast(`写入规则失败:${e.message}`); }
finally { ruleSaving.value = false; }
}
function reloadByGroup() { loadChatHistory(); loadBans(); loadStats(); loadShopItems(); loadShopTransactions(); loadLotteries(); }
function connectSSE() {
@@ -485,6 +522,7 @@ onBeforeUnmount(() => {
<button :class="{active: activePage === 'shop'}" @click="goPage('shop')"><span>商城管理</span><em>Shop</em></button>
<button :class="{active: activePage === 'lottery'}" @click="goPage('lottery')"><span>抽奖管理</span><em>Lottery</em></button>
<button :class="{active: activePage === 'bans'}" @click="goPage('bans')"><span>封禁记录</span><em>Bans</em></button>
<button :class="{active: activePage === 'rules'}" @click="goPage('rules')"><span>规则测试</span><em>Rules</em></button>
</nav>
<div class="side-footer">
<span class="conn" :class="sseConnected ? 'on' : 'off'">{{ sseConnected ? '实时连接' : '连接中' }}</span>
+19
View File
@@ -300,3 +300,22 @@ body { background: #f3f6fb; }
.main-grid { height: auto; max-height: none; padding-bottom: 82px; }
.main-grid .panel { height: 38vh; min-height: 280px; }
}
/* Spam rule test page */
.rule-test-page { display: grid; gap: 12px; }
.rule-test-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr); gap: 12px; }
.rule-editor-block { display: grid; gap: 12px; }
.rule-textarea { min-height: 190px; resize: vertical; line-height: 1.55; font-family: inherit; }
.rule-result-card { min-height: 230px; border: 1px solid #e2e8f0; border-radius: 16px; background: #f8fafc; padding: 14px; display: grid; gap: 12px; align-content: start; }
.rule-result-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.rule-reason { display: grid; gap: 6px; padding: 10px; border-radius: 12px; background: #fff; border: 1px solid #e5e7eb; }
.rule-reason b { color: #334155; font-size: 12px; }
.rule-reason span { color: #0f172a; line-height: 1.55; word-break: break-word; }
.rule-counts { color: #64748b; font-size: 12px; }
.rule-write-card .s-card__content { display: grid; gap: 10px; }
.rule-write-grid { display: grid; grid-template-columns: 140px 120px minmax(0, 1fr) auto; align-items: end; gap: 10px; }
.rule-pattern-field { min-width: 0; }
@media (max-width: 980px) {
.rule-test-grid, .rule-write-grid { grid-template-columns: 1fr; }
}
+3
View File
@@ -174,3 +174,6 @@ da案|55
资金短缺|45
质押贷款|65
小额贷款|65
# Web 手动添加规则 2026-07-07 15:27:59
跑分车队|85
+9
View File
@@ -84,6 +84,15 @@ EXTERNAL_REGEX_RULES = load_regex_rules(RULES_DIR / "spam_regex.txt")
logger.info(f"🧩 已加载外部垃圾规则:关键词 {len(EXTERNAL_KEYWORD_RULES)} 条,正则 {len(EXTERNAL_REGEX_RULES)}")
def reload_external_rules() -> dict:
"""重新加载外部关键词/正则规则,用于 Web 写规则后热更新。"""
global EXTERNAL_KEYWORD_RULES, EXTERNAL_REGEX_RULES
EXTERNAL_KEYWORD_RULES = load_keyword_rules(RULES_DIR / "spam_keywords.txt")
EXTERNAL_REGEX_RULES = load_regex_rules(RULES_DIR / "spam_regex.txt")
logger.info(f"🧩 已热加载外部垃圾规则:关键词 {len(EXTERNAL_KEYWORD_RULES)} 条,正则 {len(EXTERNAL_REGEX_RULES)}")
return {"keywords": len(EXTERNAL_KEYWORD_RULES), "regex": len(EXTERNAL_REGEX_RULES)}
# 高危黑产:单项命中即可高度可疑或直接垃圾
HARD_RULES = [
Rule("博彩赌博", r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注|百家乐|时时彩|六合彩", 100),
+60
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
import hmac
import hashlib
import ipaddress
@@ -22,6 +23,7 @@ from config import WEB_AUTH_ENABLED, WEB_AUTH_PASSWORD, WEB_AUTH_USERNAME, WEB_S
from services.stats_service import get_daily_stats
from services.time_utils import format_dt, from_timestamp, now_tz
from services.game_service import list_shop_items, upsert_shop_item, delete_shop_item, list_shop_transactions, draw_lottery_event
from services import spam_detector
BASE_DIR = Path(__file__).parent
@@ -504,6 +506,64 @@ async def api_restart_bot():
return JSONResponse({"ok": True, "message": "TG Bot 正在重启"})
@app.post("/api/spam-test")
async def api_spam_test(request: Request):
data = await request.json()
text = str(data.get("text") or "").strip()
use_ai = bool(data.get("use_ai"))
if not text:
return JSONResponse({"ok": False, "error": "请输入要检测的文本"}, status_code=400)
decision, reason, score = spam_detector.local_classify(text)
result = {
"ok": True,
"text": text,
"local_decision": decision,
"local_is_spam": bool(decision) if decision is not None else None,
"reason": reason,
"score": score,
"rule_counts": {"keywords": len(spam_detector.EXTERNAL_KEYWORD_RULES), "regex": len(spam_detector.EXTERNAL_REGEX_RULES)},
}
if use_ai:
ai_is_spam, ai_reason = await spam_detector.detect(text)
result.update({"ai_is_spam": ai_is_spam, "ai_reason": ai_reason, "final_is_spam": ai_is_spam})
else:
result["final_is_spam"] = bool(decision) if decision is not None else None
return JSONResponse(result)
@app.post("/api/spam-rules")
async def api_add_spam_rule(request: Request):
data = await request.json()
rule_type = str(data.get("type") or "keyword").strip().lower()
pattern = str(data.get("pattern") or "").strip()
try:
score = int(data.get("score") or 60)
except (TypeError, ValueError):
score = 60
if rule_type not in {"keyword", "regex"}:
return JSONResponse({"ok": False, "error": "规则类型只能是 keyword 或 regex"}, status_code=400)
if not pattern or len(pattern) > 500:
return JSONResponse({"ok": False, "error": "规则内容不能为空,且不能超过 500 字"}, status_code=400)
score = max(1, min(score, 200))
if rule_type == "regex":
try:
re.compile(pattern)
except re.error as e:
return JSONResponse({"ok": False, "error": f"正则无效:{e}"}, status_code=400)
path = spam_detector.RULES_DIR / ("spam_keywords.txt" if rule_type == "keyword" else "spam_regex.txt")
line = f"{pattern}|{score}"
existing = path.read_text(encoding="utf-8") if path.exists() else ""
existing_patterns = {raw.rsplit("|", 1)[0].strip() for raw in existing.splitlines() if raw.strip() and not raw.strip().startswith("#")}
if pattern in existing_patterns:
return JSONResponse({"ok": False, "error": "规则已存在"}, status_code=409)
with path.open("a", encoding="utf-8") as f:
if existing and not existing.endswith("\n"):
f.write("\n")
f.write(f"\n# Web 手动添加规则 {format_dt(now_tz())}\n{line}\n")
counts = spam_detector.reload_external_rules()
return JSONResponse({"ok": True, "type": rule_type, "pattern": pattern, "score": score, "rule_counts": counts})
@app.post("/api/restart")
async def api_restart():
"""重启服务:返回响应后退出当前进程,由 Docker restart 策略拉起。"""
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
<title>TG Spam Guard</title>
<script type="module" crossorigin src="/static/assets/index-DpTM7qAZ.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-uKj_bMAC.css">
<script type="module" crossorigin src="/static/assets/index-C6kPOPdz.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-DfsWb80Q.css">
</head>
<body>
<div id="app"></div>