nex_math/backend/routers/archive.py

378 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""学习档案导入 / 导出(对应静态版的导出 / 导入 JSON)。"""
from __future__ import annotations
import json
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_learning_user
from models import (
AttemptItem,
AttemptSession,
DailyCompletion,
ErrorEntry,
Knowledge,
User,
UserKnowledge,
)
from schemas import ImportPayload, ImportResult
from services.knowledge_service import ensure_knowledge
router = APIRouter(prefix="/archive", tags=["archive"])
def _as_date(value) -> date:
if isinstance(value, date):
return value
return date.fromisoformat(str(value))
def _options_to_json(value) -> str:
if isinstance(value, list):
return json.dumps([str(option) for option in value], ensure_ascii=False)
return ""
def _parse_options(value) -> list[str]:
if not isinstance(value, list):
return []
return [str(option) for option in value]
def _parse_item_dict(value, index: int) -> dict:
if not isinstance(value, dict):
raise ValueError(f"第 {index} 题明细格式不正确")
stem = str(value.get("stem", "")).strip()
options = _parse_options(value.get("options"))
if not stem or len(options) != 4:
raise ValueError(f"第 {index} 题明细缺少题干或选项")
try:
correct_index = int(value.get("correct_index", -1))
except (TypeError, ValueError) as exc:
raise ValueError(f"第 {index} 题明细 correct_index 不是数字") from exc
selected = value.get("selected")
if selected is not None:
try:
selected = int(selected)
except (TypeError, ValueError) as exc:
raise ValueError(f"第 {index} 题明细 selected 不是数字") from exc
raw_names = value.get("knowledge_names") or []
if not isinstance(raw_names, list) or not raw_names:
raw_names = [value.get("knowledge_name") or "章节综合"]
return {
"question_index": index,
"question_id": value.get("question_id"),
"stem": stem,
"options": options,
"correct_index": correct_index,
"selected": selected,
"correct": bool(value.get("correct", correct_index == selected)),
"knowledge_name": str(value.get("knowledge_name", "")).strip() or "章节综合",
"knowledge_names": [str(name) for name in raw_names],
"explanation": str(value.get("explanation", "")).strip(),
}
def _parse_record(item) -> dict:
if isinstance(item, list):
if len(item) != 4:
raise ValueError("练习记录需要 4 个字段")
return {
"happened_on": _as_date(item[0]),
"mode": "imported",
"title": str(item[1]),
"total": int(item[2]),
"correct": int(item[3]),
"items": [],
}
if isinstance(item, dict):
raw_items = item.get("items") or []
parsed_items = []
for index, raw in enumerate(raw_items, start=1):
parsed_items.append(_parse_item_dict(raw, index))
return {
"happened_on": _as_date(item.get("date") or item.get("happened_on")),
"mode": str(item.get("mode", "imported")),
"title": str(item.get("title", "")),
"total": int(item.get("total", 0)),
"correct": int(item.get("correct", 0)),
"items": parsed_items,
}
raise ValueError("练习记录格式不正确")
def _parse_error(item) -> dict:
if isinstance(item, list):
if len(item) != 3:
raise ValueError("错题需要 3 个字段")
return {
"source_label": str(item[0]),
"knowledge_name": str(item[1]),
"knowledge_names": [str(item[1])],
"diagnosis": str(item[2]),
"stem": "",
"options": [],
"selected": None,
"correct_index": None,
"explanation": "",
}
if isinstance(item, dict):
raw_names = item.get("knowledge_names") or []
knowledge_name = str(item.get("knowledge_name", ""))
if not isinstance(raw_names, list) or not raw_names:
raw_names = [knowledge_name]
return {
"source_label": str(item.get("source_label", "")),
"knowledge_name": knowledge_name,
"knowledge_names": [str(name) for name in raw_names],
"diagnosis": str(item.get("diagnosis", "")),
"stem": str(item.get("stem", "")),
"options": _parse_options(item.get("options")),
"selected": item.get("selected"),
"correct_index": item.get("correct_index"),
"explanation": str(item.get("explanation", "")),
}
raise ValueError("错题格式不正确")
def _session_items(db: Session, session_id: int) -> list[dict]:
rows = (
db.query(AttemptItem)
.filter(AttemptItem.attempt_id == session_id)
.order_by(AttemptItem.question_index.asc(), AttemptItem.id.asc())
.all()
)
result = []
for row in rows:
try:
options = json.loads(row.options)
if not isinstance(options, list):
options = []
except (TypeError, ValueError):
options = []
result.append(
{
"question_index": row.question_index,
"question_id": row.question_id,
"stem": row.stem,
"options": [str(option) for option in options],
"correct_index": row.correct_index,
"selected": row.selected,
"correct": bool(row.correct),
"knowledge_name": row.knowledge_name,
"knowledge_names": (
json.loads(row.knowledge_names)
if row.knowledge_names
else [row.knowledge_name]
),
"explanation": row.explanation,
}
)
return result
@router.get("/export")
def export_archive(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
knowledge = [
{"name": knowledge.name, "mastery": user_knowledge.mastery}
for user_knowledge, knowledge in (
db.query(UserKnowledge, Knowledge)
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
.filter(UserKnowledge.user_id == user.id)
.order_by(UserKnowledge.position)
.all()
)
]
sessions = (
db.query(AttemptSession)
.filter(AttemptSession.user_id == user.id)
.order_by(AttemptSession.id)
.all()
)
records = [
{
"date": session.happened_on.isoformat(),
"mode": session.mode,
"title": session.title,
"total": session.total,
"correct": session.correct,
"items": _session_items(db, session.id),
}
for session in sessions
]
errors = []
for error in (
db.query(ErrorEntry)
.filter(ErrorEntry.user_id == user.id)
.order_by(ErrorEntry.id)
.all()
):
item = {
"source_label": error.source_label,
"knowledge_name": error.knowledge_name,
"knowledge_names": (
json.loads(error.knowledge_names)
if error.knowledge_names
else [error.knowledge_name]
),
"diagnosis": error.diagnosis,
"stem": error.stem,
"selected": error.selected,
"correct_index": error.correct_index,
"explanation": error.explanation,
}
if error.options:
try:
parsed = json.loads(error.options)
item["options"] = parsed if isinstance(parsed, list) else []
except (TypeError, ValueError):
item["options"] = []
errors.append(item)
daily = [
d.done_on.isoformat()
for d in db.query(DailyCompletion)
.filter(DailyCompletion.user_id == user.id)
.order_by(DailyCompletion.done_on)
.all()
]
payload = {
"version": 2,
"exported_at": datetime.now().isoformat(timespec="seconds"),
"knowledge": knowledge,
"records": records,
"errors": errors,
"daily_completions": daily,
}
return Response(
content=json.dumps(payload, ensure_ascii=False, indent=2),
media_type="application/json",
headers={
"Content-Disposition": 'attachment; filename="math-learning-archive.json"'
},
)
@router.post("/import", response_model=ImportResult)
def import_archive(
payload: ImportPayload,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
if not payload.knowledge:
raise HTTPException(status_code=400, detail="档案中缺少 knowledge 字段")
knowledge_items = []
for item in payload.knowledge:
name = str(item.get("name", "")).strip()
mastery = item.get("mastery", item.get("v"))
if not name or mastery is None:
raise HTTPException(status_code=400, detail="knowledge 项缺少 name 或 mastery")
knowledge_items.append({"name": name, "mastery": max(0, min(100, int(mastery)))})
parsed_records: list[dict] = []
for item in payload.records:
try:
parsed_records.append(_parse_record(item))
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=f"记录格式错误:{exc}") from exc
parsed_errors: list[dict] = []
for item in payload.errors:
try:
parsed_errors.append(_parse_error(item))
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=f"错题格式错误:{exc}") from exc
try:
done_dates = [_as_date(value) for value in payload.daily_completions]
except ValueError as exc:
raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc
# 恢复式导入:档案内容作为当前学习状态替换进来
db.query(AttemptItem).filter(AttemptItem.user_id == user.id).delete()
db.query(DailyCompletion).filter(DailyCompletion.user_id == user.id).delete()
db.query(ErrorEntry).filter(ErrorEntry.user_id == user.id).delete()
db.query(AttemptSession).filter(AttemptSession.user_id == user.id).delete()
db.query(UserKnowledge).filter(UserKnowledge.user_id == user.id).delete()
for index, item in enumerate(knowledge_items):
knowledge = ensure_knowledge(db, item["name"])
db.add(
UserKnowledge(
user_id=user.id,
knowledge_id=knowledge.id,
mastery=item["mastery"],
position=index,
)
)
for done_on in done_dates:
db.add(DailyCompletion(user_id=user.id, done_on=done_on))
item_count = 0
for error in parsed_errors:
db.add(
ErrorEntry(
user_id=user.id,
source_label=error["source_label"],
knowledge_name=error["knowledge_name"],
knowledge_names=json.dumps(
error["knowledge_names"], ensure_ascii=False
),
diagnosis=error["diagnosis"],
stem=error["stem"],
options=_options_to_json(error["options"]),
selected=error["selected"],
correct_index=error["correct_index"],
explanation=error["explanation"],
)
)
for record in parsed_records:
session = AttemptSession(
user_id=user.id,
mode=record["mode"],
title=record["title"],
total=record["total"],
correct=record["correct"],
happened_on=record["happened_on"],
)
db.add(session)
db.flush()
for item in record["items"]:
db.add(
AttemptItem(
attempt_id=session.id,
user_id=user.id,
question_index=item["question_index"],
# 题库题目可能已被删除,导入只保留快照不重建外键
question_id=None,
stem=item["stem"],
options=_options_to_json(item["options"]),
correct_index=item["correct_index"],
selected=item.get("selected"),
correct=bool(item.get("correct")),
knowledge_name=item["knowledge_name"],
knowledge_names=json.dumps(
item["knowledge_names"], ensure_ascii=False
),
explanation=item["explanation"],
)
)
item_count += 1
db.commit()
return ImportResult(
imported=True,
knowledge=len(knowledge_items),
records=len(parsed_records),
errors=len(parsed_errors),
items=item_count,
message="学习档案导入成功。",
)