1
This commit is contained in:
429
backend/app/routes/admin_routes.py
Normal file
429
backend/app/routes/admin_routes.py
Normal 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({}, "用户已删除")
|
||||
|
||||
Reference in New Issue
Block a user