2026-07-28 02:12:46 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 数据库引擎与会话(moduleId: db-database, 可重生 ✅)
|
|
|
|
|
|
# SQLite 单文件:server/data/master.db(APS_DB_PATH 可覆盖)
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
|
|
from sqlalchemy.engine import Engine
|
|
|
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
|
|
|
|
|
|
|
_PATH_ENV = "APS_DB_PATH"
|
|
|
|
|
|
_URL_ENV = "APS_DATABASE_URL"
|
2026-08-20 11:39:21 +08:00
|
|
|
|
_APPROVAL_SQLITE_TEST_ENV = "APS_APPROVAL_DATABASE_ALLOW_SQLITE"
|
|
|
|
|
|
_MIGRATION_MANAGED_TABLES = frozenset({
|
|
|
|
|
|
"aps_approval_requests",
|
|
|
|
|
|
"aps_approval_grants",
|
|
|
|
|
|
"aps_approval_events",
|
|
|
|
|
|
})
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default_db_path() -> str:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from server.aps_home import default_db_path
|
|
|
|
|
|
return default_db_path()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return "server/data/master.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_DEFAULT_PATH = "server/data/master.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
|
_engine: Engine | None = None
|
|
|
|
|
|
_engine_path: str | None = None
|
|
|
|
|
|
_SessionLocal: sessionmaker | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _db_path() -> str:
|
|
|
|
|
|
return os.environ.get(_PATH_ENV) or _default_db_path()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _database_url() -> str:
|
|
|
|
|
|
configured = (os.environ.get(_URL_ENV) or "").strip()
|
|
|
|
|
|
if configured:
|
|
|
|
|
|
return configured
|
|
|
|
|
|
return f"sqlite:///{_db_path()}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_engine() -> Engine:
|
|
|
|
|
|
"""懒加载引擎;APS_DB_PATH 变化时自动重建(测试隔离用)。"""
|
|
|
|
|
|
global _engine, _engine_path, _SessionLocal
|
|
|
|
|
|
url = _database_url()
|
|
|
|
|
|
with _lock:
|
|
|
|
|
|
if _engine is None or _engine_path != url:
|
|
|
|
|
|
kwargs = {"pool_pre_ping": True}
|
|
|
|
|
|
if url.startswith("sqlite:///"):
|
|
|
|
|
|
path = url.removeprefix("sqlite:///")
|
|
|
|
|
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
|
|
|
|
kwargs["connect_args"] = {"check_same_thread": False}
|
|
|
|
|
|
_engine = create_engine(url, **kwargs)
|
|
|
|
|
|
_engine_path = url
|
|
|
|
|
|
_SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False)
|
|
|
|
|
|
init_db(_engine)
|
|
|
|
|
|
return _engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_engine() -> None:
|
|
|
|
|
|
"""丢弃当前引擎(测试切换 APS_DB_PATH 后调用)。"""
|
|
|
|
|
|
global _engine, _engine_path, _SessionLocal
|
|
|
|
|
|
with _lock:
|
|
|
|
|
|
if _engine is not None:
|
|
|
|
|
|
_engine.dispose()
|
|
|
|
|
|
_engine = None
|
|
|
|
|
|
_engine_path = None
|
|
|
|
|
|
_SessionLocal = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_session() -> Session:
|
|
|
|
|
|
get_engine()
|
|
|
|
|
|
assert _SessionLocal is not None
|
|
|
|
|
|
return _SessionLocal()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def init_db(engine: Engine | None = None) -> None:
|
|
|
|
|
|
"""建表(幂等)。"""
|
|
|
|
|
|
from server.db.models import Base
|
|
|
|
|
|
target = engine or get_engine()
|
|
|
|
|
|
if target.dialect.name == "sqlite":
|
|
|
|
|
|
_migrate_legacy_sqlite(target)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
allow_sqlite_approval_ddl = (
|
|
|
|
|
|
target.dialect.name == "sqlite"
|
|
|
|
|
|
and os.environ.get(_APPROVAL_SQLITE_TEST_ENV) == "1"
|
|
|
|
|
|
)
|
|
|
|
|
|
runtime_tables = None
|
|
|
|
|
|
if not allow_sqlite_approval_ddl:
|
|
|
|
|
|
runtime_tables = [
|
|
|
|
|
|
table
|
|
|
|
|
|
for table in Base.metadata.sorted_tables
|
|
|
|
|
|
if table.name not in _MIGRATION_MANAGED_TABLES
|
|
|
|
|
|
]
|
|
|
|
|
|
Base.metadata.create_all(target, tables=runtime_tables)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _migrate_legacy_sqlite(engine: Engine) -> None:
|
|
|
|
|
|
"""Small compatibility migration for databases created before tenant columns existed."""
|
|
|
|
|
|
from sqlalchemy import inspect, text
|
|
|
|
|
|
|
|
|
|
|
|
inspector = inspect(engine)
|
|
|
|
|
|
additions = {
|
|
|
|
|
|
"projects": {
|
|
|
|
|
|
"tenant_uuid": "VARCHAR(32) NOT NULL DEFAULT 'platform'",
|
|
|
|
|
|
"creator_id": "BIGINT",
|
|
|
|
|
|
"updater_id": "BIGINT",
|
|
|
|
|
|
"updated_at": "VARCHAR(32) NOT NULL DEFAULT ''",
|
|
|
|
|
|
"deleted": "BIGINT NOT NULL DEFAULT 0",
|
|
|
|
|
|
"owner_user_id": "BIGINT",
|
|
|
|
|
|
"data_version": "INTEGER NOT NULL DEFAULT 0",
|
|
|
|
|
|
},
|
|
|
|
|
|
"master_records": {
|
|
|
|
|
|
"tenant_uuid": "VARCHAR(32) NOT NULL DEFAULT 'platform'",
|
|
|
|
|
|
"creator_id": "BIGINT",
|
|
|
|
|
|
"updater_id": "BIGINT",
|
|
|
|
|
|
"updated_at": "VARCHAR(32) NOT NULL DEFAULT ''",
|
|
|
|
|
|
"deleted": "BIGINT NOT NULL DEFAULT 0",
|
|
|
|
|
|
},
|
|
|
|
|
|
"project_settings": {
|
|
|
|
|
|
"tenant_uuid": "VARCHAR(32) NOT NULL DEFAULT 'platform'",
|
|
|
|
|
|
"creator_id": "BIGINT",
|
|
|
|
|
|
"updater_id": "BIGINT",
|
|
|
|
|
|
"updated_at": "VARCHAR(32) NOT NULL DEFAULT ''",
|
|
|
|
|
|
"deleted": "BIGINT NOT NULL DEFAULT 0",
|
|
|
|
|
|
},
|
|
|
|
|
|
"routing_templates": {
|
|
|
|
|
|
"tenant_uuid": "VARCHAR(32) NOT NULL DEFAULT 'platform'",
|
|
|
|
|
|
"creator_id": "BIGINT",
|
|
|
|
|
|
"updater_id": "BIGINT",
|
|
|
|
|
|
"updated_at": "VARCHAR(32) NOT NULL DEFAULT ''",
|
|
|
|
|
|
"deleted": "BIGINT NOT NULL DEFAULT 0",
|
|
|
|
|
|
},
|
|
|
|
|
|
"routing_template_steps": {
|
|
|
|
|
|
"tenant_uuid": "VARCHAR(32) NOT NULL DEFAULT 'platform'",
|
|
|
|
|
|
"creator_id": "BIGINT",
|
|
|
|
|
|
"updater_id": "BIGINT",
|
|
|
|
|
|
"updated_at": "VARCHAR(32) NOT NULL DEFAULT ''",
|
|
|
|
|
|
"deleted": "BIGINT NOT NULL DEFAULT 0",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
tables = set(inspector.get_table_names())
|
|
|
|
|
|
with engine.begin() as connection:
|
|
|
|
|
|
for table, columns in additions.items():
|
|
|
|
|
|
if table not in tables:
|
|
|
|
|
|
continue
|
|
|
|
|
|
existing = {col["name"] for col in inspector.get_columns(table)}
|
|
|
|
|
|
for name, ddl in columns.items():
|
|
|
|
|
|
if name not in existing:
|
|
|
|
|
|
connection.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}"))
|