from flask import abort, Blueprint, render_template, request, flash, redirect, url_for, current_app from sqlalchemy import or_ from sqlalchemy.orm.attributes import flag_modified from flask_login import login_required, current_user from app import db from app.forms import SearchForm, 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.filter( or_( Blacklist.normalized_email == normalized_email, Blacklist.username == search_term ), Blacklist.status == 'active' ).first() if search_result: current_app.logger.info(f'黑名单查询命中: {search_term} by {current_user.username}') flash(f'警告: 查询到与 "{search_term}" 相关的公开不良记录。详情如下。', 'warning') else: current_app.logger.info(f'黑名单查询未命中: {search_term} by {current_user.username}') 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(): """创建新举报""" if current_user.status != 'active': flash('您的账户尚未激活,无法提交举报。请等待管理员审核。', 'warning') return redirect(url_for('main.index')) 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(): existing_report = Report.query.filter_by( reported_email=form.reported_email.data, status='pending' ).first() if existing_report: current_app.logger.warning(f'重复举报: {form.reported_email.data} by {current_user.username}') flash(f'该邮箱已有待审核的举报 (#{existing_report.id}),请勿重复提交。', 'warning') return render_template('create_report.html', form=form) new_report = Report( reporter_id=current_user.id, reported_pt_site=form.reported_pt_site.data, reported_username=form.reported_username.data, reported_email=form.reported_email.data, reason_category=form.reason_category.data, description=form.description.data, status='pending' ) db.session.add(new_report) 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) 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() current_app.logger.error(f'无效证据链接: {url[:50]} by {current_user.username}') flash(f'链接 "{url[:50]}..." 格式不正确或不是支持的图片格式。请提供以 http/https 开头,以 .png, .jpg 等结尾的图片链接。', 'danger') return render_template('create_report.html', form=form) db.session.commit() current_app.logger.info(f'新举报提交: #{new_report.id} - {form.reported_email.data} by {current_user.username}') 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) 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/', methods=['GET', 'POST']) @login_required def report_detail(report_id): """举报详情""" report = Report.query.get_or_404(report_id) # 权限检查:管理员、信任用户、或举报提交者本人可以查看 if current_user.role not in ['admin', 'trust_user'] and report.reporter_id != current_user.id: flash('您无权查看此举报。', 'warning') return redirect(url_for('main.index')) 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() current_app.logger.info(f'举报评论: #{report.id} by {current_user.username}') flash('你的审核建议已成功提交。', 'success') return redirect(url_for('main.report_detail', report_id=report.id)) comments = report.comments.order_by(Comment.created_at.desc()).all() related_reports = Report.query.filter( Report.reported_email == report.reported_email, Report.id != report.id ).order_by(Report.created_at.desc()).all() return render_template( 'admin/report_detail.html', report=report, form=comment_form, revoke_form=revoke_form, comments=comments, related_reports=related_reports ) @main.route('/admin/report//process', methods=['POST']) @login_required @admin_required def process_report(report_id): """处理举报(批准/驳回)""" report = Report.query.get_or_404(report_id) action = request.form.get('action') if action not in ['confirm', 'invalidate']: flash('无效的操作。', 'danger') return redirect(url_for('main.report_detail', report_id=report_id)) if action == 'confirm': report.status = 'approved' normalized = normalize_email(report.reported_email) existing_blacklist = Blacklist.query.filter_by(normalized_email=normalized, status='active').first() if not existing_blacklist: new_blacklist_entry = Blacklist( email=report.reported_email, normalized_email=normalized, pt_site=report.reported_pt_site, uid=report.reported_username, report_ids=[report.id], reason_categories=[report.reason_category], username=report.reported_username or None ) db.session.add(new_blacklist_entry) current_app.logger.info(f'举报批准: #{report.id} - {report.reported_email} by {current_user.username}') flash('举报已批准,并已将相关信息添加到黑名单。', 'success') else: if report.reason_category not in existing_blacklist.reason_categories: existing_blacklist.reason_categories.append(report.reason_category) existing_blacklist.report_ids.append(report.id) flag_modified(existing_blacklist, 'reason_categories') flag_modified(existing_blacklist, 'report_ids') current_app.logger.info(f'举报合并: #{report.id} 合并到黑名单#{existing_blacklist.id} - 新增原因: {report.reason_category}') flash(f'举报已批准并合并到现有黑名单记录(新增违规原因:{report.reason_category})。', 'success') else: current_app.logger.info(f'举报批准: #{report.id} - 相同原因已存在,不合并') flash('举报已批准。该用户已有相同违规原因的记录,未进行合并。', 'info') other_pending = Report.query.filter( Report.reported_email == report.reported_email, Report.id != report.id, Report.status == 'pending' ).all() merged_count = 0 for other_report in other_pending: other_report.status = 'approved' bl = existing_blacklist or new_blacklist_entry if other_report.reason_category not in bl.reason_categories: bl.reason_categories.append(other_report.reason_category) bl.report_ids.append(other_report.id) flag_modified(bl, 'reason_categories') flag_modified(bl, 'report_ids') merged_count += 1 comment = Comment( body=f'该举报已自动批准(关联举报 #{report.id} 已确认违规)', report=other_report, author=current_user._get_current_object() ) db.session.add(comment) if other_pending: current_app.logger.info(f'自动批准关联举报: {len(other_pending)}个,合并{merged_count}个不同原因') flash(f'同时自动处理了 {len(other_pending)} 个相关举报(其中 {merged_count} 个不同原因已合并)。', 'info') elif action == 'invalidate': report.status = 'rejected' current_app.logger.info(f'举报驳回: #{report.id} by {current_user.username}') flash('举报状态已更新为"无效"。', 'success') else: flash('无效的操作。', 'danger') db.session.commit() return redirect(url_for('main.report_detail', report_id=report.id)) @main.route('/admin/report//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(): normalized = normalize_email(report.reported_email) blacklist_entry = Blacklist.query.filter_by(normalized_email=normalized, status='active').first() if blacklist_entry and report.id in blacklist_entry.report_ids: blacklist_entry.report_ids.remove(report.id) if report.reason_category in blacklist_entry.reason_categories: blacklist_entry.reason_categories.remove(report.reason_category) flag_modified(blacklist_entry, 'report_ids') flag_modified(blacklist_entry, 'reason_categories') if len(blacklist_entry.report_ids) == 0: db.session.delete(blacklist_entry) current_app.logger.warning(f'举报撤销: #{report.id} - 黑名单记录已删除') flash('举报已成功撤销,并已从黑名单中移除。', 'success') else: current_app.logger.warning(f'举报撤销: #{report.id} - 从黑名单中移除该举报') flash(f'举报已成功撤销,已从黑名单中移除该违规原因(剩余 {len(blacklist_entry.report_ids)} 个举报)。', 'success') report.status = 'revoked' 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() current_app.logger.warning(f'举报撤销: #{report.id} by {current_user.username} - 理由: {form.reason.data[:50]}') 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//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() 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() current_app.logger.info(f'用户更新: {user.username} - 角色:{user.role} 状态:{user.status} by {current_user.username}') 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//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() current_app.logger.info(f'用户注册批准: {user.username} ({user.email}) by {current_user.username}') flash(f'用户 {user.username} 的注册申请已被批准。', 'success') return redirect(url_for('main.pending_users')) @main.route('/admin/user//reject', methods=['POST']) @login_required @admin_required def reject_user(user_id): """拒绝用户注册""" user = User.query.get_or_404(user_id) username = user.username email = user.email db.session.delete(user) db.session.commit() current_app.logger.info(f'用户注册拒绝: {username} ({email}) by {current_user.username}') flash(f'用户 {username} 的注册申请已被拒绝并删除。', 'success') return redirect(url_for('main.pending_users')) @main.route('/appeal/create/', methods=['GET', 'POST']) @login_required def create_appeal(blacklist_id): """创建申诉""" blacklist_entry = Blacklist.query.get_or_404(blacklist_id) if not (current_user.email == blacklist_entry.email or (current_user.uid == blacklist_entry.uid and current_user.pt_site == blacklist_entry.pt_site)): current_app.logger.warning(f'非法申诉尝试: 用户{current_user.username} 尝试申诉黑名单#{blacklist_id}') abort(403) 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() current_app.logger.info(f'新申诉提交: #{appeal.id} - 黑名单#{blacklist_id} by {current_user.username}') flash('您的申诉已成功提交,请等待管理员审核。', 'success') return redirect(url_for('main.appeal_detail', appeal_id=appeal.id)) return render_template('create_appeal.html', form=form, entry=blacklist_entry) @main.route('/appeal/', 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': abort(403) form = AppealMessageForm() if form.validate_on_submit(): if appeal.status in ['approved', 'rejected']: flash('该申诉已关闭,无法继续对话。', 'warning') return redirect(url_for('main.appeal_detail', appeal_id=appeal.id)) 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() current_app.logger.info(f'申诉消息: #{appeal.id} by {current_user.username}') flash('消息已发送。', 'success') return redirect(url_for('main.appeal_detail', appeal_id=appeal.id)) messages = appeal.messages.order_by(AppealMessage.created_at.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//decide', methods=['POST']) @login_required @permission_required('admin') def decide_appeal(appeal_id): """处理申诉(批准/驳回)""" appeal = Appeal.query.get_or_404(appeal_id) if appeal.status in ['approved', 'rejected']: flash('该申诉已处理,无法重复操作。', 'warning') return redirect(url_for('main.appeal_detail', appeal_id=appeal.id)) action = request.form.get('action') if action == 'approve': blacklist_entry = appeal.blacklist_entry blacklist_entry.status = 'revoked' appeal.status = 'approved' for report_id in blacklist_entry.report_ids: report = Report.query.get(report_id) if report: report.status = 'overturned' db.session.add(report) db.session.add(blacklist_entry) db.session.add(appeal) db.session.commit() current_app.logger.info(f'申诉批准: #{appeal.id} by {current_user.username}') flash(f'申诉 #{appeal.id} 已被批准,对应的黑名单条目已撤销。', 'success') elif action == 'reject': appeal.status = 'rejected' db.session.add(appeal) db.session.commit() current_app.logger.info(f'申诉驳回: #{appeal.id} by {current_user.username}') flash(f'已驳回申诉 #{appeal.id}。', 'info') else: flash('无效操作。', 'danger') return redirect(url_for('main.appeal_list')) return redirect(url_for('main.appeal_list')) @main.route('/my/reports') @login_required def my_reports(): """我的举报列表""" page = request.args.get('page', 1, type=int) reports_pagination = Report.query.filter_by(reporter_id=current_user.id).order_by( Report.created_at.desc() ).paginate(page=page, per_page=20, error_out=False) return render_template('my_reports.html', reports=reports_pagination) @main.route('/my/appeals') @login_required def my_appeals(): """我的申诉列表""" page = request.args.get('page', 1, type=int) appeals_pagination = Appeal.query.filter_by(appealer_id=current_user.id).order_by( Appeal.created_at.desc() ).paginate(page=page, per_page=20, error_out=False) return render_template('my_appeals.html', appeals=appeals_pagination) @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() current_app.logger.info(f'新站点添加: {form.name.data} by {current_user.username}') 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//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: site_name = site.name db.session.delete(site) db.session.commit() current_app.logger.info(f'站点删除: {site_name} by {current_user.username}') flash(f'站点 "{site_name}" 已被删除。', 'success') return redirect(url_for('main.manage_sites')) @main.route('/admin/site//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 "禁用" current_app.logger.info(f'站点状态切换: {site.name} - {status} by {current_user.username}') flash(f'站点 "{site.name}" 已被{status}。', 'success') return redirect(url_for('main.manage_sites'))