database.py 16 KB

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