"""学习档案导入 / 导出(对应静态版的导出 / 导入 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="学习档案导入成功。", )