first commit

This commit is contained in:
2026-03-05 11:50:15 +08:00
commit b1b14fd964
45 changed files with 7779 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Routers package marker.

13
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, HTTPException, status
from backend.auth import create_access_token, verify_password
from backend.schemas import LoginRequest, Token
router = APIRouter()
@router.post("/login", response_model=Token)
def login(payload: LoginRequest) -> Token:
if not verify_password(payload.username, payload.password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
return Token(access_token=create_access_token(payload.username))

View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Category, Question
from backend.schemas import CategoryCreate, CategoryOut
router = APIRouter(dependencies=[Depends(get_current_user)])
def _build_tree(rows: list[Category], counts: dict[str, int]) -> list[dict]:
nodes = {
row.id: {
"id": row.id,
"name": row.name,
"level": row.level,
"parent_id": row.parent_id,
"count": counts.get(row.name, 0),
"children": [],
}
for row in rows
}
tree = []
for node in nodes.values():
pid = node["parent_id"]
if pid and pid in nodes:
nodes[pid]["children"].append(node)
else:
tree.append(node)
return tree
@router.get("")
def list_categories(db: Session = Depends(get_db)) -> dict:
rows = db.query(Category).order_by(Category.level.asc(), Category.id.asc()).all()
counts = dict(db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all())
return {"items": _build_tree(rows, counts)}
@router.post("", response_model=CategoryOut)
def create_category(payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
if payload.parent_id:
parent = db.get(Category, payload.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="父分类不存在")
item = Category(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return CategoryOut.model_validate(item)
@router.put("/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
item = db.get(Category, category_id)
if not item:
raise HTTPException(status_code=404, detail="分类不存在")
for key, value in payload.model_dump().items():
setattr(item, key, value)
db.commit()
db.refresh(item)
return CategoryOut.model_validate(item)
@router.delete("/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)) -> dict:
item = db.get(Category, category_id)
if not item:
raise HTTPException(status_code=404, detail="分类不存在")
db.delete(item)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,68 @@
import csv
import json
from io import StringIO
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.services.excel_service import export_excel_bytes
router = APIRouter(dependencies=[Depends(get_current_user)])
def _filter_questions(
db: Session, chapter: str = "", question_type: str = "", difficulty: str = ""
) -> list[Question]:
query = db.query(Question)
if chapter:
query = query.filter(Question.chapter == chapter)
if question_type:
query = query.filter(Question.question_type == question_type)
if difficulty:
query = query.filter(Question.difficulty == difficulty)
return query.order_by(Question.id.desc()).all()
@router.get("")
def export_questions(
format: str = Query(default="json", pattern="^(json|csv|xlsx)$"),
chapter: str = "",
question_type: str = "",
difficulty: str = "",
db: Session = Depends(get_db),
) -> dict:
items = _filter_questions(db, chapter, question_type, difficulty)
rows = [
{
"id": i.id,
"chapter": i.chapter,
"primary_knowledge": i.primary_knowledge,
"secondary_knowledge": i.secondary_knowledge,
"question_type": i.question_type,
"difficulty": i.difficulty,
"stem": i.stem,
"option_a": i.option_a,
"option_b": i.option_b,
"option_c": i.option_c,
"option_d": i.option_d,
"answer": i.answer,
"explanation": i.explanation,
"notes": i.notes,
"source_file": i.source_file,
}
for i in items
]
if format == "json":
return {"format": "json", "content": json.dumps(rows, ensure_ascii=False)}
if format == "csv":
out = StringIO()
writer = csv.DictWriter(out, fieldnames=rows[0].keys() if rows else ["id"])
writer.writeheader()
for row in rows:
writer.writerow(row)
return {"format": "csv", "content": out.getvalue()}
xlsx_bytes = export_excel_bytes(rows)
return {"format": "xlsx", "content_base64": xlsx_bytes.hex()}

View File

@@ -0,0 +1,97 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import ImportHistory, Question
from backend.schemas import ImportHistoryOut, QuestionOut
from backend.services.excel_service import create_template_bytes, parse_excel_file
from backend.services.file_utils import save_upload
from backend.services.parser import DMXAPIService, extract_metadata
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.post("/ai/parse")
def parse_by_ai(file: UploadFile = File(...)) -> dict:
path = save_upload(file)
parser = DMXAPIService()
metadata = extract_metadata(file.filename or path.name)
questions = parser.parse_file(str(path))
preview = []
for q in questions:
preview.append(
{
"chapter": metadata["chapter"],
"primary_knowledge": "",
"secondary_knowledge": metadata["secondary_knowledge"],
"question_type": metadata["question_type"],
"difficulty": metadata["difficulty"],
"stem": q.get("题干", ""),
"option_a": q.get("选项A", ""),
"option_b": q.get("选项B", ""),
"option_c": q.get("选项C", ""),
"option_d": q.get("选项D", ""),
"answer": q.get("正确答案", ""),
"explanation": q.get("解析", ""),
"notes": q.get("备注", ""),
"source_file": metadata["source_file"],
}
)
return {"filename": file.filename, "preview": preview}
@router.post("/ai/confirm", response_model=list[QuestionOut])
def confirm_ai_import(payload: list[dict], db: Session = Depends(get_db)) -> list[QuestionOut]:
if not payload:
raise HTTPException(status_code=400, detail="没有可导入数据")
items = [Question(**item) for item in payload]
db.add_all(items)
db.add(
ImportHistory(
filename=items[0].source_file if items else "",
method="ai",
question_count=len(items),
status="success",
)
)
db.commit()
for item in items:
db.refresh(item)
return [QuestionOut.model_validate(item) for item in items]
@router.post("/excel", response_model=list[QuestionOut])
def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)) -> list[QuestionOut]:
path = save_upload(file)
if Path(path).suffix.lower() not in [".xlsx", ".xlsm", ".xltx", ".xltm"]:
raise HTTPException(status_code=400, detail="仅支持 Excel 文件")
rows = parse_excel_file(Path(path))
items = [Question(**row) for row in rows]
db.add_all(items)
db.add(
ImportHistory(
filename=file.filename or "",
method="excel",
question_count=len(items),
status="success",
)
)
db.commit()
for item in items:
db.refresh(item)
return [QuestionOut.model_validate(item) for item in items]
@router.get("/template")
def download_template() -> dict:
content = create_template_bytes()
return {"filename": "question_template.xlsx", "content_base64": content.hex()}
@router.get("/history", response_model=list[ImportHistoryOut])
def import_history(db: Session = Depends(get_db)) -> list[ImportHistoryOut]:
rows = db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(100).all()
return [ImportHistoryOut.model_validate(r) for r in rows]

View File

@@ -0,0 +1,57 @@
import random
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.schemas import PracticeCheckRequest, PracticeStartRequest
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.post("/start")
def start_practice(payload: PracticeStartRequest, db: Session = Depends(get_db)) -> dict:
query = db.query(Question)
if payload.chapter:
query = query.filter(Question.chapter == payload.chapter)
if payload.secondary_knowledge:
query = query.filter(Question.secondary_knowledge == payload.secondary_knowledge)
if payload.question_type:
query = query.filter(Question.question_type == payload.question_type)
if payload.difficulty:
query = query.filter(Question.difficulty == payload.difficulty)
items = query.all()
if payload.random_mode:
random.shuffle(items)
items = items[: payload.limit]
return {
"total": len(items),
"items": [
{
"id": i.id,
"stem": i.stem,
"option_a": i.option_a,
"option_b": i.option_b,
"option_c": i.option_c,
"option_d": i.option_d,
"question_type": i.question_type,
}
for i in items
],
}
@router.post("/check")
def check_answer(payload: PracticeCheckRequest, db: Session = Depends(get_db)) -> dict:
item = db.get(Question, payload.question_id)
if not item:
return {"correct": False, "message": "题目不存在"}
user_answer = payload.user_answer.strip().upper()
right_answer = item.answer.strip().upper()
return {
"correct": user_answer == right_answer,
"right_answer": right_answer,
"explanation": item.explanation,
}

View File

@@ -0,0 +1,136 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.schemas import (
BatchDeleteRequest,
BatchUpdateRequest,
QuestionCreate,
QuestionOut,
QuestionUpdate,
)
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.get("")
def list_questions(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
keyword: str = "",
chapter: str = "",
secondary_knowledge: str = "",
question_type: str = "",
difficulty: str = "",
db: Session = Depends(get_db),
) -> dict:
query = db.query(Question)
if keyword:
like = f"%{keyword}%"
query = query.filter(
or_(
Question.stem.like(like),
Question.option_a.like(like),
Question.option_b.like(like),
Question.option_c.like(like),
Question.option_d.like(like),
Question.explanation.like(like),
)
)
if chapter:
query = query.filter(Question.chapter == chapter)
if secondary_knowledge:
query = query.filter(Question.secondary_knowledge == secondary_knowledge)
if question_type:
query = query.filter(Question.question_type == question_type)
if difficulty:
query = query.filter(Question.difficulty == difficulty)
total = query.with_entities(func.count(Question.id)).scalar() or 0
items = (
query.order_by(Question.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {"total": total, "items": [QuestionOut.model_validate(i).model_dump() for i in items]}
@router.get("/id/{question_id}", response_model=QuestionOut)
def get_question(question_id: int, db: Session = Depends(get_db)) -> QuestionOut:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
return QuestionOut.model_validate(item)
@router.post("", response_model=QuestionOut)
def create_question(payload: QuestionCreate, db: Session = Depends(get_db)) -> QuestionOut:
item = Question(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return QuestionOut.model_validate(item)
@router.delete("/batch")
def batch_delete(payload: BatchDeleteRequest, db: Session = Depends(get_db)) -> dict:
if not payload.ids:
return {"deleted": 0}
deleted = db.query(Question).filter(Question.id.in_(payload.ids)).delete(synchronize_session=False)
db.commit()
return {"deleted": deleted}
@router.put("/batch/update")
def batch_update(payload: BatchUpdateRequest, db: Session = Depends(get_db)) -> dict:
if not payload.ids:
return {"updated": 0}
updates = payload.model_dump(exclude_none=True, exclude={"ids"})
if not updates:
return {"updated": 0}
updated = db.query(Question).filter(Question.id.in_(payload.ids)).update(
updates, synchronize_session=False
)
db.commit()
return {"updated": updated}
@router.get("/meta/options")
def get_filter_options(db: Session = Depends(get_db)) -> dict:
def distinct_values(column) -> list[str]:
rows = db.query(column).filter(column != "").distinct().all()
return [row[0] for row in rows]
return {
"chapters": distinct_values(Question.chapter),
"secondary_knowledge_list": distinct_values(Question.secondary_knowledge),
"question_types": distinct_values(Question.question_type),
"difficulties": distinct_values(Question.difficulty),
}
@router.put("/{question_id}", response_model=QuestionOut)
def update_question(question_id: int, payload: QuestionUpdate, db: Session = Depends(get_db)) -> QuestionOut:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
update_data = payload.model_dump(exclude_none=True)
for key, value in update_data.items():
setattr(item, key, value)
db.commit()
db.refresh(item)
return QuestionOut.model_validate(item)
@router.delete("/{question_id}")
def delete_question(question_id: int, db: Session = Depends(get_db)) -> dict:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
db.delete(item)
db.commit()
return {"ok": True}

37
backend/routers/stats.py Normal file
View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import ImportHistory, Question
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.get("")
def get_stats(db: Session = Depends(get_db)) -> dict:
total = db.query(func.count(Question.id)).scalar() or 0
by_type = db.query(Question.question_type, func.count(Question.id)).group_by(Question.question_type).all()
by_difficulty = db.query(Question.difficulty, func.count(Question.id)).group_by(Question.difficulty).all()
by_chapter = db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all()
latest_imports = (
db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(10).all()
)
return {
"total": total,
"by_type": [{"name": n or "未分类", "value": v} for n, v in by_type],
"by_difficulty": [{"name": n or "未分类", "value": v} for n, v in by_difficulty],
"by_chapter": [{"name": n or "未分类", "value": v} for n, v in by_chapter],
"latest_imports": [
{
"id": i.id,
"filename": i.filename,
"method": i.method,
"question_count": i.question_count,
"status": i.status,
"created_at": i.created_at.isoformat(),
}
for i in latest_imports
],
}