"""PG 连接 + Session factory + 本地 sentinel 初始化。 `ZCBOT_DB_URL` 必填,标准 SQLAlchemy URL,如: postgresql+psycopg://user:pass@host:5432/zcbot 未设置时 get_engine() 抛 RuntimeError 并打印指引(不引导 docker)。 """ from __future__ import annotations import os from contextlib import contextmanager from typing import Iterator, Optional from sqlalchemy import Engine, create_engine, select from sqlalchemy.orm import Session, sessionmaker from .models import SENTINEL_USER_ID, User _engine: Optional[Engine] = None _SessionLocal: Optional[sessionmaker[Session]] = None _DB_URL_HINT = ( "ZCBOT_DB_URL is not set.\n" " export ZCBOT_DB_URL='postgresql+psycopg://user:pass@host:5432/dbname'\n" " (local: dev/staging PG; SaaS: production PG)" ) def _read_db_url() -> str: url = os.environ.get("ZCBOT_DB_URL", "").strip() if not url: raise RuntimeError(_DB_URL_HINT) return url def get_engine() -> Engine: """单例 engine。线程安全(SQLAlchemy 内置 pool)。""" global _engine, _SessionLocal if _engine is None: url = _read_db_url() _engine = create_engine(url, pool_pre_ping=True, future=True) _SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False, future=True) return _engine def get_sessionmaker() -> sessionmaker[Session]: if _SessionLocal is None: get_engine() assert _SessionLocal is not None return _SessionLocal @contextmanager def session_scope() -> Iterator[Session]: """事务上下文:成功 commit,异常 rollback,总是 close。""" sm = get_sessionmaker() s = sm() try: yield s s.commit() except Exception: s.rollback() raise finally: s.close() def ensure_local_sentinel() -> None: """本地形态:若 users 表无 sentinel 行则 INSERT。 本地 CLI 启动时调用一次,SaaS 形态不调用(用户由 auth 流程创建)。 幂等。 """ with session_scope() as s: existing = s.execute( select(User).where(User.user_id == SENTINEL_USER_ID) ).scalar_one_or_none() if existing is None: s.add(User(user_id=SENTINEL_USER_ID))