"""笔记 CRUD 接口,全部按用户隔离。""" from datetime import datetime from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db import get_db from app.deps import get_current_user from app.models.question import Question, QuestionType, Tag from app.models.user import User from app.schemas.question import ( QuestionCreate, QuestionList, QuestionOut, QuestionUpdate, ) from app.services.tags import get_or_create_tags router = APIRouter(prefix="/api/questions", tags=["questions"]) async def _get_owned_question(db: AsyncSession, user_id: str, question_id: str) -> Question: q = await db.scalar( select(Question) .where(Question.id == question_id, Question.user_id == user_id) .options(selectinload(Question.tags)) ) if q is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="题目不存在") return q def _options_to_json(options) -> list | None: if options is None: return None return [o.model_dump() for o in options] @router.get("", response_model=QuestionList) async def list_questions( db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), type: QuestionType | None = None, tag: list[str] | None = Query(None, description="知识点,可多个(全部满足)"), q: str | None = Query(None, description="关键词,搜题干与我的笔记"), difficulty: int | None = Query(None, ge=1, le=5), difficulty_min: int | None = Query(None, ge=1, le=5), difficulty_max: int | None = Query(None, ge=1, le=5), created_after: datetime | None = Query(None, description="记录时间下限(含)"), created_before: datetime | None = Query(None, description="记录时间上限(含)"), needs_review: bool | None = Query(None, description="只看待复习的"), untagged: bool = Query(False, description="只看还没归类知识点的"), sort: Literal["created_desc", "created_asc", "updated_desc"] = "created_desc", ): """多维检索:知识点 + 时间 + 难度 + 待复习状态,可自由组合。""" stmt = select(Question).where(Question.user_id == current.id) if type is not None: stmt = stmt.where(Question.type == type) if q: # 题干和我自己的笔记都要能搜到 stmt = stmt.where( or_( Question.stem_markdown.contains(q), Question.my_note_markdown.contains(q), ) ) if difficulty is not None: stmt = stmt.where(Question.difficulty == difficulty) if difficulty_min is not None: stmt = stmt.where(Question.difficulty >= difficulty_min) if difficulty_max is not None: stmt = stmt.where(Question.difficulty <= difficulty_max) if created_after is not None: stmt = stmt.where(Question.created_at >= created_after) if created_before is not None: stmt = stmt.where(Question.created_at <= created_before) if needs_review is not None: stmt = stmt.where(Question.needs_review.is_(needs_review)) if untagged: stmt = stmt.where(~Question.tags.any()) if tag: # 多个知识点取交集:每个都要命中 for name in tag: stmt = stmt.where( Question.tags.any(and_(Tag.user_id == current.id, Tag.name == name)) ) total = await db.scalar( select(func.count()).select_from(stmt.order_by(None).subquery()) ) order = { "created_desc": Question.created_at.desc(), "created_asc": Question.created_at.asc(), "updated_desc": Question.updated_at.desc(), }[sort] stmt = ( stmt.options(selectinload(Question.tags)) .order_by(order) .offset((page - 1) * page_size) .limit(page_size) ) rows = (await db.scalars(stmt)).unique().all() return QuestionList( items=[QuestionOut.from_model(x) for x in rows], total=total or 0, page=page, page_size=page_size, ) @router.post("", response_model=QuestionOut, status_code=status.HTTP_201_CREATED) async def create_question( payload: QuestionCreate, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): question = Question( user_id=current.id, type=payload.type, stem_markdown=payload.stem_markdown, options=_options_to_json(payload.options), correct_answer=payload.correct_answer, explanation_markdown=payload.explanation_markdown, my_note_markdown=payload.my_note_markdown, needs_review=payload.needs_review, difficulty=payload.difficulty, ) question.tags = await get_or_create_tags(db, current.id, payload.tags) db.add(question) await db.commit() await db.refresh(question, ["tags"]) return QuestionOut.from_model(question) @router.get("/{question_id}", response_model=QuestionOut) async def get_question( question_id: str, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): q = await _get_owned_question(db, current.id, question_id) return QuestionOut.from_model(q) @router.put("/{question_id}", response_model=QuestionOut) async def update_question( question_id: str, payload: QuestionUpdate, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): q = await _get_owned_question(db, current.id, question_id) data = payload.model_dump(exclude_unset=True) if "options" in data: q.options = _options_to_json(payload.options) if "tags" in data and payload.tags is not None: q.tags = await get_or_create_tags(db, current.id, payload.tags) for field in ( "type", "stem_markdown", "correct_answer", "explanation_markdown", "my_note_markdown", "needs_review", "difficulty", ): if field in data: setattr(q, field, data[field]) await db.commit() await db.refresh(q, ["tags"]) return QuestionOut.from_model(q) @router.delete("/{question_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_question( question_id: str, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): q = await _get_owned_question(db, current.id, question_id) await db.delete(q) await db.commit()