This commit is contained in:
DengDai
2025-11-24 10:10:00 +08:00
commit aa516a8d71
37 changed files with 2426 additions and 0 deletions

122
.gitignore vendored Normal file
View File

@@ -0,0 +1,122 @@
# ===================================================================
# .gitignore for a FastAPI (Python) + Vue.js (Node.js) Project
# ===================================================================
**/config.json
# --- General ---
#忽略所有 .env 文件,只提交 .env.example 模板
.env
.env.*
!.env.example
#日志文件
*.log
*.log*
#临时文件
*~
*.swp
*.swo
# ===================================================================
# Python / FastAPI (Backend)
# ===================================================================
#虚拟环境 (常见的名称venv, env, .venv, .env)
#使用斜杠 / 确保只匹配根目录下的,避免误伤子目录
/venv/
/env/
/.venv/
/.env/
# Python 缓存文件
__pycache__/
*.py[cod]
*$py.class
#运行时文件
*.pyc
#单元测试和覆盖率报告
.pytest_cache/
.coverage
.coverage.*
/htmlcov/
nosetests.xml
coverage.xml
# Sphinx 文档
/docs/_build/
# Jupyter Notebook 检查点
.ipynb_checkpoints
#数据库文件 (开发时使用的本地数据库)
*.sqlite3
db.sqlite3
# ===================================================================
# Node.js / Vue.js (Frontend)
# ===================================================================
#依赖目录 (最重要的规则!)
/node_modules/
#本地环境配置文件 (Vue CLI 和 Vite 的标准)
.env.local
.env.*.local
#构建输出目录 (Vue CLI, Vite, etc.)
/dist/
/public/build/
#日志文件
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Vite 缓存
.vite/
# ===================================================================
# IDE 和编辑器配置
# ===================================================================
# VSCode
#忽略所有 .vscode 下的文件,但保留团队共享的配置
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
!.vscode/launch.json
# JetBrains / PyCharm
.idea/
# Sublime Text
*.sublime-project
*.sublime-workspace
# Atom
.atom/
# ===================================================================
# 操作系统生成的文件
# ===================================================================
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

44
app/__init__.py Normal file
View File

@@ -0,0 +1,44 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_session import Session
from config import config
from flask_bootstrap import Bootstrap
# 初始化扩展,但此时不传入 app
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
sess = Session()
bootstrap = Bootstrap()
# login_manager 的基本配置
login_manager.login_view = 'auth.login' # 后面我们会创建一个叫 'auth' 的蓝图
def create_app(config_name='default'):
"""
应用工厂函数
:param config_name: 配置名称 ('development', 'production')
:return: Flask app instance
"""
app = Flask(__name__)
# 1. 加载配置
app.config.from_object(config[config_name])
config[config_name].init_app(app)
# 2. 初始化扩展
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
sess.init_app(app)
bootstrap.init_app(app)
# 3. 注册蓝图 (Blueprint)
# 后面我们会在这里添加蓝图
from .routes import main as main_blueprint
app.register_blueprint(main_blueprint)
from .auth import auth as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth')
return app

5
app/auth/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from flask import Blueprint
auth = Blueprint('auth', __name__)
from . import routes

53
app/auth/routes.py Normal file
View File

@@ -0,0 +1,53 @@
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from . import auth
from .. import db
from ..models import User, PartnerSite
from ..forms import LoginForm, RegistrationForm
@auth.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
active_sites = PartnerSite.query.filter_by(is_active=True).order_by(PartnerSite.name).all()
form.pt_site.choices = [(site.name, site.name) for site in active_sites]
if form.validate_on_submit():
user = User(
email=form.email.data.lower(),
username=form.username.data,
pt_site=form.pt_site.data,
uid=form.uid.data,
status='pending' # 新用户需要管理员审核
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('注册申请已提交,请等待管理员审核。', 'info')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@auth.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is None or not user.check_password(form.password.data):
flash('无效的邮箱或密码。', 'danger')
return redirect(url_for('auth.login'))
if user.status != 'active':
flash(f'您的账户当前状态为 "{user.status}",无法登录。请联系管理员。', 'warning')
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
return redirect(url_for('main.index'))
return render_template('auth/login.html', form=form)
@auth.route('/logout')
@login_required
def logout():
logout_user()
flash('您已成功登出。')
return redirect(url_for('main.index'))

27
app/decorators.py Normal file
View File

@@ -0,0 +1,27 @@
from functools import wraps
from flask import abort
from flask_login import current_user
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role != 'admin':
abort(403) # HTTP 403 Forbidden error
return f(*args, **kwargs)
return decorated_function
# === 修正后的通用权限装饰器 ===
def permission_required(*roles):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
abort(401) # Unauthorized
if current_user.role not in roles:
abort(403) # Forbidden
return f(*args, **kwargs)
# 正确的返回:返回包含了权限检查逻辑的包装函数
return decorated_function # <--- 已修正
return decorator

108
app/forms.py Normal file
View File

@@ -0,0 +1,108 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, PasswordField, BooleanField, TextAreaField, SelectField
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError, Optional, URL
from .models import User
from wtforms_sqlalchemy.fields import QuerySelectField
def get_active_partner_sites():
return PartnerSite.query.filter_by(is_active=True).order_by(PartnerSite.name)
class SearchForm(FlaskForm):
search_term = StringField(
'输入用户名或邮箱进行查询',
validators=[DataRequired(message="查询内容不能为空。"), Length(min=3, max=100)],
render_kw={"placeholder": "用户名 / 邮箱"}
)
submit = SubmitField('查询')
class RegistrationForm(FlaskForm):
username = StringField('用户名(与PT站点相同)', validators=[DataRequired(), Length(min=3, max=64)])
email = StringField('邮箱(与PT站点相同)', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired(), Length(min=6)])
password2 = PasswordField(
'确认密码', validators=[DataRequired(), EqualTo('password', message='两次输入的密码必须一致。')])
pt_site = SelectField('所属站点', validators=[DataRequired()])
uid = StringField('你在该站点的 UID', validators=[DataRequired(), Length(max=50)])
submit = SubmitField('提交注册申请')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('该用户名已被使用。')
def validate_email(self, email):
user = User.query.filter_by(email=email.data.lower()).first()
if user:
raise ValidationError('该邮箱已被注册。')
class LoginForm(FlaskForm):
email = StringField('邮箱', validators=[DataRequired(), Length(1, 120), Email()])
password = PasswordField('密码', validators=[DataRequired()])
remember_me = BooleanField('记住我')
submit = SubmitField('登录')
class ReportForm(FlaskForm):
# reported_pt_site = StringField(
# '被举报用户所在的 PT 站点',
# validators=[DataRequired(), Length(min=2, max=100)],
# render_kw={"placeholder": "例如some.site.com"}
# )
reported_pt_site = SelectField('违规站点', validators=[DataRequired()])
reported_uid = StringField(
'被举报的用户名',
validators=[Optional(), Length(max=50)],
render_kw={"placeholder": "可选"}
)
reported_email = StringField(
'被举报的邮箱',
validators=[DataRequired(), Email(message="请输入有效的邮箱地址。")],
render_kw={"placeholder": "必须填写"}
)
reason_category = SelectField(
'举报原因分类',
choices=[
('cheating', '作弊 (刷上传/下载)'),
('trading', '账号交易/共享'),
('spam', '发布垃圾/违禁信息'),
('abusive', '辱骂/人身攻击'),
('radio', '分享率过低'),
('other', '其他 (请在描述中详述)')
],
validators=[DataRequired()]
)
description = TextAreaField(
'详细情况描述',
validators=[DataRequired(), Length(min=10, max=2000)],
render_kw={"rows": 5, "placeholder": "请详细描述事件经过至少10个字符。"}
)
evidences = TextAreaField(
'证据图片链接 (每个链接占一行)',
validators=[DataRequired(message="请至少提供一个有效的图片链接。")],
render_kw={"rows": 4, "placeholder": "https://example.com/image1.png\nhttps://example.com/image2.jpg"}
)
submit = SubmitField('提交举报')
class RevokeForm(FlaskForm):
reason = TextAreaField('撤销理由', validators=[DataRequired(message='必须填写撤销理由。')])
submit = SubmitField('确认撤销')
class UpdateUserForm(FlaskForm):
role = SelectField('角色', choices=[
('user', '普通用户 (User)'),
('trust_user', '信任用户 (Trust User)'),
('admin', '管理员 (Admin)')
], validators=[DataRequired()])
status = SelectField('状态', choices=[
('active', '激活 (Active)'),
('disabled', '禁用 (Disabled)')
], validators=[DataRequired()])
submit = SubmitField('更新')
class CommentForm(FlaskForm):
body = TextAreaField('添加你的审核建议', validators=[DataRequired()])
submit = SubmitField('提交')
class AppealForm(FlaskForm):
reason = TextAreaField('申诉理由', validators=[DataRequired()], render_kw={'rows': 5, 'placeholder': '请详细说明您认为该举报不成立的原因,并提供任何可以支持您观点的证据。'})
submit = SubmitField('提交申诉')
class AppealMessageForm(FlaskForm):
body = TextAreaField('回复内容', validators=[DataRequired()], render_kw={'rows': 4, 'placeholder': '请输入您的回复...'})
submit = SubmitField('发送')
class PartnerSiteForm(FlaskForm):
name = StringField('站点名称', validators=[DataRequired(), Length(max=100)])
url = StringField('站点 URL (可选)', validators=[Optional(), URL(), Length(max=255)])
submit = SubmitField('添加站点')

117
app/models.py Normal file
View File

@@ -0,0 +1,117 @@
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login_manager
class PartnerSite(db.Model):
__tablename__ = 'partner_sites'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
url = db.Column(db.String(255), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False, index=True)
def __repr__(self):
return f'<PartnerSite {self.name}>'
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True, nullable=False)
email = db.Column(db.String(120), unique=True, index=True, nullable=False)
password_hash = db.Column(db.String(256))
role = db.Column(db.String(20), default='user', index=True) # 'user', 'admin', 'trusted_user'
status = db.Column(db.String(20), default='pending', index=True) # 'pending', 'active'
created_at = db.Column(db.DateTime, default=datetime.utcnow)
pt_site = db.Column(db.String(100)) # 注册时填写的站点
uid = db.Column(db.String(50)) # 注册时填写的 UID
reports = db.relationship('Report', backref='reporter', lazy='dynamic')
# comments = db.relationship('Comment', backref='author', lazy='dynamic')
comments = db.relationship('Comment', back_populates='author', lazy='dynamic')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
class Report(db.Model):
__tablename__ = 'reports'
id = db.Column(db.Integer, primary_key=True)
reporter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
reported_pt_site = db.Column(db.String(100), nullable=False)
reported_uid = db.Column(db.String(50))
reported_email = db.Column(db.String(120), index=True, nullable=False)
reason_category = db.Column(db.String(50), nullable=False) # e.g., 'cheating', 'trading', 'spam'
description = db.Column(db.Text, nullable=False)
status = db.Column(db.String(20), index=True, default='pending') # 'pending', 'in_review', 'approved', 'rejected'
created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow)
comments = db.relationship('Comment', backref='report', lazy='dynamic', cascade='all, delete-orphan')
evidences = db.relationship('Evidence', backref='report', lazy='dynamic', cascade="all, delete-orphan")
blacklist_entry = db.relationship('Blacklist', backref='report', uselist=False) # one-to-one
def __repr__(self):
return f'<Report {self.id}>'
class Evidence(db.Model):
__tablename__ = 'evidences'
id = db.Column(db.Integer, primary_key=True)
report_id = db.Column(db.Integer, db.ForeignKey('reports.id'), nullable=False)
file_url = db.Column(db.String(512), nullable=False) # 存储OSS或本地路径
file_type = db.Column(db.String(20)) # 'image', 'zip', 'text'
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<Evidence {self.id} for Report {self.report_id}>'
class Blacklist(db.Model):
__tablename__ = 'blacklist'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True)
email = db.Column(db.String(120), index=True)
normalized_email = db.Column(db.String(120), index=True) # 归一化后的邮箱
pt_site = db.Column(db.String(100), index=True)
uid = db.Column(db.String(50))
report_id = db.Column(db.Integer, db.ForeignKey('reports.id'), unique=True) # 确保一个举报只对应一个黑名单条目
status = db.Column(db.String(20), default='active', index=True) # 'active', 'appealed_ok', 'expired'
created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow)
appeals = db.relationship('Appeal', backref='blacklist_entry', lazy='dynamic')
def __repr__(self):
return f'<Blacklist {self.normalized_email} on {self.pt_site}>'
class Comment(db.Model):
__tablename__ = 'comments'
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.Text)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
report_id = db.Column(db.Integer, db.ForeignKey('reports.id'))
# author = db.relationship('User') # 方便地通过 comment.author 访问用户
author = db.relationship('User', back_populates='comments')
class Appeal(db.Model):
__tablename__ = 'appeals'
id = db.Column(db.Integer, primary_key=True)
reason = db.Column(db.Text, nullable=False) # 用户最初的申诉理由
status = db.Column(db.String(64), nullable=False, default='awaiting_admin_reply') # 状态: awaiting_admin_reply, awaiting_user_reply, closed_approved, closed_rejected
created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
appealer_id = db.Column(db.Integer, db.ForeignKey('users.id')) # 申诉人
blacklist_entry_id = db.Column(db.Integer, db.ForeignKey('blacklist.id')) # 关联的黑名单条目
messages = db.relationship('AppealMessage', backref='appeal', lazy='dynamic', cascade='all, delete-orphan')
appealer = db.relationship('User', backref='appeals')
def __repr__(self):
return f'<Appeal {self.id}>'
class AppealMessage(db.Model):
__tablename__ = 'appeal_messages'
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.Text, nullable=False)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
# 关系
author_id = db.Column(db.Integer, db.ForeignKey('users.id')) # 消息发送者
appeal_id = db.Column(db.Integer, db.ForeignKey('appeals.id'))
def __repr__(self):
return f'<AppealMessage {self.id}>'

417
app/routes.py Normal file
View File

@@ -0,0 +1,417 @@
from flask import Blueprint, render_template, request, flash,redirect, url_for
from sqlalchemy import or_
from flask_login import login_required, current_user
from app import db
from app.forms import SearchForm, ReportForm, ReportForm, UpdateUserForm, CommentForm, RevokeForm, AppealForm, AppealMessageForm, PartnerSiteForm
from app.models import Blacklist, Report, Evidence, User, Comment, AppealMessage, Appeal, PartnerSite
from app.decorators import admin_required, permission_required
from app.services.email_normalizer import normalize_email
main = Blueprint('main', __name__)
@main.route('/', methods=['GET', 'POST'])
def index():
form = SearchForm()
search_result = None
searched = False
if form.validate_on_submit():
# 在处理表单之前,检查用户是否已登录
if not current_user.is_authenticated:
flash('请登录后才能使用查询功能。', 'warning')
# 重定向到登录页面,或者直接返回首页
return redirect(url_for('main.index'))
# 如果用户已登录,则执行以下查询逻辑
searched = True
search_term = form.search_term.data
normalized_email = normalize_email(search_term)
search_result = Blacklist.query.join(Report).filter(
or_(
Blacklist.normalized_email == normalized_email,
Blacklist.username == search_term
),
Blacklist.status == 'active',
Report.status == 'approved'
).first()
if search_result:
flash(f'警告: 查询到与 "{search_term}" 相关的公开不良记录。详情如下。', 'warning')
else:
flash(f'未查询到与 "{search_term}" 相关的公开不良记录。', 'info')
return render_template('index.html', form=form, search_result=search_result, searched=searched, Appeal=Appeal)
@main.route('/report/new', methods=['GET', 'POST'])
@login_required
def create_report():
form = ReportForm()
active_sites = PartnerSite.query.filter_by(is_active=True).order_by(PartnerSite.name).all()
form.reported_pt_site.choices = [(site.name, site.name) for site in active_sites]
if form.validate_on_submit():
# 1. 创建 Report 对象
new_report = Report(
reporter_id=current_user.id,
reported_pt_site=form.reported_pt_site.data,
reported_uid=form.reported_uid.data,
reported_email=form.reported_email.data,
reason_category=form.reason_category.data,
description=form.description.data,
status='pending'
)
db.session.add(new_report)
# 2. 处理证据链接
urls_text = form.evidences.data
# 按行分割,并移除空白行和首尾空格
evidence_urls = [url.strip() for url in urls_text.strip().splitlines() if url.strip()]
if not evidence_urls:
flash('必须提供至少一个有效的证据链接。', 'danger')
return render_template('create_report.html', form=form)
# 对每个 URL 进行简单的验证和保存
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')
for url in evidence_urls:
if url.lower().startswith(('http://', 'https://')) and url.lower().endswith(valid_extensions):
evidence = Evidence(
file_url=url,
file_type='image_url', # 标记类型为图片链接
report=new_report
)
db.session.add(evidence)
else:
# 如果有任何一个链接格式不正确,则回滚并提示用户
db.session.rollback()
flash(f'链接 "{url[:50]}..." 格式不正确或不是支持的图片格式。请提供以 http/https 开头,以 .png, .jpg 等结尾的图片链接。', 'danger')
return render_template('create_report.html', form=form)
# 3. 提交到数据库
db.session.commit()
flash('举报提交成功,请等待管理员审核。', 'success')
return redirect(url_for('main.index'))
return render_template('create_report.html', form=form)
@main.route('/admin/reports')
@login_required
@permission_required('admin', 'trust_user')
def report_list():
# 获取页码,默认为第一页
page = request.args.get('page', 1, type=int)
# 查询举报,按创建时间倒序排列,并进行分页
# 每页显示 20 条记录
query = Report.query
if current_user.role == 'trust_user':
query = query.filter_by(status='pending')
reports_pagination = query.order_by(Report.created_at.desc()).paginate(
page=page, per_page=20, error_out=False
)
return render_template(
'admin/report_list.html',
reports=reports_pagination
)
@main.route('/report/<int:report_id>', methods=['GET', 'POST'])
@login_required
@permission_required('admin', 'trust_user') # 允许 admin 和 trust_user 访问
def report_detail(report_id):
report = Report.query.get_or_404(report_id)
if current_user.role == 'trust_user' and report.status != 'pending':
flash('您无权查看已处理的举报。', 'warning')
return redirect(url_for('main.report_list'))
comment_form = CommentForm()
revoke_form = RevokeForm()
if comment_form.validate_on_submit():
if report.status != 'pending':
flash('错误:该举报已被处理,无法添加新的评论。', 'danger')
return redirect(url_for('main.report_detail', report_id=report.id))
comment = Comment(
body=comment_form.body.data,
report=report,
author=current_user._get_current_object()
)
db.session.add(comment)
db.session.commit()
flash('你的审核建议已成功提交。', 'success')
return redirect(url_for('main.report_detail', report_id=report.id))
comments = report.comments.order_by(Comment.timestamp.desc()).all()
return render_template(
'admin/report_detail.html',
report=report,
form=comment_form,
revoke_form=revoke_form,
comments=comments
)
# === 独立的举报处理视图 (仅限 Admin) ===
# 这个视图只处理动作,不渲染页面。它接收来自详情页按钮的 POST 请求。
@main.route('/admin/report/<int:report_id>/process/<action>', methods=['POST'])
@login_required
@admin_required # 严格限制为 admin
def process_report(report_id, action):
report = Report.query.get_or_404(report_id)
if action == 'confirm':
report.status = 'approved'
# 检查是否已在黑名单中
existing_blacklist = Blacklist.query.filter_by(report_id=report.id).first()
if not existing_blacklist:
# 创建新的黑名单记录
new_blacklist_entry = Blacklist(
email=report.reported_email,
normalized_email=normalize_email(report.reported_email),
pt_site=report.reported_pt_site,
uid=report.reported_uid,
report_id=report.id,
username=report.reported_uid or None
)
db.session.add(new_blacklist_entry)
flash('举报已批准,并已将相关信息添加到黑名单。', 'success')
else:
flash('举报状态已更新为“批准”。该举报已在黑名单中,无需重复添加。', 'info')
elif action == 'invalidate':
report.status = 'rejected'
flash('举报状态已更新为“无效”。', 'success')
else:
flash('无效的操作。', 'danger')
db.session.commit()
return redirect(url_for('main.report_detail', report_id=report.id))
@main.route('/admin/report/<int:report_id>/revoke', methods=['POST'])
@login_required
@admin_required
def revoke_report(report_id):
report = Report.query.get_or_404(report_id)
# 只有已批准的举报才能被撤销
if report.status != 'approved':
flash('错误:只有已批准的举报才能被撤销。', 'danger')
return redirect(url_for('main.report_detail', report_id=report.id))
form = RevokeForm()
if form.validate_on_submit():
# 1. 从黑名单中移除
blacklist_entry = Blacklist.query.filter_by(report_id=report.id).first()
if blacklist_entry:
db.session.delete(blacklist_entry)
# 2. 更新举报状态
report.status = 'revoked'
# 3. 将撤销理由记录为一条特殊的评论(审计日志)
revocation_comment = Comment(
body=f"[系统操作:撤销批准]\n理由:{form.reason.data}",
report=report,
author=current_user._get_current_object()
)
db.session.add(revocation_comment)
db.session.commit()
flash('举报已成功撤销,并已从黑名单中移除。', 'success')
else:
# 如果表单验证失败(例如理由为空),显示错误信息
flash('撤销失败:' + ' '.join(form.reason.errors), 'danger')
return redirect(url_for('main.report_detail', report_id=report.id))
@main.route('/admin/users')
@login_required
@admin_required
def manage_users():
# 查询所有非待审核的用户
users = User.query.filter(User.status != 'pending').order_by(User.created_at.desc()).all()
# 为每个用户创建一个预填充了当前数据的表单实例
# 这样在模板中可以直接渲染,并且下拉框会默认选中用户的当前角色/状态
forms = {}
for user in users:
forms[user.id] = UpdateUserForm(obj=user)
return render_template('admin/manage_users.html', users=users, forms=forms)
@main.route('/admin/user/<int:user_id>/update', methods=['POST'])
@login_required
@admin_required
def update_user(user_id):
if user_id == 1:
flash('错误:禁止修改初始管理员账户的角色和状态。', 'danger')
return redirect(url_for('main.manage_users'))
user = User.query.get_or_404(user_id)
form = UpdateUserForm() # 创建一个空的表单来接收 POST 数据
if form.validate_on_submit():
# 安全检查:防止管理员误操作禁用或降级自己
if user == current_user:
if form.role.data != 'admin' or form.status.data != 'active':
flash('警告:您不能禁用或降级自己的管理员账户。', 'danger')
return redirect(url_for('main.manage_users'))
user.role = form.role.data
user.status = form.status.data
db.session.commit()
flash(f'用户 {user.username} 的信息已成功更新。', 'success')
else:
# 如果表单验证失败,也给出提示
flash('更新失败,请检查提交的数据。', 'danger')
return redirect(url_for('main.manage_users'))
@main.route('/admin/users/pending')
@login_required
@admin_required
def pending_users():
users_to_review = User.query.filter_by(status='pending').order_by(User.created_at.asc()).all()
return render_template('admin/pending_users.html', users=users_to_review)
@main.route('/admin/user/<int:user_id>/approve', methods=['POST'])
@login_required
@admin_required
def approve_user(user_id):
user = User.query.get_or_404(user_id)
user.status = 'active'
db.session.commit()
flash(f'用户 {user.username} 的注册申请已被批准。', 'success')
return redirect(url_for('main.pending_users'))
@main.route('/admin/user/<int:user_id>/reject', methods=['POST'])
@login_required
@admin_required
def reject_user(user_id):
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
flash(f'用户 {user.username} 的注册申请已被拒绝并删除。', 'success')
return redirect(url_for('main.pending_users'))
@main.route('/appeal/create/<int:blacklist_id>', methods=['GET', 'POST'])
@login_required
def create_appeal(blacklist_id):
blacklist_entry = Blacklist.query.get_or_404(blacklist_id)
# 安全检查:确保用户只能为自己的黑名单记录申诉
# 注意:请根据你的 User 模型修改 current_user.reported_uid
if not (hasattr(current_user, 'reported_uid') and current_user.reported_uid == blacklist_entry.uid) and not (current_user.email == blacklist_entry.email):
abort(403) # Forbidden
# 检查是否已有进行中的申诉
if blacklist_entry.appeals.filter(Appeal.status.in_(['awaiting_admin_reply', 'awaiting_user_reply'])).first():
flash('您已有一个正在进行中的申诉,请勿重复提交。', 'warning')
return redirect(url_for('main.index'))
form = AppealForm()
if form.validate_on_submit():
appeal = Appeal(
reason=form.reason.data,
appealer_id=current_user.id,
blacklist_entry=blacklist_entry
)
db.session.add(appeal)
db.session.commit()
flash('您的申诉已成功提交,请等待管理员审核。', 'success')
# 提交成功后,跳转到申诉详情页
return redirect(url_for('main.appeal_detail', appeal_id=appeal.id))
# 如果是 GET 请求,或表单验证失败,则渲染创建页面
return render_template('create_appeal.html', form=form, entry=blacklist_entry)
@main.route('/appeal/<int:appeal_id>', methods=['GET', 'POST'])
@login_required
def appeal_detail(appeal_id):
appeal = Appeal.query.get_or_404(appeal_id)
# 权限检查:只有申诉人或管理员可以查看
if appeal.appealer_id != current_user.id and not current_user.role=='admin': # 假设你有 MANAGE_REPORTS 权限
abort(403)
form = AppealMessageForm()
if form.validate_on_submit():
msg = AppealMessage(
body=form.body.data,
author_id=current_user.id,
appeal_id=appeal.id
)
# 更新申诉状态
if current_user.role=='admin':
appeal.status = 'awaiting_user_reply'
else:
appeal.status = 'awaiting_admin_reply'
db.session.add(msg)
db.session.commit()
flash('消息已发送。', 'success')
return redirect(url_for('main.appeal_detail', appeal_id=appeal.id))
messages = appeal.messages.order_by(AppealMessage.timestamp.asc()).all()
return render_template('appeal_detail.html', appeal=appeal, messages=messages, form=form)
@main.route('/admin/appeals')
@login_required
@permission_required('admin') # 或者你的管理权限
def appeal_list():
page = request.args.get('page', 1, type=int)
# 优先显示待处理的
appeals_pagination = Appeal.query.order_by(
db.case(
(Appeal.status == 'awaiting_admin_reply', 0),
(Appeal.status == 'awaiting_user_reply', 1),
else_=2
),
Appeal.updated_at.desc()
).paginate(page=page, per_page=20, error_out=False)
return render_template('admin/appeal_list.html', appeals=appeals_pagination)
@main.route('/appeal/<int:appeal_id>/decide', methods=['POST'])
@login_required
@permission_required('admin') # 必须是管理员
def decide_appeal(appeal_id):
appeal = Appeal.query.get_or_404(appeal_id)
action = request.form.get('action')
if action == 'approve':
# 批准申诉:删除黑名单记录,更新申诉状态
blacklist_entry = appeal.blacklist_entry
blacklist_entry.status = 'revoked' # 将黑名单条目状态改为“已撤销”
appeal.status = 'approved' # 同时更新申诉本身的状态
if blacklist_entry.report:
# 使用 'overturned' (已推翻) 可能比 'revoked' 更能描述 Report 的状态
blacklist_entry.report.status = 'overturned'
db.session.add(blacklist_entry.report)
db.session.add(blacklist_entry)
db.session.add(appeal)
db.session.commit()
# --- 修改结束 ---
flash(f'申诉 #{appeal.id} 已被批准,对应的黑名单条目已撤销。', 'success')
elif action == 'reject':
# 驳回申诉:仅更新申诉状态
appeal.status = 'rejected'
flash(f'已驳回申诉 #{appeal.id}', 'info')
else:
flash('无效操作。', 'danger')
return redirect(url_for('main.appeal_list'))
@main.route('/admin/sites', methods=['GET', 'POST'])
@login_required
@admin_required
def manage_sites():
form = PartnerSiteForm()
if form.validate_on_submit():
# ... (添加站点的逻辑保持不变)
existing_site = PartnerSite.query.filter_by(name=form.name.data).first()
if existing_site:
flash('该站点名称已存在。', 'danger')
else:
new_site = PartnerSite(name=form.name.data, url=form.url.data)
db.session.add(new_site)
db.session.commit()
flash(f'合作站点 "{form.name.data}" 已成功添加。', 'success')
return redirect(url_for('main.manage_sites'))
sites = PartnerSite.query.order_by(PartnerSite.name.asc()).all()
return render_template('admin/manage_sites.html', sites=sites, form=form)
# 添加用于删除和切换状态的路由
@main.route('/admin/site/<int:site_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_site(site_id):
site = PartnerSite.query.get_or_404(site_id)
user_count = User.query.filter_by(pt_site=site.name).count()
if user_count > 0:
flash(f'无法删除站点 "{site.name}",因为已有 {user_count} 名用户关联到该站点。请先将其禁用。', 'danger')
else:
db.session.delete(site)
db.session.commit()
flash(f'站点 "{site.name}" 已被删除。', 'success')
return redirect(url_for('main.manage_sites'))
@main.route('/admin/site/<int:site_id>/toggle_active', methods=['POST'])
@login_required
@admin_required
def toggle_site_active(site_id):
site = PartnerSite.query.get_or_404(site_id)
site.is_active = not site.is_active
db.session.commit()
status = "启用" if site.is_active else "禁用"
flash(f'站点 "{site.name}" 已被{status}', 'success')
return redirect(url_for('main.manage_sites'))

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,21 @@
def normalize_email(email):
"""
对邮箱地址进行归一化处理。
- 全部转为小写。
- 对 Gmail/Googlemail: 移除 '.''+' 后面的所有内容。
"""
if not email or '@' not in email:
return None
email = email.lower().strip()
local_part, domain_part = email.split('@', 1)
# 针对 Gmail 和 Googlemail 的特殊处理
if domain_part in ('gmail.com', 'googlemail.com'):
# 移除 '+' 及其后面的部分
local_part = local_part.split('+', 1)[0]
# 移除 '.'
local_part = local_part.replace('.', '')
return f"{local_part}@{domain_part}"

6
app/static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

67
app/static/css/custom.css Normal file
View File

@@ -0,0 +1,67 @@
/* static/css/custom.css */
/* 全局样式调整 */
body {
background-color: #f8f9fa;
}
/* 聊天框样式 */
.chat-box {
display: flex;
flex-direction: column;
}
.message {
max-width: 80%;
margin-bottom: 15px;
}
.message-header {
display: flex;
justify-content: space-between;
font-size: 0.85em;
margin-bottom: 5px;
color: #6c757d;
}
.message-body {
padding: 10px 15px;
border-radius: 15px;
line-height: 1.5;
word-wrap: break-word; /* 保证长单词或链接换行 */
}
/* 用户消息(靠左)*/
.user-message {
align-self: flex-start;
}
.user-message .message-body {
background-color: #e9ecef;
border-bottom-left-radius: 0;
}
/* 管理员消息(靠右)*/
.admin-message {
align-self: flex-end;
}
.admin-message .message-header {
flex-direction: row-reverse; /* 时间和名字换位置 */
}
.admin-message .message-body {
background-color: #0d6efd;
color: white;
border-bottom-right-radius: 0;
}
/* 表单和表格对齐 */
.table.align-middle th,
.table.align-middle td {
vertical-align: middle;
}
.description-text {
white-space: pre-wrap; /* 保留空格和换行符,并允许自动换行 */
word-break: break-all; /* 防止长单词溢出 */
}

View File

@@ -0,0 +1,5 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M50 10 L15 25 C10 50, 10 70, 50 90 C90 70, 90 50, 85 25 Z" fill="#2C3E50"/>
<rect x="45" y="30" width="10" height="32" rx="3" fill="#E74C3C"/>
<circle cx="50" cy="73" r="6" fill="#E74C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

7
app/static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
app/static/js/jquery-3.5.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}申诉管理{% endblock %}
{% block content %}
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">申诉管理</h2>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>#ID</th>
<th>申诉人</th>
<th>关联用户名</th>
<th>状态</th>
<th>最后更新</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for appeal in appeals.items %}
<tr>
<td>{{ appeal.id }}</td>
<td>{{ appeal.appealer.username if appeal.appealer else '未知用户' }}</td>
<td>{{ appeal.blacklist_entry.uid }}</td>
<td>
<span class="badge
{% if 'closed' in appeal.status %} bg-secondary
{% elif 'user' in appeal.status %} bg-warning text-dark
{% else %} bg-info text-dark {% endif %}">
{{ appeal.status }}
</span>
</td>
<td>{{ appeal.updated_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<a href="{{ url_for('main.appeal_detail', appeal_id=appeal.id) }}" class="btn btn-sm btn-outline-primary">查看详情</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted">暂无申诉记录。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer">
<!-- 分页逻辑 -->
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}合作站点管理{% endblock %}
{% block content %}
<h2 class="mb-4">合作站点管理</h2>
<div class="row">
<!-- 添加新站点表单 -->
<div class="col-md-4">
<div class="card">
<div class="card-header">添加新站点</div>
<div class="card-body">
<form method="POST" action="{{ url_for('main.manage_sites') }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% for error in form.name.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.url.label(class="form-label") }}
{{ form.url(class="form-control", placeholder="https://example.com") }}
{% for error in form.url.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
</div>
</div>
<!-- 站点列表 -->
<div class="col-md-8">
<div class="card">
<div class="card-header">站点列表</div>
<div class="list-group list-group-flush">
{% if sites %}
{% for site in sites %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ site.name }}</strong>
{% if not site.is_active %}<span class="badge bg-secondary">已禁用</span>{% endif %}
<br>
<small class="text-muted">{{ site.url or '未提供 URL' }}</small>
</div>
<div class="btn-group">
<!-- 切换启用/禁用状态的按钮 -->
<form action="{{ url_for('main.toggle_site_active', site_id=site.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-{{ 'warning' if site.is_active else 'success' }}">
{{ '禁用' if site.is_active else '启用' }}
</button>
</form>
<!-- 删除按钮 -->
<form action="{{ url_for('main.delete_site', site_id=site.id) }}" method="POST" class="d-inline ms-2">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('确定要删除站点 {{ site.name }} 吗?此操作不可逆!');">删除</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item text-center text-muted">暂无合作站点。</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}管理后台 - 用户管理{% endblock %}
{% block content %}
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">用户管理</h2>
<p class="text-muted mb-0">在这里您可以管理所有已注册用户的角色和状态。</p>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-light">
<tr>
<th>ID</th>
<th>用户名 / 邮箱</th>
<th>注册站点 / UID</th>
<th>注册时间</th>
<th style="min-width: 200px;">角色与状态</th>
<th style="min-width: 120px;">操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>
<div><strong>{{ user.username }}</strong></div>
<small class="text-muted">{{ user.email }}</small>
</td>
<td>{{ user.pt_site }} / {{ user.uid }}</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
{% if user.id == 1 %}
<td>
<div>角色: <span class="badge bg-danger">{{ user.role }}</span></div>
<div>状态: <span class="badge bg-success">{{ user.status }}</span></div>
</td>
<td>
<button class="btn btn-sm btn-secondary" disabled>不可修改</button>
</td>
{% else %}
<form action="{{ url_for('main.update_user', user_id=user.id) }}" method="POST">
{% set form = forms[user.id] %}
{{ form.hidden_tag() }}
<td>
<div class="input-group input-group-sm mb-2">
<span class="input-group-text">角色</span>
{{ form.role(class="form-select") }}
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">状态</span>
{{ form.status(class="form-select") }}
</div>
</td>
<td>
{{ form.submit(class="btn btn-sm btn-primary") }}
</td>
</form>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted">没有已注册的用户。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}管理后台 - 用户审核{% endblock %}
{% block content %}
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">待审核用户列表</h2>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>所在站点</th>
<th>UID</th>
<th>申请时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>{{ user.pt_site }}</td>
<td>{{ user.uid }}</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<form action="{{ url_for('main.approve_user', user_id=user.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-success">批准</button>
</form>
<form action="{{ url_for('main.reject_user', user_id=user.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('确定要拒绝并删除该用户吗?');">拒绝</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted">目前没有待审核的用户。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}举报详情 - #{{ report.id }}{% endblock %}
{% block content %}
<div class="row">
<!-- 左侧信息栏 -->
<div class="col-lg-5">
<div class="card shadow-sm mb-4">
<div class="card-header"><h4 class="mb-0">举报详情 #{{ report.id }}</h4></div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item"><strong>被举报邮箱:</strong> {{ report.reported_email }}</li>
<li class="list-group-item"><strong>被举报用户名:</strong> {{ report.reported_uid or 'N/A' }}</li>
<li class="list-group-item"><strong>所属站点:</strong> {{ report.reported_pt_site }}</li>
<li class="list-group-item"><strong>举报理由:</strong> {{ report.reason_category }}</li>
<li class="list-group-item"><strong>举报人:</strong> {{ report.reporter.username }}</li>
<li class="list-group-item"><strong>状态:</strong> <strong class="text-capitalize">{{ report.status }}</strong></li>
<li class="list-group-item"><strong>详细描述:</strong><br><span style="white-space: pre-wrap;">{{ report.description }}</span></li>
</ul>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">证据链接</h5></div>
<div class="list-group list-group-flush">
{% for evidence in report.evidences %}
<a href="{{ evidence.file_url }}" target="_blank" class="list-group-item list-group-item-action">
{{ evidence.file_url }}
</a>
{% else %}
<div class="list-group-item text-muted">未提供证据链接。</div>
{% endfor %}
</div>
</div>
{% if current_user.role == 'admin' %}
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">管理员操作</h5></div>
<div class="card-body d-grid gap-2">
{% if report.status == 'pending' or report.status == 'in_review' %}
<form action="{{ url_for('main.process_report', report_id=report.id, action='confirm') }}" method="POST" class="d-grid">
<button type="submit" class="btn btn-success">确认违规 (加入黑名单)</button>
</form>
<form action="{{ url_for('main.process_report', report_id=report.id, action='invalidate') }}" method="POST" class="d-grid">
<button type="submit" class="btn btn-warning">举报无效</button>
</form>
{% elif report.status == 'approved' %}
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#revokeModal">
撤销批准并移出黑名单
</button>
{% else %}
<p class="text-muted mb-0">该举报已处理完毕,无更多操作。</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- 右侧评论区 -->
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">审核与讨论</h5></div>
<div class="card-body" style="max-height: 500px; overflow-y: auto;">
{% for comment in comments %}
<div class="d-flex mb-3">
<div class="flex-shrink-0"><span class="badge rounded-pill bg-secondary">{{ comment.author.username }}</span></div>
<div class="flex-grow-1 ms-3">
<div class="p-2 bg-light rounded">
<p class="small mb-0">{{ comment.body | safe }}</p>
</div>
<small class="text-muted">{{ comment.timestamp.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
</div>
{% else %}
<p class="text-muted">暂无审核建议。</p>
{% endfor %}
</div>
{% if report.status == 'pending' or report.status == 'in_review' %}
<div class="card-footer">
<form method="POST">
{{ form.hidden_tag() }}
<div class="input-group">
{{ form.body(class="form-control", placeholder="提交审核建议...") }}
{{ form.submit(class="btn btn-outline-secondary") }}
</div>
</form>
</div>
{% else %}
<div class="card-footer text-center bg-light">
<p class="mb-0 text-muted">举报已处理,评论已关闭。</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 撤销操作的 Modal -->
<div class="modal fade" id="revokeModal" tabindex="-1" aria-labelledby="revokeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="revokeModalLabel">撤销举报批准</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('main.revoke_report', report_id=report.id) }}" method="POST">
<div class="modal-body">
{{ revoke_form.hidden_tag() }}
<p>你确定要撤销对举报 #{{ report.id }} 的批准吗?这将把相关用户从黑名单中移除。</p>
<div class="mb-3">
{{ revoke_form.reason.label(class="form-label") }}
{{ revoke_form.reason(class="form-control", rows=3) }}
{% for error in revoke_form.reason.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
{{ revoke_form.submit(class="btn btn-danger") }}
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}管理后台 - 举报列表{% endblock %}
{% block content %}
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">举报列表</h2>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>#ID</th>
<th>被举报邮箱 / 站点</th>
<th>举报人</th>
<th>状态</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for report in reports.items %}
<tr>
<td>{{ report.id }}</td>
<td>
<div><strong>{{ report.reported_email }}</strong></div>
<small class="text-muted">{{ report.reported_pt_site }}</small>
</td>
<td>{{ report.reporter.username }}</td>
<td><span class="badge bg-info text-dark">{{ report.status }}</span></td>
<td>{{ report.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td><a href="{{ url_for('main.report_detail', report_id=report.id) }}" class="btn btn-sm btn-outline-primary">查看详情</a></td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted">目前没有举报记录。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer">
{# 分页导航 #}
<div class="pagination">
{% if reports.has_prev %}
<a href="{{ url_for('main.report_list', page=reports.prev_num) }}" class="btn btn-sm btn-outline-secondary">&laquo; 上一页</a>
{% endif %}
<span class="mx-2">Page {{ reports.page }} of {{ reports.pages }}.</span>
{% if reports.has_next %}
<a href="{{ url_for('main.report_list', page=reports.next_num) }}" class="btn btn-sm btn-outline-secondary">下一页 &raquo;</a>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}申诉详情 - #{{ appeal.id }}{% endblock %}
{% block content %}
<div class="row">
<!-- 左侧信息栏 -->
<div class="col-lg-4">
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">申诉信息</h5></div>
<div class="card-body">
<p><strong>申诉ID:</strong> #{{ appeal.id }}</p>
<p><strong>申诉人:</strong> {{ appeal.appealer.username }}</p>
<p><strong>状态:</strong>
<span class="badge
{% if 'closed' in appeal.status %} bg-secondary
{% elif 'user' in appeal.status %} bg-warning text-dark
{% else %} bg-info text-dark {% endif %}">
{{ appeal.status }}
</span>
</p>
<hr>
<h6>针对黑名单记录</h6>
<p class="mb-0"><strong>站点:</strong> {{ appeal.blacklist_entry.pt_site }}</p>
<p class="mb-0"><strong>UID:</strong> {{ appeal.blacklist_entry.uid }}</p>
</div>
</div>
{% if current_user.role=='admin' and appeal.status not in ['closed_approved', 'closed_rejected'] %}
<div class="card shadow-sm mb-4">
<div class="card-header"><h5 class="mb-0">管理员操作</h5></div>
<div class="card-body text-center d-grid gap-2">
<form action="{{ url_for('main.decide_appeal', appeal_id=appeal.id) }}" method="POST" class="d-grid">
<input type="hidden" name="action" value="approve">
<button type="submit" class="btn btn-success" onclick="return confirm('确定批准申诉并将该用户移出黑名单吗?')">批准申诉</button>
</form>
<form action="{{ url_for('main.decide_appeal', appeal_id=appeal.id) }}" method="POST" class="d-grid">
<input type="hidden" name="action" value="reject">
<button type="submit" class="btn btn-danger" onclick="return confirm('确定驳回此申诉吗?')">驳回申诉</button>
</form>
</div>
</div>
{% endif %}
</div>
<!-- 右侧对话框 -->
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">对话历史</h5></div>
<div class="card-body chat-box" style="height: 500px; overflow-y: auto;">
<!-- 初始申诉理由 -->
<div class="message user-message mb-3">
<div class="message-header">
<strong>{{ appeal.appealer.username }}</strong>
<small>{{ appeal.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<div class="message-body">
<p class="fw-bold">[初始申诉理由]</p>
<p class="mb-0" style="white-space: pre-wrap;">{{ appeal.reason }}</p>
</div>
</div>
<!-- 后续对话 -->
{% for message in messages %}
<div class="message mb-3 {% if message.author_id == appeal.appealer_id %}user-message{% else %}admin-message{% endif %}">
<div class="message-header">
{% if message.author_id.role == 'admin' %}
<strong>{{ message.author_id.username }} (管理员)</strong>
<small>{{ message.timestamp.strftime('%Y-%m-%d %H:%M') }}</small>
{% else %}
<strong>{{ message.author_id.username }}</strong>
<small>{{ message.timestamp.strftime('%Y-%m-%d %H:%M') }}</small>
{% endif %}
</div>
<div class="message-body">
<p class="mb-0" style="white-space: pre-wrap;">{{ message.body }}</p>
</div>
</div>
{% endfor %}
</div>
{% if appeal.status not in ['closed_approved', 'closed_rejected'] %}
<div class="card-footer">
<form method="POST">
{{ form.hidden_tag() }}
<div class="input-group">
{{ form.body(class="form-control", placeholder="输入回复内容...", rows="2") }}
{{ form.submit(class="btn btn-primary") }}
</div>
{% for error in form.body.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</form>
</div>
{% else %}
<div class="card-footer text-center bg-light">
<p class="mb-0 text-muted">该申诉已关闭,无法回复。</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}登录{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<h2 class="card-title text-center mb-4">用户登录</h2>
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% for error in form.password.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
<hr>
<p class="text-center mb-0">新用户? <a href="{{ url_for('auth.register') }}">点此注册</a></p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}注册{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-body p-4">
<h2 class="card-title text-center mb-4">创建新账户</h2>
<form method="POST" action="{{ url_for('auth.register') }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% for error in form.username.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.pt_site.label(class="form-label") }}
{{ form.pt_site(class="form-control") }}
{% for error in form.pt_site.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6 mb-3">
{{ form.uid.label(class="form-label") }}
{{ form.uid(class="form-control") }}
{% for error in form.uid.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% for error in form.password.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6 mb-3">
{{ form.password2.label(class="form-label") }}
{{ form.password2(class="form-control") }}
{% for error in form.password2.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
</div>
<div class="d-grid mt-2">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
<hr>
<p class="text-center mb-0">已有账户? <a href="{{ url_for('auth.login') }}">点此登录</a></p>
</div>
</div>
</div>
</div>
{% endblock %}

104
app/templates/base.html Normal file
View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PT 黑名单中心{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='images/logo.svg') }}" />
<script src="{{ url_for('static', filename='js/jquery-3.5.1.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<!-- 自定义 CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
{% block page_css %}{% endblock %}
</head>
<body>
<!-- 响应式导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.index') }}">PT 黑名单中心</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.index') }}">首页查询</a>
</li>
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.create_report') }}">提交举报</a>
</li>
{% if current_user.role != 'user' %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.report_list') }}">举报管理</a>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
管理员面板
</a>
<ul class="dropdown-menu" aria-labelledby="adminDropdown">
<li><a class="dropdown-item" href="{{ url_for('main.manage_sites') }}">合作站点</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.appeal_list') }}">申诉管理</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.pending_users') }}">用户审核</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.manage_users') }}">用户管理</a></li>
</ul>
</li>
{% endif %}
{% endif %}
</ul>
<!-- 右侧用户菜单 -->
<ul class="navbar-nav">
{% if current_user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
你好, {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<!-- <li><a class="dropdown-item" href="#">我的资料</a></li>
<li><hr class="dropdown-divider"></li> -->
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">退出登录</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">登录</a>
</li>
<li class="nav-item">
<a class="btn btn-outline-light" href="{{ url_for('auth.register') }}">注册</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- 主体内容 -->
<main class="container py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category or 'info' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="container text-center text-muted py-4">
<hr>
<p>&copy; 2025 - now PT Blacklist Center. All Rights Reserved.</p>
</footer>
{% block page_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}发起申诉{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">为黑名单记录 #{{ entry.id }} 发起申诉</h2>
</div>
<div class="card-body">
<ul class="list-group list-group-flush mb-4">
<li class="list-group-item"><strong>UID:</strong> {{ entry.uid }}</li>
<li class="list-group-item"><strong>邮箱:</strong> {{ entry.email }}</li>
<li class="list-group-item"><strong>站点:</strong> {{ entry.pt_site }}</li>
{% if entry.report %}
<li class="list-group-item"><strong>违规原因:</strong> {{ entry.report.reason_category }}</li>
{% endif %}
</ul>
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.reason.label(class="form-label") }}
{{ form.reason(class="form-control", rows=8, placeholder="请详细说明您申诉的理由,并提供任何可以证明您清白的证据或说明。") }}
{% for error in form.reason.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="text-end">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}提交新举报{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">提交新举报</h2>
</div>
<div class="card-body">
<p class="text-muted">请填写真实的举报信息,所有提交都将被记录。滥用举报系统可能会导致您的账户被封禁。</p>
<hr>
<form method="POST" action="{{ url_for('main.create_report') }}" novalidate>
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.reported_pt_site.label(class="form-label") }}
{{ form.reported_pt_site(class="form-control") }}
{% for error in form.reported_pt_site.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6 mb-3">
{{ form.reported_uid.label(class="form-label") }}
{{ form.reported_uid(class="form-control") }}
{% for error in form.reported_uid.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
</div>
<div class="mb-3">
{{ form.reported_email.label(class="form-label") }}
{{ form.reported_email(class="form-control") }}
{% for error in form.reported_email.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.reason_category.label(class="form-label") }}
{{ form.reason_category(class="form-select") }}
{% for error in form.reason_category.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows=5) }}
{% for error in form.description.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.evidences.label(class="form-label") }}
{{ form.evidences(class="form-control", rows=3, placeholder="每行一个证据链接(请使用图床!)") }}
<div class="form-text">请提供所有相关证据的URL每行一个。</div>
{% for error in form.evidences.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="text-end">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

98
app/templates/index.html Normal file
View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}首页 - 黑名单查询{% endblock %}
{% block content %}
<div class="text-center">
<h1 class="display-5">PT 圈黑名单查询系统</h1>
<p class="lead text-muted">请输入邮箱地址或用户名进行查询。本系统旨在帮助 PTer 发放邀请前识别有不良记录的用户。</p>
</div>
{% if current_user.is_authenticated %}
{# 如果用户已登录,显示查询表单和结果区域 #}
<div class="row justify-content-center mt-4">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="{{ url_for('main.index') }}" novalidate>
{{ form.hidden_tag() }}
<div class="input-group mb-2">
{{ form.search_term(class="form-control form-control-lg", placeholder="例如: user@example.com 或 BadUser123") }}
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
{% for error in form.search_term.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</form>
</div>
</div>
</div>
</div>
{% if searched %}
<hr class="my-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header">
<h5 class="mb-0">查询结果</h5>
</div>
<div class="card-body">
{% if search_result %}
<div class="alert alert-danger">
<strong>状态: </strong> 发现相关不良记录
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item"><strong>违规站点:</strong> {{ search_result.pt_site }}</li>
{% if search_result.report %}
<li class="list-group-item"><strong>违规原因:</strong> {{ search_result.report.reason_category }}</li>
{% endif %}
<li class="list-group-item"><strong>记录时间:</strong> {{ search_result.created_at.strftime('%Y-%m-%d') }}</li>
</ul>
<p class="text-muted small mt-3">为保护隐私,仅展示必要的脱敏信息。具体违规描述不对外公开。</p>
{% if current_user.is_authenticated and (current_user.uid == search_result.uid or current_user.email == search_result.email) %}
<hr>
<h6>申诉通道</h6>
{% set active_appeal = search_result.appeals.filter(Appeal.status.in_(['awaiting_admin_reply', 'awaiting_user_reply'])).first() %}
{% if active_appeal %}
<p>您已对该记录发起了申诉,请点击下方按钮查看进展。</p>
<a href="{{ url_for('main.appeal_detail', appeal_id=active_appeal.id) }}" class="btn btn-warning">查看进行中的申诉</a>
{% else %}
<p>如果您认为该记录有误,可以发起申诉,管理员将会与您沟通。</p>
<a href="{{ url_for('main.create_appeal', blacklist_id=search_result.id) }}" class="btn btn-primary">对该记录发起申诉</a>
{% endif %}
{% endif %}
{% else %}
<div class="alert alert-success">
<strong>状态: </strong> 未查询到相关不良记录
</div>
<p class="text-muted">请注意,未查询到记录不代表用户完全清白,也可能是其违规行为尚未被举报。</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% else %}
{# 如果用户未登录,显示一个提示框 #}
<div class="row justify-content-center mt-5">
<div class="col-lg-8">
<div class="alert alert-warning text-center" role="alert">
<h4 class="alert-heading">功能受限</h4>
<p>查询功能仅对注册并登录的用户开放。请登录以使用本系统的全部功能。</p>
<hr>
<p class="mb-0">
<a href="{{ url_for('auth.login') }}" class="btn btn-primary">前往登录</a>
<a href="{{ url_for('auth.register') }}" class="btn btn-outline-secondary ms-2">立即注册</a>
</p>
</div>
</div>
</div>
{% endif %}
{% endblock %}

50
config.py Normal file
View File

@@ -0,0 +1,50 @@
import os
from dotenv import load_dotenv
import redis
# 加载 .env 文件中的环境变量
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'))
class Config:
"""基础配置类"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'a-hard-to-guess-string'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Session 配置
SESSION_TYPE = 'redis'
SESSION_PERMANENT = False
SESSION_USE_SIGNER = True
SESSION_REDIS = redis.from_url(os.environ.get('REDIS_URL'))
@staticmethod
def init_app(app):
pass
class DevelopmentConfig(Config):
"""开发环境配置"""
DEBUG = True
# 数据库 URI
DB_USER = os.environ.get('DB_USER')
DB_PASSWORD = os.environ.get('DB_PASSWORD')
DB_HOST = os.environ.get('DB_HOST')
DB_NAME = os.environ.get('DB_NAME')
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}'
class ProductionConfig(Config):
"""生产环境配置"""
DEBUG = False
# 数据库 URI
DB_USER = os.environ.get('DB_USER')
DB_PASSWORD = os.environ.get('DB_PASSWORD')
DB_HOST = os.environ.get('DB_HOST')
DB_NAME = os.environ.get('DB_NAME')
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}'
# 方便地通过字典来选择配置
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,169 @@
"""Initial migration
Revision ID: 054c116946ac
Revises:
Create Date: 2025-11-22 00:37:30.417108
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '054c116946ac'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('email', sa.String(length=120), nullable=False),
sa.Column('password_hash', sa.String(length=256), nullable=True),
sa.Column('role', sa.String(length=20), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('pt_site', sa.String(length=100), nullable=True),
sa.Column('uid', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
batch_op.create_index(batch_op.f('ix_users_role'), ['role'], unique=False)
batch_op.create_index(batch_op.f('ix_users_status'), ['status'], unique=False)
batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True)
op.create_table('reports',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('reporter_id', sa.Integer(), nullable=False),
sa.Column('reported_pt_site', sa.String(length=100), nullable=False),
sa.Column('reported_uid', sa.String(length=50), nullable=True),
sa.Column('reported_email', sa.String(length=120), nullable=False),
sa.Column('reason_category', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['reporter_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_reports_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_reports_reported_email'), ['reported_email'], unique=False)
batch_op.create_index(batch_op.f('ix_reports_status'), ['status'], unique=False)
op.create_table('blacklist',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=64), nullable=True),
sa.Column('email', sa.String(length=120), nullable=True),
sa.Column('normalized_email', sa.String(length=120), nullable=True),
sa.Column('pt_site', sa.String(length=100), nullable=True),
sa.Column('uid', sa.String(length=50), nullable=True),
sa.Column('report_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('report_id')
)
with op.batch_alter_table('blacklist', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_blacklist_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_blacklist_email'), ['email'], unique=False)
batch_op.create_index(batch_op.f('ix_blacklist_normalized_email'), ['normalized_email'], unique=False)
batch_op.create_index(batch_op.f('ix_blacklist_pt_site'), ['pt_site'], unique=False)
batch_op.create_index(batch_op.f('ix_blacklist_status'), ['status'], unique=False)
batch_op.create_index(batch_op.f('ix_blacklist_username'), ['username'], unique=False)
op.create_table('comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.Column('author_id', sa.Integer(), nullable=True),
sa.Column('report_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_comments_timestamp'), ['timestamp'], unique=False)
op.create_table('evidences',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('report_id', sa.Integer(), nullable=False),
sa.Column('file_url', sa.String(length=512), nullable=False),
sa.Column('file_type', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('appeals',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('appealer_id', sa.Integer(), nullable=True),
sa.Column('blacklist_entry_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['appealer_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['blacklist_entry_id'], ['blacklist.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('appeals', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_appeals_created_at'), ['created_at'], unique=False)
op.create_table('appeal_messages',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.Column('author_id', sa.Integer(), nullable=True),
sa.Column('appeal_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['appeal_id'], ['appeals.id'], ),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('appeal_messages', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_appeal_messages_timestamp'), ['timestamp'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('appeal_messages', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_appeal_messages_timestamp'))
op.drop_table('appeal_messages')
with op.batch_alter_table('appeals', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_appeals_created_at'))
op.drop_table('appeals')
op.drop_table('evidences')
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comments_timestamp'))
op.drop_table('comments')
with op.batch_alter_table('blacklist', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_blacklist_username'))
batch_op.drop_index(batch_op.f('ix_blacklist_status'))
batch_op.drop_index(batch_op.f('ix_blacklist_pt_site'))
batch_op.drop_index(batch_op.f('ix_blacklist_normalized_email'))
batch_op.drop_index(batch_op.f('ix_blacklist_email'))
batch_op.drop_index(batch_op.f('ix_blacklist_created_at'))
op.drop_table('blacklist')
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_reports_status'))
batch_op.drop_index(batch_op.f('ix_reports_reported_email'))
batch_op.drop_index(batch_op.f('ix_reports_created_at'))
op.drop_table('reports')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_username'))
batch_op.drop_index(batch_op.f('ix_users_status'))
batch_op.drop_index(batch_op.f('ix_users_role'))
batch_op.drop_index(batch_op.f('ix_users_email'))
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -0,0 +1,42 @@
"""Add PartnerSite model for official sites
Revision ID: ee88f6dd173c
Revises: 054c116946ac
Create Date: 2025-11-23 22:14:57.981853
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ee88f6dd173c'
down_revision = '054c116946ac'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('partner_sites',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('url', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('partner_sites', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_partner_sites_is_active'), ['is_active'], unique=False)
batch_op.create_index(batch_op.f('ix_partner_sites_name'), ['name'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('partner_sites', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_partner_sites_name'))
batch_op.drop_index(batch_op.f('ix_partner_sites_is_active'))
op.drop_table('partner_sites')
# ### end Alembic commands ###

14
requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
Flask
Flask-SQLAlchemy
Flask-Migrate
Flask-Login
Flask-WTF
WTForms[email]
PyMySQL
python-dotenv
redis
Flask-Session
gunicorn
boto3
Flask-Bootstrap
wtforms-sqlalchemy

35
run.py Normal file
View File

@@ -0,0 +1,35 @@
import os
import click
from app import create_app, db
from app.models import User
app = create_app(os.getenv('FLASK_CONFIG') or 'default')
@app.shell_context_processor
def make_shell_context():
return dict(db=db, User=User)
@app.cli.command("create-user")
@click.argument("username")
@click.argument("email")
@click.argument("password")
@click.option("--admin", is_flag=True, help="Flag to create an admin user.")
def create_user(username, email, password, admin):
"""Creates a new user."""
if User.query.filter_by(email=email).first():
print(f"Error: Email {email} already exists.")
return
if User.query.filter_by(username=username).first():
print(f"Error: Username {username} already exists.")
return
user = User(username=username, email=email, status='active')
user.set_password(password)
if admin:
user.role = 'admin'
db.session.add(user)
db.session.commit()
print(f"User {username} created successfully.")
if admin:
print("Role: Admin")