db_conn.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  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. # ============================================================
  127. # MySQL 适配
  128. # ============================================================
  129. class _MySqlAdapter:
  130. """MySQL 后端:线程本地连接 + 空闲自动 ping。
  131. 线程本地是因为 pymysql 的 Connection 不是线程安全的;空闲 ping 是为了
  132. 绕开服务端 ``wait_timeout`` 主动断开导致的 "MySQL server has gone away"。
  133. """
  134. def __init__(self) -> None:
  135. self._local = threading.local()
  136. self._last_used: dict[int, float] = {}
  137. def _connect(self, database: str | None):
  138. """建一个 pymysql 连接。``database=None`` 表示不选库。"""
  139. import pymysql
  140. from pymysql.cursors import DictCursor
  141. return pymysql.connect(
  142. host=config.DB_HOST,
  143. port=config.DB_PORT,
  144. user=config.DB_USER,
  145. password=config.DB_PASSWORD,
  146. database=database,
  147. charset="utf8mb4",
  148. cursorclass=DictCursor,
  149. autocommit=True,
  150. connect_timeout=10,
  151. read_timeout=60,
  152. write_timeout=60,
  153. )
  154. def _ensure_database(self) -> None:
  155. """库不存在则建库。
  156. 骨架不该要求使用者先手工跑一次 CREATE DATABASE——那是最容易被漏掉、
  157. 且报错信息("Unknown database")不提示该怎么解决的一步。
  158. """
  159. conn = self._connect(None)
  160. try:
  161. with conn.cursor() as cur:
  162. cur.execute(
  163. f"CREATE DATABASE IF NOT EXISTS `{config.DB_NAME}` "
  164. f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
  165. )
  166. logger.info("已确保数据库存在:%s", config.DB_NAME)
  167. finally:
  168. conn.close()
  169. def _connection(self):
  170. conn = getattr(self._local, "conn", None)
  171. if conn is None:
  172. try:
  173. conn = self._connect(config.DB_NAME)
  174. except Exception as exc:
  175. # 1049 = Unknown database:自动建库后重试一次
  176. if getattr(exc, "args", [None])[0] == 1049:
  177. self._ensure_database()
  178. conn = self._connect(config.DB_NAME)
  179. else:
  180. raise
  181. self._local.conn = conn
  182. else:
  183. ident = id(conn)
  184. if not getattr(self._local, "transaction_depth", 0) and time.time() - self._last_used.get(ident, 0) > config.DB_PING_IDLE_SECONDS:
  185. try:
  186. conn.ping(reconnect=True)
  187. except Exception:
  188. self._local.conn = None
  189. return self._connection()
  190. self._last_used[id(conn)] = time.time()
  191. return conn
  192. @staticmethod
  193. def _translate(sql: str) -> str:
  194. """把调用方写的 ``?`` 占位符翻成 pymysql 的 ``%s``。
  195. 同时把 SQL 里字面的 ``%`` 转义成 ``%%``(pymysql 用 % 做参数格式化,
  196. 不转义会把它误当占位符)。参数值本身不参与翻译。
  197. """
  198. parts = sql.split("?")
  199. escaped = [p.replace("%", "%%") for p in parts]
  200. return "%s".join(escaped)
  201. def query(self, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]:
  202. conn = self._connection()
  203. with conn.cursor() as cur:
  204. cur.execute(self._translate(sql), tuple(params))
  205. return list(cur.fetchall())
  206. def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
  207. conn = self._connection()
  208. with conn.cursor() as cur:
  209. cur.execute(self._translate(sql), tuple(params))
  210. return cur.rowcount
  211. def execute_many(self, sql: str, params_seq: Sequence[Sequence[Any]]) -> int:
  212. conn = self._connection()
  213. with conn.cursor() as cur:
  214. cur.executemany(self._translate(sql), [tuple(p) for p in params_seq])
  215. return cur.rowcount
  216. @contextmanager
  217. def transaction(self) -> Iterator[None]:
  218. conn = self._connection()
  219. depth = getattr(self._local, "transaction_depth", 0)
  220. savepoint = f"nested_{depth}"
  221. if depth == 0:
  222. conn.begin()
  223. else:
  224. with conn.cursor() as cur:
  225. cur.execute(f"SAVEPOINT {savepoint}")
  226. self._local.transaction_depth = depth + 1
  227. try:
  228. yield
  229. if depth == 0:
  230. conn.commit()
  231. else:
  232. with conn.cursor() as cur:
  233. cur.execute(f"RELEASE SAVEPOINT {savepoint}")
  234. except BaseException:
  235. if depth == 0:
  236. conn.rollback()
  237. else:
  238. with conn.cursor() as cur:
  239. cur.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
  240. cur.execute(f"RELEASE SAVEPOINT {savepoint}")
  241. raise
  242. finally:
  243. self._local.transaction_depth = depth
  244. def schema_file(self) -> Path:
  245. return config.ROOT / "db" / "mysql_schema.sql"
  246. def ping(self) -> None:
  247. self._connection().ping(reconnect=True)
  248. def _build_adapter() -> _SqliteAdapter | _MySqlAdapter:
  249. if DIALECT == "sqlite":
  250. path = config.DB_SQLITE_PATH
  251. if path != ":memory:":
  252. path = str(config.resolve_path(path))
  253. return _SqliteAdapter(path)
  254. if DIALECT == "mysql":
  255. return _MySqlAdapter()
  256. raise ValueError(
  257. f"不支持的 DB_DIALECT={DIALECT!r},可选值:mysql | sqlite"
  258. )
  259. _adapter = _build_adapter()
  260. def backend_name() -> str:
  261. """当前后端名,供 /health 与日志展示。"""
  262. return DIALECT
  263. # ============================================================
  264. # 对外 API
  265. # ============================================================
  266. def _normalize(row: dict[str, Any]) -> dict[str, Any]:
  267. """把驱动特有类型收敛成可 JSON 化的通用类型。"""
  268. out: dict[str, Any] = {}
  269. for key, value in row.items():
  270. if isinstance(value, datetime):
  271. # 与 fmt_dt 对齐到微秒,保证「写入再读出」是同一个字符串
  272. out[key] = value.strftime("%Y-%m-%d %H:%M:%S.%f")
  273. elif isinstance(value, date):
  274. out[key] = value.isoformat()
  275. elif isinstance(value, Decimal):
  276. out[key] = float(value)
  277. elif isinstance(value, bytes):
  278. out[key] = value.decode("utf-8", errors="replace")
  279. else:
  280. out[key] = value
  281. return out
  282. def query(sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]:
  283. """执行查询,返回归一化后的行列表。"""
  284. return [_normalize(row) for row in _adapter.query(sql, params)]
  285. def query_one(sql: str, params: Sequence[Any] = ()) -> dict[str, Any] | None:
  286. """执行查询并返回首行;无结果返回 None。"""
  287. rows = query(sql, params)
  288. return rows[0] if rows else None
  289. def scalar(sql: str, params: Sequence[Any] = (), default: Any = None) -> Any:
  290. """取首行首列;无结果返回 default。"""
  291. row = query_one(sql, params)
  292. if not row:
  293. return default
  294. return next(iter(row.values()), default)
  295. def execute(sql: str, params: Sequence[Any] = ()) -> int:
  296. """执行写入语句,返回受影响行数。"""
  297. return _adapter.execute(sql, params)
  298. def execute_many(sql: str, params_seq: Sequence[Sequence[Any]]) -> int:
  299. """批量写入,返回受影响行数。"""
  300. return _adapter.execute_many(sql, params_seq)
  301. @contextmanager
  302. def transaction() -> Iterator[None]:
  303. """事务上下文。块内异常自动回滚并向上抛出。"""
  304. with _adapter.transaction():
  305. yield
  306. # ============================================================
  307. # 建表与健康检查
  308. # ============================================================
  309. def _split_statements(sql_text: str) -> list[str]:
  310. """把 .sql 文件切成可逐条执行的语句。
  311. 先剥掉 ``--`` 行注释与空行,再按 ``;`` 切分。刻意不做通用 SQL 解析——
  312. 本项目的 schema 文件里没有字符串内的分号,够用即可。
  313. """
  314. lines: list[str] = []
  315. for raw in sql_text.splitlines():
  316. line = raw.split("--", 1)[0].rstrip()
  317. if line.strip():
  318. lines.append(line)
  319. return [stmt.strip() for stmt in "\n".join(lines).split(";") if stmt.strip()]
  320. def init_schema(force: bool = False) -> None:
  321. """执行建表脚本(幂等,全部 IF NOT EXISTS)。"""
  322. global _schema_ready
  323. with _schema_lock:
  324. if _schema_ready and not force:
  325. return
  326. path = _adapter.schema_file()
  327. statements = _split_statements(path.read_text(encoding="utf-8"))
  328. for stmt in statements:
  329. _adapter.execute(stmt, ())
  330. _schema_ready = True
  331. logger.info(
  332. "建表完成 dialect=%s 语句数=%d 文件=%s", DIALECT, len(statements), path.name
  333. )
  334. def health() -> dict[str, Any]:
  335. """连通性探测,供 /health 使用。异常不抛出,转为 status=error。"""
  336. try:
  337. _adapter.ping()
  338. detail = (
  339. f"{config.DB_USER}@{config.DB_HOST}:{config.DB_PORT}/{config.DB_NAME}"
  340. if DIALECT == "mysql"
  341. else config.DB_SQLITE_PATH
  342. )
  343. return {"status": "ok", "dialect": DIALECT, "target": detail}
  344. except Exception as exc: # pragma: no cover - 取决于外部环境
  345. return {"status": "error", "dialect": DIALECT, "error": f"{type(exc).__name__}: {exc}"}
  346. def reset_for_tests() -> None:
  347. """仅供测试:丢弃缓存连接与建表标记,让下一次调用重新初始化。"""
  348. global _adapter, _schema_ready
  349. _adapter = _build_adapter()
  350. _schema_ready = False