4606 lines
194 KiB
Python
4606 lines
194 KiB
Python
# 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()
|
||
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()
|
||
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()
|
||
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() 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() 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()
|
||
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() 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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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=2,AA₁=3,M为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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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) |