Files
zlqy/app.py
2026-02-27 11:50:16 +08:00

4608 lines
195 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app.py
import os
import time
import json
import random
import string
import smtplib
from datetime import datetime, timedelta
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from functools import wraps
from flask import (
Flask, render_template, request, jsonify, session,
redirect, url_for, flash, abort, send_from_directory
)
from flask_caching import Cache
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
from captcha.image import ImageCaptcha
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
import fitz # PyMuPDF
from openai import OpenAI as DashScopeClient
# 引入数据库模型(添加 TeacherApplication
from models import db, User, Exam, Submission, Draft, Contest, ContestMembership, ContestApplication, Post, Reply, Poll, Report, Bookmark, Reaction, Notification, EditHistory, ContestRegistration, TeacherApplication, Friend, ExamBookmark, ChatRoom, ChatRoomMember, Message, MessageReaction, QuestionBankItem, InviteCode, SystemNotification
from flask_socketio import SocketIO, emit, join_room as sio_join, leave_room as sio_leave
load_dotenv()
app = Flask(__name__)
app.secret_key = os.urandom(24)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
# 内存存储(用于临时验证码)
captcha_store = {}
email_codes = {}
# 在线用户追踪(记录用户最后活跃时间)
online_users = {} # {user_id: last_active_timestamp}
# 数据库配置
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
# 缓存配置
app.config['CACHE_TYPE'] = 'SimpleCache' # 使用简单的内存缓存,如果需要可以改成 RedisCache
app.config['CACHE_DEFAULT_TIMEOUT'] = 60 # 默认缓存 60 秒
cache = Cache(app)
# 自定义 Jinja2 过滤器:渲染图片标签
import re as _re
from markupsafe import Markup, escape as _escape
@app.template_filter('render_images')
def render_images_filter(text):
if not text:
return text
escaped = _escape(text)
result = _re.sub(
r'\[img:(\/static\/uploads\/[^\]]+)\]',
r'<img src="\1" class="max-w-full rounded-lg border border-slate-200 my-2" style="max-height:400px">',
str(escaped)
)
return Markup(result)
# 阿里云号码认证服务客户端
acs_client = AcsClient(
os.getenv('ALIBABA_CLOUD_ACCESS_KEY_ID', ''),
os.getenv('ALIBABA_CLOUD_ACCESS_KEY_SECRET', ''),
'cn-hangzhou'
)
# ========== 辅助函数 ==========
def get_online_count():
"""获取5分钟内活跃的用户数"""
now = time.time()
cutoff = now - 300 # 5分钟
# 清理过期记录
expired = [uid for uid, ts in online_users.items() if ts < cutoff]
for uid in expired:
del online_users[uid]
return len(online_users)
@app.before_request
def track_online_user():
user = session.get('user')
if user:
online_users[user['id']] = time.time()
def generate_captcha_id():
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=16))
def generate_code(length=6):
return ''.join(random.choices(string.digits, k=length))
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if 'user' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated
# ========== 考试加密工具 ==========
import base64
import hashlib
EXAM_ENCRYPT_KEY = os.getenv('EXAM_ENCRYPT_KEY', 'default_exam_secret_key_2026')
def _derive_key(key_str):
return hashlib.sha256(key_str.encode()).digest()
def encrypt_questions(questions_json):
"""使用 XOR + base64 加密试卷内容"""
key = _derive_key(EXAM_ENCRYPT_KEY)
data = questions_json.encode('utf-8')
encrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])
return base64.b64encode(encrypted).decode('utf-8')
def decrypt_questions(encrypted_str):
"""解密试卷内容"""
key = _derive_key(EXAM_ENCRYPT_KEY)
data = base64.b64decode(encrypted_str.encode('utf-8'))
decrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])
return decrypted.decode('utf-8')
def get_exam_questions(exam):
"""统一获取考试题目(自动处理加密)"""
if exam.is_encrypted and exam.encrypted_questions:
return json.loads(decrypt_questions(exam.encrypted_questions))
return exam.get_questions()
def teacher_required(f):
@wraps(f)
def decorated(*args, **kwargs):
user = session.get('user')
if not user:
return redirect(url_for('login'))
if user.get('role') not in ('teacher', 'admin'):
return redirect(url_for('exam_list'))
return f(*args, **kwargs)
return decorated
def send_email(to_email, subject, html_content):
smtp_host = os.getenv('SMTP_HOST', 'smtp.163.com')
smtp_port = int(os.getenv('SMTP_PORT', 465))
smtp_user = os.getenv('SMTP_USER', '')
smtp_pass = os.getenv('SMTP_PASS', '')
msg = MIMEMultipart('alternative')
msg['From'] = f'"验证码" <{smtp_user}>'
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
with smtplib.SMTP_SSL(smtp_host, smtp_port) as server:
server.login(smtp_user, smtp_pass)
server.sendmail(smtp_user, to_email, msg.as_string())
def get_current_user():
return session.get('user')
def require_login():
user = get_current_user()
if not user:
abort(401)
return user
def require_admin_or_teacher():
user = require_login()
if user['role'] not in ['admin', 'teacher']:
abort(403)
return user
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
user = get_current_user()
if not user or user['role'] != 'admin':
abort(403)
return f(*args, **kwargs)
return decorated
# 积分等级相关
LEVEL_TITLES = {1:'新手上路',2:'初出茅庐',3:'小有名气',4:'渐入佳境',5:'驾轻就熟',6:'炉火纯青',7:'学富五车',8:'出类拔萃',9:'登峰造极',10:'一代宗师'}
def calc_level(points):
if points >= 5000: return 10
if points >= 3000: return 9
if points >= 2000: return 8
if points >= 1200: return 7
if points >= 800: return 6
if points >= 500: return 5
if points >= 300: return 4
if points >= 150: return 3
if points >= 50: return 2
return 1
def add_notification(user_id, ntype, content, from_user='', post_id=0):
notif = Notification(
user_id=user_id,
type=ntype,
content=content,
from_user=from_user,
post_id=post_id
)
db.session.add(notif)
db.session.commit()
def send_private_message(from_user_id, to_user_id, content, msg_type='system'):
"""发送私聊消息(查找或创建私聊室)"""
# 查找已有私聊室
my_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=from_user_id).subquery()
target_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=to_user_id).subquery()
room = ChatRoom.query.filter(
ChatRoom.type == 'private',
ChatRoom.id.in_(db.session.query(my_rooms.c.room_id)),
ChatRoom.id.in_(db.session.query(target_rooms.c.room_id))
).first()
if not room:
room = ChatRoom(type='private', creator_id=from_user_id)
db.session.add(room)
db.session.flush()
db.session.add(ChatRoomMember(room_id=room.id, user_id=from_user_id, role='member'))
db.session.add(ChatRoomMember(room_id=room.id, user_id=to_user_id, role='member'))
msg = Message(room_id=room.id, sender_id=from_user_id, type=msg_type, content=content)
db.session.add(msg)
db.session.flush()
sender = User.query.get(from_user_id)
socketio.emit('new_message', {
'id': msg.id, 'room_id': room.id, 'sender_id': from_user_id,
'sender_name': sender.name if sender else '系统',
'sender_avatar': (sender.avatar or '') if sender else '',
'type': msg_type, 'content': content,
'file_url': None, 'file_name': None, 'reply_to': None,
'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
}, room=f'room_{room.id}')
# ========== 杯赛讨论区发帖权限检查 ==========
def can_post_in_contest(user, contest):
"""检查用户是否可以在指定杯赛的讨论区发帖"""
if user['role'] in ['admin', 'teacher']:
return True
# 杯赛负责人或老师
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest.id).first()
if membership and membership.role in ['owner', 'teacher']:
return True
# 报名且至少参与一次考试
registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest.id).first()
if not registration:
return False
# 获取该杯赛下的所有考试 ID
exam_ids = [exam.id for exam in contest.exams]
if not exam_ids:
return False
# 查询用户在这些考试中的提交记录
submission_count = Submission.query.filter(
Submission.user_id == user['id'],
Submission.exam_id.in_(exam_ids)
).count()
return submission_count >= 1
def can_grade_exam(user, exam):
"""检查用户是否可以批改指定考试"""
if not user:
return False
if user.get('role') in ('teacher', 'admin'):
return True
# 杯赛老师可以批改所属杯赛的考试
if exam.contest_id:
membership = ContestMembership.query.filter_by(
user_id=user['id'], contest_id=exam.contest_id).first()
if membership and membership.role in ('owner', 'teacher'):
return True
return False
# ========== Jinja2 过滤器 ==========
@app.template_filter('fromjson')
def fromjson_filter(s):
"""将 JSON 字符串解析为 Python 对象"""
try:
return json.loads(s) if s else []
except (json.JSONDecodeError, TypeError):
return []
# ========== 上下文处理器 ==========
def get_display_name(user_id, base_name):
"""获取用户显示名称,如果是杯赛负责人或老师则附加杯赛信息"""
memberships = ContestMembership.query.filter_by(user_id=user_id).all()
if not memberships:
return base_name
role_names = []
for m in memberships:
contest = Contest.query.get(m.contest_id)
if contest and contest.status != 'abolished':
if m.role == 'owner':
role_names.append(f'{contest.name}负责人')
elif m.role == 'teacher':
role_names.append(f'{contest.name}老师')
if not role_names:
return base_name
return f"{base_name}{''.join(role_names)}"
@app.context_processor
def inject_user():
return {'user': get_current_user()}
@app.context_processor
def inject_display_name():
def get_user_display_name():
user = session.get('user')
if not user:
return ''
return get_display_name(user['id'], user['name'])
return dict(get_user_display_name=get_user_display_name, get_display_name=get_display_name)
# ========== 页面路由 ==========
@app.route('/')
def home():
online_count = get_online_count()
contest_count = Contest.query.count()
return render_template('home.html', online_count=online_count, contest_count=contest_count)
@app.route('/login', methods=['GET'])
def login():
return render_template('login.html')
@app.route('/register', methods=['GET'])
def register():
return render_template('register.html')
@app.route('/logout')
def logout():
session.pop('user', None)
return redirect(url_for('login'))
@app.route('/contests')
def contest_list():
return render_template('contest_list.html')
@app.route('/contests/<int:contest_id>')
def contest_detail(contest_id):
contest = Contest.query.get(contest_id)
if not contest:
return redirect(url_for('contest_list'))
user = get_current_user()
# 未发布的杯赛只有负责人和管理员可见
if not contest.visible:
if not user:
return redirect(url_for('contest_list'))
if user.get('role') != 'admin':
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
if not membership:
return redirect(url_for('contest_list'))
registered = False
can_post = False
is_member = False
is_owner = False
if user:
# 检查是否报名
registered = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() is not None
# 检查是否有发帖权限
can_post = can_post_in_contest(user, contest)
# 检查是否为成员(负责人或老师)
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_member = membership is not None
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
return render_template('contest_detail.html', contest=contest, registered=registered, can_post=can_post, is_member=is_member, is_owner=is_owner)
@app.route('/contests/<int:contest_id>/question-bank')
@login_required
def contest_question_bank(contest_id):
contest = Contest.query.get(contest_id)
if not contest:
return redirect(url_for('contest_list'))
if contest.status == 'abolished':
return redirect(url_for('contest_detail', contest_id=contest_id))
user = get_current_user()
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
if not membership and user.get('role') != 'admin':
return redirect(url_for('contest_detail', contest_id=contest_id))
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
return render_template('contest_question_bank.html', contest=contest, is_owner=is_owner)
@app.route('/contests/<int:contest_id>/edit')
@login_required
def contest_edit(contest_id):
contest = Contest.query.get(contest_id)
if not contest:
return redirect(url_for('contest_list'))
user = get_current_user()
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return redirect(url_for('contest_detail', contest_id=contest_id))
return render_template('contest_edit.html', contest=contest)
@app.route('/api/contests/<int:contest_id>/edit', methods=['PUT'])
@login_required
def api_contest_edit(contest_id):
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
user = get_current_user()
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return jsonify({'success': False, 'message': '没有权限'}), 403
data = request.get_json(force=True, silent=True)
if data.get('description') is not None:
contest.description = data['description']
if data.get('organizer') is not None:
contest.organizer = data['organizer']
if data.get('start_date') is not None:
contest.start_date = data['start_date']
if data.get('end_date') is not None:
contest.end_date = data['end_date']
if data.get('status') in ('upcoming', 'registering', 'ongoing', 'ended'):
contest.status = data['status']
db.session.commit()
return jsonify({'success': True})
ALLOWED_PAPER_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp'}
@app.route('/api/contests/<int:contest_id>/past-papers', methods=['POST'])
@login_required
def api_contest_upload_paper(contest_id):
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
user = get_current_user()
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return jsonify({'success': False, 'message': '没有权限'}), 403
if 'file' not in request.files:
return jsonify({'success': False, 'message': '没有选择文件'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': '没有选择文件'}), 400
ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else ''
if ext not in ALLOWED_PAPER_EXTENSIONS:
return jsonify({'success': False, 'message': '仅支持 PDF 和图片文件'}), 400
file.seek(0, 2)
if file.tell() > 10 * 1024 * 1024:
return jsonify({'success': False, 'message': '文件不能超过10MB'}), 400
file.seek(0)
year = request.form.get('year', '')
title = request.form.get('title', '')
if not year or not title:
return jsonify({'success': False, 'message': '年份和标题不能为空'}), 400
filename = f"paper_{contest_id}_{int(time.time())}_{secure_filename(file.filename)}"
upload_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
os.makedirs(upload_dir, exist_ok=True)
file.save(os.path.join(upload_dir, filename))
url = f'/static/uploads/{filename}'
papers = contest.get_past_papers()
papers.append({'year': year, 'title': title, 'file': url})
contest.set_past_papers(papers)
db.session.commit()
return jsonify({'success': True, 'papers': contest.get_past_papers()})
@app.route('/api/contests/<int:contest_id>/past-papers/<int:index>', methods=['DELETE'])
@login_required
def api_contest_delete_paper(contest_id, index):
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
user = get_current_user()
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return jsonify({'success': False, 'message': '没有权限'}), 403
papers = contest.get_past_papers()
if index < 0 or index >= len(papers):
return jsonify({'success': False, 'message': '索引无效'}), 400
papers.pop(index)
contest.set_past_papers(papers)
db.session.commit()
return jsonify({'success': True, 'papers': contest.get_past_papers()})
@app.route('/exams')
@login_required
def exam_list():
user = session.get('user')
search_query = request.args.get('q', '').strip()
subject_filter = request.args.get('subject', '').strip()
query = Exam.query
if subject_filter:
query = query.filter_by(subject=subject_filter)
if search_query:
query = query.filter(
(Exam.title.contains(search_query)) | (Exam.subject.contains(search_query))
)
all_exams = query.all()
all_subjects = db.session.query(Exam.subject).distinct().all()
all_subjects = [s[0] for s in all_subjects if s[0]]
user_submissions = {}
for sub in Submission.query.filter_by(user_id=user.get('id')).all():
user_submissions[sub.exam_id] = {
'id': sub.id,
'graded': sub.graded,
'score': sub.score
}
return render_template(
'exam_list.html',
exams=all_exams,
user_submissions=user_submissions,
search_query=search_query,
subject_filter=subject_filter,
all_subjects=all_subjects
)
@app.route('/exams/create')
@login_required
def exam_create():
user = session.get('user')
# 允许 teacher/admin 创建任意考试
if user.get('role') in ('teacher', 'admin'):
return render_template('exam_create.html')
# 允许杯赛负责人创建其杯赛的考试
contest_id = request.args.get('contest_id', type=int)
if contest_id:
membership = ContestMembership.query.filter_by(
user_id=user['id'], contest_id=contest_id, role='owner').first()
if membership:
return render_template('exam_create.html')
# 检查用户是否是任何杯赛的负责人
any_ownership = ContestMembership.query.filter_by(user_id=user['id'], role='owner').first()
if any_ownership:
return render_template('exam_create.html')
return redirect(url_for('exam_list'))
@app.route('/exams/<int:exam_id>')
@login_required
def exam_detail(exam_id):
exam = Exam.query.get(exam_id)
if not exam:
return redirect(url_for('exam_list'))
user = session.get('user')
# 密码验证
if exam.access_password:
verified_key = f'exam_verified_{exam_id}'
if not session.get(verified_key):
return render_template('exam_detail.html', exam=exam, need_password=True,
questions=[], existing_submission=None, draft=None,
schedule_status='available')
existing = Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
questions = get_exam_questions(exam)
# 检查预定时间
now = datetime.utcnow()
schedule_status = 'available' # available, not_started, ended
if exam.scheduled_start and now < exam.scheduled_start:
schedule_status = 'not_started'
if exam.scheduled_end and now > exam.scheduled_end:
schedule_status = 'ended'
return render_template('exam_detail.html', exam=exam, questions=questions,
existing_submission=existing, draft=draft,
schedule_status=schedule_status, need_password=False)
@app.route('/exams/<int:exam_id>/result')
@login_required
def exam_result(exam_id):
exam = Exam.query.get(exam_id)
if not exam:
return redirect(url_for('exam_list'))
user = session.get('user')
submission = Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
if not submission:
return redirect(url_for('exam_detail', exam_id=exam_id))
# 检查成绩公布时间
score_hidden = False
if exam.score_release_time and datetime.utcnow() < exam.score_release_time:
if user.get('role') != 'teacher' and user.get('role') != 'admin':
score_hidden = True
questions = get_exam_questions(exam)
answers = submission.get_answers()
question_scores = submission.get_question_scores()
return render_template('exam_result.html', exam=exam, submission=submission,
questions=questions, answers=answers, question_scores=question_scores,
score_hidden=score_hidden)
@app.route('/exams/<int:exam_id>/submissions')
@login_required
def exam_submissions(exam_id):
exam = Exam.query.get(exam_id)
if not exam:
return redirect(url_for('exam_list'))
user = session.get('user')
if not can_grade_exam(user, exam):
return redirect(url_for('exam_list'))
subs = Submission.query.filter_by(exam_id=exam_id).all()
stats = {}
if subs:
graded_subs = [s for s in subs if s.graded]
scores = [s.score for s in graded_subs] if graded_subs else []
stats = {
'total': len(subs),
'graded': len(graded_subs),
'ungraded': len(subs) - len(graded_subs),
'avg_score': round(sum(scores) / len(scores), 1) if scores else 0,
'max_score': max(scores) if scores else 0,
'min_score': min(scores) if scores else 0,
}
next_ungraded = None
for s in subs:
if not s.graded:
next_ungraded = s.id
break
return render_template('exam_submissions.html', exam=exam, submissions=subs, stats=stats, next_ungraded=next_ungraded)
@app.route('/exams/<int:exam_id>/grade/<int:sub_id>')
@login_required
def exam_grade(exam_id, sub_id):
exam = Exam.query.get(exam_id)
sub = Submission.query.get(sub_id)
if not exam or not sub:
return redirect(url_for('exam_list'))
user = session.get('user')
if not can_grade_exam(user, exam):
return redirect(url_for('exam_list'))
next_ungraded = None
next_sub = Submission.query.filter_by(exam_id=exam_id, graded=False).filter(Submission.id != sub_id).first()
if next_sub:
next_ungraded = next_sub.id
questions = get_exam_questions(exam)
answers = sub.get_answers()
question_scores = sub.get_question_scores()
return render_template('exam_grade.html', exam=exam, submission=sub,
questions=questions, answers=answers, question_scores=question_scores,
next_ungraded=next_ungraded)
@app.route('/exams/<int:exam_id>/print')
@login_required
def exam_print(exam_id):
exam = Exam.query.get(exam_id)
if not exam:
return redirect(url_for('exam_list'))
questions = get_exam_questions(exam)
return render_template('exam_print.html', exam=exam, questions=questions)
@app.route('/forum')
def forum():
return render_template('forum.html')
@app.route('/profile')
@login_required
def profile():
user_data = session.get('user')
user = User.query.get(user_data['id'])
post_count = Post.query.filter_by(author_id=user.id).count()
reply_count = Reply.query.filter_by(author_id=user.id).count()
likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=user.id).scalar() or 0
points = post_count * 10 + reply_count * 3 + likes_received * 2
level = calc_level(points)
return render_template('profile.html', profile_user=user, post_count=post_count,
reply_count=reply_count, likes_received=likes_received,
points=points, level=level)
# ========== 好友系统 API ==========
@app.route('/api/user/friends')
@login_required
def api_user_friends():
user_id = session['user']['id']
friendships = Friend.query.filter(
((Friend.user_id == user_id) | (Friend.friend_id == user_id)) &
(Friend.status == 'accepted')
).all()
friends = []
for f in friendships:
friend_user = User.query.get(f.friend_id if f.user_id == user_id else f.user_id)
if friend_user:
friends.append({
'id': friend_user.id,
'name': friend_user.name,
'avatar': friend_user.avatar or '',
'created_at': f.created_at.strftime('%Y-%m-%d')
})
return jsonify({'success': True, 'friends': friends})
@app.route('/api/friend/add', methods=['POST'])
@login_required
def api_add_friend():
user_id = session['user']['id']
data = request.get_json(force=True, silent=True)
friend_id = data.get('friend_id')
if not friend_id or friend_id == user_id:
return jsonify({'success': False, 'message': '无效的好友ID'}), 400
existing = Friend.query.filter(
((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) |
((Friend.user_id == friend_id) & (Friend.friend_id == user_id))
).first()
if existing:
return jsonify({'success': False, 'message': '已经是好友或已发送请求'}), 400
friend_req = Friend(user_id=user_id, friend_id=friend_id, status='pending')
db.session.add(friend_req)
# 发送通知给对方
sender_name = session['user'].get('name', '未知用户')
notif = Notification(user_id=friend_id, type='friend_request', content=f'{sender_name} 请求添加你为好友', from_user=sender_name)
db.session.add(notif)
db.session.commit()
return jsonify({'success': True, 'message': '好友请求已发送'})
@app.route('/api/friend/accept/<int:request_id>', methods=['POST'])
@login_required
def api_accept_friend(request_id):
user_id = session['user']['id']
req = Friend.query.get(request_id)
if not req or req.friend_id != user_id or req.status != 'pending':
return jsonify({'success': False, 'message': '请求不存在或无权操作'}), 404
req.status = 'accepted'
db.session.commit()
return jsonify({'success': True, 'message': '好友已添加'})
@app.route('/api/friend/reject/<int:request_id>', methods=['POST'])
@login_required
def api_reject_friend(request_id):
user_id = session['user']['id']
req = Friend.query.get(request_id)
if not req or req.friend_id != user_id or req.status != 'pending':
return jsonify({'success': False, 'message': '请求不存在或无权操作'}), 404
db.session.delete(req)
db.session.commit()
return jsonify({'success': True, 'message': '已拒绝'})
@app.route('/api/friend/requests')
@login_required
def api_friend_requests():
user_id = session['user']['id']
pending = Friend.query.filter_by(friend_id=user_id, status='pending').all()
result = []
for f in pending:
u = User.query.get(f.user_id)
if u:
result.append({'id': f.id, 'user_id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'created_at': f.created_at.strftime('%Y-%m-%d')})
return jsonify({'success': True, 'requests': result})
@app.route('/api/users/search')
@login_required
def api_users_search():
user_id = session['user']['id']
q = request.args.get('q', '').strip()
if not q:
return jsonify({'success': True, 'users': []})
users = User.query.filter(User.name.contains(q), User.id != user_id).limit(20).all()
result = []
for u in users:
existing = Friend.query.filter(
((Friend.user_id == user_id) & (Friend.friend_id == u.id)) |
((Friend.user_id == u.id) & (Friend.friend_id == user_id))
).first()
status = existing.status if existing else None
result.append({'id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'friend_status': status})
return jsonify({'success': True, 'users': result})
# ========== 我的帖子 API ==========
@app.route('/api/user/posts')
@login_required
def api_user_posts():
user_id = session['user']['id']
posts = Post.query.filter_by(author_id=user_id).order_by(Post.created_at.desc()).all()
data = [{
'id': p.id,
'title': p.title,
'content': p.content[:100] + '...' if len(p.content) > 100 else p.content,
'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'),
'replies': p.replies_count,
'likes': p.likes
} for p in posts]
return jsonify({'success': True, 'posts': data})
# ========== 试卷收藏 API ==========
@app.route('/api/user/exam-bookmarks')
@login_required
def api_user_exam_bookmarks():
user_id = session['user']['id']
bookmarks = ExamBookmark.query.filter_by(user_id=user_id).order_by(ExamBookmark.created_at.desc()).all()
data = []
for bm in bookmarks:
exam = bm.exam
if exam:
data.append({
'id': exam.id,
'title': exam.title,
'subject': exam.subject,
'total_score': exam.total_score,
'duration': exam.duration,
'status': exam.status,
'bookmarked_at': bm.created_at.strftime('%Y-%m-%d %H:%M')
})
return jsonify({'success': True, 'bookmarks': data})
@app.route('/api/user/exam-history')
@login_required
def api_user_exam_history():
user = session['user']
subs = Submission.query.filter_by(user_id=user['id']).order_by(Submission.submitted_at.desc()).all()
now = datetime.utcnow()
result = []
for s in subs:
exam = s.exam
if not exam:
continue
contest_name = ''
if exam.contest_id:
contest = Contest.query.get(exam.contest_id)
if contest:
contest_name = contest.name
# 成绩公布时间控制
score_visible = True
if exam.score_release_time and now < exam.score_release_time and user.get('role') not in ('teacher', 'admin'):
score_visible = False
result.append({
'exam_id': exam.id,
'title': exam.title,
'subject': exam.subject,
'total_score': exam.total_score,
'contest_name': contest_name,
'submitted_at': s.submitted_at.strftime('%Y-%m-%d %H:%M') if s.submitted_at else '',
'graded': s.graded,
'score': s.score if score_visible else None
})
return jsonify({'success': True, 'history': result})
@app.route('/api/exams/<int:exam_id>/bookmark', methods=['POST', 'DELETE'])
@login_required
def api_toggle_exam_bookmark(exam_id):
user_id = session['user']['id']
if request.method == 'POST':
existing = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first()
if existing:
return jsonify({'success': False, 'message': '已收藏'}), 400
bm = ExamBookmark(user_id=user_id, exam_id=exam_id)
db.session.add(bm)
db.session.commit()
return jsonify({'success': True, 'message': '收藏成功'})
else:
bm = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first()
if bm:
db.session.delete(bm)
db.session.commit()
return jsonify({'success': True, 'message': '已取消收藏'})
@app.route('/api/user/avatar', methods=['POST'])
@login_required
def api_upload_avatar():
user_data = session.get('user')
if 'file' not in request.files:
return jsonify({'success': False, 'message': '没有选择文件'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': '没有选择文件'}), 400
if not allowed_file(file.filename):
return jsonify({'success': False, 'message': '不支持的文件格式'}), 400
file.seek(0, 2)
if file.tell() > MAX_FILE_SIZE:
return jsonify({'success': False, 'message': '文件不能超过10MB'}), 400
file.seek(0)
ext = file.filename.rsplit('.', 1)[1].lower()
filename = f"avatar_{user_data['id']}_{int(time.time())}.{ext}"
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
filepath = os.path.join(UPLOAD_FOLDER, filename)
file.save(filepath)
url = f'/static/uploads/{filename}'
user = User.query.get(user_data['id'])
user.avatar = url
db.session.commit()
session['user'] = {**user_data, 'avatar': url}
return jsonify({'success': True, 'url': url})
@app.route('/api/user/change-name', methods=['POST'])
@login_required
def api_change_name():
user_data = session['user']
user = User.query.get(user_data['id'])
if not user:
return jsonify({'success': False, 'message': '用户不存在'}), 404
# 每月仅可修改一次
if user.name_changed_at:
from datetime import timedelta
if datetime.utcnow() - user.name_changed_at < timedelta(days=30):
remaining = 30 - (datetime.utcnow() - user.name_changed_at).days
return jsonify({'success': False, 'message': f'每月仅可修改一次用户名,还需等待{remaining}'}), 400
data = request.get_json(force=True, silent=True)
new_name = data.get('name', '').strip()
if not new_name or len(new_name) > 80:
return jsonify({'success': False, 'message': '用户名不能为空且不超过80字符'}), 400
if new_name == user.name:
return jsonify({'success': False, 'message': '新用户名与当前相同'}), 400
existing = User.query.filter(User.name == new_name, User.id != user.id).first()
if existing:
return jsonify({'success': False, 'message': '该用户名已被使用'}), 400
user.name = new_name
user.name_changed_at = datetime.utcnow()
db.session.commit()
session['user'] = {**user_data, 'name': new_name}
return jsonify({'success': True, 'message': '用户名修改成功', 'name': new_name})
# ========== 杯赛申请相关页面 ==========
@app.route('/apply-contest', methods=['GET', 'POST'])
@login_required
def apply_contest():
if request.method == 'POST':
name = request.form.get('name')
organizer = request.form.get('organizer')
description = request.form.get('description')
contact = request.form.get('contact')
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
total_score = request.form.get('total_score', type=int)
responsible_person = request.form.get('responsible_person')
responsible_phone = request.form.get('responsible_phone')
responsible_email = request.form.get('responsible_email')
organization = request.form.get('organization')
if not all([name, organizer, description, contact, start_date, end_date, total_score,
responsible_person, responsible_phone, responsible_email, organization]):
flash('请填写所有必填项')
return redirect(url_for('apply_contest'))
if total_score < 1:
flash('满分分数必须大于0')
return redirect(url_for('apply_contest'))
app = ContestApplication(
user_id=session['user']['id'],
name=name,
organizer=organizer,
description=description,
contact=contact,
start_date=start_date,
end_date=end_date,
total_score=total_score,
responsible_person=responsible_person,
responsible_phone=responsible_phone,
responsible_email=responsible_email,
organization=organization
)
db.session.add(app)
db.session.commit()
# 通知所有管理员有新的杯赛申请
admins = User.query.filter_by(role='admin').all()
for admin_user in admins:
add_notification(
admin_user.id,
'contest_application',
f'用户 {session["user"]["name"]} 申请举办杯赛「{name}」,请审核。',
from_user=session['user']['name'],
post_id=app.id
)
flash('申请已提交,请等待管理员审核')
return redirect(url_for('contest_list'))
return render_template('apply_contest.html')
@app.route('/admin/contest-applications')
@admin_required
def admin_contest_applications():
apps = ContestApplication.query.order_by(ContestApplication.applied_at.desc()).all()
return render_template('admin_contest_applications.html', apps=apps)
@app.route('/admin/contest-applications/<int:app_id>/approve', methods=['POST'])
@admin_required
def approve_contest_application(app_id):
app = ContestApplication.query.get_or_404(app_id)
if app.status != 'pending':
flash('该申请已处理')
return redirect(url_for('admin_contest_applications'))
contest = Contest(
name=app.name,
organizer=app.organizer,
description=app.description,
start_date=app.start_date or '待定',
end_date=app.end_date or '待定',
total_score=app.total_score or 150,
visible=False,
status='upcoming',
participants=0,
created_by=session['user']['id'],
responsible_person=app.responsible_person,
responsible_phone=app.responsible_phone,
responsible_email=app.responsible_email,
organization=app.organization
)
db.session.add(contest)
db.session.flush()
membership = ContestMembership(
user_id=app.user_id,
contest_id=contest.id,
role='owner'
)
db.session.add(membership)
app.status = 'approved'
app.reviewed_at = datetime.utcnow()
# 自动创建杯赛讨论群
chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群',
creator_id=app.user_id, contest_id=contest.id)
db.session.add(chatroom)
db.session.flush()
db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=app.user_id, role='admin'))
# 通知申请人审核通过
add_notification(app.user_id, 'contest_result',
f'您申请举办的杯赛「{app.name}」已通过审核!杯赛当前仅您和管理员可见,完善资料后请手动发布。', from_user='系统')
db.session.commit()
flash('申请已批准,杯赛已创建')
return redirect(url_for('admin_contest_applications'))
@app.route('/admin/contest-applications/<int:app_id>/reject', methods=['POST'])
@admin_required
def reject_contest_application(app_id):
app = ContestApplication.query.get_or_404(app_id)
if app.status != 'pending':
flash('该申请已处理')
return redirect(url_for('admin_contest_applications'))
app.status = 'rejected'
app.reviewed_at = datetime.utcnow()
# 通知申请人审核未通过
add_notification(app.user_id, 'contest_result',
f'您申请举办的杯赛「{app.name}」未通过审核。', from_user='系统')
db.session.commit()
flash('申请已拒绝')
return redirect(url_for('admin_contest_applications'))
@app.route('/admin/contests/create', methods=['GET', 'POST'])
@admin_required
def admin_create_contest():
if request.method == 'POST':
name = request.form.get('name', '').strip()
organizer = request.form.get('organizer', '').strip()
description = request.form.get('description', '').strip()
start_date = request.form.get('start_date', '').strip() or '待定'
end_date = request.form.get('end_date', '').strip() or '待定'
total_score = int(request.form.get('total_score', 150) or 150)
status = request.form.get('status', 'upcoming')
responsible_person = request.form.get('responsible_person', '').strip()
responsible_phone = request.form.get('responsible_phone', '').strip()
responsible_email = request.form.get('responsible_email', '').strip()
organization = request.form.get('organization', '').strip()
if not name or not organizer:
flash('杯赛名称和主办方为必填项')
return render_template('admin_create_contest.html')
contest = Contest(
name=name, organizer=organizer, description=description,
start_date=start_date, end_date=end_date, total_score=total_score,
visible=True, status=status, participants=0,
created_by=session['user']['id'],
responsible_person=responsible_person,
responsible_phone=responsible_phone,
responsible_email=responsible_email,
organization=organization
)
db.session.add(contest)
db.session.flush()
membership = ContestMembership(
user_id=session['user']['id'],
contest_id=contest.id,
role='owner'
)
db.session.add(membership)
chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群',
creator_id=session['user']['id'], contest_id=contest.id)
db.session.add(chatroom)
db.session.flush()
db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=session['user']['id'], role='admin'))
db.session.commit()
flash('杯赛已创建并发布')
return redirect(url_for('contest_detail', contest_id=contest.id))
return render_template('admin_create_contest.html')
# ========== 通知 API ==========
@app.route('/api/notifications')
@login_required
def api_notifications():
notifs = Notification.query.filter_by(user_id=session['user']['id']).order_by(Notification.created_at.desc()).limit(50).all()
result = []
for n in notifs:
item = {
'id': n.id,
'type': n.type,
'content': n.content,
'from_user': n.from_user,
'post_id': n.post_id,
'read': n.read,
'created_at': n.created_at.strftime('%Y-%m-%d %H:%M')
}
if n.type == 'contest_application' and n.post_id:
ca = ContestApplication.query.get(n.post_id)
if ca:
item['application_status'] = ca.status
if n.type == 'teacher_application' and n.post_id:
ta = TeacherApplication.query.get(n.post_id)
if ta:
item['application_status'] = ta.status
item['application_id'] = ta.id
contest = Contest.query.get(ta.contest_id)
item['contest_name'] = contest.name if contest else ''
applicant = User.query.get(ta.user_id)
item['applicant_name'] = applicant.name if applicant else ''
result.append(item)
return jsonify({'success': True, 'notifications': result})
@app.route('/api/notifications/unread-count')
@login_required
def api_notifications_unread_count():
count = Notification.query.filter_by(user_id=session['user']['id'], read=False).count()
return jsonify({'success': True, 'count': count})
@app.route('/api/notifications/<int:nid>/read', methods=['POST'])
@login_required
def api_mark_notification_read(nid):
n = Notification.query.get_or_404(nid)
if n.user_id != session['user']['id']:
return jsonify({'success': False}), 403
n.read = True
db.session.commit()
return jsonify({'success': True})
@app.route('/api/notifications/read-all', methods=['POST'])
@login_required
def api_mark_all_notifications_read():
Notification.query.filter_by(user_id=session['user']['id'], read=False).update({'read': True})
db.session.commit()
return jsonify({'success': True})
@app.route('/notifications')
@login_required
def notifications_page():
return redirect(url_for('chat'))
# ========== 系统公告管理 API ==========
@app.route('/api/system-notifications')
def api_system_notifications():
"""获取系统公告列表(所有人可见)"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
q = SystemNotification.query.order_by(SystemNotification.pinned.desc(), SystemNotification.created_at.desc())
pagination = q.paginate(page=page, per_page=per_page, error_out=False)
items = []
for sn in pagination.items:
author = User.query.get(sn.author_id)
items.append({
'id': sn.id, 'title': sn.title, 'content': sn.content,
'pinned': sn.pinned,
'author_name': author.name if author else '系统',
'created_at': sn.created_at.strftime('%Y-%m-%d %H:%M'),
'updated_at': sn.updated_at.strftime('%Y-%m-%d %H:%M') if sn.updated_at else ''
})
return jsonify({'success': True, 'notifications': items, 'total': pagination.total, 'pages': pagination.pages, 'page': page})
@app.route('/api/system-notifications', methods=['POST'])
@admin_required
def api_create_system_notification():
"""管理员发布系统公告"""
data = request.get_json(force=True, silent=True) or {}
title = (data.get('title') or '').strip()
content = (data.get('content') or '').strip()
if not title or not content:
return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
sn = SystemNotification(
title=title, content=content,
author_id=session['user']['id'],
pinned=bool(data.get('pinned', False))
)
db.session.add(sn)
db.session.commit()
# 给所有用户发送个人通知
admin_name = session['user'].get('name', '管理员')
all_users = User.query.all()
for u in all_users:
add_notification(u.id, 'system_announcement', f'{title}{content}', from_user=admin_name)
return jsonify({'success': True, 'message': '通知已发布', 'id': sn.id})
@app.route('/api/system-notifications/<int:sn_id>', methods=['PUT'])
@admin_required
def api_update_system_notification(sn_id):
"""管理员修改系统公告"""
sn = SystemNotification.query.get_or_404(sn_id)
data = request.get_json(force=True, silent=True) or {}
if 'title' in data:
sn.title = data['title'].strip()
if 'content' in data:
sn.content = data['content'].strip()
if 'pinned' in data:
sn.pinned = bool(data['pinned'])
db.session.commit()
return jsonify({'success': True, 'message': '公告已更新'})
@app.route('/api/system-notifications/<int:sn_id>', methods=['DELETE'])
@admin_required
def api_delete_system_notification(sn_id):
"""管理员删除系统公告"""
sn = SystemNotification.query.get_or_404(sn_id)
db.session.delete(sn)
db.session.commit()
return jsonify({'success': True, 'message': '公告已删除'})
@app.route('/admin/notifications')
@admin_required
def admin_notifications():
return render_template('admin_notifications.html')
@app.route('/api/admin/search-users')
@admin_required
def api_admin_search_users():
q = request.args.get('q', '').strip()
if not q:
return jsonify({'success': True, 'users': []})
users = User.query.filter(User.name.contains(q)).limit(20).all()
return jsonify({'success': True, 'users': [
{'id': u.id, 'name': u.name, 'role': u.role} for u in users
]})
@app.route('/api/admin/send-private-notification', methods=['POST'])
@admin_required
def api_admin_send_private_notification():
data = request.get_json(force=True, silent=True)
user_ids = data.get('user_ids', [])
content = data.get('content', '').strip()
if not user_ids or not content:
return jsonify({'success': False, 'message': '请选择用户并输入内容'}), 400
admin_name = session['user'].get('name', '管理员')
for uid in user_ids:
add_notification(uid, 'system_announcement', content, from_user=admin_name)
return jsonify({'success': True, 'message': f'已发送给 {len(user_ids)} 位用户'})
@app.route('/api/contest-applications/<int:app_id>/approve', methods=['POST'])
@admin_required
def api_approve_contest(app_id):
ca = ContestApplication.query.get_or_404(app_id)
if ca.status != 'pending':
return jsonify({'success': False, 'message': '该申请已处理'}), 400
contest = Contest(
name=ca.name, organizer=ca.organizer, description=ca.description,
start_date='待定', end_date='待定', status='upcoming',
participants=0, created_by=session['user']['id']
)
db.session.add(contest)
db.session.flush()
db.session.add(ContestMembership(user_id=ca.user_id, contest_id=contest.id, role='owner'))
ca.status = 'approved'
ca.reviewed_at = datetime.utcnow()
chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群',
creator_id=ca.user_id, contest_id=contest.id)
db.session.add(chatroom)
db.session.flush()
db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=ca.user_id, role='admin'))
add_notification(ca.user_id, 'contest_result',
f'您申请举办的杯赛「{ca.name}」已通过审核!', from_user='系统')
db.session.commit()
return jsonify({'success': True, 'message': '已批准'})
@app.route('/api/teacher-applications/<int:app_id>/approve', methods=['POST'])
@teacher_required
def api_approve_teacher(app_id):
user = session['user']
ta = TeacherApplication.query.get_or_404(app_id)
if ta.status != 'pending':
return jsonify({'success': False, 'message': '该申请已处理'}), 400
# 权限检查:管理员或该杯赛负责人
if user['role'] != 'admin':
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id, role='owner').first()
if not membership:
return jsonify({'success': False, 'message': '无权限'}), 403
existing = ContestMembership.query.filter_by(user_id=ta.user_id, contest_id=ta.contest_id).first()
if existing:
ta.status = 'rejected'
ta.reviewed_at = datetime.utcnow()
ta.reviewed_by = user['id']
db.session.commit()
return jsonify({'success': False, 'message': '用户已是杯赛成员'}), 400
# 生成一次性邀请码
code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
while InviteCode.query.filter_by(code=code_str).first():
code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
invite = InviteCode(code=code_str, user_id=ta.user_id, application_id=ta.id)
db.session.add(invite)
ta.status = 'approved'
ta.reviewed_at = datetime.utcnow()
ta.reviewed_by = user['id']
contest = Contest.query.get(ta.contest_id)
contest_name = contest.name if contest else ''
# 通过私聊发送邀请码
msg_content = (
f'恭喜!您申请成为杯赛「{contest_name}」老师已通过审核。\n'
f'请使用以下邀请码激活您的教师身份:\n\n'
f'🎫 邀请码:{code_str}\n\n'
f'请前往「申请成为老师」页面,在邀请码输入框中输入此码完成激活。\n'
f'注意:此邀请码仅限您本人使用,且仅限一次。'
)
send_private_message(user['id'], ta.user_id, msg_content, msg_type='system')
add_notification(ta.user_id, 'teacher_result',
f'您申请成为杯赛「{contest_name}」老师已通过审核,请查看私聊消息获取邀请码。',
from_user='系统')
db.session.commit()
return jsonify({'success': True, 'message': '已批准,邀请码已通过私聊发送给老师'})
@app.route('/api/teacher-applications/<int:app_id>/reject', methods=['POST'])
@teacher_required
def api_reject_teacher(app_id):
user = session['user']
ta = TeacherApplication.query.get_or_404(app_id)
if ta.status != 'pending':
return jsonify({'success': False, 'message': '该申请已处理'}), 400
if user['role'] != 'admin':
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id, role='owner').first()
if not membership:
return jsonify({'success': False, 'message': '无权限'}), 403
ta.status = 'rejected'
ta.reviewed_at = datetime.utcnow()
ta.reviewed_by = user['id']
contest = Contest.query.get(ta.contest_id)
add_notification(ta.user_id, 'teacher_result',
f'您申请成为杯赛「{contest.name if contest else ""}」老师未通过审核。',
from_user='系统')
db.session.commit()
return jsonify({'success': True, 'message': '已拒绝'})
@app.route('/api/contest-applications/<int:app_id>/reject', methods=['POST'])
@admin_required
def api_reject_contest(app_id):
ca = ContestApplication.query.get_or_404(app_id)
if ca.status != 'pending':
return jsonify({'success': False, 'message': '该申请已处理'}), 400
ca.status = 'rejected'
ca.reviewed_at = datetime.utcnow()
add_notification(ca.user_id, 'contest_result',
f'您申请举办的杯赛「{ca.name}」未通过审核。', from_user='系统')
db.session.commit()
return jsonify({'success': True, 'message': '已拒绝'})
# ========== 教师申请路由(针对具体杯赛)==========
@app.route('/apply-teacher', methods=['GET', 'POST'])
@login_required
def apply_teacher():
if request.method == 'POST':
contest_id = request.form.get('contest_id')
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip()
reason = request.form.get('reason', '').strip()
if not contest_id or not name or not email or not reason:
flash('请填写完整信息', 'error')
return redirect(url_for('apply_teacher'))
contest = Contest.query.get(contest_id)
if not contest:
flash('杯赛不存在', 'error')
return redirect(url_for('apply_teacher'))
user = session['user']
# 检查是否已经申请过该杯赛且为pending
existing = TeacherApplication.query.filter_by(user_id=user['id'], contest_id=contest_id, status='pending').first()
if existing:
flash('您已提交过该杯赛的申请,请耐心等待审核', 'error')
return redirect(url_for('apply_teacher'))
# 检查是否已经是该杯赛的老师或负责人
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
if membership and membership.role in ['owner', 'teacher']:
flash('您已经是该杯赛的老师或负责人', 'error')
return redirect(url_for('contest_detail', contest_id=contest_id))
# 创建申请记录
appli = TeacherApplication(
user_id=user['id'],
contest_id=contest_id,
name=name,
email=email,
reason=reason
)
db.session.add(appli)
db.session.commit()
# 通知杯赛负责人
owner_memberships = ContestMembership.query.filter_by(contest_id=contest_id, role='owner').all()
for om in owner_memberships:
add_notification(om.user_id, 'teacher_application',
f'用户 {name} 申请成为杯赛「{contest.name}」的老师,请审核。',
from_user=user['name'], post_id=appli.id)
# 通知所有管理员
admins = User.query.filter_by(role='admin').all()
for admin_user in admins:
add_notification(admin_user.id, 'teacher_application',
f'用户 {name} 申请成为杯赛「{contest.name}」的老师,请审核。',
from_user=user['name'], post_id=appli.id)
flash('申请已提交,管理员或杯赛负责人会尽快审核', 'success')
return redirect(url_for('contest_detail', contest_id=contest_id))
# GET 请求:显示申请表单,可传入 contest_id 预选
contest_id = request.args.get('contest_id', type=int)
contests = Contest.query.all()
selected_contest = Contest.query.get(contest_id) if contest_id else None
return render_template('apply_teacher.html', contests=contests, selected_contest=selected_contest)
# ========== 管理后台教师申请审核 ==========
@app.route('/admin/teacher-applications')
@teacher_required
def admin_teacher_applications():
user = session['user']
# 管理员可见所有申请,杯赛负责人只能看到自己负责杯赛的申请
if user['role'] == 'admin':
apps = TeacherApplication.query.filter_by(status='pending').order_by(TeacherApplication.applied_at.desc()).all()
else:
# 获取用户作为负责人的所有杯赛ID
owned_contests = [m.contest_id for m in ContestMembership.query.filter_by(user_id=user['id'], role='owner').all()]
if not owned_contests:
apps = []
else:
apps = TeacherApplication.query.filter(
TeacherApplication.status == 'pending',
TeacherApplication.contest_id.in_(owned_contests)
).order_by(TeacherApplication.applied_at.desc()).all()
return render_template('admin_teacher_applications.html', apps=apps)
@app.route('/admin/teacher-applications/<int:app_id>/approve', methods=['POST'])
@teacher_required
def approve_teacher_application(app_id):
user = session['user']
app = TeacherApplication.query.get_or_404(app_id)
if app.status != 'pending':
flash('该申请已处理', 'error')
return redirect(url_for('admin_teacher_applications'))
# 权限检查:管理员或该杯赛负责人
if user['role'] != 'admin':
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=app.contest_id, role='owner').first()
if not membership:
flash('您没有权限审批此申请', 'error')
return redirect(url_for('admin_teacher_applications'))
# 检查是否已经是该杯赛成员
existing = ContestMembership.query.filter_by(user_id=app.user_id, contest_id=app.contest_id).first()
if existing:
app.status = 'rejected' # 已存在,无法再次添加
app.reviewed_at = datetime.utcnow()
app.reviewed_by = user['id']
db.session.commit()
flash('用户已是杯赛成员,申请已拒绝', 'error')
return redirect(url_for('admin_teacher_applications'))
# 生成一次性邀请码 TC-XXXXXXXX
code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
while InviteCode.query.filter_by(code=code_str).first():
code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
invite = InviteCode(code=code_str, user_id=app.user_id, application_id=app.id)
db.session.add(invite)
app.status = 'approved'
app.reviewed_at = datetime.utcnow()
app.reviewed_by = user['id']
contest = Contest.query.get(app.contest_id)
contest_name = contest.name if contest else ''
# 通过私聊发送邀请码给老师
msg_content = (
f'恭喜!您申请成为杯赛「{contest_name}」老师已通过审核。\n'
f'请使用以下邀请码激活您的教师身份:\n\n'
f'🎫 邀请码:{code_str}\n\n'
f'请前往「申请成为老师」页面,在邀请码输入框中输入此码完成激活。\n'
f'注意:此邀请码仅限您本人使用,且仅限一次。'
)
send_private_message(user['id'], app.user_id, msg_content, msg_type='system')
# 通知申请人去查看消息
add_notification(app.user_id, 'teacher_result',
f'您申请成为杯赛「{contest_name}」老师已通过审核,请查看私聊消息获取邀请码。',
from_user='系统')
db.session.commit()
flash('申请已批准,邀请码已通过私聊发送给老师', 'success')
return redirect(url_for('admin_teacher_applications'))
@app.route('/admin/teacher-applications/<int:app_id>/reject', methods=['POST'])
@teacher_required
def reject_teacher_application(app_id):
user = session['user']
app = TeacherApplication.query.get_or_404(app_id)
if app.status != 'pending':
flash('该申请已处理', 'error')
return redirect(url_for('admin_teacher_applications'))
# 权限检查:管理员或该杯赛负责人
if user['role'] != 'admin':
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=app.contest_id, role='owner').first()
if not membership:
flash('您没有权限审批此申请', 'error')
return redirect(url_for('admin_teacher_applications'))
app.status = 'rejected'
app.reviewed_at = datetime.utcnow()
app.reviewed_by = user['id']
# 通知申请人
contest = Contest.query.get(app.contest_id)
add_notification(app.user_id, 'teacher_result',
f'您申请成为杯赛「{contest.name if contest else ""}」老师未通过审核。',
from_user='系统')
db.session.commit()
flash('申请已拒绝', 'success')
return redirect(url_for('admin_teacher_applications'))
# ========== API 路由 ==========
@app.route('/api/activate-invite-code', methods=['POST'])
@login_required
def api_activate_invite_code():
"""老师输入邀请码激活教师身份"""
user = session['user']
data = request.get_json(force=True, silent=True) or {}
code_str = (data.get('code') or '').strip()
if not code_str:
return jsonify({'success': False, 'message': '请输入邀请码'}), 400
invite = InviteCode.query.filter_by(code=code_str).first()
if not invite:
return jsonify({'success': False, 'message': '邀请码不存在'}), 404
if invite.user_id != user['id']:
return jsonify({'success': False, 'message': '此邀请码不属于您'}), 403
if invite.used:
return jsonify({'success': False, 'message': '此邀请码已被使用'}), 400
ta = TeacherApplication.query.get(invite.application_id)
if not ta:
return jsonify({'success': False, 'message': '关联的申请记录不存在'}), 404
# 检查是否已是杯赛成员
existing = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id).first()
if existing:
return jsonify({'success': False, 'message': '您已是该杯赛成员'}), 400
# 创建杯赛成员teacher
membership = ContestMembership(user_id=user['id'], contest_id=ta.contest_id, role='teacher')
db.session.add(membership)
# 自动加入杯赛讨论群
chatroom = ChatRoom.query.filter_by(contest_id=ta.contest_id).first()
if chatroom:
existing_chat = ChatRoomMember.query.filter_by(room_id=chatroom.id, user_id=user['id']).first()
if not existing_chat:
db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=user['id'], role='member'))
# 标记邀请码已使用
invite.used = True
invite.used_at = datetime.utcnow()
db.session.commit()
contest = Contest.query.get(ta.contest_id)
return jsonify({'success': True, 'message': f'激活成功!您已成为杯赛「{contest.name if contest else ""}」的老师。'})
@app.route('/api/captcha')
def api_captcha():
global captcha_store
captcha_text = ''.join(random.choices(string.digits, k=4))
captcha_id = generate_captcha_id()
image = ImageCaptcha(width=150, height=50, font_sizes=[40])
data = image.generate(captcha_text)
import base64
img_base64 = base64.b64encode(data.read()).decode('utf-8')
captcha_store[captcha_id] = {
'text': captcha_text.lower(),
'expires': time.time() + 300
}
expired = [k for k, v in captcha_store.items() if time.time() > v['expires']]
for k in expired:
del captcha_store[k]
return jsonify({'captchaId': captcha_id, 'img': img_base64})
@app.route('/api/send-email-code', methods=['POST'])
def api_send_email_code():
global captcha_store, email_codes
data = request.get_json(force=True, silent=True)
email = data.get('email', '')
captcha_id = data.get('captchaId', '')
captcha_text = data.get('captchaText', '')
if not email:
return jsonify({'success': False, 'message': '邮箱不能为空'}), 400
if not captcha_id or not captcha_text:
return jsonify({'success': False, 'message': '请输入图形验证码'}), 400
record = captcha_store.get(captcha_id)
if not record or time.time() > record['expires']:
captcha_store.pop(captcha_id, None)
return jsonify({'success': False, 'message': '图形验证码已过期', 'refreshCaptcha': True}), 400
if record['text'] != captcha_text.lower():
captcha_store.pop(captcha_id, None)
return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400
captcha_store.pop(captcha_id, None)
code = generate_code(6)
email_codes[email] = {'code': code, 'expires': time.time() + 600}
try:
send_email(email, '您的注册验证码',
f'<p>您的验证码是:<b>{code}</b>10分钟内有效。</p>')
print(f'邮箱验证码已发送到 {email}: {code}')
return jsonify({'success': True, 'message': '验证码已发送到邮箱'})
except Exception as e:
print(f'发送邮件失败: {e}')
email_codes.pop(email, None)
return jsonify({'success': False, 'message': f'发送邮件失败: {str(e)}'}), 500
# ========== 注册相关 API ==========
@app.route('/api/register', methods=['POST'])
def api_register():
"""邮箱注册(需绑定手机号)"""
global email_codes
data = request.get_json(force=True, silent=True)
name = data.get('name', '')
email = data.get('email', '')
phone = data.get('phone', '')
password = data.get('password', '')
email_code = data.get('emailCode', '')
if not name or not email or not password or not phone:
return jsonify({'success': False, 'message': '请填写完整信息'}), 400
if not email_code:
return jsonify({'success': False, 'message': '请输入邮箱验证码'}), 400
if not phone.isdigit() or len(phone) != 11:
return jsonify({'success': False, 'message': '手机号格式不正确'}), 400
record = email_codes.get(email)
if not record or time.time() > record['expires']:
email_codes.pop(email, None)
return jsonify({'success': False, 'message': '邮箱验证码已过期', 'refreshCaptcha': True}), 400
if record['code'] != email_code:
record.setdefault('attempts', 0)
record['attempts'] += 1
if record['attempts'] >= 5:
email_codes.pop(email, None)
return jsonify({'success': False, 'message': '验证码错误次数过多,请重新获取', 'refreshCaptcha': True}), 400
return jsonify({'success': False, 'message': f'邮箱验证码错误,还可尝试{5 - record["attempts"]}'}), 400
email_codes.pop(email, None)
if User.query.filter_by(email=email).first():
return jsonify({'success': False, 'message': '该邮箱已注册'}), 400
if User.query.filter_by(phone=phone).first():
return jsonify({'success': False, 'message': '该手机号已绑定其他账户'}), 400
role = 'student'
user = User(name=name, email=email, phone=phone, password=password, role=role)
db.session.add(user)
db.session.commit()
user_data = {'name': user.name, 'email': user.email, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
session['user'] = user_data
return jsonify({'success': True, 'message': '注册成功', 'user': user_data})
@app.route('/api/register-mobile', methods=['POST'])
def api_register_mobile():
"""手机号注册"""
data = request.get_json(force=True, silent=True)
name = data.get('name', '')
phone = data.get('phone', '')
password = data.get('password', '')
sms_code = data.get('smsCode', '')
if not name or not phone or not password or not sms_code:
return jsonify({'success': False, 'message': '请填写完整信息'}), 400
if not phone.isdigit() or len(phone) != 11:
return jsonify({'success': False, 'message': '手机号格式不正确'}), 400
try:
req = CommonRequest()
req.set_accept_format('json')
req.set_domain('dypnsapi.aliyuncs.com')
req.set_method('POST')
req.set_protocol_type('https')
req.set_version('2017-05-25')
req.set_action_name('CheckSmsVerifyCode')
req.add_query_param('PhoneNumber', phone)
req.add_query_param('VerifyCode', sms_code)
response = acs_client.do_action_with_exception(req)
result = json.loads(response)
print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}')
model = result.get('Model', {})
if not (result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS'):
return jsonify({'success': False, 'message': '短信验证码错误或已过期'}), 400
except Exception as e:
print(f'验证短信验证码出错: {e}')
return jsonify({'success': False, 'message': '短信验证码验证失败'}), 500
if User.query.filter_by(phone=phone).first():
return jsonify({'success': False, 'message': '该手机号已注册'}), 400
role = 'student'
user = User(name=name, phone=phone, password=password, role=role)
db.session.add(user)
db.session.commit()
user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
session['user'] = user_data
return jsonify({'success': True, 'message': '注册成功', 'user': user_data})
# ========== 登录相关 API ==========
@app.route('/api/login', methods=['POST'])
def api_login():
data = request.get_json(force=True, silent=True)
if not data:
return jsonify({'success': False, 'message': '请求数据格式错误'}), 400
email = data.get('email', '')
password = data.get('password', '')
if not email or not password:
return jsonify({'success': False, 'message': '请输入邮箱和密码'}), 400
user = User.query.filter_by(email=email).first()
if not user:
return jsonify({'success': False, 'message': '账号不存在,请先注册'}), 400
if user.password != password:
return jsonify({'success': False, 'message': '密码错误'}), 400
user_data = {'name': user.name, 'email': user.email, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
session['user'] = user_data
return jsonify({'success': True, 'message': '登录成功', 'user': user_data})
@app.route('/api/send-sms', methods=['POST'])
def api_send_sms():
global captcha_store
data = request.get_json(force=True, silent=True)
phone = data.get('phone', '')
captcha_id = data.get('captchaId', '')
captcha_text = data.get('captchaText', '')
if not phone:
return jsonify({'success': False, 'message': '手机号不能为空'}), 400
if not captcha_id or not captcha_text:
return jsonify({'success': False, 'message': '请输入图形验证码'}), 400
record = captcha_store.get(captcha_id)
if not record or time.time() > record['expires']:
captcha_store.pop(captcha_id, None)
return jsonify({'success': False, 'message': '图形验证码已过期,请刷新', 'refreshCaptcha': True}), 400
if record['text'] != captcha_text.lower():
captcha_store.pop(captcha_id, None)
return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400
captcha_store.pop(captcha_id, None)
try:
req = CommonRequest()
req.set_accept_format('json')
req.set_domain('dypnsapi.aliyuncs.com')
req.set_method('POST')
req.set_protocol_type('https')
req.set_version('2017-05-25')
req.set_action_name('SendSmsVerifyCode')
req.add_query_param('PhoneNumber', phone)
req.add_query_param('SignName', os.getenv('SIGN_NAME', ''))
req.add_query_param('TemplateCode', os.getenv('TEMPLATE_CODE', ''))
req.add_query_param('TemplateParam', json.dumps({'code': '##code##', 'min': '5'}))
req.add_query_param('CountryCode', '86')
req.add_query_param('ValidTime', 300)
req.add_query_param('Interval', 60)
req.add_query_param('ReturnVerifyCode', True)
req.add_query_param('CodeType', 1)
req.add_query_param('CodeLength', 4)
response = acs_client.do_action_with_exception(req)
result = json.loads(response)
print(f'号码认证服务返回: {json.dumps(result, ensure_ascii=False, indent=2)}')
if result.get('Code') == 'OK':
resp_data = {'success': True, 'message': '验证码发送成功'}
model = result.get('Model', {})
if model.get('VerifyCode'):
resp_data['mockCode'] = model['VerifyCode']
print(f'验证码: {model["VerifyCode"]}')
return jsonify(resp_data)
else:
print(f'发送失败: {result.get("Message")}')
return jsonify({'success': False, 'message': result.get('Message', '发送失败')}), 500
except Exception as e:
print(f'发送短信出错: {e}')
return jsonify({'success': False, 'message': f'发送短信出错: {str(e)}'}), 500
@app.route('/api/verify-code', methods=['POST'])
def api_verify_code():
data = request.get_json(force=True, silent=True)
phone = data.get('phone', '')
code = data.get('code', '')
if not phone or not code:
return jsonify({'success': False, 'message': '手机号和验证码不能为空'}), 400
try:
req = CommonRequest()
req.set_accept_format('json')
req.set_domain('dypnsapi.aliyuncs.com')
req.set_method('POST')
req.set_protocol_type('https')
req.set_version('2017-05-25')
req.set_action_name('CheckSmsVerifyCode')
req.add_query_param('PhoneNumber', phone)
req.add_query_param('VerifyCode', code)
response = acs_client.do_action_with_exception(req)
result = json.loads(response)
print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}')
model = result.get('Model', {})
if result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS':
user = User.query.filter_by(phone=phone).first()
if not user:
user = User(
name=f'用户{phone[-4:]}',
phone=phone,
password=generate_code(8),
role='student'
)
db.session.add(user)
db.session.commit()
user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
session['user'] = user_data
return jsonify({'success': True, 'message': '验证成功', 'user': user_data})
else:
msg = '验证码已过期' if model.get('VerifyResult') == 'UNKNOWN' else '验证码错误'
return jsonify({'success': False, 'message': msg}), 400
except Exception as e:
print(f'验证出错: {e}')
return jsonify({'success': False, 'message': '验证码错误或已过期'}), 400
# ========== 图片上传 API ==========
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/api/upload', methods=['POST'])
def api_upload():
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
if 'file' not in request.files:
return jsonify({'success': False, 'message': '没有选择文件'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': '没有选择文件'}), 400
if not allowed_file(file.filename):
return jsonify({'success': False, 'message': '不支持的文件格式,请上传 PNG/JPG/GIF/WebP'}), 400
# 检查文件大小
file.seek(0, 2)
size = file.tell()
file.seek(0)
if size > MAX_FILE_SIZE:
return jsonify({'success': False, 'message': '文件大小不能超过10MB'}), 400
# 生成唯一文件名
ext = file.filename.rsplit('.', 1)[1].lower()
filename = f"{int(time.time())}_{random.randint(1000,9999)}.{ext}"
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
filepath = os.path.join(UPLOAD_FOLDER, filename)
file.save(filepath)
url = f'/static/uploads/{filename}'
return jsonify({'success': True, 'url': url, 'filename': filename})
# ========== AI 诊断接口(测试完可删除) ==========
@app.route('/api/ai-diagnose')
def api_ai_diagnose():
"""诊断 DashScope API 连通性"""
import httpx
results = {}
# 1. 检查 API key 配置
api_key = os.getenv('DASHSCOPE_API_KEY', '')
if not api_key or api_key == 'sk-xxxxxxxxxxxxx':
results['api_key'] = '未配置'
return jsonify(results)
results['api_key'] = f'{api_key[:8]}...{api_key[-4:]}' if len(api_key) > 12 else '已配置(太短,可能无效)'
# 2. 测试网络连通性跳过SSL验证
try:
resp = httpx.get('https://dashscope.aliyuncs.com', timeout=10, follow_redirects=True, verify=False)
results['network'] = f'连通,状态码 {resp.status_code}'
except Exception as e:
results['network'] = f'不通: {str(e)}'
return jsonify(results)
# 3. 测试 API 调用(最简单的请求)
try:
client = DashScopeClient(
api_key=api_key,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
http_client=httpx.Client(verify=False)
)
response = client.chat.completions.create(
model="qwen-turbo",
messages=[{"role": "user", "content": "回复OK"}],
max_tokens=10
)
results['api_call'] = f'成功: {response.choices[0].message.content.strip()}'
except Exception as e:
results['api_call'] = f'失败: {type(e).__name__}: {str(e)}'
# 4. 测试视觉模型
try:
client2 = DashScopeClient(
api_key=api_key,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
http_client=httpx.Client(verify=False)
)
response2 = client2.chat.completions.create(
model="qwen-vl-plus",
messages=[{"role": "user", "content": [{"type": "text", "text": "回复OK"}]}],
max_tokens=10
)
results['vision_model'] = f'成功: {response2.choices[0].message.content.strip()}'
except Exception as e:
results['vision_model'] = f'失败: {type(e).__name__}: {str(e)}'
return jsonify(results)
# ========== PDF 智能识别 ==========
@app.route('/api/parse-pdf', methods=['POST'])
def api_parse_pdf():
user = session.get('user')
if not user or user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '仅教师可使用此功能'}), 403
if 'file' not in request.files:
return jsonify({'success': False, 'message': '请选择PDF文件'}), 400
file = request.files['file']
if not file.filename.lower().endswith('.pdf'):
return jsonify({'success': False, 'message': '仅支持PDF格式文件'}), 400
# 检查文件大小 ≤ 20MB
file.seek(0, 2)
size = file.tell()
file.seek(0)
if size > 20 * 1024 * 1024:
return jsonify({'success': False, 'message': '文件大小不能超过20MB'}), 400
api_key = os.getenv('DASHSCOPE_API_KEY', '')
if not api_key or api_key == 'sk-xxxxxxxxxxxxx':
return jsonify({'success': False, 'message': '未配置AI接口密钥请联系管理员在.env中设置DASHSCOPE_API_KEY'}), 500
import base64 as _b64
import re as _pdf_re
import uuid as _uuid
# 读取PDF将每页渲染为图片 + 提取文本
try:
pdf_bytes = file.read()
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
page_images = [] # base64 编码的页面图片发给AI
page_texts = [] # 每页提取的文本
page_image_urls = [] # 保存到磁盘的页面图片URL
page_sizes = [] # 每页的像素尺寸 (w, h)
max_pages = 20 # 最多处理20页
_dpi_scale = 150 / 72
for i, page in enumerate(doc):
if i >= max_pages:
break
# 提取文本
page_texts.append(page.get_text("text", sort=True))
# 渲染页面为图片150 DPI平衡清晰度和大小
mat = fitz.Matrix(_dpi_scale, _dpi_scale)
pix = page.get_pixmap(matrix=mat)
img_bytes = pix.tobytes("png")
img_b64 = _b64.b64encode(img_bytes).decode('utf-8')
page_images.append(img_b64)
page_sizes.append((pix.width, pix.height))
# 保存页面图片到磁盘,供题目引用
img_filename = f"pdf_page_{_uuid.uuid4().hex[:8]}_{i+1}.png"
img_path = os.path.join(UPLOAD_FOLDER, img_filename)
with open(img_path, 'wb') as f:
f.write(img_bytes)
page_image_urls.append(f'/static/uploads/{img_filename}')
doc.close()
except Exception as e:
return jsonify({'success': False, 'message': f'PDF读取失败: {str(e)}'}), 400
all_text = "\n".join(page_texts)
has_text = bool(all_text.strip())
# 判断是否包含图形(文本量少或有几何关键词)
has_figures = not has_text or len(all_text.strip()) < 200
figure_keywords = ['如图', '图示', '图中', '所示', '示意图', '几何体', '三角形', '四边形',
'', '', '', '', '', '坐标', '函数图', '图像', '图形',
'电路', '示波器', '实验装置', '曲线', '折线', '直方图', '散点图',
'表格', '数轴', '向量', '抛物线', '双曲线', '椭圆', '正弦', '余弦',
'结构式', '分子式', '装置图', '流程图', '框图', '韦恩图']
if any(kw in all_text for kw in figure_keywords):
has_figures = True
# 构造系统提示
system_prompt = """你是一个顶级专业的试卷解析助手擅长处理包含几何图形、数学公式、物理电路图、化学结构式等的各类试卷。你必须做到100%准确识别每一道题目,不遗漏、不合并、不截断。
关键规则:
- 仔细逐页观察每页图片,识别所有题目(包括大题下的小题),包括图片中的几何图形、坐标图、函数图像等
- 如果题目包含图形如立体几何、平面几何、函数图像、电路图、实验装置图等在content中用文字非常详细地描述图形内容例如"正三棱柱ABC-A₁B₁C₁其中AB=2AA₁=3M为BB₁中点连接AM、CM"
- 所有数学公式必须用LaTeX格式并用 $ 符号包裹。例如:
- 行内公式:$x^2+2x+1=0$、$\\sqrt{2}$、$\\frac{a}{b}$、$\\int_0^1 f(x)dx$
- 分段函数:$f(x)=\\begin{cases} 2^x+1, & x>1 \\\\ ax^2+(b-3)x, & x\\leq 1 \\end{cases}$
- 希腊字母:$\\alpha$、$\\beta$、$\\theta$、$\\pi$
- 集合符号:$\\in$、$\\subset$、$\\cup$、$\\cap$、$\\emptyset$
- 几何符号:$\\perp$、$\\parallel$、$\\triangle$、$\\angle$
- 不等号:$\\geq$、$\\leq$、$\\neq$、$\\pm$、$\\infty$
- 求和/积分:$\\sum_{i=1}^{n}$、$\\int$、$\\lim$
- 分数:$\\frac{a}{b}$ 指数:$x^{2}$ 下标:$a_{1}$
- 根号:$\\sqrt{3}$ 向量:$\\vec{a}$ 绝对值:$|x|$
- 矩阵:$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$
- 对数:$\\log$、$\\ln$、$\\lg$
- 化学式也用LaTeX$\\text{H}_2\\text{O}$、$\\text{CO}_2$、$\\text{Fe}_2\\text{O}_3$
- 选项中的公式也必须用 $ 包裹
- 每道题必须完整,不要截断或合并
- 注意区分大题和小题:如果一道大题下有(1)(2)(3)等小题每个小题单独作为一个题目输出content中注明属于哪道大题
- 严格只输出JSON不要有任何其他文字
- 每道题必须标注 has_figure 字段:如果该题包含或引用了图形(几何图、函数图像、坐标图、电路图、实验装置图、结构式图等),设为 true纯文字题设为 false
- 如果 has_figure 为 true必须提供 figure_bbox 字段,格式为 [x0, y0, x1, y1]表示图形在该页图片中的大致位置比例0~1之间的小数其中 (x0,y0) 是左上角,(x1,y1) 是右下角。例如图形在页面右半部分中间:[0.5, 0.3, 0.95, 0.7]
【题型识别指南】
- choice: 有A/B/C/D选项的选择题含单选和多选
- fill: 填空题含有____或括号需要填写的
- judge: 判断题(判断对错/正误/是否)
- text: 解答题、证明题、计算题、简答题、论述题、作图题等
【输出格式强制要求】
- 你的回复必须是一个合法的JSON数组以 [ 开头,以 ] 结尾
- 禁止输出任何解释、注释、markdown标记如```)、前言或后语
- 直接输出JSON不要包裹在代码块中
示例输出格式:
[{"type":"choice","content":"已知函数 $f(x)=x^2+2x+1$,则 $f(0)$ 的值为","options":["$0$","$1$","$2$","$3$"],"answer":"B","score":5,"page":1,"has_figure":false},{"type":"fill","content":"计算 $\\\\sin 30°=$ ____","options":[],"answer":"$\\\\frac{1}{2}$","score":5,"page":1,"has_figure":false},{"type":"text","content":"第18题第(1)小题)已知数列 $\\\\{a_n\\\\}$ 满足 $a_1=1$$a_{n+1}=2a_n+1$,求通项公式 $a_n$","options":[],"answer":"","score":6,"page":2,"has_figure":false}]"""
user_content_parts = []
# 如果有图形或扫描件,使用视觉模型发送图片
use_vision = has_figures or not has_text
if use_vision:
user_content_parts.append({
"type": "text",
"text": """请仔细查看以下试卷页面图片识别所有题目并解析为JSON数组。
特别注意:
1. 识别图片中的几何图形、坐标系、函数图像等用文字描述在content中
2. 不要遗漏任何题目
3. 保留所有数学符号和公式
4. 如果题目包含或引用了图形has_figure设为true并用figure_bbox标注图形在该页中的位置比例[x0,y0,x1,y1]0~1之间
每个元素格式:
{"type":"choice/fill/text/judge", "content":"题目内容(含图形描述)", "options":["A","B","C","D"], "answer":"答案", "score":分值, "page":页码, "has_figure":true/false, "figure_bbox":[x0,y0,x1,y1]}
- options 仅选择题需要
- answer: 选择题A/B/C/D判断题"A"=正确/"B"=错误,其他填参考答案或""
- score: 按试卷标注未标注则选择题5、填空题5、判断题3、解答题10
- page: 该题所在的PDF页码从1开始必须填写
- has_figure: 该题是否包含图形true/false
- figure_bbox: 仅has_figure为true时需要图形在页面中的位置比例[x0,y0,x1,y1]"""
})
# 添加页面图片最多10页发给视觉模型
for i, img_b64 in enumerate(page_images[:10]):
user_content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}"}
})
# 如果有提取到的文本,也附上作为辅助
if has_text:
clean_text = _pdf_re.sub(r'\n{3,}', '\n\n', all_text)[:20000]
user_content_parts.append({
"type": "text",
"text": f"\n以下是PDF提取的文字内容作为辅助参考\n{clean_text}"
})
else:
# 纯文本模式
clean_text = _pdf_re.sub(r'\n{3,}', '\n\n', all_text)[:30000]
user_content_parts.append({
"type": "text",
"text": f"""请将以下试卷内容解析为JSON数组。每个元素格式
{{"type":"choice/fill/text/judge", "content":"题目内容", "options":["A","B","C","D"], "answer":"答案", "score":分值, "page":页码, "has_figure":true/false}}
- options 仅选择题需要
- answer: 选择题A/B/C/D判断题"A"=正确/"B"=错误,其他填参考答案或""
- score: 按试卷标注未标注则选择题5、填空题5、判断题3、解答题10
- page: 该题所在的大致页码从1开始必须填写
- has_figure: 该题是否包含或引用了图形(如"如图所示"true/false
试卷内容:
{clean_text}"""
})
# --- 健壮的 JSON 清洗函数 ---
def _clean_and_parse_json(raw_text):
"""尝试从 AI 返回文本中提取合法 JSON 数组"""
text = raw_text.strip()
# 去除 BOM 头
text = text.lstrip('\ufeff')
# 去除所有 markdown 代码围栏(支持多段)
text = _pdf_re.sub(r'```(?:json)?[ \t]*\n?', '', text)
text = _pdf_re.sub(r'```', '', text)
text = text.strip()
# 尝试直接解析
parsed = _try_json_loads(text)
if parsed is not None:
return parsed
# 找到第一个 [ 和最后一个 ] 之间的内容
first_bracket = text.find('[')
last_bracket = text.rfind(']')
if first_bracket != -1 and last_bracket > first_bracket:
subset = text[first_bracket:last_bracket + 1]
parsed = _try_json_loads(subset)
if parsed is not None:
return parsed
# 尝试修复尾部多余逗号: ,] -> ]
fixed = _pdf_re.sub(r',\s*([}\]])', r'\1', subset)
parsed = _try_json_loads(fixed)
if parsed is not None:
return parsed
return None
def _try_json_loads(text):
"""解析 JSON返回题目列表或 None"""
try:
obj = json.loads(text)
except (json.JSONDecodeError, ValueError):
return None
if isinstance(obj, list) and len(obj) > 0:
return obj
if isinstance(obj, dict):
for key in ['questions', 'data', 'items', 'result']:
if key in obj and isinstance(obj[key], list):
return obj[key]
return None
# --- 调用 AI ---
try:
import httpx
client = DashScopeClient(
api_key=api_key,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
http_client=httpx.Client(verify=False, timeout=httpx.Timeout(120.0, connect=30.0))
)
model = "qwen-vl-plus" if use_vision else "qwen-turbo"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content_parts}
]
call_kwargs = dict(model=model, messages=messages, temperature=0.01, max_tokens=8192)
# qwen-plus 支持 response_format视觉模型不支持
if not use_vision:
call_kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**call_kwargs)
result_text = response.choices[0].message.content.strip()
except Exception as e:
return jsonify({'success': False, 'message': f'AI解析失败: {str(e)}'}), 500
# --- 解析 + 自动重试 ---
questions = _clean_and_parse_json(result_text)
if questions is None:
# 解析失败,打印调试日志
app.logger.warning("AI首次返回解析失败原始内容前500字符: %s", result_text[:500])
# 自动重试:把原始文本发给 AI 做格式修正(仅 1 次)
try:
retry_messages = [
{"role": "system", "content": "你是一个JSON格式修正助手。用户会给你一段包含试卷题目信息但格式不正确的文本请将其转换为合法的JSON数组。只输出JSON数组以[开头,以]结尾,禁止输出任何其他文字。"},
{"role": "user", "content": f"请将以下文本转换为合法JSON数组\n{result_text[:16000]}"}
]
retry_kwargs = dict(model="qwen-turbo", messages=retry_messages, temperature=0.01,
max_tokens=8192, response_format={"type": "json_object"})
retry_resp = client.chat.completions.create(**retry_kwargs)
retry_text = retry_resp.choices[0].message.content.strip()
questions = _clean_and_parse_json(retry_text)
if questions is None:
app.logger.warning("AI重试返回仍解析失败原始内容前500字符: %s", retry_text[:500])
except Exception as e:
app.logger.warning("AI重试请求异常: %s", str(e))
if not questions or not isinstance(questions, list) or len(questions) == 0:
return jsonify({'success': False, 'message': 'AI返回格式异常请重试或手动录入'}), 500
# 图形关键词兜底检测
_fig_keywords = ['如图', '图示', '图中', '所示', '示意图', '几何体', '三角形', '四边形',
'', '', '', '', '', '坐标', '函数图', '图像', '图形',
'电路', '示波器', '实验装置', '曲线', '折线', '直方图', '散点图',
'表格', '数轴', '向量', '抛物线', '双曲线', '椭圆',
'结构式', '分子式', '装置图', '流程图', '框图']
def _detect_has_figure(q_item):
"""判断题目是否含图形优先用AI返回的has_figure否则关键词检测"""
val = q_item.get('has_figure')
if val is True or val == 'true' or val == 1:
return True
if val is False or val == 'false' or val == 0:
return False
# AI 没返回该字段,用关键词兜底
content = q_item.get('content', '')
return any(kw in content for kw in _fig_keywords)
def _crop_page_image(page_idx, bbox):
"""用 fitz 从页面图片中裁剪指定区域,返回保存后的 URL 或 None"""
if page_idx < 0 or page_idx >= len(page_images):
return None
if not bbox or not isinstance(bbox, list) or len(bbox) != 4:
return None
try:
w, h = page_sizes[page_idx]
x0 = max(0, int(bbox[0] * w) - 20)
y0 = max(0, int(bbox[1] * h) - 20)
x1 = min(w, int(bbox[2] * w) + 20)
y1 = min(h, int(bbox[3] * h) + 20)
if x1 - x0 < 10 or y1 - y0 < 10:
return None
# 用 fitz 从 base64 解码后裁剪
img_data = _b64.b64decode(page_images[page_idx])
src_pix = fitz.Pixmap(img_data)
clip = fitz.IRect(x0, y0, x1, y1)
cropped_pix = fitz.Pixmap(src_pix, clip)
crop_bytes = cropped_pix.tobytes("png")
crop_filename = f"pdf_crop_{_uuid.uuid4().hex[:8]}.png"
crop_path = os.path.join(UPLOAD_FOLDER, crop_filename)
with open(crop_path, 'wb') as f:
f.write(crop_bytes)
return f'/static/uploads/{crop_filename}'
except Exception:
return None
# 只为含图形的题目附加图片(裁剪优先,整页兜底)
referenced_urls = set()
for q in questions:
has_fig = _detect_has_figure(q)
page_num = q.get('page')
bbox = q.get('figure_bbox')
if has_fig and page_num and isinstance(page_num, (int, float)):
idx = int(page_num) - 1
# 尝试裁剪
crop_url = _crop_page_image(idx, bbox)
if crop_url:
q.setdefault('images', [])
q['images'].append(crop_url)
referenced_urls.add(crop_url)
elif 0 <= idx < len(page_image_urls):
# 裁剪失败,回退整页
q.setdefault('images', [])
if page_image_urls[idx] not in q['images']:
q['images'].append(page_image_urls[idx])
referenced_urls.add(page_image_urls[idx])
# 清理前端不需要的字段
q.pop('page', None)
q.pop('has_figure', None)
q.pop('figure_bbox', None)
# 清理未被引用的整页图片文件
for url in page_image_urls:
if url not in referenced_urls:
try:
unused_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), url.lstrip('/'))
if os.path.exists(unused_path):
os.remove(unused_path)
except Exception:
pass
return jsonify({'success': True, 'questions': questions})
# ========== 考试系统 API ==========
@app.route('/api/exams', methods=['POST'])
def api_create_exam():
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
data = request.get_json(force=True, silent=True)
contest_id = data.get('contest_id')
# 杯赛考试:只有杯赛负责人或系统管理员可以创建
if contest_id:
contest = Contest.query.get(contest_id)
if not contest or contest.status == 'abolished':
return jsonify({'success': False, 'message': '杯赛不存在或已废止'}), 400
membership = ContestMembership.query.filter_by(
user_id=user['id'], contest_id=contest_id, role='owner').first()
if not membership and user.get('role') != 'admin':
return jsonify({'success': False, 'message': '只有杯赛负责人才能组织考试'}), 403
else:
# 普通考试:需要 teacher 或 admin 角色
if user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '无权限'}), 403
title = data.get('title', '')
subject = data.get('subject', '')
duration = data.get('duration', 120)
questions = data.get('questions', [])
scheduled_start = data.get('scheduled_start', '')
scheduled_end = data.get('scheduled_end', '')
score_release_time = data.get('score_release_time', '')
if not title or not questions:
return jsonify({'success': False, 'message': '请填写试卷标题和题目'}), 400
total_score = sum(q.get('score', 0) for q in questions)
exam = Exam(
title=title,
subject=subject,
duration=duration,
total_score=total_score,
creator_id=user.get('id'),
contest_id=contest_id
)
# 解析预定时间
if scheduled_start:
try:
exam.scheduled_start = datetime.strptime(scheduled_start, '%Y-%m-%dT%H:%M')
except ValueError:
pass
if scheduled_end:
try:
exam.scheduled_end = datetime.strptime(scheduled_end, '%Y-%m-%dT%H:%M')
except ValueError:
pass
if score_release_time:
try:
release_dt = datetime.strptime(score_release_time, '%Y-%m-%dT%H:%M')
# 验证公布时间必须晚于考试结束时间
if exam.scheduled_end and release_dt <= exam.scheduled_end:
return jsonify({'success': False, 'message': '成绩公布时间必须晚于考试结束时间'}), 400
exam.score_release_time = release_dt
except ValueError:
pass
# 考试密码
access_password = data.get('access_password', '').strip()
if access_password:
exam.access_password = access_password
# 加密存储试卷内容
questions_json = json.dumps(questions)
exam.encrypted_questions = encrypt_questions(questions_json)
exam.is_encrypted = True
exam.set_questions(questions) # 同时保留明文用于兼容
db.session.add(exam)
db.session.commit()
# 通知杯赛已报名用户
if contest_id:
registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all()
for reg in registrations:
if reg.user_id != user['id']:
add_notification(reg.user_id, 'contest_new_exam',
f'您报名的杯赛「{contest.name}」发布了新考试「{title}」,快去查看吧!',
from_user=user.get('name', ''), post_id=exam.id)
return jsonify({'success': True, 'message': '试卷创建成功', 'exam_id': exam.id})
@app.route('/api/exams/<int:exam_id>/verify-password', methods=['POST'])
@login_required
def api_verify_exam_password(exam_id):
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '考试不存在'}), 404
data = request.get_json(force=True, silent=True)
password = data.get('password', '')
if password == exam.access_password:
session[f'exam_verified_{exam_id}'] = True
return jsonify({'success': True, 'message': '验证通过'})
return jsonify({'success': False, 'message': '密码错误'}), 403
@app.route('/api/exams/<int:exam_id>/save-draft', methods=['POST'])
def api_save_draft(exam_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '试卷不存在'}), 404
data = request.get_json(force=True, silent=True)
answers = data.get('answers', {})
draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
if not draft:
draft = Draft(exam_id=exam_id, user_id=user.get('id'))
draft.set_answers(answers)
db.session.add(draft)
db.session.commit()
return jsonify({'success': True, 'message': '草稿已保存'})
@app.route('/api/exams/<int:exam_id>/submit', methods=['POST'])
def api_submit_exam(exam_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '试卷不存在'}), 404
if exam.status == 'closed':
return jsonify({'success': False, 'message': '该考试已关闭'}), 400
# 检查预定时间
now = datetime.utcnow()
if exam.scheduled_start and now < exam.scheduled_start:
return jsonify({'success': False, 'message': '考试尚未开始'}), 400
if exam.scheduled_end and now > exam.scheduled_end:
return jsonify({'success': False, 'message': '考试已结束'}), 400
if Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first():
return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400
data = request.get_json(force=True, silent=True)
answers = data.get('answers', {})
score = 0
auto_graded = True
question_scores = {}
questions = get_exam_questions(exam)
for q in questions:
qid = str(q['id'])
if q['type'] == 'choice':
if answers.get(qid) == q.get('answer'):
score += q.get('score', 0)
question_scores[qid] = q.get('score', 0)
else:
question_scores[qid] = 0
elif q['type'] == 'fill':
student_answer = answers.get(qid, '').strip()
correct_answers = [a.strip() for a in q.get('answer', '').split('|')]
if student_answer in correct_answers:
score += q.get('score', 0)
question_scores[qid] = q.get('score', 0)
else:
question_scores[qid] = 0
else:
auto_graded = False
question_scores[qid] = 0
sub = Submission(
exam_id=exam_id,
user_id=user.get('id'),
score=score,
graded=auto_graded,
graded_by='系统自动' if auto_graded else ''
)
sub.set_answers(answers)
sub.set_question_scores(question_scores)
db.session.add(sub)
Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).delete()
db.session.commit()
return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id})
@app.route('/api/exams/<int:exam_id>/grade/<int:sub_id>', methods=['POST'])
def api_grade_submission(exam_id, sub_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
sub = Submission.query.get(sub_id)
if not sub or sub.exam_id != exam_id:
return jsonify({'success': False, 'message': '提交记录不存在'}), 404
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '考试不存在'}), 404
if not can_grade_exam(user, exam):
return jsonify({'success': False, 'message': '无权限'}), 403
data = request.get_json(force=True, silent=True)
scores = data.get('scores', {})
total = sum(scores.values())
sub.score = total
sub.set_question_scores(scores)
sub.graded = True
sub.graded_by = user.get('name', '')
db.session.commit()
# 通知学生批改完成
add_notification(sub.user_id, 'exam_graded',
f'您在考试「{exam.title}」中的答卷已被批改,得分:{total}/{exam.total_score}',
from_user=user.get('name', ''), post_id=exam.id)
return jsonify({'success': True, 'message': '批改完成', 'total_score': total})
@app.route('/api/exams/<int:exam_id>/status', methods=['POST'])
def api_update_exam_status(exam_id):
user = session.get('user')
if not user or user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '无权限'}), 403
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '试卷不存在'}), 404
if exam.creator_id != user.get('id'):
return jsonify({'success': False, 'message': '只能修改自己创建的试卷'}), 403
data = request.get_json(force=True, silent=True)
new_status = data.get('status', '')
if new_status not in ('available', 'closed'):
return jsonify({'success': False, 'message': '无效状态'}), 400
exam.status = new_status
db.session.commit()
return jsonify({'success': True, 'message': f'试卷已{"发布" if new_status == "available" else "关闭"}'})
@app.route('/api/exams/<int:exam_id>', methods=['DELETE'])
def api_delete_exam(exam_id):
user = session.get('user')
if not user or user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '无权限'}), 403
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '试卷不存在'}), 404
if exam.creator_id != user.get('id'):
return jsonify({'success': False, 'message': '只能删除自己创建的试卷'}), 403
Submission.query.filter_by(exam_id=exam_id).delete()
Draft.query.filter_by(exam_id=exam_id).delete()
db.session.delete(exam)
db.session.commit()
return jsonify({'success': True, 'message': '试卷已删除'})
# ========== 杯赛相关 API ==========
@app.route('/api/contests/search')
@cache.cached(timeout=60, query_string=True)
def api_search_contests():
keyword = request.args.get('q', '').strip().lower()
query = Contest.query
if keyword:
query = query.filter(
(Contest.name.contains(keyword)) |
(Contest.organizer.contains(keyword)) |
(Contest.description.contains(keyword))
)
# 普通用户只能看到已发布的杯赛,负责人和管理员可以看到自己的隐藏杯赛
user = get_current_user()
all_contests = query.all()
filtered = []
for c in all_contests:
if c.visible:
filtered.append(c)
elif user:
if user.get('role') == 'admin':
filtered.append(c)
elif ContestMembership.query.filter_by(user_id=user['id'], contest_id=c.id).first():
filtered.append(c)
contests = filtered
data = [{
'id': c.id,
'name': c.name,
'organizer': c.organizer,
'description': c.description,
'start_date': c.start_date,
'end_date': c.end_date,
'status': c.status,
'participants': c.participants,
'created_by': c.created_by
} for c in contests]
return jsonify({'success': True, 'data': data})
@app.route('/api/contests/<int:contest_id>/publish', methods=['PUT'])
@login_required
def api_publish_contest(contest_id):
user = get_current_user()
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return jsonify({'success': False, 'message': '无权操作'}), 403
contest.visible = not contest.visible
db.session.commit()
return jsonify({'success': True, 'visible': contest.visible})
@app.route('/api/contests/<int:contest_id>/exams', methods=['GET'])
def api_contest_exams(contest_id):
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
exams = Exam.query.filter_by(contest_id=contest_id).all()
user = get_current_user()
is_owner = False
if user:
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
data = []
for e in exams:
item = {
'id': e.id,
'title': e.title,
'subject': e.subject or '',
'total_score': e.total_score or 0,
'status': e.status,
'duration': e.duration,
'created_at': e.created_at.strftime('%Y-%m-%d %H:%M'),
'submission_count': Submission.query.filter_by(exam_id=e.id).count() if is_owner else None
}
data.append(item)
return jsonify({'success': True, 'exams': data})
@app.route('/api/contests/<int:contest_id>/import-exam', methods=['POST'])
@login_required
def api_import_exam_to_contest(contest_id):
"""将已有考试导入到杯赛"""
user = get_current_user()
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return jsonify({'success': False, 'message': '无权操作'}), 403
data = request.get_json(force=True, silent=True)
exam_id = data.get('exam_id')
if not exam_id:
return jsonify({'success': False, 'message': '请选择考试'}), 400
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '考试不存在'}), 404
if exam.contest_id and exam.contest_id != contest_id:
return jsonify({'success': False, 'message': '该考试已关联到其他杯赛'}), 400
exam.contest_id = contest_id
db.session.commit()
return jsonify({'success': True, 'message': '导入成功'})
@app.route('/api/contests/<int:contest_id>/remove-exam', methods=['POST'])
@login_required
def api_remove_exam_from_contest(contest_id):
"""将考试从杯赛移除(不删除考试)"""
user = get_current_user()
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return jsonify({'success': False, 'message': '无权操作'}), 403
data = request.get_json(force=True, silent=True)
exam_id = data.get('exam_id')
exam = Exam.query.get(exam_id)
if not exam or exam.contest_id != contest_id:
return jsonify({'success': False, 'message': '考试不存在或不属于该杯赛'}), 404
exam.contest_id = None
db.session.commit()
return jsonify({'success': True, 'message': '已移除'})
@app.route('/api/contests/<int:contest_id>/available-exams', methods=['GET'])
@login_required
def api_available_exams_for_contest(contest_id):
"""获取可导入到杯赛的考试列表(当前用户创建的、未关联杯赛的考试)"""
user = get_current_user()
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
if not is_owner:
return jsonify({'success': False, 'message': '无权操作'}), 403
# 查找未关联杯赛的考试(负责人自己创建的,或管理员可看所有)
query = Exam.query.filter((Exam.contest_id == None) | (Exam.contest_id == 0))
if user.get('role') != 'admin':
query = query.filter_by(creator_id=user['id'])
exams = query.all()
data = [{
'id': e.id,
'title': e.title,
'subject': e.subject or '',
'total_score': e.total_score or 0,
'status': e.status,
'created_at': e.created_at.strftime('%Y-%m-%d %H:%M')
} for e in exams]
return jsonify({'success': True, 'exams': data})
@app.route('/api/contests/<int:contest_id>/leaderboard', methods=['GET'])
def api_contest_leaderboard(contest_id):
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
exam_ids = [e.id for e in Exam.query.filter_by(contest_id=contest_id).all()]
if not exam_ids:
return jsonify({'success': True, 'leaderboard': []})
from sqlalchemy import func
results = db.session.query(
Submission.user_id,
func.sum(Submission.score).label('total_score'),
func.count(Submission.id).label('exam_count')
).filter(
Submission.exam_id.in_(exam_ids),
Submission.graded == True
).group_by(Submission.user_id).order_by(func.sum(Submission.score).desc()).limit(50).all()
leaderboard = []
for rank, (user_id, total_score, exam_count) in enumerate(results, 1):
u = User.query.get(user_id)
leaderboard.append({
'rank': rank,
'user_name': u.name if u else '未知用户',
'total_score': total_score or 0,
'exam_count': exam_count
})
return jsonify({'success': True, 'leaderboard': leaderboard})
@app.route('/api/contests', methods=['POST'])
def api_create_contest():
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
data = request.get_json(force=True, silent=True)
contest = Contest(
name=data.get('name'),
organizer=data.get('organizer'),
description=data.get('description'),
start_date=data.get('start_date'),
end_date=data.get('end_date'),
status=data.get('status', 'upcoming'),
participants=data.get('participants', 0),
created_by=user.get('id')
)
db.session.add(contest)
db.session.commit()
return jsonify({'success': True, 'message': '杯赛创建成功', 'contest_id': contest.id})
# ========== 题库 API ==========
@app.route('/api/contests/<int:contest_id>/question-bank', methods=['GET'])
@login_required
def api_get_question_bank(contest_id):
"""获取杯赛题库列表"""
user = session['user']
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
if contest.status == 'abolished':
return jsonify({'success': False, 'message': '杯赛已废止'}), 400
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
if not membership and user.get('role') != 'admin':
return jsonify({'success': False, 'message': '无权限访问题库'}), 403
items = QuestionBankItem.query.filter_by(contest_id=contest_id).order_by(QuestionBankItem.created_at.desc()).all()
data = []
for item in items:
contributor = User.query.get(item.contributor_id)
data.append({
'id': item.id,
'type': item.type,
'content': item.content,
'options': json.loads(item.options) if item.options else [],
'answer': item.answer,
'score': item.score,
'contributor_id': item.contributor_id,
'contributor_name': contributor.name if contributor else '未知',
'created_at': item.created_at.strftime('%Y-%m-%d %H:%M')
})
return jsonify({'success': True, 'questions': data})
@app.route('/api/contests/<int:contest_id>/question-bank', methods=['POST'])
@login_required
def api_add_question_bank(contest_id):
"""添加题目到题库(杯赛老师和负责人)"""
user = session['user']
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
if contest.status == 'abolished':
return jsonify({'success': False, 'message': '杯赛已废止'}), 400
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
if not membership and user.get('role') != 'admin':
return jsonify({'success': False, 'message': '无权限'}), 403
data = request.get_json(force=True, silent=True)
qtype = data.get('type', '')
content = data.get('content', '')
if not qtype or not content:
return jsonify({'success': False, 'message': '题目类型和内容不能为空'}), 400
item = QuestionBankItem(
contest_id=contest_id,
contributor_id=user['id'],
type=qtype,
content=content,
options=json.dumps(data.get('options', [])),
answer=data.get('answer', ''),
score=data.get('score', 10)
)
db.session.add(item)
db.session.commit()
return jsonify({'success': True, 'message': '题目已添加', 'id': item.id})
@app.route('/api/contests/<int:contest_id>/question-bank/<int:qid>', methods=['DELETE'])
@login_required
def api_delete_question_bank(contest_id, qid):
"""删除题库题目(负责人或题目贡献者)"""
user = session['user']
item = QuestionBankItem.query.get(qid)
if not item or item.contest_id != contest_id:
return jsonify({'success': False, 'message': '题目不存在'}), 404
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id, role='owner').first()
is_owner = membership is not None
is_contributor = item.contributor_id == user['id']
is_admin = user.get('role') == 'admin'
if not is_owner and not is_contributor and not is_admin:
return jsonify({'success': False, 'message': '无权限删除'}), 403
db.session.delete(item)
db.session.commit()
return jsonify({'success': True, 'message': '题目已删除'})
@app.route('/api/contests/<int:contest_id>/create-exam-from-bank', methods=['POST'])
@login_required
def api_create_exam_from_bank(contest_id):
"""负责人从题库选题创建考试"""
user = session['user']
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
if contest.status == 'abolished':
return jsonify({'success': False, 'message': '杯赛已废止'}), 400
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id, role='owner').first()
if not membership and user.get('role') != 'admin':
return jsonify({'success': False, 'message': '只有杯赛负责人才能组卷'}), 403
data = request.get_json(force=True, silent=True)
title = data.get('title', '')
duration = data.get('duration', 120)
question_ids = data.get('question_ids', [])
scheduled_start = data.get('scheduled_start', '')
scheduled_end = data.get('scheduled_end', '')
score_release_time = data.get('score_release_time', '')
if not title or not question_ids:
return jsonify({'success': False, 'message': '请填写标题并选择题目'}), 400
# 从题库获取题目并转换为考试题目格式
questions = []
for qid in question_ids:
item = QuestionBankItem.query.get(qid)
if not item or item.contest_id != contest_id:
continue
q = {
'type': item.type,
'content': item.content,
'score': item.score,
'answer': item.answer or ''
}
if item.type == 'choice':
q['options'] = json.loads(item.options) if item.options else []
questions.append(q)
if not questions:
return jsonify({'success': False, 'message': '未找到有效题目'}), 400
total_score = sum(q.get('score', 0) for q in questions)
exam = Exam(
title=title,
duration=duration,
total_score=total_score,
creator_id=user['id'],
contest_id=contest_id
)
if scheduled_start:
try:
exam.scheduled_start = datetime.strptime(scheduled_start, '%Y-%m-%dT%H:%M')
except ValueError:
pass
if scheduled_end:
try:
exam.scheduled_end = datetime.strptime(scheduled_end, '%Y-%m-%dT%H:%M')
except ValueError:
pass
if score_release_time:
try:
release_dt = datetime.strptime(score_release_time, '%Y-%m-%dT%H:%M')
if not exam.scheduled_end or release_dt > exam.scheduled_end:
exam.score_release_time = release_dt
except ValueError:
pass
# 加密存储试卷内容
questions_json = json.dumps(questions)
exam.encrypted_questions = encrypt_questions(questions_json)
exam.is_encrypted = True
exam.set_questions(questions) # 同时保留明文用于兼容
db.session.add(exam)
db.session.commit()
# 通知杯赛已报名用户
registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all()
for reg in registrations:
if reg.user_id != user['id']:
add_notification(reg.user_id, 'contest_new_exam',
f'您报名的杯赛「{contest.name}」发布了新考试「{title}」,快去查看吧!',
from_user=user.get('name', ''), post_id=exam.id)
return jsonify({'success': True, 'message': '考试创建成功', 'exam_id': exam.id})
# ========== 杯赛报名 API ==========
@app.route('/api/contests/<int:contest_id>/register', methods=['POST'])
@login_required
def api_register_contest(contest_id):
"""用户报名杯赛"""
user = session['user']
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
if contest.status == 'abolished':
return jsonify({'success': False, 'message': '杯赛已废止,无法报名'}), 400
existing = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
if existing:
return jsonify({'success': False, 'message': '您已报名该杯赛'}), 400
registration = ContestRegistration(user_id=user['id'], contest_id=contest_id)
db.session.add(registration)
contest.participants += 1
# 自动加入杯赛讨论群
chatroom = ChatRoom.query.filter_by(contest_id=contest_id).first()
if chatroom:
existing_member = ChatRoomMember.query.filter_by(room_id=chatroom.id, user_id=user['id']).first()
if not existing_member:
db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=user['id'], role='member'))
db.session.commit()
return jsonify({'success': True, 'message': '报名成功', 'participants': contest.participants})
@app.route('/api/contests/<int:contest_id>/unregister', methods=['POST'])
@login_required
def api_unregister_contest(contest_id):
"""用户取消报名"""
user = session['user']
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
if not registration:
return jsonify({'success': False, 'message': '您尚未报名该杯赛'}), 400
db.session.delete(registration)
if contest.participants > 0:
contest.participants -= 1
db.session.commit()
return jsonify({'success': True, 'message': '已取消报名', 'participants': contest.participants})
# ========== 杯赛讨论区 API ==========
@app.route('/api/contests/<int:contest_id>/posts', methods=['GET'])
def api_contest_posts(contest_id):
"""获取杯赛讨论区帖子列表"""
contest = Contest.query.get_or_404(contest_id)
posts = Post.query.filter_by(contest_id=contest_id).order_by(Post.created_at.desc()).all()
user = get_current_user()
uid = user['id'] if user else None
data = []
for p in posts:
p_dict = {
'id': p.id,
'title': p.title,
'content': p.content[:200] + ('...' if len(p.content) > 200 else ''),
'author': p.author.name,
'author_id': p.author_id,
'tag': p.tag,
'is_official': p.is_official,
'pinned': p.pinned,
'likes': p.likes,
'replies': p.replies_count,
'views': p.views,
'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'),
}
if uid:
p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None
data.append(p_dict)
return jsonify({'success': True, 'data': data})
@app.route('/api/contests/<int:contest_id>/posts', methods=['POST'])
@login_required
def api_create_contest_post(contest_id):
"""在杯赛讨论区发布帖子"""
user = session['user']
contest = Contest.query.get_or_404(contest_id)
# 权限检查
if not can_post_in_contest(user, contest):
return jsonify({'success': False, 'message': '您没有权限在此杯赛讨论区发帖'}), 403
data = request.get_json(force=True, silent=True)
title = data.get('title', '').strip()
content = data.get('content', '').strip()
if not title or not content:
return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
if len(title) > 100:
return jsonify({'success': False, 'message': '标题不能超过100字'}), 400
post = Post(
title=title,
content=content,
author_id=user['id'],
tag='杯赛讨论', # 可自定义
contest_id=contest_id,
is_official=(user['role'] in ['admin', 'teacher'] and data.get('is_official', False))
)
db.session.add(post)
db.session.commit()
return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id})
# ========== 论坛相关 API ==========
@app.route('/api/posts/search')
@cache.cached(timeout=30, query_string=True)
def api_search_posts():
keyword = request.args.get('q', '').strip().lower()
tag = request.args.get('tag', '').strip()
sort = request.args.get('sort', 'newest')
# 只获取非杯赛的帖子contest_id 为 NULL
query = Post.query.filter_by(contest_id=None)
if keyword:
query = query.filter(
(Post.title.contains(keyword)) | (Post.content.contains(keyword))
)
if tag:
query = query.filter_by(tag=tag)
if sort == 'hottest':
query = query.order_by(Post.likes.desc())
elif sort == 'most_replies':
query = query.order_by(Post.replies_count.desc())
else:
query = query.order_by(Post.created_at.desc())
posts = query.all()
user = session.get('user')
uid = user.get('id') if user else None
data = []
for p in posts:
p_dict = {
'id': p.id,
'title': p.title,
'content': p.content[:200] + ('...' if len(p.content) > 200 else ''),
'author': p.author.name,
'author_id': p.author_id,
'tag': p.tag,
'is_official': p.is_official,
'pinned': p.pinned,
'likes': p.likes,
'replies': p.replies_count,
'views': p.views,
'has_poll': p.has_poll,
'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'),
}
if uid:
p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None
p_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=p.id).first() is not None
data.append(p_dict)
return jsonify({'success': True, 'data': data})
@app.route('/api/posts', methods=['POST'])
def api_create_post():
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
data = request.get_json(force=True, silent=True)
title = data.get('title', '').strip()
content = data.get('content', '').strip()
tag = data.get('tag', '全部')
if not title or not content:
return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
if len(title) > 100:
return jsonify({'success': False, 'message': '标题不能超过100字'}), 400
post = Post(
title=title,
content=content,
author_id=user.get('id'),
tag=tag,
is_official=(user.get('role') in ('teacher', 'admin') and data.get('is_official', False))
)
db.session.add(post)
db.session.commit()
return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id})
@app.route('/api/posts/<int:post_id>')
def api_get_post(post_id):
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
post.views += 1
db.session.commit()
replies = Reply.query.filter_by(post_id=post_id).order_by(Reply.created_at).all()
user = session.get('user')
uid = user.get('id') if user else None
post_dict = {
'id': post.id,
'title': post.title,
'content': post.content,
'author': post.author.name,
'author_id': post.author_id,
'tag': post.tag,
'is_official': post.is_official,
'pinned': post.pinned,
'likes': post.likes,
'replies': post.replies_count,
'views': post.views,
'has_poll': post.has_poll,
'created_at': post.created_at.strftime('%Y-%m-%d %H:%M'),
'edited': (post.updated_at > post.created_at),
}
if uid:
post_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=post.id).first() is not None
post_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=post.id).first() is not None
replies_data = []
for r in replies:
r_dict = {
'id': r.id,
'content': r.content,
'author': r.author.name,
'author_id': r.author_id,
'likes': r.likes,
'reply_to': r.reply_to,
'created_at': r.created_at.strftime('%Y-%m-%d %H:%M'),
'edited': (r.updated_at > r.created_at),
}
if uid:
r_dict['liked'] = Reaction.query.filter_by(user_id=uid, reply_id=r.id).first() is not None
replies_data.append(r_dict)
return jsonify({'success': True, 'data': post_dict, 'replies': replies_data})
@app.route('/api/posts/<int:post_id>', methods=['DELETE'])
def api_delete_post(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
if post.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '无权删除'}), 403
db.session.delete(post)
db.session.commit()
return jsonify({'success': True, 'message': '帖子已删除'})
@app.route('/api/posts/<int:post_id>/like', methods=['POST'])
def api_like_post(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
uid = user.get('id')
reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first()
if reaction:
db.session.delete(reaction)
post.likes -= 1
db.session.commit()
return jsonify({'success': True, 'liked': False, 'likes': post.likes})
else:
reaction = Reaction(user_id=uid, post_id=post_id, reaction='like')
db.session.add(reaction)
post.likes += 1
db.session.commit()
return jsonify({'success': True, 'liked': True, 'likes': post.likes})
@app.route('/api/posts/<int:post_id>/bookmark', methods=['POST'])
def api_bookmark_post(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
uid = user.get('id')
bookmark = Bookmark.query.filter_by(user_id=uid, post_id=post_id).first()
if bookmark:
db.session.delete(bookmark)
db.session.commit()
return jsonify({'success': True, 'bookmarked': False})
else:
bookmark = Bookmark(user_id=uid, post_id=post_id)
db.session.add(bookmark)
db.session.commit()
return jsonify({'success': True, 'bookmarked': True})
@app.route('/api/posts/<int:post_id>/pin', methods=['POST'])
def api_pin_post(post_id):
user = session.get('user')
if not user or user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '无权限'}), 403
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
post.pinned = not post.pinned
db.session.commit()
return jsonify({'success': True, 'pinned': post.pinned})
@app.route('/api/posts/<int:post_id>/replies', methods=['POST'])
def api_create_reply(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
data = request.get_json(force=True, silent=True)
content = data.get('content', '').strip()
if not content:
return jsonify({'success': False, 'message': '回复内容不能为空'}), 400
reply = Reply(
post_id=post_id,
author_id=user.get('id'),
content=content,
reply_to=data.get('reply_to', '')
)
db.session.add(reply)
post.replies_count += 1
db.session.commit()
return jsonify({'success': True, 'message': '回复成功', 'reply': {
'id': reply.id,
'content': reply.content,
'author': user.get('name'),
'author_id': user.get('id'),
'likes': 0,
'reply_to': reply.reply_to,
'created_at': reply.created_at.strftime('%Y-%m-%d %H:%M')
}})
@app.route('/api/replies/<int:reply_id>/like', methods=['POST'])
def api_like_reply(reply_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
reply = Reply.query.get(reply_id)
if not reply:
return jsonify({'success': False, 'message': '回复不存在'}), 404
uid = user.get('id')
reaction = Reaction.query.filter_by(user_id=uid, reply_id=reply_id).first()
if reaction:
db.session.delete(reaction)
reply.likes -= 1
db.session.commit()
return jsonify({'success': True, 'liked': False, 'likes': reply.likes})
else:
reaction = Reaction(user_id=uid, reply_id=reply_id, reaction='like')
db.session.add(reaction)
reply.likes += 1
db.session.commit()
return jsonify({'success': True, 'liked': True, 'likes': reply.likes})
@app.route('/api/replies/<int:reply_id>', methods=['DELETE'])
def api_delete_reply(reply_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
reply = Reply.query.get(reply_id)
if not reply:
return jsonify({'success': False, 'message': '回复不存在'}), 404
if reply.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '无权删除'}), 403
post = Post.query.get(reply.post_id)
if post:
post.replies_count -= 1
db.session.delete(reply)
db.session.commit()
return jsonify({'success': True, 'message': '回复已删除'})
@app.route('/api/user/bookmarks')
def api_user_bookmarks():
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
uid = user.get('id')
bookmarks = Bookmark.query.filter_by(user_id=uid).all()
post_ids = [b.post_id for b in bookmarks]
posts = Post.query.filter(Post.id.in_(post_ids)).all()
data = [{
'id': p.id,
'title': p.title,
'author': p.author.name,
'created_at': p.created_at.strftime('%Y-%m-%d %H:%M')
} for p in posts]
return jsonify({'success': True, 'data': data})
# ========== 论坛增强 API ==========
@app.route('/api/posts/<int:post_id>/edit', methods=['POST'])
def api_edit_post(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
if post.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'):
return jsonify({'success': False, 'message': '无权编辑'}), 403
data = request.get_json(force=True, silent=True)
new_title = data.get('title', '').strip()
new_content = data.get('content', '').strip()
new_tag = data.get('tag', post.tag)
if not new_title or not new_content:
return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
post.title = new_title
post.content = new_content
post.tag = new_tag
db.session.commit()
return jsonify({'success': True, 'message': '编辑成功'})
@app.route('/api/replies/<int:reply_id>/edit', methods=['POST'])
def api_edit_reply(reply_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
reply = Reply.query.get(reply_id)
if not reply:
return jsonify({'success': False, 'message': '回复不存在'}), 404
if reply.author_id != user.get('id'):
return jsonify({'success': False, 'message': '无权编辑'}), 403
data = request.get_json(force=True, silent=True)
new_content = data.get('content', '').strip()
if not new_content:
return jsonify({'success': False, 'message': '内容不能为空'}), 400
reply.content = new_content
db.session.commit()
return jsonify({'success': True, 'message': '编辑成功'})
@app.route('/api/posts/<int:post_id>/poll', methods=['POST'])
def api_create_poll(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
if post.author_id != user.get('id'):
return jsonify({'success': False, 'message': '只有作者可以创建投票'}), 403
if post.poll:
return jsonify({'success': False, 'message': '该帖子已有投票'}), 400
data = request.get_json(force=True, silent=True)
question = data.get('question', '').strip()
options = data.get('options', [])
multi = data.get('multi', False)
if not question or len(options) < 2:
return jsonify({'success': False, 'message': '请填写问题和至少2个选项'}), 400
poll = Poll(
post_id=post_id,
question=question,
multi=multi
)
poll.set_options([{'text': o, 'votes': 0} for o in options])
poll.set_voters({})
db.session.add(poll)
post.has_poll = True
db.session.commit()
return jsonify({'success': True, 'message': '投票创建成功'})
@app.route('/api/posts/<int:post_id>/vote', methods=['POST'])
def api_vote_poll(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
poll = Poll.query.filter_by(post_id=post_id).first()
if not poll:
return jsonify({'success': False, 'message': '投票不存在'}), 404
uid = user.get('id')
voters = poll.get_voters()
if str(uid) in voters:
return jsonify({'success': False, 'message': '您已投过票'}), 400
data = request.get_json(force=True, silent=True)
choices = data.get('choices', [])
if not choices:
return jsonify({'success': False, 'message': '请选择选项'}), 400
if not poll.multi and len(choices) > 1:
return jsonify({'success': False, 'message': '该投票为单选'}), 400
options = poll.get_options()
for idx in choices:
if 0 <= idx < len(options):
options[idx]['votes'] += 1
voters[str(uid)] = choices
poll.set_options(options)
poll.set_voters(voters)
poll.total_votes += 1
db.session.commit()
return jsonify({'success': True, 'message': '投票成功', 'poll': {
'options': options,
'total_votes': poll.total_votes
}})
@app.route('/api/posts/<int:post_id>/poll')
def api_get_poll(post_id):
poll = Poll.query.filter_by(post_id=post_id).first()
if not poll:
return jsonify({'success': False, 'message': '投票不存在'}), 404
user = session.get('user')
voted = False
my_choices = []
if user:
voters = poll.get_voters()
if str(user.get('id')) in voters:
voted = True
my_choices = voters[str(user.get('id'))]
return jsonify({
'success': True,
'poll': {
'question': poll.question,
'options': poll.get_options(),
'total_votes': poll.total_votes,
'multi': poll.multi
},
'voted': voted,
'my_choices': my_choices
})
@app.route('/api/report', methods=['POST'])
def api_report():
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
data = request.get_json(force=True, silent=True)
rtype = data.get('type', '')
target_id = data.get('target_id', 0)
reason = data.get('reason', '')
detail = data.get('detail', '')
if not rtype or not target_id or not reason:
return jsonify({'success': False, 'message': '请填写举报信息'}), 400
report = Report(
type=rtype,
target_id=target_id,
reporter_id=user.get('id'),
reason=reason,
detail=detail
)
db.session.add(report)
db.session.commit()
return jsonify({'success': True, 'message': '举报已提交,管理员将尽快处理'})
@app.route('/api/posts/<int:post_id>/react', methods=['POST'])
def api_react_post(post_id):
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
data = request.get_json(force=True, silent=True)
reaction_type = data.get('reaction', '')
if reaction_type not in ('like', 'love', 'haha', 'wow', 'sad', 'angry'):
return jsonify({'success': False, 'message': '无效反应'}), 400
uid = user.get('id')
reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first()
if reaction:
if reaction.reaction == reaction_type:
db.session.delete(reaction)
else:
reaction.reaction = reaction_type
else:
reaction = Reaction(user_id=uid, post_id=post_id, reaction=reaction_type)
db.session.add(reaction)
db.session.commit()
counts = {}
for r in Reaction.query.filter_by(post_id=post_id).all():
counts[r.reaction] = counts.get(r.reaction, 0) + 1
return jsonify({'success': True, 'reactions': counts})
@app.route('/api/user/profile/<user_id>')
def api_user_profile(user_id):
user = User.query.get(int(user_id)) if user_id.isdigit() else None
if not user:
return jsonify({'success': False, 'message': '用户不存在'}), 404
post_count = Post.query.filter_by(author_id=user.id).count()
reply_count = Reply.query.filter_by(author_id=user.id).count()
likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=user.id).scalar() or 0
points = post_count * 10 + reply_count * 3 + likes_received * 2
level = calc_level(points)
recent_posts = Post.query.filter_by(author_id=user.id).order_by(Post.created_at.desc()).limit(5).all()
recent_posts_data = [{'id': p.id, 'title': p.title} for p in recent_posts]
badges = []
if post_count >= 1: badges.append({'icon': '✍️', 'name': '初次发帖', 'desc': '发布第一篇帖子'})
if post_count >= 10: badges.append({'icon': '📝', 'name': '笔耕不辍', 'desc': '发布10篇帖子'})
if post_count >= 50: badges.append({'icon': '📚', 'name': '著作等身', 'desc': '发布50篇帖子'})
if reply_count >= 10: badges.append({'icon': '💬', 'name': '热心回复', 'desc': '回复10次'})
if reply_count >= 100: badges.append({'icon': '🗣️', 'name': '话题达人', 'desc': '回复100次'})
if likes_received >= 10: badges.append({'icon': '👍', 'name': '小有人气', 'desc': '获得10个赞'})
if likes_received >= 100: badges.append({'icon': '🌟', 'name': '人气之星', 'desc': '获得100个赞'})
if likes_received >= 500: badges.append({'icon': '👑', 'name': '万人迷', 'desc': '获得500个赞'})
return jsonify({
'success': True,
'profile': {
'user_id': user.id,
'name': user.name,
'points': points,
'level': level,
'level_title': LEVEL_TITLES.get(level, ''),
'posts_count': post_count,
'replies_count': reply_count,
'likes_received': likes_received,
'badges': badges,
'recent_posts': recent_posts_data
}
})
@app.route('/api/forum/hot')
@cache.cached(timeout=120)
def api_hot_posts():
posts = Post.query.order_by((Post.likes*3 + Post.replies_count*2 + Post.views).desc()).limit(10).all()
data = [{'id': p.id, 'title': p.title, 'likes': p.likes, 'replies': p.replies_count} for p in posts]
return jsonify({'success': True, 'data': data})
@app.route('/api/forum/stats')
@cache.cached(timeout=60)
def api_forum_stats():
total_posts = Post.query.count()
total_replies = Reply.query.count()
total_users = User.query.count()
today = datetime.utcnow().date()
today_posts = Post.query.filter(db.func.date(Post.created_at) == today).count()
today_replies = Reply.query.filter(db.func.date(Reply.created_at) == today).count()
online_count = get_online_count()
tag_counts = {}
for tag in db.session.query(Post.tag, db.func.count(Post.tag)).group_by(Post.tag).all():
tag_counts[tag[0]] = tag[1]
active_users = []
recent_posts_users = Post.query.order_by(Post.created_at.desc()).limit(10).all()
seen = set()
for p in recent_posts_users:
if p.author_id not in seen and len(active_users) < 5:
seen.add(p.author_id)
points = (Post.query.filter_by(author_id=p.author_id).count()*10 +
Reply.query.filter_by(author_id=p.author_id).count()*3)
level = calc_level(points)
active_users.append({
'user_id': p.author_id,
'name': p.author.name,
'level': level,
'level_title': LEVEL_TITLES.get(level, '')
})
return jsonify({
'success': True,
'stats': {
'total_posts': total_posts,
'total_replies': total_replies,
'total_users': total_users,
'today_posts': today_posts,
'today_replies': today_replies,
'online_count': online_count,
'tag_counts': tag_counts,
'active_users': active_users
}
})
@app.route('/api/forum/leaderboard')
@cache.cached(timeout=300)
def api_leaderboard():
users = User.query.all()
board = []
for u in users:
post_count = Post.query.filter_by(author_id=u.id).count()
reply_count = Reply.query.filter_by(author_id=u.id).count()
likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=u.id).scalar() or 0
points = post_count * 10 + reply_count * 3 + likes_received * 2
level = calc_level(points)
board.append({
'user_id': u.id,
'name': u.name,
'points': points,
'level': level,
'level_title': LEVEL_TITLES.get(level, ''),
'posts_count': post_count,
'likes_received': likes_received
})
board.sort(key=lambda x: x['points'], reverse=True)
return jsonify({'success': True, 'data': board[:20]})
@app.route('/api/posts/with-poll', methods=['POST'])
def api_create_post_with_poll():
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
data = request.get_json(force=True, silent=True)
title = data.get('title', '').strip()
content = data.get('content', '').strip()
tag = data.get('tag', '全部')
if not title or not content:
return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
post = Post(
title=title,
content=content,
author_id=user.get('id'),
tag=tag,
is_official=(user.get('role') in ('teacher', 'admin') and data.get('is_official', False))
)
db.session.add(post)
db.session.flush()
poll_data = data.get('poll')
if poll_data and poll_data.get('question') and len(poll_data.get('options', [])) >= 2:
poll = Poll(
post_id=post.id,
question=poll_data['question'],
multi=poll_data.get('multi', False)
)
poll.set_options([{'text': o, 'votes': 0} for o in poll_data['options']])
poll.set_voters({})
db.session.add(poll)
post.has_poll = True
db.session.commit()
return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id})
@app.route('/api/posts/<int:post_id>/share', methods=['POST'])
def api_share_post(post_id):
return jsonify({'success': True})
# ========== 聊天系统页面 ==========
@app.route('/chat')
@login_required
def chat_page():
return render_template('chat.html')
# ========== 聊天系统 REST API ==========
@app.route('/api/chat/rooms')
@login_required
def api_chat_rooms():
"""获取当前用户所有聊天室(含最后一条消息、未读数)"""
user_id = session['user']['id']
memberships = ChatRoomMember.query.filter_by(user_id=user_id).all()
rooms_data = []
for m in memberships:
room = m.room
last_msg = Message.query.filter_by(room_id=room.id).order_by(Message.created_at.desc()).first()
unread = Message.query.filter(
Message.room_id == room.id,
Message.created_at > m.last_read_at,
Message.sender_id != user_id
).count()
# 私聊显示对方名字和头像
room_name = room.name
room_avatar = room.avatar
if room.type == 'private':
other_member = ChatRoomMember.query.filter(
ChatRoomMember.room_id == room.id,
ChatRoomMember.user_id != user_id
).first()
if other_member:
other_user = User.query.get(other_member.user_id)
if other_user:
room_name = other_user.name
room_avatar = other_user.avatar or ''
rooms_data.append({
'id': room.id,
'type': room.type,
'name': room_name or '未命名群聊',
'avatar': room_avatar or '',
'muted': m.muted,
'unread': unread,
'last_message': {
'content': ('撤回了一条消息' if last_msg.recalled else (last_msg.content[:50] if last_msg.content else '[文件]')) if last_msg else '',
'sender_name': User.query.get(last_msg.sender_id).name if last_msg else '',
'created_at': last_msg.created_at.strftime('%Y-%m-%d %H:%M') if last_msg else '',
'type': last_msg.type if last_msg else 'text'
} if last_msg else None,
'member_count': ChatRoomMember.query.filter_by(room_id=room.id).count()
})
rooms_data.sort(key=lambda r: r['last_message']['created_at'] if r['last_message'] else '', reverse=True)
return jsonify({'success': True, 'rooms': rooms_data})
@app.route('/api/chat/rooms', methods=['POST'])
@login_required
def api_create_chat_room():
"""创建群聊"""
user_id = session['user']['id']
data = request.get_json(force=True, silent=True)
name = data.get('name', '').strip()
member_ids = data.get('member_ids', [])
if not name:
return jsonify({'success': False, 'message': '请输入群名称'}), 400
room = ChatRoom(type='group', name=name, creator_id=user_id)
db.session.add(room)
db.session.flush()
# 创建者为管理员
db.session.add(ChatRoomMember(room_id=room.id, user_id=user_id, role='admin'))
# 添加成员
for mid in member_ids:
if mid != user_id:
db.session.add(ChatRoomMember(room_id=room.id, user_id=mid, role='member'))
# 系统消息
db.session.add(Message(room_id=room.id, sender_id=user_id, type='system', content=f'{session["user"]["name"]} 创建了群聊'))
db.session.commit()
return jsonify({'success': True, 'room_id': room.id})
@app.route('/api/chat/rooms/<int:room_id>', methods=['PUT'])
@login_required
def api_update_chat_room(room_id):
"""修改群名/群头像(仅管理员)"""
user_id = session['user']['id']
room = ChatRoom.query.get_or_404(room_id)
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member or member.role != 'admin':
return jsonify({'success': False, 'message': '无权限'}), 403
data = request.get_json(force=True, silent=True)
if 'name' in data:
room.name = data['name']
if 'avatar' in data:
room.avatar = data['avatar']
db.session.commit()
socketio.emit('room_updated', {'room_id': room_id, 'name': room.name, 'avatar': room.avatar}, room=f'room_{room_id}')
return jsonify({'success': True})
@app.route('/api/chat/private/<int:target_user_id>', methods=['POST'])
@login_required
def api_get_or_create_private_chat(target_user_id):
"""获取或创建与某好友的私聊室"""
user_id = session['user']['id']
if target_user_id == user_id:
return jsonify({'success': False, 'message': '不能和自己聊天'}), 400
# 查找已有私聊
my_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=user_id).subquery()
target_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=target_user_id).subquery()
existing = ChatRoom.query.filter(
ChatRoom.type == 'private',
ChatRoom.id.in_(db.session.query(my_rooms.c.room_id)),
ChatRoom.id.in_(db.session.query(target_rooms.c.room_id))
).first()
if existing:
return jsonify({'success': True, 'room_id': existing.id})
# 创建新私聊
room = ChatRoom(type='private', creator_id=user_id)
db.session.add(room)
db.session.flush()
db.session.add(ChatRoomMember(room_id=room.id, user_id=user_id, role='member'))
db.session.add(ChatRoomMember(room_id=room.id, user_id=target_user_id, role='member'))
db.session.commit()
return jsonify({'success': True, 'room_id': room.id})
@app.route('/api/chat/rooms/<int:room_id>/messages')
@login_required
def api_chat_messages(room_id):
"""分页获取历史消息"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
before_id = request.args.get('before_id', type=int)
limit = request.args.get('limit', 30, type=int)
query = Message.query.filter_by(room_id=room_id)
if before_id:
query = query.filter(Message.id < before_id)
messages = query.order_by(Message.created_at.desc()).limit(limit).all()
messages.reverse()
data = []
for msg in messages:
sender = User.query.get(msg.sender_id)
msg_data = {
'id': msg.id,
'room_id': msg.room_id,
'sender_id': msg.sender_id,
'sender_name': sender.name if sender else '未知用户',
'sender_avatar': (sender.avatar or '') if sender else '',
'type': msg.type,
'content': msg.content,
'file_url': msg.file_url,
'file_name': msg.file_name,
'recalled': msg.recalled,
'reply_to': None,
'mentions': msg.mentions or '',
'reactions': _get_reactions_summary(msg.id) if not msg.recalled else {},
'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
}
if msg.reply_to_id:
reply_msg = Message.query.get(msg.reply_to_id)
if reply_msg:
reply_sender = User.query.get(reply_msg.sender_id)
msg_data['reply_to'] = {
'id': reply_msg.id,
'sender_name': reply_sender.name if reply_sender else '未知',
'content': reply_msg.content[:50] if reply_msg.content else '[文件]'
}
data.append(msg_data)
return jsonify({'success': True, 'messages': data})
@app.route('/api/chat/rooms/<int:room_id>/members')
@login_required
def api_chat_room_members(room_id):
"""获取聊天室成员列表"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
room = ChatRoom.query.get_or_404(room_id)
members = ChatRoomMember.query.filter_by(room_id=room_id).all()
data = []
for m in members:
u = User.query.get(m.user_id)
if u:
data.append({'id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'role': m.role,
'nickname': m.nickname or '', 'muted': m.muted,
'is_creator': u.id == room.creator_id,
'joined_at': m.joined_at.strftime('%Y-%m-%d %H:%M')})
return jsonify({'success': True, 'members': data, 'creator_id': room.creator_id})
@app.route('/api/chat/rooms/<int:room_id>/members', methods=['POST'])
@login_required
def api_invite_chat_members(room_id):
"""邀请成员入群"""
user_id = session['user']['id']
room = ChatRoom.query.get_or_404(room_id)
if room.type == 'private':
return jsonify({'success': False, 'message': '私聊不能邀请成员'}), 400
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
data = request.get_json(force=True, silent=True)
user_ids = data.get('user_ids', [])
added = []
for uid in user_ids:
if not ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first():
db.session.add(ChatRoomMember(room_id=room_id, user_id=uid, role='member'))
u = User.query.get(uid)
if u:
added.append(u.name)
if added:
db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
content=f'{session["user"]["name"]} 邀请了 {", ".join(added)} 加入群聊'))
db.session.commit()
socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
return jsonify({'success': True, 'message': f'已邀请 {len(added)}'})
@app.route('/api/chat/rooms/<int:room_id>/members/<int:uid>', methods=['DELETE'])
@login_required
def api_remove_chat_member(room_id, uid):
"""移除成员 / 退出群聊"""
user_id = session['user']['id']
room = ChatRoom.query.get_or_404(room_id)
if room.type == 'private':
return jsonify({'success': False, 'message': '不能退出私聊'}), 400
my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not my_member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
if uid == user_id:
target_user = User.query.get(user_id)
db.session.delete(my_member)
db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
content=f'{target_user.name} 退出了群聊'))
db.session.commit()
return jsonify({'success': True, 'message': '已退出群聊'})
if my_member.role != 'admin':
return jsonify({'success': False, 'message': '无权限'}), 403
target_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
if target_member:
target_user = User.query.get(uid)
db.session.delete(target_member)
db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
content=f'{target_user.name} 被移出了群聊'))
db.session.commit()
socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
return jsonify({'success': True})
@app.route('/api/chat/rooms/<int:room_id>/read', methods=['POST'])
@login_required
def api_mark_chat_read(room_id):
"""标记已读"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if member:
member.last_read_at = datetime.utcnow()
db.session.commit()
socketio.emit('read_update', {
'room_id': room_id, 'user_id': user_id,
'last_read_at': member.last_read_at.strftime('%Y-%m-%d %H:%M:%S')
}, room=f'room_{room_id}')
return jsonify({'success': True})
@app.route('/api/chat/unread-total')
@login_required
def api_chat_unread_total():
"""获取所有聊天室未读总数"""
user_id = session['user']['id']
memberships = ChatRoomMember.query.filter_by(user_id=user_id).all()
total = 0
for m in memberships:
if not m.muted:
total += Message.query.filter(
Message.room_id == m.room_id,
Message.created_at > m.last_read_at,
Message.sender_id != user_id
).count()
return jsonify({'success': True, 'total': total})
@app.route('/api/chat/messages/<int:msg_id>/recall', methods=['POST'])
@login_required
def api_recall_message(msg_id):
"""撤回消息2分钟内仅发送者"""
user_id = session['user']['id']
msg = Message.query.get_or_404(msg_id)
if msg.sender_id != user_id:
return jsonify({'success': False, 'message': '只能撤回自己的消息'}), 403
if (datetime.utcnow() - msg.created_at).total_seconds() > 120:
return jsonify({'success': False, 'message': '超过2分钟无法撤回'}), 400
msg.recalled = True
msg.content = ''
msg.file_url = None
msg.file_name = None
db.session.commit()
socketio.emit('message_recalled', {'message_id': msg.id, 'room_id': msg.room_id}, room=f'room_{msg.room_id}')
return jsonify({'success': True})
# ========== SocketIO 事件处理 ==========
@socketio.on('connect')
def handle_connect():
user = session.get('user')
if not user:
return False
memberships = ChatRoomMember.query.filter_by(user_id=user['id']).all()
for m in memberships:
sio_join(f'room_{m.room_id}')
@socketio.on('send_message')
def handle_send_message(data):
user = session.get('user')
if not user:
return
user_id = user['id']
room_id = data.get('room_id')
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return
# 检查是否被禁言
if member.muted:
emit('error', {'message': '您已被禁言'})
return
mentions_raw = data.get('mentions', '')
msg = Message(
room_id=room_id, sender_id=user_id,
type=data.get('type', 'text'), content=data.get('content', ''),
file_url=data.get('file_url'), file_name=data.get('file_name'),
reply_to_id=data.get('reply_to_id'),
mentions=mentions_raw if mentions_raw else ''
)
db.session.add(msg)
db.session.commit()
sender = User.query.get(user_id)
reply_to_data = None
if msg.reply_to_id:
reply_msg = Message.query.get(msg.reply_to_id)
if reply_msg:
rs = User.query.get(reply_msg.sender_id)
reply_to_data = {'id': reply_msg.id, 'sender_name': rs.name if rs else '未知',
'content': reply_msg.content[:50] if reply_msg.content else '[文件]'}
emit('new_message', {
'id': msg.id, 'room_id': room_id, 'sender_id': user_id,
'sender_name': sender.name if sender else user['name'],
'sender_avatar': (sender.avatar or '') if sender else '',
'type': msg.type, 'content': msg.content,
'file_url': msg.file_url, 'file_name': msg.file_name,
'reply_to': reply_to_data,
'mentions': mentions_raw,
'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
}, room=f'room_{room_id}')
@socketio.on('typing')
def handle_typing(data):
user = session.get('user')
if not user:
return
emit('user_typing', {
'room_id': data.get('room_id'), 'user_id': user['id'], 'user_name': user['name']
}, room=f'room_{data.get("room_id")}', include_self=False)
@socketio.on('mark_read')
def handle_mark_read(data):
user = session.get('user')
if not user:
return
room_id = data.get('room_id')
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user['id']).first()
if member:
member.last_read_at = datetime.utcnow()
db.session.commit()
emit('read_update', {
'room_id': room_id, 'user_id': user['id'],
'last_read_at': member.last_read_at.strftime('%Y-%m-%d %H:%M:%S')
}, room=f'room_{room_id}')
# ========== 群聊增强 API ==========
@app.route('/api/chat/rooms/<int:room_id>/announcement', methods=['GET'])
@login_required
def api_get_announcement(room_id):
"""获取群公告"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
room = ChatRoom.query.get_or_404(room_id)
announcer = User.query.get(room.announcement_by) if room.announcement_by else None
return jsonify({'success': True, 'announcement': room.announcement or '',
'by': announcer.name if announcer else '', 'at': room.announcement_at.strftime('%Y-%m-%d %H:%M') if room.announcement_at else ''})
@app.route('/api/chat/rooms/<int:room_id>/announcement', methods=['POST'])
@login_required
def api_set_announcement(room_id):
"""设置群公告(仅管理员)"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member or member.role != 'admin':
return jsonify({'success': False, 'message': '无权限'}), 403
room = ChatRoom.query.get_or_404(room_id)
data = request.get_json(force=True, silent=True)
room.announcement = data.get('content', '').strip()
room.announcement_by = user_id
room.announcement_at = datetime.utcnow()
db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
content=f'{session["user"]["name"]} 更新了群公告'))
db.session.commit()
socketio.emit('announcement_updated', {
'room_id': room_id, 'content': room.announcement,
'by': session['user']['name'], 'at': room.announcement_at.strftime('%Y-%m-%d %H:%M')
}, room=f'room_{room_id}')
return jsonify({'success': True})
@app.route('/api/chat/rooms/<int:room_id>/search')
@login_required
def api_chat_search(room_id):
"""搜索聊天记录"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
q = request.args.get('q', '').strip()
if not q:
return jsonify({'success': True, 'messages': []})
msgs = Message.query.filter(
Message.room_id == room_id, Message.recalled == False,
Message.type.in_(['text', 'file']),
Message.content.ilike(f'%{q}%')
).order_by(Message.created_at.desc()).limit(50).all()
data = []
for m in msgs:
sender = User.query.get(m.sender_id)
data.append({'id': m.id, 'sender_name': sender.name if sender else '未知',
'content': m.content, 'type': m.type,
'created_at': m.created_at.strftime('%Y-%m-%d %H:%M')})
return jsonify({'success': True, 'messages': data})
@app.route('/api/chat/rooms/<int:room_id>/files')
@login_required
def api_chat_files(room_id):
"""获取群文件列表"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
msgs = Message.query.filter(
Message.room_id == room_id, Message.recalled == False,
Message.type.in_(['file', 'image']), Message.file_url.isnot(None)
).order_by(Message.created_at.desc()).limit(100).all()
data = []
for m in msgs:
sender = User.query.get(m.sender_id)
data.append({'id': m.id, 'type': m.type, 'file_url': m.file_url,
'file_name': m.file_name or '未命名', 'sender_name': sender.name if sender else '未知',
'created_at': m.created_at.strftime('%Y-%m-%d %H:%M')})
return jsonify({'success': True, 'files': data})
@app.route('/api/chat/rooms/<int:room_id>/mute/<int:uid>', methods=['POST'])
@login_required
def api_mute_member(room_id, uid):
"""禁言/解禁成员(仅管理员)"""
user_id = session['user']['id']
my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not my_member or my_member.role != 'admin':
return jsonify({'success': False, 'message': '无权限'}), 403
target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
if not target:
return jsonify({'success': False, 'message': '成员不存在'}), 404
if target.role == 'admin':
return jsonify({'success': False, 'message': '不能禁言管理员'}), 400
target.muted = not target.muted
target_user = User.query.get(uid)
action = '禁言' if target.muted else '解除禁言'
db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
content=f'{session["user"]["name"]} {action}{target_user.name}'))
db.session.commit()
socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
return jsonify({'success': True, 'muted': target.muted})
@app.route('/api/chat/rooms/<int:room_id>/transfer/<int:uid>', methods=['POST'])
@login_required
def api_transfer_admin(room_id, uid):
"""转让群主"""
user_id = session['user']['id']
room = ChatRoom.query.get_or_404(room_id)
if room.creator_id != user_id:
return jsonify({'success': False, 'message': '只有群主可以转让'}), 403
my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
if not target:
return jsonify({'success': False, 'message': '成员不存在'}), 404
room.creator_id = uid
target.role = 'admin'
my_member.role = 'member'
target_user = User.query.get(uid)
db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
content=f'{session["user"]["name"]} 将群主转让给了 {target_user.name}'))
db.session.commit()
socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
return jsonify({'success': True})
@app.route('/api/chat/rooms/<int:room_id>/set-admin/<int:uid>', methods=['POST'])
@login_required
def api_set_admin(room_id, uid):
"""设置/取消管理员"""
user_id = session['user']['id']
room = ChatRoom.query.get_or_404(room_id)
if room.creator_id != user_id:
return jsonify({'success': False, 'message': '只有群主可以设置管理员'}), 403
target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
if not target:
return jsonify({'success': False, 'message': '成员不存在'}), 404
target.role = 'admin' if target.role == 'member' else 'member'
target_user = User.query.get(uid)
action = '设为管理员' if target.role == 'admin' else '取消管理员'
db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
content=f'{session["user"]["name"]}{target_user.name} {action}'))
db.session.commit()
socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
return jsonify({'success': True, 'role': target.role})
@app.route('/api/chat/rooms/<int:room_id>/nickname', methods=['POST'])
@login_required
def api_set_nickname(room_id):
"""设置群内昵称"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
data = request.get_json(force=True, silent=True)
member.nickname = data.get('nickname', '').strip()[:50]
db.session.commit()
return jsonify({'success': True})
@app.route('/api/chat/messages/<int:msg_id>/reactions', methods=['POST'])
@login_required
def api_toggle_reaction(msg_id):
"""添加/取消表情回应"""
user_id = session['user']['id']
data = request.get_json(force=True, silent=True)
emoji = data.get('emoji', '')
if not emoji:
return jsonify({'success': False, 'message': '请选择表情'}), 400
msg = Message.query.get_or_404(msg_id)
member = ChatRoomMember.query.filter_by(room_id=msg.room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '无权限'}), 403
existing = MessageReaction.query.filter_by(message_id=msg_id, user_id=user_id, emoji=emoji).first()
if existing:
db.session.delete(existing)
action = 'removed'
else:
db.session.add(MessageReaction(message_id=msg_id, user_id=user_id, emoji=emoji))
action = 'added'
db.session.commit()
# 获取该消息所有 reactions 汇总
reactions = _get_reactions_summary(msg_id)
socketio.emit('reaction_updated', {
'message_id': msg_id, 'room_id': msg.room_id, 'reactions': reactions
}, room=f'room_{msg.room_id}')
return jsonify({'success': True, 'action': action, 'reactions': reactions})
def _get_reactions_summary(msg_id):
"""汇总消息的表情回应"""
all_r = MessageReaction.query.filter_by(message_id=msg_id).all()
summary = {}
for r in all_r:
if r.emoji not in summary:
summary[r.emoji] = {'count': 0, 'users': []}
summary[r.emoji]['count'] += 1
u = User.query.get(r.user_id)
if u:
summary[r.emoji]['users'].append({'id': u.id, 'name': u.name})
return summary
@app.route('/api/chat/rooms/<int:room_id>/voice', methods=['POST'])
@login_required
def api_upload_voice(room_id):
"""上传语音消息"""
user_id = session['user']['id']
member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
if not member:
return jsonify({'success': False, 'message': '无权限'}), 403
if member.muted:
return jsonify({'success': False, 'message': '您已被禁言'}), 403
file = request.files.get('file')
if not file:
return jsonify({'success': False, 'message': '无文件'}), 400
filename = secure_filename(f'voice_{user_id}_{int(time.time()*1000)}.webm')
upload_dir = os.path.join('static', 'uploads', 'voice')
os.makedirs(upload_dir, exist_ok=True)
filepath = os.path.join(upload_dir, filename)
file.save(filepath)
url = '/' + filepath.replace('\\', '/')
duration = request.form.get('duration', '0')
msg = Message(room_id=room_id, sender_id=user_id, type='voice',
content=f'{duration}s', file_url=url)
db.session.add(msg)
db.session.commit()
sender = User.query.get(user_id)
socketio.emit('new_message', {
'id': msg.id, 'room_id': room_id, 'sender_id': user_id,
'sender_name': sender.name if sender else '', 'sender_avatar': (sender.avatar or '') if sender else '',
'type': 'voice', 'content': msg.content, 'file_url': url,
'file_name': None, 'reply_to': None, 'mentions': '',
'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
}, room=f'room_{room_id}')
return jsonify({'success': True})
# ========== 管理后台页面 ==========
@app.route('/admin')
def admin_dashboard():
user = require_admin_or_teacher()
return render_template('admin_dashboard.html')
@app.route('/admin/contests')
def admin_contests():
user = require_admin_or_teacher()
return render_template('admin_contests.html')
@app.route('/admin/exams')
def admin_exams():
user = require_admin_or_teacher()
return render_template('admin_exams.html')
@app.route('/admin/posts')
def admin_posts():
user = require_admin_or_teacher()
return render_template('admin_posts.html')
@app.route('/admin/users')
def admin_users():
user = require_admin_or_teacher()
return render_template('admin_users.html')
# ========== 管理后台 API ==========
@app.route('/api/admin/contests', methods=['GET', 'POST'])
def admin_contests_api():
user = require_admin_or_teacher()
if request.method == 'GET':
contests = Contest.query.all()
data = [{
'id': c.id,
'name': c.name,
'organizer': c.organizer,
'start_date': c.start_date,
'description': c.description,
'contact': c.contact if hasattr(c, 'contact') else '',
'status': c.status
} for c in contests]
return jsonify({'success': True, 'contests': data})
elif request.method == 'POST':
data = request.get_json(force=True, silent=True)
contest = Contest(
name=data['name'],
organizer=data['organizer'],
start_date=data['start_date'],
description=data['description'],
contact=data.get('contact', ''),
status=data['status']
)
db.session.add(contest)
db.session.commit()
return jsonify({'success': True})
@app.route('/api/admin/contests/<int:contest_id>', methods=['GET', 'PUT', 'DELETE'])
def admin_contest_detail(contest_id):
user = require_admin_or_teacher()
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'})
if request.method == 'GET':
return jsonify({'success': True, 'contest': {
'id': contest.id,
'name': contest.name,
'organizer': contest.organizer,
'start_date': contest.start_date,
'description': contest.description,
'contact': contest.contact if hasattr(contest, 'contact') else '',
'status': contest.status
}})
elif request.method == 'PUT':
data = request.get_json(force=True, silent=True)
contest.name = data.get('name', contest.name)
contest.organizer = data.get('organizer', contest.organizer)
contest.start_date = data.get('start_date', contest.start_date)
contest.description = data.get('description', contest.description)
contest.contact = data.get('contact', contest.contact if hasattr(contest, 'contact') else '')
contest.status = data.get('status', contest.status)
db.session.commit()
return jsonify({'success': True})
elif request.method == 'DELETE':
db.session.delete(contest)
db.session.commit()
return jsonify({'success': True})
@app.route('/api/admin/contests/<int:contest_id>/abolish', methods=['POST'])
@admin_required
def admin_abolish_contest(contest_id):
"""管理员废止杯赛"""
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
if contest.status == 'abolished':
return jsonify({'success': False, 'message': '杯赛已废止'}), 400
contest.status = 'abolished'
# 关闭该杯赛下所有考试
for exam in contest.exams:
exam.status = 'closed'
# 通知杯赛负责人和老师
for member in contest.members:
add_notification(member.user_id, 'contest_result',
f'杯赛「{contest.name}」已被管理员废止。', from_user='系统')
# 通知所有已报名用户
registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all()
for reg in registrations:
add_notification(reg.user_id, 'contest_result',
f'您报名的杯赛「{contest.name}」已被废止。', from_user='系统')
db.session.commit()
return jsonify({'success': True, 'message': '杯赛已废止'})
@app.route('/api/admin/users', methods=['GET'])
def admin_users_api():
user = require_admin_or_teacher()
q = request.args.get('q', '')
role = request.args.get('role', '')
query = User.query
if q:
query = query.filter((User.name.contains(q)) | (User.email.contains(q)))
if role:
query = query.filter_by(role=role)
users = query.all()
data = [{
'id': u.id,
'name': u.name,
'email': u.email,
'role': u.role,
'is_banned': u.is_banned,
'created_at': u.created_at.strftime('%Y-%m-%d %H:%M')
} for u in users]
return jsonify({'success': True, 'users': data})
@app.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
def admin_delete_user(user_id):
user = require_admin_or_teacher()
if user_id == session.get('user', {}).get('id'):
return jsonify({'success': False, 'message': '不能删除自己'})
target = User.query.get(user_id)
if not target:
return jsonify({'success': False, 'message': '用户不存在'})
if target.role == 'admin':
return jsonify({'success': False, 'message': '不能删除管理员'})
db.session.delete(target)
db.session.commit()
return jsonify({'success': True})
@app.route('/api/admin/posts', methods=['GET'])
def admin_posts_api():
user = require_admin_or_teacher()
q = request.args.get('q', '')
tag = request.args.get('tag', '')
query = Post.query
if q:
query = query.filter((Post.title.contains(q)) | (Post.content.contains(q)))
if tag:
query = query.filter_by(tag=tag)
posts = query.all()
data = [{
'id': p.id,
'title': p.title,
'author': p.author.name if p.author else '未知用户',
'tag': p.tag,
'pinned': p.pinned,
'created_at': p.created_at.strftime('%Y-%m-%d %H:%M')
} for p in posts]
return jsonify({'success': True, 'posts': data})
@app.route('/api/admin/posts/<int:post_id>', methods=['DELETE'])
def admin_delete_post(post_id):
user = require_admin_or_teacher()
post = Post.query.get(post_id)
if post:
db.session.delete(post)
db.session.commit()
return jsonify({'success': True})
return jsonify({'success': False, 'message': '帖子不存在'})
@app.route('/api/admin/exams', methods=['GET'])
def admin_exams_api():
user = require_admin_or_teacher()
exams = Exam.query.all()
data = [{
'id': e.id,
'title': e.title,
'subject': e.subject,
'creator_name': e.creator.name if e.creator else '',
'status': e.status,
'created_at': e.created_at.strftime('%Y-%m-%d %H:%M')
} for e in exams]
return jsonify({'success': True, 'exams': data})
@app.route('/api/admin/exams/<int:exam_id>/status', methods=['PUT'])
def admin_update_exam_status(exam_id):
user = require_admin_or_teacher()
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '考试不存在'})
data = request.get_json(force=True, silent=True)
exam.status = data.get('status', exam.status)
db.session.commit()
return jsonify({'success': True})
@app.route('/api/admin/exams/<int:exam_id>', methods=['DELETE'])
def admin_delete_exam(exam_id):
user = require_admin_or_teacher()
exam = Exam.query.get(exam_id)
if exam:
db.session.delete(exam)
db.session.commit()
return jsonify({'success': True})
return jsonify({'success': False, 'message': '考试不存在'})
@app.route('/api/admin/users/<int:user_id>/ban', methods=['PUT'])
@admin_required
def admin_ban_user(user_id):
current = get_current_user()
if user_id == current['id']:
return jsonify({'success': False, 'message': '不能封禁自己'}), 400
target = User.query.get(user_id)
if not target:
return jsonify({'success': False, 'message': '用户不存在'}), 404
target.is_banned = not target.is_banned
db.session.commit()
return jsonify({'success': True, 'is_banned': target.is_banned})
@app.route('/api/admin/posts/<int:post_id>/pin', methods=['PUT'])
def admin_pin_post(post_id):
user = require_admin_or_teacher()
post = Post.query.get(post_id)
if not post:
return jsonify({'success': False, 'message': '帖子不存在'}), 404
post.pinned = not post.pinned
db.session.commit()
return jsonify({'success': True, 'pinned': post.pinned})
@app.route('/api/admin/contest-applications', methods=['GET'])
@admin_required
def admin_contest_applications_api():
apps = ContestApplication.query.order_by(ContestApplication.applied_at.desc()).all()
data = [{
'id': a.id,
'user_name': a.user.name if a.user else '未知用户',
'name': a.name,
'organizer': a.organizer,
'description': a.description,
'contact': a.contact,
'start_date': a.start_date or '',
'end_date': a.end_date or '',
'total_score': a.total_score or 150,
'responsible_person': a.responsible_person or '',
'responsible_phone': a.responsible_phone or '',
'responsible_email': a.responsible_email or '',
'organization': a.organization or '',
'status': a.status,
'applied_at': a.applied_at.strftime('%Y-%m-%d %H:%M') if a.applied_at else ''
} for a in apps]
return jsonify({'success': True, 'applications': data})
@app.route('/api/admin/stats', methods=['GET'])
def admin_stats():
user = require_admin_or_teacher()
stats = {
'users': User.query.count(),
'posts': Post.query.count(),
'exams': Exam.query.count(),
'contests': Contest.query.count(),
'pending_teacher_apps': TeacherApplication.query.filter_by(status='pending').count(),
'pending_contest_apps': ContestApplication.query.filter_by(status='pending').count()
}
return jsonify({'success': True, 'stats': stats})
@app.route('/api/admin/recent-activities', methods=['GET'])
def admin_recent_activities():
user = require_admin_or_teacher()
activities = []
for s in Submission.query.order_by(Submission.submitted_at.desc()).limit(5).all():
activities.append({
'time': s.submitted_at.strftime('%Y-%m-%d %H:%M'),
'user': s.user.name if s.user else '未知用户',
'action': f'提交了试卷 ID {s.exam_id}'
})
for p in Post.query.order_by(Post.created_at.desc()).limit(5).all():
activities.append({
'time': p.created_at.strftime('%Y-%m-%d %H:%M'),
'user': p.author.name if p.author else '未知用户',
'action': f'发布了帖子《{p.title}'
})
activities.sort(key=lambda x: x['time'], reverse=True)
return jsonify({'success': True, 'activities': activities[:10]})
# ========== 初始化测试数据 ==========
with app.app_context():
db.create_all()
# 迁移:添加 avatar 列(如果不存在)
try:
from sqlalchemy import inspect as _inspect, text as _text
insp = _inspect(db.engine)
cols = [c['name'] for c in insp.get_columns('user')]
if 'avatar' not in cols:
with db.engine.connect() as conn:
conn.execute(_text('ALTER TABLE user ADD COLUMN avatar VARCHAR(200) DEFAULT ""'))
conn.commit()
except Exception:
pass
# 迁移:添加 score_release_time 列(如果不存在)
try:
from sqlalchemy import inspect as _inspect2, text as _text2
insp2 = _inspect2(db.engine)
exam_cols = [c['name'] for c in insp2.get_columns('exam')]
if 'score_release_time' not in exam_cols:
with db.engine.connect() as conn:
conn.execute(_text2('ALTER TABLE exam ADD COLUMN score_release_time DATETIME'))
conn.commit()
except Exception:
pass
# 迁移:添加 name_changed_at 列(如果不存在)
try:
from sqlalchemy import inspect as _inspect_nc, text as _text_nc
insp_nc = _inspect_nc(db.engine)
user_cols = [c['name'] for c in insp_nc.get_columns('user')]
if 'name_changed_at' not in user_cols:
with db.engine.connect() as conn:
conn.execute(_text_nc('ALTER TABLE user ADD COLUMN name_changed_at DATETIME'))
conn.commit()
except Exception:
pass
# 迁移:为已有杯赛创建讨论群
try:
for contest in Contest.query.all():
if not ChatRoom.query.filter_by(contest_id=contest.id).first():
cr = ChatRoom(type='contest', name=contest.name + ' 讨论群',
creator_id=contest.created_by if isinstance(contest.created_by, int) else None,
contest_id=contest.id)
db.session.add(cr)
db.session.flush()
if isinstance(contest.created_by, int):
db.session.add(ChatRoomMember(room_id=cr.id, user_id=contest.created_by, role='admin'))
db.session.commit()
except Exception:
db.session.rollback()
# 迁移ContestApplication 添加 total_score, start_date, end_date
try:
from sqlalchemy import inspect as _inspect_ca, text as _text_ca
insp_ca = _inspect_ca(db.engine)
ca_cols = [c['name'] for c in insp_ca.get_columns('contest_application')]
with db.engine.connect() as conn:
if 'total_score' not in ca_cols:
conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN total_score INTEGER DEFAULT 150'))
if 'start_date' not in ca_cols:
conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN start_date VARCHAR(20)'))
if 'end_date' not in ca_cols:
conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN end_date VARCHAR(20)'))
conn.commit()
except Exception:
pass
# 迁移Contest 添加 total_score, visible
try:
from sqlalchemy import inspect as _inspect_ct, text as _text_ct
insp_ct = _inspect_ct(db.engine)
ct_cols = [c['name'] for c in insp_ct.get_columns('contest')]
with db.engine.connect() as conn:
if 'total_score' not in ct_cols:
conn.execute(_text_ct('ALTER TABLE contest ADD COLUMN total_score INTEGER DEFAULT 150'))
if 'visible' not in ct_cols:
conn.execute(_text_ct('ALTER TABLE contest ADD COLUMN visible BOOLEAN DEFAULT 1'))
conn.commit()
except Exception:
pass
# 迁移ContestApplication 添加报备字段
try:
from sqlalchemy import inspect as _inspect_ca2, text as _text_ca2
insp_ca2 = _inspect_ca2(db.engine)
ca2_cols = [c['name'] for c in insp_ca2.get_columns('contest_application')]
with db.engine.connect() as conn:
if 'responsible_person' not in ca2_cols:
conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_person VARCHAR(80)'))
if 'responsible_phone' not in ca2_cols:
conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_phone VARCHAR(20)'))
if 'responsible_email' not in ca2_cols:
conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_email VARCHAR(120)'))
if 'organization' not in ca2_cols:
conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN organization VARCHAR(100)'))
conn.commit()
except Exception:
pass
# 迁移Contest 添加报备字段
try:
from sqlalchemy import inspect as _inspect_ct2, text as _text_ct2
insp_ct2 = _inspect_ct2(db.engine)
ct2_cols = [c['name'] for c in insp_ct2.get_columns('contest')]
with db.engine.connect() as conn:
if 'responsible_person' not in ct2_cols:
conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_person VARCHAR(80)'))
if 'responsible_phone' not in ct2_cols:
conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_phone VARCHAR(20)'))
if 'responsible_email' not in ct2_cols:
conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_email VARCHAR(120)'))
if 'organization' not in ct2_cols:
conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN organization VARCHAR(100)'))
conn.commit()
except Exception:
pass
# 迁移Exam 添加加密字段
try:
from sqlalchemy import inspect as _inspect_ex, text as _text_ex
insp_ex = _inspect_ex(db.engine)
ex_cols = [c['name'] for c in insp_ex.get_columns('exam')]
with db.engine.connect() as conn:
if 'access_password' not in ex_cols:
conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN access_password VARCHAR(128)'))
if 'encrypted_questions' not in ex_cols:
conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN encrypted_questions TEXT'))
if 'is_encrypted' not in ex_cols:
conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN is_encrypted BOOLEAN DEFAULT 0'))
conn.commit()
except Exception:
pass
if __name__ == '__main__':
socketio.run(app, debug=True,host='0.0.0.0', port=5080)