feat: add spam rule tester page
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -174,3 +174,6 @@ da案|55
|
||||
资金短缺|45
|
||||
质押贷款|65
|
||||
小额贷款|65
|
||||
|
||||
# Web 手动添加规则 2026-07-07 15:27:59
|
||||
跑分车队|85
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user