problems.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """题目查询路由"""
  2. from fastapi import APIRouter, Request, HTTPException, Query, Depends
  3. from app.database import (
  4. get_connection, get_problem, search_problems, search_fts,
  5. update_problem_metadata, get_next_unannotated,
  6. )
  7. from app.routers.auth import verify_token
  8. from app.services.renderer import render_docx_to_html
  9. router = APIRouter(dependencies=[])
  10. @router.get("/stats")
  11. def get_stats(request: Request, _=Depends(verify_token)):
  12. """仪表盘统计"""
  13. from app.database import count_by_question_type, count_by_import_type, count_by_chapter
  14. config = request.app.state.config
  15. conn = get_connection(config.db_path)
  16. try:
  17. total_row = conn.execute("SELECT COUNT(*) FROM problems").fetchone()
  18. total = total_row[0] if total_row else 0
  19. return {
  20. "total": total,
  21. "by_question_type": count_by_question_type(conn),
  22. "by_import_type": count_by_import_type(conn),
  23. "by_chapter": count_by_chapter(conn),
  24. }
  25. finally:
  26. conn.close()
  27. @router.get("/problems")
  28. def list_problems(
  29. request: Request,
  30. chapter: str | None = Query(None),
  31. knowledge_code: str | None = Query(None),
  32. question_type: str | None = Query(None),
  33. import_type: str | None = Query(None),
  34. annotated: str | None = Query(None),
  35. page: int = Query(1, ge=1),
  36. page_size: int = Query(20, ge=1, le=10000),
  37. _=Depends(verify_token),
  38. ):
  39. """题目列表(分页+筛选)"""
  40. config = request.app.state.config
  41. conn = get_connection(config.db_path)
  42. try:
  43. items, total = search_problems(
  44. conn, chapter=chapter, knowledge_code=knowledge_code,
  45. question_type=question_type, import_type=import_type,
  46. annotated=annotated, page=page, page_size=page_size
  47. )
  48. # Add body_preview for each item from FTS
  49. for item in items:
  50. row = conn.execute(
  51. "SELECT body FROM problems_fts WHERE problem_id = ?",
  52. (item["id"],)
  53. ).fetchone()
  54. if row and row["body"]:
  55. # Get first 80 chars, strip question number
  56. body = row["body"]
  57. # Strip question number like "1."
  58. import re
  59. body = re.sub(r'^\d+[..]\s*', '', body)
  60. item["body_preview"] = body[:80]
  61. else:
  62. item["body_preview"] = ""
  63. return {
  64. "items": items,
  65. "total": total,
  66. "page": page,
  67. "page_size": page_size,
  68. }
  69. finally:
  70. conn.close()
  71. @router.get("/problems/{problem_id}")
  72. def get_problem_detail(request: Request, problem_id: str, _=Depends(verify_token)):
  73. """题目详情(元数据 + 渲染 HTML)"""
  74. config = request.app.state.config
  75. conn = get_connection(config.db_path)
  76. try:
  77. problem = get_problem(conn, problem_id)
  78. if not problem:
  79. raise HTTPException(status_code=404, detail={
  80. "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"}
  81. })
  82. # 渲染 HTML
  83. docx_path = config.problems_dir / f"{problem_id}.docx"
  84. if docx_path.exists():
  85. problem["render_html"] = render_docx_to_html(docx_path, include_answer=True)
  86. else:
  87. problem["render_html"] = "<p>文件不存在</p>"
  88. return problem
  89. finally:
  90. conn.close()
  91. @router.get("/problems/{problem_id}/render")
  92. def render_problem(
  93. request: Request,
  94. problem_id: str,
  95. include_answer: bool = Query(True),
  96. _=Depends(verify_token),
  97. ):
  98. """渲染题目为 HTML(JSON 格式,用于 Android WebView)"""
  99. config = request.app.state.config
  100. docx_path = config.problems_dir / f"{problem_id}.docx"
  101. if not docx_path.exists():
  102. raise HTTPException(status_code=404, detail={
  103. "error": {"code": "not_found", "message": f"题目文件不存在: {problem_id}"}
  104. })
  105. html_fragment = render_docx_to_html(docx_path, include_answer=include_answer)
  106. full_html = _wrap_full_html(html_fragment, config.token)
  107. return {"html": full_html, "problem_id": problem_id}
  108. @router.get("/problems/{problem_id}/view")
  109. def view_problem(
  110. request: Request,
  111. problem_id: str,
  112. include_answer: bool = Query(True),
  113. ):
  114. """渲染题目为完整 HTML 页面(用 IFrame 加载,token 从 query string 获取)"""
  115. config = request.app.state.config
  116. token = request.query_params.get("token", "")
  117. if token != config.token:
  118. raise HTTPException(status_code=401, detail={
  119. "error": {"code": "unauthorized", "message": "Token 无效"}
  120. })
  121. docx_path = config.problems_dir / f"{problem_id}.docx"
  122. if not docx_path.exists():
  123. return _wrap_full_html("<p>题目文件不存在</p>", "")
  124. html_fragment = render_docx_to_html(docx_path, include_answer=include_answer)
  125. return HTMLResponse(content=_wrap_full_html(html_fragment, ""))
  126. def _wrap_full_html(body: str, token: str = "") -> str:
  127. """将 pandoc HTML 片段包装成完整的 HTML 页面"""
  128. return f"""<!DOCTYPE html>
  129. <html lang="zh-CN">
  130. <head>
  131. <meta charset="UTF-8">
  132. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  133. <script>
  134. window.MathJax = {{
  135. tex: {{
  136. inlineMath: [['\\\\(', '\\\\)']],
  137. displayMath: [['\\\\[', '\\\\]']]
  138. }},
  139. svg: {{ fontCache: 'global' }}
  140. }};
  141. </script>
  142. <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" async></script>
  143. <style>
  144. body {{
  145. font-family: '宋体', 'SimSun', 'Noto Serif CJK SC', 'STSong', serif;
  146. font-size: 18px;
  147. line-height: 2.0;
  148. color: #333;
  149. padding: 16px;
  150. max-width: 100%;
  151. overflow-x: hidden;
  152. }}
  153. img {{ max-width: 100%; height: auto; }}
  154. blockquote {{
  155. margin: 8px 0;
  156. padding: 4px 12px;
  157. border-left: 3px solid #e0e0e0;
  158. color: #555;
  159. }}
  160. p {{ margin: 6px 0; }}
  161. </style>
  162. </head>
  163. <body>
  164. {body}
  165. <script>
  166. if (window.MathJax) {{
  167. MathJax.startup.promise.then(() => {{
  168. MathJax.typesetPromise();
  169. }});
  170. }}
  171. </script>
  172. </body>
  173. </html>"""
  174. from fastapi.responses import HTMLResponse
  175. @router.get("/problems/next-unannotated")
  176. def next_unannotated(
  177. request: Request,
  178. current_id: str | None = Query(None),
  179. direction: str = Query("next"),
  180. _=Depends(verify_token),
  181. ):
  182. """连续标注导航"""
  183. config = request.app.state.config
  184. conn = get_connection(config.db_path)
  185. try:
  186. problem = get_next_unannotated(conn, current_id, direction)
  187. return problem or {}
  188. finally:
  189. conn.close()
  190. @router.put("/problems/{problem_id}/metadata")
  191. def update_metadata(request: Request, problem_id: str, body: dict, _=Depends(verify_token)):
  192. """更新题目元数据"""
  193. config = request.app.state.config
  194. conn = get_connection(config.db_path)
  195. try:
  196. problem = get_problem(conn, problem_id)
  197. if not problem:
  198. raise HTTPException(status_code=404, detail={
  199. "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"}
  200. })
  201. update_problem_metadata(conn, problem_id, body)
  202. # 同时更新 docx 自定义属性
  203. docx_path = config.problems_dir / f"{problem_id}.docx"
  204. if docx_path.exists():
  205. _update_docx_props(docx_path, body)
  206. # 自动备份
  207. try:
  208. from app.services.backup import auto_backup
  209. auto_backup(config, changed_files=[str(config.problems_dir / f"{problem_id}.docx")])
  210. except Exception:
  211. pass
  212. return {"ok": True}
  213. finally:
  214. conn.close()
  215. @router.put("/problems/{problem_id}/knowledge")
  216. def update_knowledge(request: Request, problem_id: str, body: dict, _=Depends(verify_token)):
  217. """更新知识点编码"""
  218. config = request.app.state.config
  219. codes = body.get("knowledge_codes", [])
  220. conn = get_connection(config.db_path)
  221. try:
  222. problem = get_problem(conn, problem_id)
  223. if not problem:
  224. raise HTTPException(status_code=404, detail={
  225. "error": {"code": "not_found", "message": f"题目 {problem_id} 不存在"}
  226. })
  227. update_problem_metadata(conn, problem_id, {"knowledge_codes": codes})
  228. # 更新 docx 自定义属性
  229. import json
  230. docx_path = config.problems_dir / f"{problem_id}.docx"
  231. if docx_path.exists():
  232. _update_docx_props(docx_path, {"knowledge_codes": json.dumps(codes, ensure_ascii=False)})
  233. # 自动运行增量索引以更新章节
  234. from app.services.indexer import incremental_update
  235. incremental_update(config)
  236. # 自动备份
  237. try:
  238. from app.services.backup import auto_backup
  239. auto_backup(config, changed_files=[str(config.problems_dir / f"{problem_id}.docx")])
  240. except Exception:
  241. pass
  242. return {"ok": True}
  243. finally:
  244. conn.close()
  245. @router.get("/search")
  246. def search_problems_api(
  247. request: Request,
  248. q: str = Query(..., min_length=1),
  249. page: int = Query(1, ge=1),
  250. page_size: int = Query(20, ge=1, le=100),
  251. _=Depends(verify_token),
  252. ):
  253. """全文搜索"""
  254. from app.services.search import search
  255. config = request.app.state.config
  256. return search(config, q, page, page_size)
  257. def _update_docx_props(docx_path, fields: dict):
  258. """更新 docx 自定义属性"""
  259. from docx import Document
  260. import json
  261. try:
  262. doc = Document(str(docx_path))
  263. for key, value in fields.items():
  264. if isinstance(value, list):
  265. value = json.dumps(value, ensure_ascii=False)
  266. try:
  267. doc.custom_document_properties[key] = value
  268. except Exception:
  269. pass
  270. doc.save(str(docx_path))
  271. except Exception:
  272. pass