db_conn.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. """数据库连接与方言适配。
  2. 两条路径(``DB_DIALECT``):
  3. - ``mysql``:pymysql + DictCursor + 线程本地连接(默认,生产/联调)
  4. - ``sqlite``:单连接 + 互斥锁(本地开发与自动化测试用,无需任何数据库服务)
  5. 调用方只写 ``?`` 占位符,MySQL 适配器负责翻成 ``%s``;因此存储层 SQL 是
  6. 方言无关的公共子集,两条路径跑的是同一份逻辑。
  7. 读出的行统一做归一化:``datetime``/``Decimal`` 一律转成字符串/浮点,
  8. 调用方不必区分两种驱动返回的类型差异。
  9. """
  10. from __future__ import annotations
  11. import sqlite3
  12. import threading
  13. import time
  14. from contextlib import contextmanager
  15. from datetime import date, datetime
  16. from decimal import Decimal
  17. from pathlib import Path
  18. from typing import Any, Iterator, Sequence
  19. import config
  20. from obs import get_logger
  21. logger = get_logger(__name__)
  22. DIALECT: str = config.DB_DIALECT
  23. _schema_ready = False
  24. _schema_lock = threading.Lock()
  25. # ============================================================
  26. # 时间与取值帮助函数
  27. # ============================================================
  28. def now() -> datetime:
  29. """统一的「当前时间」:本地时区的朴素 datetime。
  30. 全仓只在这里取当前时间,将来要统一改 UTC 只需改这一处。
  31. """
  32. return datetime.now()
  33. def fmt_dt(value: datetime | None = None) -> str:
  34. """把 datetime 格式化为 ``YYYY-MM-DD HH:MM:SS.ffffff``,供落库使用。
  35. 必须带微秒:历史消息的排序完全依赖 ``created_at``,而一轮对话里
  36. user / assistant 两条消息往往落在同一秒。只到秒级时同秒记录只能靠
  37. 随机 UUID 做次级排序,顺序会随机颠倒——冷启动补种图状态时这会直接把
  38. 「用户说了什么、助手回了什么」的顺序喂反。
  39. """
  40. return (value or now()).strftime("%Y-%m-%d %H:%M:%S.%f")
  41. def day_key(value: datetime | None = None) -> str:
  42. """用量归属日的键:``YYYYMMDD``,与 billing_usage.usage_day 对应。"""
  43. return (value or now()).strftime("%Y%m%d")
  44. def new_id() -> str:
  45. """生成 32 位十六进制主键(对应 VARCHAR(64) 主键列)。"""
  46. import uuid
  47. return uuid.uuid4().hex
  48. # ============================================================
  49. # SQLite 适配
  50. # ============================================================
  51. class _SqliteAdapter:
  52. """SQLite 后端:单连接 + 锁。
  53. 单连接是为了让 :memory: 与文件库行为一致,也避免多连接下的写锁竞争。
  54. 代价是并发写串行化——对本地开发与测试足够;生产走 MySQL 分支。
  55. """
  56. def __init__(self, path: str) -> None:
  57. self._path = path
  58. self._lock = threading.RLock()
  59. self._conn: sqlite3.Connection | None = None
  60. self._transaction_depth = 0
  61. def _connection(self) -> sqlite3.Connection:
  62. if self._conn is None:
  63. if self._path != ":memory:":
  64. Path(self._path).parent.mkdir(parents=True, exist_ok=True)
  65. conn = sqlite3.connect(self._path, check_same_thread=False, timeout=30)
  66. conn.row_factory = sqlite3.Row
  67. conn.execute("PRAGMA journal_mode=WAL")
  68. conn.execute("PRAGMA foreign_keys=ON")
  69. self._conn = conn
  70. return self._conn
  71. def query(self, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]:
  72. with self._lock:
  73. cur = self._connection().execute(sql, tuple(params))
  74. return [dict(row) for row in cur.fetchall()]
  75. def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
  76. with self._lock:
  77. conn = self._connection()
  78. try:
  79. cur = conn.execute(sql, tuple(params))
  80. except BaseException:
  81. if not self._transaction_depth:
  82. conn.rollback()
  83. raise
  84. if not self._transaction_depth:
  85. conn.commit()
  86. return cur.rowcount
  87. def execute_many(self, sql: str, params_seq: Sequence[Sequence[Any]]) -> int:
  88. with self._lock:
  89. conn = self._connection()
  90. try:
  91. cur = conn.executemany(sql, [tuple(p) for p in params_seq])
  92. except BaseException:
  93. if not self._transaction_depth:
  94. conn.rollback()
  95. raise
  96. if not self._transaction_depth:
  97. conn.commit()
  98. return cur.rowcount
  99. @contextmanager
  100. def transaction(self) -> Iterator[None]:
  101. with self._lock:
  102. conn = self._connection()
  103. depth = self._transaction_depth
  104. savepoint = f"nested_{depth}"
  105. conn.execute("BEGIN" if depth == 0 else f"SAVEPOINT {savepoint}")
  106. self._transaction_depth += 1
  107. try:
  108. yield
  109. if depth == 0:
  110. conn.commit()
  111. else:
  112. conn.execute(f"RELEASE SAVEPOINT {savepoint}")
  113. except BaseException:
  114. if depth == 0:
  115. conn.rollback()
  116. else:
  117. conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
  118. conn.execute(f"RELEASE SAVEPOINT {savepoint}")
  119. raise
  120. finally:
  121. self._transaction_depth -= 1
  122. def schema_file(self) -> Path:
  123. return config.ROOT / "db" / "schema_sqlite.sql"
  124. def ping(self) -> None:
  125. self._connection().execute("SELECT 1").fetchone()
  126. def close(self) -> None:
  127. with self._lock:
  128. if self._conn is not None:
  129. self._conn.close()
  130. self._conn = None
  131. self._transaction_depth = 0
  132. # ============================================================
  133. # MySQL 适配
  134. # ============================================================
  135. class _MySqlAdapter:
  136. """MySQL 后端:线程本地连接 + 空闲自动 ping。
  137. 线程本地是因为 pymysql 的 Connection 不是线程安全的;空闲 ping 是为了
  138. 绕开服务端 ``wait_timeout`` 主动断开导致的 "MySQL server has gone away"。
  139. """
  140. def __init__(self) -> None:
  141. self._local = threading.local()
  142. self._last_used: dict[int, float] = {}
  143. self._connections: dict[int, Any] = {}
  144. self._connections_lock = threading.Lock()
  145. def _connect(self, database: str | None):
  146. """建一个 pymysql 连接。``database=None`` 表示不选库。"""
  147. import pymysql
  148. from pymysql.cursors import DictCursor
  149. return pymysql.connect(
  150. host=config.DB_HOST,
  151. port=config.DB_PORT,
  152. user=config.DB_USER,
  153. password=config.DB_PASSWORD,
  154. database=database,
  155. charset="utf8mb4",
  156. cursorclass=DictCursor,
  157. autocommit=True,
  158. connect_timeout=10,
  159. read_timeout=60,
  160. write_timeout=60,
  161. )
  162. def _ensure_database(self) -> None:
  163. """库不存在则建库。
  164. 骨架不该要求使用者先手工跑一次 CREATE DATABASE——那是最容易被漏掉、
  165. 且报错信息("Unknown database")不提示该怎么解决的一步。
  166. """
  167. conn = self._connect(None)
  168. try:
  169. with conn.cursor() as cur:
  170. cur.execute(
  171. f"CREATE DATABASE IF NOT EXISTS `{config.DB_NAME}` "
  172. f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
  173. )
  174. logger.info("已确保数据库存在:%s", config.DB_NAME)
  175. finally:
  176. conn.close()
  177. def _connection(self):
  178. conn = getattr(self._local, "conn", None)
  179. if conn is None:
  180. try:
  181. conn = self._connect(config.DB_NAME)
  182. except Exception as exc:
  183. # 1049 = Unknown database:自动建库后重试一次
  184. if getattr(exc, "args", [None])[0] == 1049:
  185. self._ensure_database()
  186. conn = self._connect(config.DB_NAME)
  187. else:
  188. raise
  189. self._local.conn = conn
  190. with self._connections_lock:
  191. self._connections[id(conn)] = conn
  192. else:
  193. ident = id(conn)
  194. if not getattr(self._local, "transaction_depth", 0) and time.time() - self._last_used.get(ident, 0) > config.DB_PING_IDLE_SECONDS:
  195. try:
  196. conn.ping(reconnect=True)
  197. except Exception:
  198. with self._connections_lock:
  199. self._connections.pop(id(conn), None)
  200. self._last_used.pop(id(conn), None)
  201. try:
  202. conn.close()
  203. except Exception:
  204. pass
  205. self._local.conn = None
  206. return self._connection()
  207. self._last_used[id(conn)] = time.time()
  208. return conn
  209. @staticmethod
  210. def _translate(sql: str) -> str:
  211. """把调用方写的 ``?`` 占位符翻成 pymysql 的 ``%s``。
  212. 同时把 SQL 里字面的 ``%`` 转义成 ``%%``(pymysql 用 % 做参数格式化,
  213. 不转义会把它误当占位符)。参数值本身不参与翻译。
  214. """
  215. parts = sql.split("?")
  216. escaped = [p.replace("%", "%%") for p in parts]
  217. return "%s".join(escaped)
  218. def query(self, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]:
  219. conn = self._connection()
  220. with conn.cursor() as cur:
  221. cur.execute(self._translate(sql), tuple(params))
  222. return list(cur.fetchall())
  223. def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
  224. conn = self._connection()
  225. with conn.cursor() as cur:
  226. cur.execute(self._translate(sql), tuple(params))
  227. return cur.rowcount
  228. def execute_many(self, sql: str, params_seq: Sequence[Sequence[Any]]) -> int:
  229. conn = self._connection()
  230. with conn.cursor() as cur:
  231. cur.executemany(self._translate(sql), [tuple(p) for p in params_seq])
  232. return cur.rowcount
  233. @contextmanager
  234. def transaction(self) -> Iterator[None]:
  235. conn = self._connection()
  236. depth = getattr(self._local, "transaction_depth", 0)
  237. savepoint = f"nested_{depth}"
  238. if depth == 0:
  239. conn.begin()
  240. else:
  241. with conn.cursor() as cur:
  242. cur.execute(f"SAVEPOINT {savepoint}")
  243. self._local.transaction_depth = depth + 1
  244. try:
  245. yield
  246. if depth == 0:
  247. conn.commit()
  248. else:
  249. with conn.cursor() as cur:
  250. cur.execute(f"RELEASE SAVEPOINT {savepoint}")
  251. except BaseException:
  252. if depth == 0:
  253. conn.rollback()
  254. else:
  255. with conn.cursor() as cur:
  256. cur.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
  257. cur.execute(f"RELEASE SAVEPOINT {savepoint}")
  258. raise
  259. finally:
  260. self._local.transaction_depth = depth
  261. def schema_file(self) -> Path:
  262. return config.ROOT / "db" / "mysql_schema.sql"
  263. def ping(self) -> None:
  264. self._connection().ping(reconnect=True)
  265. def close(self) -> None:
  266. """在所有数据库任务结束后关闭各线程连接,下一轮生命周期重新建立。"""
  267. with self._connections_lock:
  268. connections = list(self._connections.values())
  269. self._connections.clear()
  270. self._local = threading.local()
  271. self._last_used.clear()
  272. for conn in connections:
  273. try:
  274. conn.close()
  275. except Exception as exc:
  276. logger.warning("关闭 MySQL 连接失败:%s", type(exc).__name__)
  277. def _build_adapter() -> _SqliteAdapter | _MySqlAdapter:
  278. if DIALECT == "sqlite":
  279. path = config.DB_SQLITE_PATH
  280. if path != ":memory:":
  281. path = str(config.resolve_path(path))
  282. return _SqliteAdapter(path)
  283. if DIALECT == "mysql":
  284. return _MySqlAdapter()
  285. raise ValueError(
  286. f"不支持的 DB_DIALECT={DIALECT!r},可选值:mysql | sqlite"
  287. )
  288. _adapter = _build_adapter()
  289. def backend_name() -> str:
  290. """当前后端名,供 /health 与日志展示。"""
  291. return DIALECT
  292. # ============================================================
  293. # 对外 API
  294. # ============================================================
  295. def _normalize(row: dict[str, Any]) -> dict[str, Any]:
  296. """把驱动特有类型收敛成可 JSON 化的通用类型。"""
  297. out: dict[str, Any] = {}
  298. for key, value in row.items():
  299. if isinstance(value, datetime):
  300. # 与 fmt_dt 对齐到微秒,保证「写入再读出」是同一个字符串
  301. out[key] = value.strftime("%Y-%m-%d %H:%M:%S.%f")
  302. elif isinstance(value, date):
  303. out[key] = value.isoformat()
  304. elif isinstance(value, Decimal):
  305. out[key] = float(value)
  306. elif isinstance(value, bytes):
  307. out[key] = value.decode("utf-8", errors="replace")
  308. else:
  309. out[key] = value
  310. return out
  311. def query(sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]:
  312. """执行查询,返回归一化后的行列表。"""
  313. return [_normalize(row) for row in _adapter.query(sql, params)]
  314. def query_one(sql: str, params: Sequence[Any] = ()) -> dict[str, Any] | None:
  315. """执行查询并返回首行;无结果返回 None。"""
  316. rows = query(sql, params)
  317. return rows[0] if rows else None
  318. def scalar(sql: str, params: Sequence[Any] = (), default: Any = None) -> Any:
  319. """取首行首列;无结果返回 default。"""
  320. row = query_one(sql, params)
  321. if not row:
  322. return default
  323. return next(iter(row.values()), default)
  324. def execute(sql: str, params: Sequence[Any] = ()) -> int:
  325. """执行写入语句,返回受影响行数。"""
  326. return _adapter.execute(sql, params)
  327. def execute_many(sql: str, params_seq: Sequence[Sequence[Any]]) -> int:
  328. """批量写入,返回受影响行数。"""
  329. return _adapter.execute_many(sql, params_seq)
  330. @contextmanager
  331. def transaction() -> Iterator[None]:
  332. """事务上下文。块内异常自动回滚并向上抛出。"""
  333. with _adapter.transaction():
  334. yield
  335. # ============================================================
  336. # 建表与健康检查
  337. # ============================================================
  338. def _split_statements(sql_text: str) -> list[str]:
  339. """把 .sql 文件切成可逐条执行的语句。
  340. 先剥掉 ``--`` 行注释与空行,再按 ``;`` 切分。刻意不做通用 SQL 解析——
  341. 本项目的 schema 文件里没有字符串内的分号,够用即可。
  342. """
  343. lines: list[str] = []
  344. for raw in sql_text.splitlines():
  345. line = raw.split("--", 1)[0].rstrip()
  346. if line.strip():
  347. lines.append(line)
  348. return [stmt.strip() for stmt in "\n".join(lines).split(";") if stmt.strip()]
  349. def init_schema(force: bool = False) -> None:
  350. """执行建表脚本(幂等,全部 IF NOT EXISTS)。"""
  351. global _schema_ready
  352. with _schema_lock:
  353. if _schema_ready and not force:
  354. return
  355. path = _adapter.schema_file()
  356. statements = _split_statements(path.read_text(encoding="utf-8"))
  357. for stmt in statements:
  358. _adapter.execute(stmt, ())
  359. _schema_ready = True
  360. logger.info(
  361. "建表完成 dialect=%s 语句数=%d 文件=%s", DIALECT, len(statements), path.name
  362. )
  363. def health() -> dict[str, Any]:
  364. """连通性探测,供 /health 使用。异常不抛出,转为 status=error。"""
  365. try:
  366. _adapter.ping()
  367. detail = (
  368. f"{config.DB_USER}@{config.DB_HOST}:{config.DB_PORT}/{config.DB_NAME}"
  369. if DIALECT == "mysql"
  370. else config.DB_SQLITE_PATH
  371. )
  372. return {"status": "ok", "dialect": DIALECT, "target": detail}
  373. except Exception as exc: # pragma: no cover - 取决于外部环境
  374. return {"status": "error", "dialect": DIALECT, "error": f"{type(exc).__name__}: {exc}"}
  375. def close() -> None:
  376. """关闭业务库连接;只能在执行任务和后台维护全部结束后调用。"""
  377. global _schema_ready
  378. with _schema_lock:
  379. try:
  380. _adapter.close()
  381. finally:
  382. _schema_ready = False
  383. def reset_for_tests() -> None:
  384. """仅供测试:丢弃缓存连接与建表标记,让下一次调用重新初始化。"""
  385. global _adapter, _schema_ready
  386. close()
  387. _adapter = _build_adapter()
  388. _schema_ready = False