"""题目查询路由""" from fastapi import APIRouter, Request, HTTPException, Query, Depends from app.database import ( get_connection, get_problem, search_problems, search_fts, update_problem_metadata, get_next_unannotated, ) from app.routers.auth import verify_token from app.services.renderer import render_docx_to_html router = APIRouter(dependencies=[]) @router.get("/stats") def get_stats(request: Request, _=Depends(verify_token)): """仪表盘统计""" from app.database import count_by_question_type, count_by_import_type, count_by_chapter config = request.app.state.config conn = get_connection(config.db_path) try: total_row = conn.execute("SELECT COUNT(*) FROM problems").fetchone() total = total_row[0] if total_row else 0 return { "total": total, "by_question_type": count_by_question_type(conn), "by_import_type": count_by_import_type(conn), "by_chapter": count_by_chapter(conn), } finally: conn.close() @router.get("/problems") def list_problems( request: Request, chapter: str | None = Query(None), knowledge_code: str | None = Query(None), question_type: str | None = Query(None), import_type: str | None = Query(None), annotated: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=10000), _=Depends(verify_token), ): """题目列表(分页+筛选)""" config = request.app.state.config conn = get_connection(config.db_path) try: items, total = search_problems( conn, chapter=chapter, knowledge_code=knowledge_code, question_type=question_type, import_type=import_type, annotated=annotated, page=page, page_size=page_size ) # Add body_preview for each item from FTS for item in items: row = conn.execute( "SELECT body FROM problems_fts WHERE problem_id = ?", (item["id"],) ).fetchone() if row and row["body"]: # Get first 80 chars, strip question number body = row["body"] # Strip question number like "1." import re body = re.sub(r'^\d+[..]\s*', '', body) item["body_preview"] = body[:80] else: item["body_preview"] = "" return { "items": items, "total": total, "page": page, "page_size": page_size, } finally: conn.close() @router.get("/problems/{problem_id}") def get_problem_detail(request: Request, problem_id: str, _=Depends(verify_token)): """题目详情(元数据 + 渲染 HTML)""" config = request.app.state.config conn = get_connection(config.db_path) try: problem = get_problem(conn, problem_id) if not problem: raise HTTPException(status_code=404, detail={ "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"} }) # 渲染 HTML docx_path = config.problems_dir / f"{problem_id}.docx" if docx_path.exists(): problem["render_html"] = render_docx_to_html(docx_path, include_answer=True) else: problem["render_html"] = "

文件不存在

" return problem finally: conn.close() @router.get("/problems/{problem_id}/render") def render_problem( request: Request, problem_id: str, include_answer: bool = Query(True), _=Depends(verify_token), ): """渲染题目为 HTML(JSON 格式,用于 Android WebView)""" config = request.app.state.config docx_path = config.problems_dir / f"{problem_id}.docx" if not docx_path.exists(): raise HTTPException(status_code=404, detail={ "error": {"code": "not_found", "message": f"题目文件不存在: {problem_id}"} }) html_fragment = render_docx_to_html(docx_path, include_answer=include_answer) full_html = _wrap_full_html(html_fragment, config.token) return {"html": full_html, "problem_id": problem_id} @router.get("/problems/{problem_id}/view") def view_problem( request: Request, problem_id: str, include_answer: bool = Query(True), ): """渲染题目为完整 HTML 页面(用 IFrame 加载,token 从 query string 获取)""" config = request.app.state.config token = request.query_params.get("token", "") if token != config.token: raise HTTPException(status_code=401, detail={ "error": {"code": "unauthorized", "message": "Token 无效"} }) docx_path = config.problems_dir / f"{problem_id}.docx" if not docx_path.exists(): return _wrap_full_html("

题目文件不存在

", "") html_fragment = render_docx_to_html(docx_path, include_answer=include_answer) return HTMLResponse(content=_wrap_full_html(html_fragment, "")) def _wrap_full_html(body: str, token: str = "") -> str: """将 pandoc HTML 片段包装成完整的 HTML 页面""" return f""" {body} """ from fastapi.responses import HTMLResponse @router.get("/problems/next-unannotated") def next_unannotated( request: Request, current_id: str | None = Query(None), direction: str = Query("next"), _=Depends(verify_token), ): """连续标注导航""" config = request.app.state.config conn = get_connection(config.db_path) try: problem = get_next_unannotated(conn, current_id, direction) return problem or {} finally: conn.close() @router.put("/problems/{problem_id}/metadata") def update_metadata(request: Request, problem_id: str, body: dict, _=Depends(verify_token)): """更新题目元数据""" config = request.app.state.config conn = get_connection(config.db_path) try: problem = get_problem(conn, problem_id) if not problem: raise HTTPException(status_code=404, detail={ "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"} }) update_problem_metadata(conn, problem_id, body) # 同时更新 docx 自定义属性 docx_path = config.problems_dir / f"{problem_id}.docx" if docx_path.exists(): _update_docx_props(docx_path, body) # 自动备份 try: from app.services.backup import auto_backup auto_backup(config, changed_files=[str(config.problems_dir / f"{problem_id}.docx")]) except Exception: pass return {"ok": True} finally: conn.close() @router.put("/problems/{problem_id}/knowledge") def update_knowledge(request: Request, problem_id: str, body: dict, _=Depends(verify_token)): """更新知识点编码""" config = request.app.state.config codes = body.get("knowledge_codes", []) conn = get_connection(config.db_path) try: problem = get_problem(conn, problem_id) if not problem: raise HTTPException(status_code=404, detail={ "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"} }) update_problem_metadata(conn, problem_id, {"knowledge_codes": codes}) # 更新 docx 自定义属性 import json docx_path = config.problems_dir / f"{problem_id}.docx" if docx_path.exists(): _update_docx_props(docx_path, {"knowledge_codes": json.dumps(codes, ensure_ascii=False)}) # 自动运行增量索引以更新章节 from app.services.indexer import incremental_update incremental_update(config) # 自动备份 try: from app.services.backup import auto_backup auto_backup(config, changed_files=[str(config.problems_dir / f"{problem_id}.docx")]) except Exception: pass return {"ok": True} finally: conn.close() @router.get("/search") def search_problems_api( request: Request, q: str = Query(..., min_length=1), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), _=Depends(verify_token), ): """全文搜索""" from app.services.search import search config = request.app.state.config return search(config, q, page, page_size) def _update_docx_props(docx_path, fields: dict): """更新 docx 自定义属性""" from docx import Document import json try: doc = Document(str(docx_path)) for key, value in fields.items(): if isinstance(value, list): value = json.dumps(value, ensure_ascii=False) try: doc.custom_document_properties[key] = value except Exception: pass doc.save(str(docx_path)) except Exception: pass