feat: 申诉常见理由快捷选择 + 证据截图上传

- 后端: 新增 appeal_reason_type, appeal_evidence_urls 字段
- 后端: 新建 upload_routes.py 图片上传接口
- 前端: history 页面添加快捷理由选择器 + 截图上传
- 前端: admin-review 页面展示证据图片 + 点击预览
- 新增 SQL 更新脚本 update_appeal_fields.sql

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
刘正航
2026-04-21 23:26:25 +08:00
parent 50440e84fb
commit f7d0601c4e
12 changed files with 344 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ from app.routes.admin_routes import admin_bp
from app.routes.auth_routes import auth_bp
from app.routes.content_routes import content_bp
from app.routes.spam_routes import spam_bp
from app.routes.upload_routes import upload_bp
from app.routes.user_routes import user_bp
@@ -22,6 +23,7 @@ def create_app() -> Flask:
app.register_blueprint(spam_bp, url_prefix="/api/spam")
app.register_blueprint(content_bp, url_prefix="/api/content")
app.register_blueprint(admin_bp, url_prefix="/api/admin")
app.register_blueprint(upload_bp, url_prefix="/api/upload")
@app.get("/api/health")
def health_check():

View File

@@ -35,3 +35,4 @@ class Config:
SPAM_DATASET_PATH = str(BASE_DIR / "seed" / "spam_samples_seed.json")
NB_MODEL_PATH = str(BASE_DIR / "models" / "spam_nb_model.joblib")
UPLOAD_FOLDER = str(BASE_DIR / "uploads")

View File

@@ -141,7 +141,9 @@ class ContentPost(db.Model):
manual_review_at = db.Column(db.DateTime, nullable=True)
appeal_status = db.Column(db.String(16), nullable=False, default="none") # none | pending | approved | rejected
appeal_reason_type = db.Column(db.String(32), default="") # 快捷理由类型
appeal_reason = db.Column(db.String(255), default="")
appeal_evidence_urls = db.Column(db.JSON, default=list) # 证据图片 URL 列表
appeal_admin_note = db.Column(db.String(255), default="")
appeal_submitted_at = db.Column(db.DateTime, nullable=True)
appeal_processed_at = db.Column(db.DateTime, nullable=True)
@@ -170,7 +172,9 @@ class ContentPost(db.Model):
"manual_review_note": self.manual_review_note,
"manual_review_at": self.manual_review_at.isoformat() if self.manual_review_at else None,
"appeal_status": self.appeal_status,
"appeal_reason_type": self.appeal_reason_type,
"appeal_reason": self.appeal_reason,
"appeal_evidence_urls": self.appeal_evidence_urls or [],
"appeal_admin_note": self.appeal_admin_note,
"appeal_submitted_at": self.appeal_submitted_at.isoformat() if self.appeal_submitted_at else None,
"appeal_processed_at": self.appeal_processed_at.isoformat() if self.appeal_processed_at else None,

View File

@@ -295,14 +295,20 @@ def submit_appeal(post_id: int):
return fail("仅被拦截的信息可申诉", 400)
payload = request.get_json(silent=True) or {}
reason_type = (payload.get("reason_type") or "").strip()
reason = (payload.get("reason") or "").strip()
if len(reason) < 2:
evidence_urls = payload.get("evidence_urls") or []
# 如果选择了快捷理由reason 可为空;否则至少 2 字符
if not reason_type and len(reason) < 2:
return fail("申诉理由至少2个字符", 400)
if post.appeal_status == "pending":
return fail("该记录已在申诉处理中", 400)
post.appeal_status = "pending"
post.appeal_reason_type = reason_type
post.appeal_reason = reason
post.appeal_evidence_urls = evidence_urls
post.appeal_submitted_at = datetime.utcnow()
post.appeal_admin_note = ""
post.appeal_processed_at = None

View File

@@ -0,0 +1,68 @@
import os
import uuid
from datetime import datetime
from flask import Blueprint, current_app, request, send_from_directory
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from app.utils.auth import current_user
from app.utils.response import fail, ok
upload_bp = Blueprint("upload", __name__)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webg"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def allowed_file(filename: str) -> bool:
ext = (filename.rsplit(".", 1)[-1] if "." in filename else "").lower()
return ext in ALLOWED_EXTENSIONS
def generate_filename(original: str) -> str:
ext = original.rsplit(".", 1)[-1] if "." in original else "jpg"
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
unique = uuid.uuid4().hex[:8]
return f"{timestamp}_{unique}.{ext.lower()}"
@upload_bp.post("/image")
@jwt_required()
def upload_image():
user = current_user()
if not user:
return fail("用户不存在", 404)
if "file" not in request.files:
return fail("未上传文件", 400)
file = request.files["file"]
if not file.filename:
return fail("文件名无效", 400)
if not allowed_file(file.filename):
return fail("仅支持 png/jpg/jpeg/gif/webg 格式", 400)
upload_folder = current_app.config.get("UPLOAD_FOLDER")
if not upload_folder:
return fail("上传目录未配置", 500)
os.makedirs(upload_folder, exist_ok=True)
filename = generate_filename(secure_filename(file.filename))
filepath = os.path.join(upload_folder, filename)
file.save(filepath)
url = f"/api/upload/images/{filename}"
return ok({"url": url, "filename": filename}, "上传成功")
@upload_bp.get("/images/<filename>")
def get_image(filename: str):
upload_folder = current_app.config.get("UPLOAD_FOLDER")
if not upload_folder:
return fail("上传目录未配置", 500)
return send_from_directory(upload_folder, filename)