Files
2026-08-01 16:50:55 +08:00

193 lines
6.5 KiB
Python

"""笔记 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()