This commit is contained in:
刘正航
2026-04-21 22:45:19 +08:00
commit b5237f9038
159 changed files with 7769 additions and 0 deletions

View File

@@ -0,0 +1,429 @@
from collections import Counter
from datetime import datetime, timedelta
from flask import Blueprint, current_app, request
from sqlalchemy import func, or_
from app.extensions import db
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
from app.models import ContentPost, DetectionConfig, SpamPredictionLog, SpamTrainingSample, User
from app.utils.auth import admin_required, current_user
from app.utils.response import fail, ok
admin_bp = Blueprint("admin", __name__)
def _day_key(day_value) -> str:
if hasattr(day_value, "isoformat"):
return day_value.isoformat()
return str(day_value)
def _tokenize(text: str) -> list[str]:
content = (text or "").strip()
if len(content) <= 1:
return []
tokens = []
for i in range(len(content) - 1):
token = content[i : i + 2]
if token.strip():
tokens.append(token)
return tokens
def _get_or_create_config() -> DetectionConfig:
cfg = DetectionConfig.query.order_by(DetectionConfig.id.asc()).first()
if cfg:
return cfg
cfg = DetectionConfig(spam_threshold=0.75)
db.session.add(cfg)
db.session.commit()
return cfg
def _serialize_post(item: ContentPost) -> dict:
row = item.to_dict()
row["username"] = item.author.username if item.author else ""
row["nickname"] = item.author.nickname if item.author else ""
row["recipient_username"] = item.recipient.username if item.recipient else ""
row["recipient_nickname"] = item.recipient.nickname if item.recipient else ""
row["reviewer_username"] = item.reviewer.username if item.reviewer else ""
return row
def _upsert_manual_sample(text: str, label: str, admin_id: int | None) -> None:
existed = SpamTrainingSample.query.filter_by(text=text, label=label).first()
if existed:
existed.is_active = True
existed.source = existed.source or "manual_review"
return
row = SpamTrainingSample(
text=text,
label=label,
source="manual_review",
created_by=admin_id,
is_active=True,
)
db.session.add(row)
@admin_bp.get("/stats")
@admin_required
def stats():
user_count = User.query.count()
sample_count = SpamTrainingSample.query.count()
predict_count = SpamPredictionLog.query.count()
post_count = ContentPost.query.count()
blocked_count = ContentPost.query.filter_by(status="blocked").count()
published_count = ContentPost.query.filter_by(status="published").count()
pending_appeal_count = ContentPost.query.filter_by(appeal_status="pending").count()
now = datetime.utcnow()
week_ago = now - timedelta(days=6)
trend_rows = (
db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
.filter(ContentPost.created_at >= week_ago)
.group_by(func.date(ContentPost.created_at))
.all()
)
blocked_7d_count = ContentPost.query.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked").count() or 0
total_7d_count = ContentPost.query.filter(ContentPost.created_at >= week_ago).count() or 0
day_map = {_day_key(day): int(count or 0) for day, count in trend_rows}
trend = []
today = now.date()
for offset in range(6, -1, -1):
day = today - timedelta(days=offset)
key = day.isoformat()
trend.append({"date": key, "label": day.strftime("%m-%d"), "post_count": day_map.get(key, 0)})
source_rows = (
db.session.query(SpamTrainingSample.source, func.count(SpamTrainingSample.id))
.group_by(SpamTrainingSample.source)
.order_by(func.count(SpamTrainingSample.id).desc())
.all()
)
source_dist = [{"name": (name or "unknown"), "value": int(value or 0)} for name, value in source_rows]
blocked_logs = (
ContentPost.query.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
.order_by(ContentPost.id.desc())
.limit(1000)
.all()
)
token_counter = Counter()
for row in blocked_logs:
token_counter.update(_tokenize(row.text))
top_keywords = [{"token": token, "count": count} for token, count in token_counter.most_common(12)]
cfg = _get_or_create_config()
clf = NaiveBayesSpamClassifier(current_app.config["NB_MODEL_PATH"])
clf.load()
return ok(
{
"user_count": user_count,
"sample_count": sample_count,
"predict_count": predict_count,
"post_count": post_count,
"blocked_count": blocked_count,
"published_count": published_count,
"pending_appeal_count": pending_appeal_count,
"blocked_ratio_7d": round(blocked_7d_count / total_7d_count, 4) if total_7d_count else 0,
"total_7d": total_7d_count,
"trend_7d": trend,
"source_distribution": source_dist,
"top_keywords": top_keywords,
"model_info": clf.model_info(),
"threshold": cfg.to_dict(),
}
)
@admin_bp.get("/detection/threshold")
@admin_required
def get_threshold():
return ok(_get_or_create_config().to_dict())
@admin_bp.put("/detection/threshold")
@admin_required
def set_threshold():
payload = request.get_json(silent=True) or {}
try:
threshold = float(payload.get("spam_threshold"))
except Exception:
return fail("spam_threshold 必须是数字", 400)
if threshold < 0.01 or threshold > 0.99:
return fail("spam_threshold 必须在 0.01 到 0.99 之间", 400)
cfg = _get_or_create_config()
admin = current_user()
cfg.spam_threshold = threshold
cfg.updated_by = admin.id if admin else None
db.session.commit()
return ok(cfg.to_dict(), "阈值更新成功")
@admin_bp.get("/intercepts")
@admin_required
def list_intercepts():
keyword = (request.args.get("keyword") or "").strip()
status = (request.args.get("status") or "blocked").strip().lower()
review_status = (request.args.get("review_status") or "").strip().lower()
page = max(int(request.args.get("page", 1) or 1), 1)
page_size = min(max(int(request.args.get("page_size", 20) or 20), 1), 100)
query = ContentPost.query
if keyword:
query = query.filter(ContentPost.text.like(f"%{keyword}%"))
if status in {"blocked", "published"}:
query = query.filter(ContentPost.status == status)
if review_status in {"none", "pending", "confirmed_spam", "approved_ham"}:
query = query.filter(ContentPost.manual_review_status == review_status)
pagination = query.order_by(ContentPost.id.desc()).paginate(page=page, per_page=page_size, error_out=False)
return ok(
{
"items": [_serialize_post(item) for item in pagination.items],
"total": pagination.total,
"page": page,
"page_size": page_size,
}
)
@admin_bp.put("/intercepts/<int:post_id>/review")
@admin_required
def review_intercept(post_id: int):
row = ContentPost.query.get(post_id)
if not row:
return fail("记录不存在", 404)
payload = request.get_json(silent=True) or {}
decision = (payload.get("decision") or "").strip().lower()
note = (payload.get("note") or "").strip()
if decision not in {"spam", "ham"}:
return fail("decision 必须是 spam 或 ham", 400)
admin = current_user()
now = datetime.utcnow()
row.manual_review_by = admin.id if admin else None
row.manual_review_note = note
row.manual_review_at = now
if decision == "spam":
row.status = "blocked"
row.prediction = "spam"
row.manual_review_status = "confirmed_spam"
if row.appeal_status == "pending":
row.appeal_status = "rejected"
row.appeal_admin_note = note or "人工复核确认为垃圾信息"
row.appeal_processed_at = now
row.appeal_processed_by = admin.id if admin else None
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
else:
row.status = "published"
row.prediction = "ham"
row.manual_review_status = "approved_ham"
if row.appeal_status == "pending":
row.appeal_status = "approved"
row.appeal_admin_note = note or "人工复核后解除拦截"
row.appeal_processed_at = now
row.appeal_processed_by = admin.id if admin else None
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
db.session.commit()
return ok(_serialize_post(row), "人工复核完成")
@admin_bp.get("/appeals")
@admin_required
def list_appeals():
keyword = (request.args.get("keyword") or "").strip()
status = (request.args.get("status") or "pending").strip().lower()
page = max(int(request.args.get("page", 1) or 1), 1)
page_size = min(max(int(request.args.get("page_size", 20) or 20), 1), 100)
query = ContentPost.query.filter(ContentPost.appeal_status != "none")
if keyword:
query = query.filter(
or_(
ContentPost.text.like(f"%{keyword}%"),
ContentPost.appeal_reason.like(f"%{keyword}%"),
ContentPost.appeal_admin_note.like(f"%{keyword}%"),
)
)
if status in {"pending", "approved", "rejected"}:
query = query.filter(ContentPost.appeal_status == status)
pagination = query.order_by(ContentPost.id.desc()).paginate(page=page, per_page=page_size, error_out=False)
return ok(
{
"items": [_serialize_post(item) for item in pagination.items],
"total": pagination.total,
"page": page,
"page_size": page_size,
}
)
@admin_bp.put("/appeals/<int:post_id>/process")
@admin_required
def process_appeal(post_id: int):
row = ContentPost.query.get(post_id)
if not row:
return fail("记录不存在", 404)
if row.appeal_status != "pending":
return fail("该申诉不在待处理状态", 400)
payload = request.get_json(silent=True) or {}
action = (payload.get("action") or "").strip().lower()
note = (payload.get("note") or "").strip()
if action not in {"approve", "reject"}:
return fail("action 必须是 approve 或 reject", 400)
admin = current_user()
now = datetime.utcnow()
row.appeal_status = "approved" if action == "approve" else "rejected"
row.appeal_admin_note = note
row.appeal_processed_at = now
row.appeal_processed_by = admin.id if admin else None
row.manual_review_by = admin.id if admin else None
row.manual_review_note = note
row.manual_review_at = now
if action == "approve":
row.status = "published"
row.prediction = "ham"
row.manual_review_status = "approved_ham"
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
else:
row.status = "blocked"
row.prediction = "spam"
row.manual_review_status = "confirmed_spam"
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
db.session.commit()
return ok(_serialize_post(row), "申诉处理完成")
@admin_bp.get("/users")
@admin_required
def list_users():
keyword = (request.args.get("keyword") or "").strip()
page = max(int(request.args.get("page", 1) or 1), 1)
page_size = min(max(int(request.args.get("page_size", 20) or 20), 1), 100)
query = User.query
if keyword:
query = query.filter(User.username.like(f"%{keyword}%") | User.nickname.like(f"%{keyword}%"))
pagination = query.order_by(User.id.desc()).paginate(page=page, per_page=page_size, error_out=False)
return ok(
{
"items": [item.to_dict() for item in pagination.items],
"total": pagination.total,
"page": page,
"page_size": page_size,
}
)
@admin_bp.post("/users/import")
@admin_required
def import_users():
payload = request.get_json(silent=True) or {}
items = payload.get("items") or []
if not isinstance(items, list) or not items:
return fail("items 必须是非空数组", 400)
created = 0
updated = 0
for row in items:
username = (row.get("username") or "").strip()
if len(username) < 3:
continue
user = User.query.filter_by(username=username).first()
if not user:
user = User(
username=username,
nickname=(row.get("nickname") or username).strip(),
company=(row.get("company") or "").strip(),
title=(row.get("title") or "").strip(),
phone=(row.get("phone") or "").strip(),
is_admin=bool(row.get("is_admin", False)),
)
user.set_password(row.get("password") or "123456")
db.session.add(user)
created += 1
continue
user.nickname = (row.get("nickname") or user.nickname).strip()
user.company = (row.get("company") or user.company).strip()
user.title = (row.get("title") or user.title).strip()
user.phone = (row.get("phone") or user.phone).strip()
if "is_admin" in row:
user.is_admin = bool(row.get("is_admin"))
if row.get("password"):
user.set_password(row["password"])
updated += 1
db.session.commit()
return ok({"created": created, "updated": updated}, "用户导入完成")
@admin_bp.put("/users/<int:user_id>")
@admin_required
def update_user(user_id: int):
user = User.query.get(user_id)
if not user:
return fail("用户不存在", 404)
payload = request.get_json(silent=True) or {}
if "nickname" in payload:
user.nickname = (payload.get("nickname") or user.nickname).strip()
if "company" in payload:
user.company = (payload.get("company") or "").strip()
if "title" in payload:
user.title = (payload.get("title") or "").strip()
if "phone" in payload:
user.phone = (payload.get("phone") or "").strip()
if "is_admin" in payload:
user.is_admin = bool(payload.get("is_admin"))
if payload.get("password"):
if len(payload["password"]) < 6:
return fail("密码至少6位", 400)
user.set_password(payload["password"])
db.session.commit()
return ok(user.to_dict(), "用户更新成功")
@admin_bp.delete("/users/<int:user_id>")
@admin_required
def delete_user(user_id: int):
user = User.query.get(user_id)
if not user:
return fail("用户不存在", 404)
if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
return fail("至少保留一个管理员账号", 400)
db.session.delete(user)
db.session.commit()
return ok({}, "用户已删除")