"""数据库连接在运行时初始化(与 EasySub 相同的做法)。 来源优先级:环境变量 DATABASE_URL > 网页安装向导保存的 data/db_config.json。 都没有时进入安装向导模式:流量照常采集到内存,数据库配置好后才落库。 """ import threading import time from fastapi import HTTPException from sqlalchemy import create_engine, event, inspect from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.orm import DeclarativeBase, sessionmaker class Base(DeclarativeBase): pass # 由 init_engine() 赋值;未配置前为 None engine = None SessionLocal = None _ready_hooks: list = [] _connect_lock = threading.Lock() _last_reconnect_attempt = 0.0 _RECONNECT_INTERVAL = 15.0 class NotConfigured(RuntimeError): pass def _make_engine(url: str): if url.startswith("sqlite"): import os path = url.split("///", 1)[-1] if path and path != ":memory:": os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) # 锁等待 30 秒(默认 5 秒):历史重算持有写锁时,每分钟落库和 API 不至于直接报 database is locked eng = create_engine(url, connect_args={"check_same_thread": False, "timeout": 30}) @event.listens_for(eng, "connect") def _pragma(conn, _): cur = conn.cursor() cur.execute("PRAGMA journal_mode=WAL") cur.execute("PRAGMA synchronous=NORMAL") cur.close() return eng return create_engine(url, pool_pre_ping=True, pool_recycle=1800, pool_size=10, max_overflow=10, connect_args={"connect_timeout": 10} if url.startswith("mysql") else {}) def init_engine(url: str): global engine, SessionLocal reset_engine() engine = _make_engine(url) SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) return engine def reset_engine(): global engine, SessionLocal if engine is not None: engine.dispose() engine = None SessionLocal = None def is_configured() -> bool: return SessionLocal is not None def session(): """后台任务用:未配置数据库时抛 NotConfigured。""" if SessionLocal is None: raise NotConfigured("数据库尚未配置") return SessionLocal() def is_mysql() -> bool: return engine is not None and engine.dialect.name == "mysql" _str_lens: dict = {} def fit(table, rows: list[dict]) -> list[dict]: """按列定义截断字符串:网络来的主机名、域名、规则名可能超长,MySQL 严格模式下一行超长会让整批写入失败 (0.6.8 的 VARCHAR(45)、全站审计的 DHCP 主机名 >128)。SQLite 不校验长度,测试里发现不了""" lens = _str_lens.get(table.name) if lens is None: lens = _str_lens[table.name] = {c.name: c.type.length for c in table.columns if getattr(c.type, "length", None)} if not lens: return rows out = [] for r in rows: bad = [k for k, n in lens.items() if isinstance(r.get(k), str) and len(r[k]) > n] out.append({**r, **{k: r[k][:lens[k]] for k in bad}} if bad else r) return out def upsert_add(session, table, rows: list[dict], keys: list[str], add_cols: list[str], set_cols: list[str] = ()): """批量 upsert:主键冲突时 add_cols 累加、set_cols 覆盖。MySQL / SQLite 通用。字符串按列长截断""" if not rows: return rows = fit(table, rows) if is_mysql(): stmt = mysql_insert(table).values(rows) upd = {c: table.c[c] + stmt.inserted[c] for c in add_cols} upd.update({c: stmt.inserted[c] for c in set_cols}) stmt = stmt.on_duplicate_key_update(**upd) else: stmt = sqlite_insert(table).values(rows) upd = {c: table.c[c] + stmt.excluded[c] for c in add_cols} upd.update({c: stmt.excluded[c] for c in set_cols}) stmt = stmt.on_conflict_do_update(index_elements=keys, set_=upd) session.execute(stmt) def _create_database_if_missing(err: Exception) -> bool: """MySQL 报 1049(库不存在)时,用同一账号连到服务器建库。成功返回 True。""" if not is_mysql() or "1049" not in str(err): return False url = engine.url name = url.database # 注意:url.set(database=None) 在 SQLAlchemy 2.0 里表示"不修改",必须用 _replace 真正去掉库名 server = create_engine(url._replace(database=None), pool_pre_ping=True) try: with server.connect() as conn: conn.exec_driver_sql( f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") print(f"[db] 已自动创建数据库 {name}", flush=True) return True finally: server.dispose() def init_db(retries: int = 20, delay: float = 3.0): """建库建表。容器启动时 MySQL 往往还没就绪,这里重试。""" from app import models # noqa: F401 注册模型 for i in range(1, retries + 1): try: Base.metadata.create_all(engine) add_missing_columns() return except Exception as e: # noqa: BLE001 try: if _create_database_if_missing(e): Base.metadata.create_all(engine) add_missing_columns() return except Exception as ce: # noqa: BLE001 print(f"[db] 数据库不存在,且自动创建失败(账号可能没有建库权限):{ce}", flush=True) print(f"[db] 数据库未就绪({i}/{retries}):{type(e).__name__}: {str(e)[:160]}", flush=True) if i == retries: raise time.sleep(delay) def add_missing_columns(): """升级时补上新版本新增的列(create_all 只建新表,不改已有的表)。新增列一律可为空,不需要回填。 同时把代码里加宽过的列在存量库上加宽(0.6.8:access_log.remote 45→255;只加宽不缩窄,SQLite 不校验长度跳过)。""" insp = inspect(engine) with engine.begin() as conn: for table in Base.metadata.sorted_tables: if not insp.has_table(table.name): continue have = {c["name"]: c for c in insp.get_columns(table.name)} for col in table.columns: if col.name not in have: if col.nullable: ctype = col.type.compile(dialect=engine.dialect) conn.exec_driver_sql(f"ALTER TABLE `{table.name}` ADD COLUMN `{col.name}` {ctype} NULL") print(f"[db] 已为 {table.name} 添加列 {col.name}", flush=True) continue length = getattr(col.type, "length", None) # 注意:inspector 的列字典没有 "length" 键,长度在反射出来的类型对象上 db_len = getattr(have[col.name].get("type"), "length", None) if insp.dialect.name == "mysql" and length and db_len and length > int(db_len): ctype = col.type.compile(dialect=engine.dialect) conn.exec_driver_sql(f"ALTER TABLE `{table.name}` MODIFY `{col.name}` {ctype}") print(f"[db] 已把 {table.name}.{col.name} 从 {db_len} 加宽到 {length}", flush=True) # 新版本加的索引:create_all 不会给已有的表补,这里补上 have_idx = {i["name"] for i in insp.get_indexes(table.name)} for idx in table.indexes: if idx.name and idx.name not in have_idx: idx.create(conn) print(f"[db] 已为 {table.name} 添加索引 {idx.name}", flush=True) def add_ready_hook(fn): """数据库连接成功后要执行的初始化(创建管理员、恢复状态等)。""" if fn not in _ready_hooks: _ready_hooks.append(fn) def connect(url: str, retries: int = 1, delay: float = 3.0): """连接并建表,成功后执行 ready hooks;失败时恢复为未配置状态并抛出异常。""" init_engine(url) try: init_db(retries=retries, delay=delay) except Exception: reset_engine() raise for fn in _ready_hooks: fn() def configured_url() -> str | None: from app import bootstrap from app.config import get_settings if get_settings().database_url: return get_settings().database_url if bootstrap.config_exists(): return bootstrap.build_url(bootstrap.load_config()) return None def connect_from_saved(force: bool = False, retries: int = 1) -> bool: """用环境变量或已保存的配置连接(幂等)。非 force 调用有节流,避免数据库故障时每个请求都阻塞。""" global _last_reconnect_attempt if SessionLocal is not None: return True now = time.monotonic() if not force and now - _last_reconnect_attempt < _RECONNECT_INTERVAL: return False _last_reconnect_attempt = now url = configured_url() if not url: return False # 启动时的后台连接和定时重连可能同时触发,只让一个去连 if not _connect_lock.acquire(blocking=False): return False try: if SessionLocal is not None: return True connect(url, retries=retries) return True except Exception as e: # noqa: BLE001 print(f"[db] 连接失败:{type(e).__name__}: {str(e)[:200]}", flush=True) return False finally: _connect_lock.release() def get_db(): if SessionLocal is None and not connect_from_saved(): raise HTTPException(503, "数据库尚未配置或暂时不可用;首次使用请在安装向导中填写 MySQL 信息") db = SessionLocal() try: yield db finally: db.close()