import re from io import BytesIO from urllib.parse import quote from docx import Document from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from database import get_db from models.exam import Exam from models.resource import Resource from models.user import User from schemas.exam import ExamCreate, ExamOut, ExamUpdate from services.auth import get_current_user, get_optional_current_user router = APIRouter(prefix="/api/exams", tags=["智能命题"]) def _safe_filename(name: str, suffix: str) -> str: stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "试卷" return f"{stem[:80]}{suffix}" def _as_list(value) -> list: if isinstance(value, list): return value if value in (None, ""): return [] return [value] def _exam_or_404(exam_id: int, current_user: User | None, db: Session) -> Exam: exam = db.query(Exam).filter(Exam.id == exam_id).first() if not exam: raise HTTPException(status_code=404, detail="试卷不存在") if (not current_user or exam.user_id != current_user.id) and not has_public_resource(db, exam_id): raise HTTPException(status_code=403, detail="无权访问该试卷") return exam def has_public_resource(db: Session, exam_id: int) -> bool: return db.query(Resource).filter( Resource.content_ref == f"exam:{exam_id}", Resource.status == "active", Resource.is_public == 1, ).first() is not None @router.post("/", response_model=ExamOut, status_code=201) def create_exam(data: ExamCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): exam = Exam(user_id=current_user.id, **data.model_dump()) db.add(exam) db.commit() db.refresh(exam) return exam @router.get("/", response_model=list[ExamOut]) def list_exams( subject: str = "", skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): query = db.query(Exam).filter(Exam.user_id == current_user.id) if subject: query = query.filter(Exam.subject == subject) return query.order_by(Exam.updated_at.desc()).offset(skip).limit(limit).all() @router.get("/{exam_id}", response_model=ExamOut) def get_exam( exam_id: int, current_user: User | None = Depends(get_optional_current_user), db: Session = Depends(get_db), ): return _exam_or_404(exam_id, current_user, db) @router.get("/{exam_id}/export-docx") def export_exam_docx( exam_id: int, current_user: User | None = Depends(get_optional_current_user), db: Session = Depends(get_db), ): exam = _exam_or_404(exam_id, current_user, db) document = Document() document.add_heading(exam.title or "智能试卷", level=1) meta = [ exam.subject, exam.grade, f"{exam.duration or 90}分钟", f"总分{exam.total_score or 100}分", f"难度:{exam.difficulty or 'medium'}", ] if exam.knowledge_points: meta.append("知识点:" + "、".join(str(item) for item in exam.knowledge_points if str(item).strip())) document.add_paragraph(" / ".join(str(part) for part in meta if part)) for index, question in enumerate(exam.questions or [], start=1): if not isinstance(question, dict): document.add_paragraph(f"{index}. {question}") continue q_type = question.get("type") or question.get("question_type") or "题目" score = question.get("score") heading = f"{index}. [{q_type}]" if score: heading += f"({score}分)" document.add_paragraph(heading) document.add_paragraph(str(question.get("content") or question.get("question") or question.get("stem") or "")) options = _as_list(question.get("options")) for option_index, option in enumerate(options): prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1) document.add_paragraph(f"{prefix}. {option}") answers = exam.answers or [question.get("answer") for question in exam.questions or [] if isinstance(question, dict)] if answers: document.add_page_break() document.add_heading("参考答案与解析", level=1) for index, question in enumerate(exam.questions or [], start=1): answer = answers[index - 1] if index - 1 < len(answers) else "" analysis = question.get("analysis") or question.get("explanation") if isinstance(question, dict) else "" document.add_paragraph(f"{index}. 答案:{answer}") if analysis: document.add_paragraph(f"解析:{analysis}") buffer = BytesIO() document.save(buffer) buffer.seek(0) filename = _safe_filename(f"{exam.title or '智能试卷'}-试卷", ".docx") return StreamingResponse( buffer, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}, ) @router.put("/{exam_id}", response_model=ExamOut) def update_exam( exam_id: int, data: ExamUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first() if not exam: raise HTTPException(status_code=404, detail="试卷不存在") for key, value in data.model_dump(exclude_unset=True).items(): setattr(exam, key, value) db.commit() db.refresh(exam) return exam @router.post("/{exam_id}/remix", response_model=ExamOut, status_code=201) def remix_exam( exam_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): source = db.query(Exam).filter(Exam.id == exam_id).first() if not source: raise HTTPException(status_code=404, detail="试卷不存在") if source.user_id != current_user.id and not has_public_resource(db, exam_id): raise HTTPException(status_code=403, detail="无权改编该试卷") clone = Exam( user_id=current_user.id, title=f"{source.title}(改编)", subject=source.subject or "", grade=source.grade or "", questions=list(source.questions or []), answers=list(source.answers or []), duration=source.duration or 90, total_score=source.total_score or 100, difficulty=source.difficulty or "medium", knowledge_points=list(source.knowledge_points or []), ) db.add(clone) db.commit() db.refresh(clone) return clone @router.delete("/{exam_id}", status_code=204) def delete_exam(exam_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first() if not exam: raise HTTPException(status_code=404, detail="试卷不存在") db.query(Resource).filter( Resource.user_id == current_user.id, Resource.content_ref == f"exam:{exam_id}", Resource.status == "active", ).update({"status": "archived"}, synchronize_session=False) db.delete(exam) db.commit()