Files

259 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据库连接在运行时初始化(与 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()