database.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. """数据库层 — SQLite CRUD + FTS5"""
  2. import hashlib
  3. import json
  4. import sqlite3
  5. import os
  6. from pathlib import Path
  7. from typing import Any
  8. import threading
  9. _db_lock = threading.Lock()
  10. def get_connection(db_path: str | Path) -> sqlite3.Connection:
  11. conn = sqlite3.connect(str(db_path))
  12. conn.row_factory = sqlite3.Row
  13. conn.execute("PRAGMA journal_mode=WAL")
  14. conn.execute("PRAGMA busy_timeout=10000")
  15. conn.execute("PRAGMA foreign_keys=ON")
  16. return conn
  17. def with_lock(fn):
  18. """装饰器:对数据库写操作加锁,防止多线程并发写入损坏数据库"""
  19. def wrapper(*args, **kwargs):
  20. with _db_lock:
  21. return fn(*args, **kwargs)
  22. return wrapper
  23. def init_db(db_path: str | Path):
  24. """建表 + FTS5 + 触发器"""
  25. conn = get_connection(db_path)
  26. try:
  27. conn.executescript("""
  28. CREATE TABLE IF NOT EXISTS problems (
  29. id TEXT PRIMARY KEY,
  30. file_path TEXT NOT NULL UNIQUE,
  31. mtime REAL NOT NULL,
  32. checksum TEXT NOT NULL,
  33. chapters TEXT DEFAULT '[]',
  34. difficulty REAL DEFAULT 0.5,
  35. question_type TEXT DEFAULT '',
  36. import_type TEXT DEFAULT '精选题',
  37. knowledge_codes TEXT DEFAULT '[]',
  38. comment TEXT DEFAULT '',
  39. source TEXT DEFAULT '',
  40. created_at TEXT DEFAULT (datetime('now','localtime')),
  41. updated_at TEXT DEFAULT (datetime('now','localtime'))
  42. );
  43. CREATE TABLE IF NOT EXISTS knowledge_points (
  44. code TEXT PRIMARY KEY,
  45. path TEXT NOT NULL,
  46. leaf_name TEXT NOT NULL,
  47. chapter_code TEXT,
  48. chapter_name TEXT
  49. );
  50. CREATE VIRTUAL TABLE IF NOT EXISTS problems_fts USING fts5(
  51. problem_id UNINDEXED,
  52. body,
  53. tokenize='trigram'
  54. );
  55. CREATE TABLE IF NOT EXISTS carts (
  56. id INTEGER PRIMARY KEY AUTOINCREMENT,
  57. name TEXT NOT NULL,
  58. created_at TEXT DEFAULT (datetime('now','localtime'))
  59. );
  60. CREATE TABLE IF NOT EXISTS cart_items (
  61. cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
  62. problem_id TEXT NOT NULL REFERENCES problems(id) ON DELETE CASCADE,
  63. added_at TEXT DEFAULT (datetime('now','localtime')),
  64. PRIMARY KEY (cart_id, problem_id)
  65. );
  66. CREATE TABLE IF NOT EXISTS import_tasks (
  67. id TEXT PRIMARY KEY,
  68. status TEXT DEFAULT 'pending',
  69. total INTEGER DEFAULT 0,
  70. completed INTEGER DEFAULT 0,
  71. message TEXT DEFAULT '',
  72. result TEXT DEFAULT '{}',
  73. created_at TEXT DEFAULT (datetime('now','localtime'))
  74. );
  75. """)
  76. # 确保 FTS 触发器存在(重建 FTS 表后需要重建触发器)
  77. conn.executescript("""
  78. DROP TRIGGER IF EXISTS problems_ai;
  79. CREATE TRIGGER problems_ai AFTER INSERT ON problems BEGIN
  80. INSERT INTO problems_fts(problem_id, body) VALUES (new.id, '');
  81. END;
  82. DROP TRIGGER IF EXISTS problems_ad;
  83. CREATE TRIGGER problems_ad AFTER DELETE ON problems BEGIN
  84. DELETE FROM problems_fts WHERE problem_id = old.id;
  85. END;
  86. DROP TRIGGER IF EXISTS problems_au;
  87. CREATE TRIGGER problems_au AFTER UPDATE ON problems BEGIN
  88. DELETE FROM problems_fts WHERE problem_id = old.id;
  89. INSERT INTO problems_fts(problem_id, body) VALUES (new.id, '');
  90. END;
  91. """)
  92. conn.commit()
  93. finally:
  94. conn.close()
  95. # ── Problems CRUD ────────────────────────────────────────
  96. def upsert_problem(conn: sqlite3.Connection, problem: dict):
  97. conn.execute("""
  98. INSERT OR REPLACE INTO problems
  99. (id, file_path, mtime, checksum, chapters, difficulty, question_type,
  100. import_type, knowledge_codes, comment, source, updated_at)
  101. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
  102. """, (
  103. problem["id"],
  104. problem["file_path"],
  105. problem.get("mtime", 0),
  106. problem.get("checksum", ""),
  107. json.dumps(problem.get("chapters", []), ensure_ascii=False),
  108. problem.get("difficulty", 0.5),
  109. problem.get("question_type", ""),
  110. problem.get("import_type", "精选题"),
  111. json.dumps(problem.get("knowledge_codes", []), ensure_ascii=False),
  112. problem.get("comment", ""),
  113. problem.get("source", ""),
  114. ))
  115. conn.commit()
  116. def delete_problem(conn: sqlite3.Connection, problem_id: str):
  117. conn.execute("DELETE FROM problems WHERE id = ?", (problem_id,))
  118. conn.commit()
  119. def get_problem(conn: sqlite3.Connection, problem_id: str) -> dict | None:
  120. row = conn.execute("SELECT * FROM problems WHERE id = ?", (problem_id,)).fetchone()
  121. if row is None:
  122. return None
  123. return _row_to_dict(row)
  124. def get_all_problems(conn: sqlite3.Connection) -> list[dict]:
  125. rows = conn.execute("SELECT * FROM problems ORDER BY updated_at DESC").fetchall()
  126. return [_row_to_dict(r) for r in rows]
  127. def search_problems(
  128. conn: sqlite3.Connection,
  129. chapter: str | None = None,
  130. knowledge_code: str | None = None,
  131. question_type: str | None = None,
  132. import_type: str | None = None,
  133. annotated: str | None = None,
  134. page: int = 1,
  135. page_size: int = 20,
  136. ) -> tuple[list[dict], int]:
  137. """分页查询题目列表"""
  138. conditions = []
  139. params = []
  140. if chapter:
  141. conditions.append("chapters LIKE ?")
  142. params.append(f'%"{chapter}"%')
  143. if knowledge_code:
  144. # Prefix match: codes like 01.01 → match "01.01.03.02.01"
  145. # Full leaf code has 5 segments; partial has fewer
  146. if knowledge_code.count('.') >= 4: # full leaf code → exact match
  147. conditions.append("knowledge_codes LIKE ?")
  148. params.append(f'%"{knowledge_code}"%')
  149. else: # partial code → prefix match
  150. conditions.append("knowledge_codes LIKE ?")
  151. params.append(f'%"{knowledge_code}.%')
  152. if question_type:
  153. conditions.append("question_type = ?")
  154. params.append(question_type)
  155. if import_type:
  156. conditions.append("import_type = ?")
  157. params.append(import_type)
  158. if annotated == "yes":
  159. conditions.append("knowledge_codes != '[]'")
  160. elif annotated == "no":
  161. conditions.append("knowledge_codes = '[]'")
  162. where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
  163. # 总数
  164. total_row = conn.execute(f"SELECT COUNT(*) FROM problems {where}", params).fetchone()
  165. total = total_row[0] if total_row else 0
  166. # 分页
  167. offset = (page - 1) * page_size
  168. rows = conn.execute(
  169. f"SELECT * FROM problems {where} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
  170. params + [page_size, offset]
  171. ).fetchall()
  172. return [_row_to_dict(r) for r in rows], total
  173. def search_fts(conn: sqlite3.Connection, query: str, limit: int = 50) -> list[dict]:
  174. """FTS5 全文搜索,短查询回退 LIKE"""
  175. # 清理查询中的特殊字符
  176. safe_query = query.strip()
  177. if len(safe_query) < 3:
  178. # 短查询 LIKE 回退
  179. like_pattern = f"%{safe_query}%"
  180. rows = conn.execute(
  181. "SELECT * FROM problems WHERE id IN ("
  182. " SELECT problem_id FROM problems_fts WHERE body LIKE ?"
  183. ") LIMIT ?",
  184. (like_pattern, limit)
  185. ).fetchall()
  186. else:
  187. try:
  188. rows = conn.execute(
  189. "SELECT p.* FROM problems p "
  190. "INNER JOIN problems_fts f ON p.id = f.problem_id "
  191. "WHERE f.body MATCH ? LIMIT ?",
  192. (safe_query, limit)
  193. ).fetchall()
  194. except sqlite3.OperationalError:
  195. # FTS5 查询语法错误时回退 LIKE
  196. like_pattern = f"%{safe_query}%"
  197. rows = conn.execute(
  198. "SELECT * FROM problems WHERE id IN ("
  199. " SELECT problem_id FROM problems_fts WHERE body LIKE ?"
  200. ") LIMIT ?",
  201. (like_pattern, limit)
  202. ).fetchall()
  203. return [_row_to_dict(r) for r in rows]
  204. def update_fts_body(conn: sqlite3.Connection, problem_id: str, body: str):
  205. """更新 FTS 全文索引"""
  206. conn.execute(
  207. "UPDATE problems_fts SET body = ? WHERE problem_id = ?",
  208. (body, problem_id)
  209. )
  210. conn.commit()
  211. def update_problem_metadata(conn: sqlite3.Connection, problem_id: str, fields: dict):
  212. """更新题目元数据"""
  213. allowed = {"difficulty", "question_type", "import_type", "comment", "source", "knowledge_codes"}
  214. updates = {}
  215. for k, v in fields.items():
  216. if k in allowed:
  217. if k in ("knowledge_codes",):
  218. updates[k] = json.dumps(v, ensure_ascii=False) if isinstance(v, list) else v
  219. else:
  220. updates[k] = v
  221. if not updates:
  222. return
  223. set_clause = ", ".join(f"{k} = ?" for k in updates)
  224. values = list(updates.values()) + [problem_id]
  225. conn.execute(
  226. f"UPDATE problems SET {set_clause}, updated_at = datetime('now','localtime') WHERE id = ?",
  227. values
  228. )
  229. conn.commit()
  230. def get_next_unannotated(conn: sqlite3.Connection, current_id: str | None, direction: str = "next") -> dict | None:
  231. """获取下一个未标注题目"""
  232. if direction == "next":
  233. if current_id:
  234. row = conn.execute(
  235. "SELECT * FROM problems WHERE knowledge_codes = '[]' AND id > ? ORDER BY id LIMIT 1",
  236. (current_id,)
  237. ).fetchone()
  238. else:
  239. row = conn.execute(
  240. "SELECT * FROM problems WHERE knowledge_codes = '[]' ORDER BY id LIMIT 1"
  241. ).fetchone()
  242. else:
  243. if current_id:
  244. row = conn.execute(
  245. "SELECT * FROM problems WHERE knowledge_codes = '[]' AND id < ? ORDER BY id DESC LIMIT 1",
  246. (current_id,)
  247. ).fetchone()
  248. else:
  249. row = conn.execute(
  250. "SELECT * FROM problems WHERE knowledge_codes = '[]' ORDER BY id DESC LIMIT 1"
  251. ).fetchone()
  252. return _row_to_dict(row) if row else None
  253. # ── 统计 ─────────────────────────────────────────────────
  254. def count_by_question_type(conn: sqlite3.Connection) -> dict:
  255. rows = conn.execute(
  256. "SELECT question_type, COUNT(*) as cnt FROM problems GROUP BY question_type"
  257. ).fetchall()
  258. return {r["question_type"] or "未知": r["cnt"] for r in rows}
  259. def count_by_import_type(conn: sqlite3.Connection) -> dict:
  260. rows = conn.execute(
  261. "SELECT import_type, COUNT(*) as cnt FROM problems GROUP BY import_type"
  262. ).fetchall()
  263. return {r["import_type"] or "未知": r["cnt"] for r in rows}
  264. def count_by_chapter(conn: sqlite3.Connection) -> dict:
  265. rows = conn.execute("SELECT chapters FROM problems").fetchall()
  266. chapter_count: dict[str, int] = {}
  267. for r in rows:
  268. try:
  269. chapters = json.loads(r["chapters"])
  270. except (json.JSONDecodeError, TypeError):
  271. chapters = []
  272. for ch in chapters:
  273. chapter_count[ch] = chapter_count.get(ch, 0) + 1
  274. return chapter_count
  275. # ── 知识点 ───────────────────────────────────────────────
  276. def sync_knowledge_points(conn: sqlite3.Connection, knowledge_map: dict):
  277. """同步知识点编码到数据库"""
  278. conn.execute("DELETE FROM knowledge_points")
  279. for code, info in knowledge_map.items():
  280. conn.execute(
  281. "INSERT INTO knowledge_points (code, path, leaf_name, chapter_code, chapter_name) VALUES (?, ?, ?, ?, ?)",
  282. (code, info["path"], info["leaf_name"], info.get("chapter_code", ""), info.get("chapter_name", ""))
  283. )
  284. conn.commit()
  285. def get_knowledge_tree(conn: sqlite3.Connection) -> list[dict]:
  286. """获取全部知识点"""
  287. rows = conn.execute("SELECT * FROM knowledge_points ORDER BY code").fetchall()
  288. return [_row_to_dict(r) for r in rows]
  289. def search_knowledge(conn: sqlite3.Connection, query: str, limit: int = 30) -> list[dict]:
  290. """搜索知识点"""
  291. rows = conn.execute(
  292. "SELECT * FROM knowledge_points WHERE leaf_name LIKE ? OR path LIKE ? LIMIT ?",
  293. (f"%{query}%", f"%{query}%", limit)
  294. ).fetchall()
  295. return [_row_to_dict(r) for r in rows]
  296. # ── 试题篮 ───────────────────────────────────────────────
  297. def get_carts(conn: sqlite3.Connection) -> list[dict]:
  298. rows = conn.execute(
  299. "SELECT c.*, COUNT(ci.problem_id) as item_count "
  300. "FROM carts c LEFT JOIN cart_items ci ON c.id = ci.cart_id "
  301. "GROUP BY c.id ORDER BY c.created_at DESC"
  302. ).fetchall()
  303. return [_row_to_dict(r) for r in rows]
  304. def create_cart(conn: sqlite3.Connection, name: str) -> dict:
  305. cur = conn.execute("INSERT INTO carts (name) VALUES (?)", (name,))
  306. conn.commit()
  307. row = conn.execute("SELECT * FROM carts WHERE id = ?", (cur.lastrowid,)).fetchone()
  308. return _row_to_dict(row)
  309. def delete_cart(conn: sqlite3.Connection, cart_id: int):
  310. conn.execute("DELETE FROM carts WHERE id = ?", (cart_id,))
  311. conn.commit()
  312. def get_cart(conn: sqlite3.Connection, cart_id: int) -> dict | None:
  313. row = conn.execute("SELECT * FROM carts WHERE id = ?", (cart_id,)).fetchone()
  314. if row is None:
  315. return None
  316. items = conn.execute(
  317. "SELECT p.*, ci.added_at as cart_added_at FROM cart_items ci "
  318. "JOIN problems p ON ci.problem_id = p.id "
  319. "WHERE ci.cart_id = ? ORDER BY ci.added_at",
  320. (cart_id,)
  321. ).fetchall()
  322. result = _row_to_dict(row)
  323. item_dicts = []
  324. for item in items:
  325. d = _row_to_dict(item)
  326. # Add body_preview from FTS
  327. fts_row = conn.execute(
  328. "SELECT body FROM problems_fts WHERE problem_id = ?",
  329. (d["id"],)
  330. ).fetchone()
  331. if fts_row and fts_row["body"]:
  332. body = fts_row["body"]
  333. import re
  334. body = re.sub(r'^\d+[..]\s*', '', body)
  335. d["body_preview"] = body[:80]
  336. else:
  337. d["body_preview"] = ""
  338. item_dicts.append(d)
  339. result["items"] = item_dicts
  340. return result
  341. def add_cart_item(conn: sqlite3.Connection, cart_id: int, problem_id: str) -> bool:
  342. try:
  343. conn.execute(
  344. "INSERT OR IGNORE INTO cart_items (cart_id, problem_id) VALUES (?, ?)",
  345. (cart_id, problem_id)
  346. )
  347. conn.commit()
  348. return True
  349. except sqlite3.IntegrityError:
  350. return False
  351. def remove_cart_item(conn: sqlite3.Connection, cart_id: int, problem_id: str):
  352. conn.execute(
  353. "DELETE FROM cart_items WHERE cart_id = ? AND problem_id = ?",
  354. (cart_id, problem_id)
  355. )
  356. conn.commit()
  357. # ── 导入任务 ─────────────────────────────────────────────
  358. def create_import_task(conn: sqlite3.Connection, task_id: str) -> dict:
  359. conn.execute("INSERT INTO import_tasks (id) VALUES (?)", (task_id,))
  360. conn.commit()
  361. row = conn.execute("SELECT * FROM import_tasks WHERE id = ?", (task_id,)).fetchone()
  362. return _row_to_dict(row)
  363. def update_import_task(conn: sqlite3.Connection, task_id: str, **kwargs):
  364. allowed = {"status", "total", "completed", "message", "result"}
  365. updates = {k: v for k, v in kwargs.items() if k in allowed}
  366. if not updates:
  367. return
  368. set_clause = ", ".join(f"{k} = ?" for k in updates)
  369. values = list(updates.values()) + [task_id]
  370. conn.execute(f"UPDATE import_tasks SET {set_clause} WHERE id = ?", values)
  371. conn.commit()
  372. def get_import_task(conn: sqlite3.Connection, task_id: str) -> dict | None:
  373. row = conn.execute("SELECT * FROM import_tasks WHERE id = ?", (task_id,)).fetchone()
  374. return _row_to_dict(row) if row else None
  375. # ── helpers ───────────────────────────────────────────────
  376. def _row_to_dict(row: sqlite3.Row) -> dict:
  377. d = dict(row)
  378. # 将 JSON 字符串字段解析
  379. for field in ("chapters", "knowledge_codes", "result"):
  380. if field in d and isinstance(d[field], str):
  381. try:
  382. d[field] = json.loads(d[field])
  383. except (json.JSONDecodeError, TypeError):
  384. pass
  385. return d
  386. def compute_checksum(file_path: str | Path) -> str:
  387. """计算 docx 内 document.xml 的 SHA256"""
  388. import zipfile
  389. try:
  390. with zipfile.ZipFile(file_path, 'r') as zf:
  391. if 'word/document.xml' in zf.namelist():
  392. return hashlib.sha256(zf.read('word/document.xml')).hexdigest()
  393. except Exception:
  394. pass
  395. # 回退到全文哈希
  396. with open(file_path, 'rb') as f:
  397. return hashlib.sha256(f.read()).hexdigest()
  398. def compute_id_from_docx(file_path: str | Path) -> str:
  399. """从 docx 的 document.xml 计算题目 ID(SHA256 前 8 位)"""
  400. return compute_checksum(file_path)[:8]