import_.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """导入路由"""
  2. import json
  3. import queue
  4. import secrets
  5. import threading
  6. from pathlib import Path
  7. from fastapi import APIRouter, Request, HTTPException, UploadFile, File, Form, Depends, Query
  8. from fastapi.responses import FileResponse
  9. from app.database import (
  10. get_connection, create_import_task, update_import_task, get_import_task,
  11. upsert_problem, update_fts_body,
  12. )
  13. from app.routers.auth import verify_token
  14. from app.services.docx_parser import split_docx_to_questions, create_single_docx, extract_plain_text
  15. from app.services.knowledge import load_knowledge_map
  16. from app.services.backup import auto_backup
  17. router = APIRouter()
  18. # 单线程导入队列
  19. _import_queue = queue.Queue()
  20. def _import_worker():
  21. """单线程消费导入任务,避免并发线程过多导致崩溃"""
  22. while True:
  23. task = _import_queue.get()
  24. if task is None:
  25. break
  26. task_id, tmp_path, import_type, source_name, config = task
  27. try:
  28. _process_import(task_id, tmp_path, import_type, source_name, config)
  29. except Exception as e:
  30. conn = get_connection(config.db_path)
  31. try:
  32. update_import_task(conn, task_id, status="error", message=str(e))
  33. except Exception:
  34. pass
  35. finally:
  36. conn.close()
  37. _import_queue.task_done()
  38. # 启动后台工作线程
  39. _worker = threading.Thread(target=_import_worker, daemon=True)
  40. _worker.start()
  41. @router.post("/import")
  42. async def import_docx(
  43. request: Request,
  44. file: UploadFile = File(...),
  45. import_type: str = Form("精选题"),
  46. _=Depends(verify_token),
  47. ):
  48. """上传整卷 docx 进行导入(队列处理)"""
  49. config = request.app.state.config
  50. if not file.filename.endswith('.docx'):
  51. raise HTTPException(status_code=400, detail={
  52. "error": {"code": "invalid_file", "message": "仅支持 .docx 文件"}
  53. })
  54. task_id = secrets.token_hex(8)
  55. upload_dir = config.data_dir / "uploads"
  56. upload_dir.mkdir(parents=True, exist_ok=True)
  57. tmp_path = upload_dir / f"{task_id}.docx"
  58. content = await file.read()
  59. with open(tmp_path, "wb") as f:
  60. f.write(content)
  61. conn = get_connection(config.db_path)
  62. try:
  63. create_import_task(conn, task_id)
  64. finally:
  65. conn.close()
  66. # 放入队列,单线程顺序处理
  67. _import_queue.put((task_id, tmp_path, import_type, file.filename, config))
  68. return {"task_id": task_id, "status": "pending"}
  69. def _process_import(task_id: str, tmp_path: Path, import_type: str, source_name: str, config):
  70. """后台处理导入"""
  71. from app.database import _db_lock
  72. with _db_lock:
  73. conn = get_connection(config.db_path)
  74. try:
  75. _do_import(conn, task_id, tmp_path, import_type, source_name, config)
  76. finally:
  77. conn.close()
  78. def _do_import(conn, task_id: str, tmp_path: Path, import_type: str, source_name: str, config):
  79. """执行导入(调用方已持有数据库锁)"""
  80. try:
  81. update_import_task(conn, task_id, status="processing", message="正在拆分题目...")
  82. # 加载知识点映射
  83. knowledge_map = load_knowledge_map(config)
  84. # 拆分
  85. questions = split_docx_to_questions(tmp_path)
  86. total = len(questions)
  87. update_import_task(conn, task_id, total=total, message=f"共 {total} 题,正在处理...")
  88. imported = []
  89. skipped = []
  90. errors = []
  91. imported_files = [] # 记录导入的 docx 文件路径,用于备份
  92. for i, q in enumerate(questions):
  93. update_import_task(conn, task_id, completed=i + 1, message=f"处理第 {i + 1}/{total} 题...")
  94. try:
  95. # 缺失元数据则使用默认值
  96. if q["metadata"]["difficulty"] is None:
  97. q["metadata"]["difficulty"] = 0.5
  98. docx_path, pid, info = create_single_docx(tmp_path, q, import_type, source_name, knowledge_map, config.problems_dir)
  99. # 入库
  100. conn2 = get_connection(config.db_path)
  101. try:
  102. # 检查是否已存在(去重)
  103. existing = conn2.execute("SELECT id FROM problems WHERE id = ?", (pid,)).fetchone()
  104. if existing:
  105. skipped.append({"num": q["question_num"], "id": pid, "reason": "内容重复"})
  106. else:
  107. problem = {
  108. "id": pid,
  109. "file_path": str(docx_path),
  110. "mtime": docx_path.stat().st_mtime,
  111. "checksum": info["checksum"],
  112. "chapters": info["chapters"],
  113. "difficulty": info["difficulty"],
  114. "question_type": info["question_type"],
  115. "import_type": info["import_type"],
  116. "knowledge_codes": info["knowledge_codes"],
  117. "comment": info["comment"],
  118. "source": info["source"],
  119. }
  120. upsert_problem(conn2, problem)
  121. body = extract_plain_text(docx_path)
  122. update_fts_body(conn2, pid, body)
  123. imported.append({"num": q["question_num"], "id": pid, "type": info["question_type"]})
  124. imported_files.append(str(docx_path))
  125. finally:
  126. conn2.close()
  127. except Exception as e:
  128. errors.append({"num": q["question_num"], "error": str(e)})
  129. # 完成
  130. result = json.dumps({
  131. "imported": len(imported),
  132. "skipped": len(skipped),
  133. "errors": len(errors),
  134. "details": {
  135. "imported": imported,
  136. "skipped": skipped,
  137. "errors": errors,
  138. }
  139. }, ensure_ascii=False)
  140. update_import_task(conn, task_id, status="done", message="导入完成", result=result)
  141. # 自动备份(bank.db + 导入的 docx)
  142. try:
  143. auto_backup(config, changed_files=imported_files if imported_files else None)
  144. except Exception:
  145. pass
  146. # 清理临时文件
  147. try:
  148. tmp_path.unlink()
  149. except Exception:
  150. pass
  151. except Exception as e:
  152. update_import_task(conn, task_id, status="error", message=str(e))
  153. finally:
  154. conn.close()
  155. @router.get("/import/{task_id}/status")
  156. def import_status(request: Request, task_id: str, _=Depends(verify_token)):
  157. """轮询导入进度"""
  158. config = request.app.state.config
  159. conn = get_connection(config.db_path)
  160. try:
  161. task = get_import_task(conn, task_id)
  162. if not task:
  163. raise HTTPException(status_code=404, detail={
  164. "error": {"code": "not_found", "message": f"任务 {task_id} 不存在"}
  165. })
  166. return task
  167. finally:
  168. conn.close()