"""platform.db——平台层单库(Phase 2 Task 1,spec §5.2)。 PlatformBase 独立 MetaData,与租户库 Base 无关:ensure_platform_schema 只跑 PlatformBase.metadata,engine/models.ensure_schema 只跑 Base.metadata—— 平台表与租户业务表互不误建,即使 engine 指错库也不会混表。 模型要点(spec §5.2 / 计划 Task 1): - tenants/sites/site_domains/tenant_config_versions/admin_users/platform_settings - FK 级联:ondelete="CASCADE"——删租户级联 sites/site_domains/ tenant_config_versions/该租户的 admin_users,删站点级联其域名。 SQLite 默认不启用 FK enforcement,make_platform_engine 的 connect 钩子 每连接 PRAGMA foreign_keys=ON(与 WAL 同一钩子)。 - tenant_config_versions:当前生效版 = MAX(version);历史不可变(回滚 = 旧版复制成新版本,Task 8);UNIQUE(tenant_id, version)。config_json 用 Text 存 JSON 字符串,读写方显式 json.loads/dumps(快照几十 KB,保持 直存直取,不经 ORM JSON 类型的隐式序列化)。 - admin_users:tenant_id NULL = 超管。password_hash/salt 存 hex 文本; hash = hashlib.scrypt(n=2**14, r=8, p=1)——偏差注记:spec §7.2 写 bcrypt/argon2,用 stdlib scrypt 为有意取舍(零新依赖,抗 GPU 性能同级); verify 走 hmac.compare_digest 常量时间比较。 测试/teardown 纪律:建过 platform engine 的测试结尾 await engine.dispose() (Windows 文件锁,同 registry)。 """ import datetime import hashlib import hmac import pathlib import secrets from sqlalchemy import ( JSON, Boolean, ForeignKey, Integer, String, Text, UniqueConstraint, event, select, ) from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, create_async_engine, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column def _utcnow() -> datetime.datetime: # 跟随租户库 models.py 既有约定:datetime.now(timezone.utc)(tz-aware UTC) return datetime.datetime.now(datetime.timezone.utc) class PlatformBase(DeclarativeBase): pass class Tenant(PlatformBase): __tablename__ = "tenants" id: Mapped[int] = mapped_column(Integer, primary_key=True) slug: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(128)) status: Mapped[str] = mapped_column(String(16), default="active") created_at: Mapped[datetime.datetime] = mapped_column(default=_utcnow) class Site(PlatformBase): """站点门面覆盖层(spec §5.2):brand_overlay 只覆盖缺省项,未覆盖项 继承租户 brand;DbTenantSource(Task 4)把它映射进站点 brand 合并链。""" __tablename__ = "sites" __table_args__ = (UniqueConstraint("tenant_id", "slug"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) tenant_id: Mapped[int] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), index=True) slug: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(128)) status: Mapped[str] = mapped_column(String(16), default="active") brand_overlay: Mapped[dict | None] = mapped_column(JSON, nullable=True) class SiteDomain(PlatformBase): """域名→站点(站点→租户随外键);domain 全局 UNIQUE(跨租户也不可 重复绑定,加载期/保存校验之外的数据库兜底)。""" __tablename__ = "site_domains" id: Mapped[int] = mapped_column(Integer, primary_key=True) domain: Mapped[str] = mapped_column(String(255), unique=True, index=True) site_id: Mapped[int] = mapped_column( ForeignKey("sites.id", ondelete="CASCADE"), index=True) class TenantConfigVersion(PlatformBase): """租户配置版本:每次保存存整份快照(几十 KB,不做增量); 当前生效版 = MAX(version);UNIQUE(tenant_id, version)。""" __tablename__ = "tenant_config_versions" __table_args__ = (UniqueConstraint("tenant_id", "version"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) tenant_id: Mapped[int] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), index=True) version: Mapped[int] = mapped_column(Integer) config_json: Mapped[str] = mapped_column(Text) updated_by: Mapped[str] = mapped_column(String(64), default="") comment: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime.datetime] = mapped_column(default=_utcnow) class AdminUser(PlatformBase): """后台账号:tenant_id NULL = 超管;must_change_password=True 表示 引导/重置出的初始密码,须首登改掉(Task 2 强制语义)。""" __tablename__ = "admin_users" id: Mapped[int] = mapped_column(Integer, primary_key=True) tenant_id: Mapped[int | None] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) username: Mapped[str] = mapped_column(String(64), unique=True) password_hash: Mapped[str] = mapped_column(String(256)) # scrypt 摘要 hex salt: Mapped[str] = mapped_column(String(64)) # 随机盐 hex must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) status: Mapped[str] = mapped_column(String(16), default="active") created_at: Mapped[datetime.datetime] = mapped_column(default=_utcnow) class PlatformSetting(PlatformBase): """平台级默认(集团 AI 凭据/默认 SMTP 等,spec §5.2)。""" __tablename__ = "platform_settings" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str] = mapped_column(Text, default="") def make_platform_engine(db_path: pathlib.Path) -> AsyncEngine: """平台 SQLite engine——纪律照抄 registry.make_tenant_engine(pool=2 无 溢出/忙等 30s/URL as_posix/打开即 WAL,spec §6.1 读写不互斥);另开 PRAGMA foreign_keys=ON:平台库的级联删除(spec §5.2 删租户连带子行) 依赖 FK enforcement,SQLite 默认关闭,必须每连接显式开。""" engine = create_async_engine( f"sqlite+aiosqlite:///{db_path.as_posix()}", pool_size=2, max_overflow=0, connect_args={"timeout": 30}) @event.listens_for(engine.sync_engine, "connect") def _pragmas(dbapi_conn, _): # dbapi_conn = aiosqlite 同步适配层 dbapi_conn.execute("PRAGMA journal_mode=WAL") dbapi_conn.execute("PRAGMA foreign_keys=ON") return engine async def ensure_platform_schema(engine: AsyncEngine) -> None: """create_all,只含 PlatformBase.metadata(与租户库 Base 互不误建)。 platform.db 是 Phase 2 新库,无旧库加列迁移负担。""" async with engine.begin() as conn: await conn.run_sync(PlatformBase.metadata.create_all) # ---- 密码哈希(scrypt,偏差注记见模块 docstring)--------------------------- _SCRYPT_N = 2 ** 14 _SCRYPT_R = 8 _SCRYPT_P = 1 def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: """scrypt(password, salt) → (password_hash, salt),均 hex 文本(便于 直读排查);salt 缺省每次随机 16 字节。显式传 salt 可复现(测试/verify)。""" if salt is None: salt = secrets.token_bytes(16) digest = hashlib.scrypt( password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P) return digest.hex(), salt.hex() def verify_password(password: str, user: AdminUser) -> bool: """常量时间比较(hmac.compare_digest)——防时序侧信道;坏 hex 按验败 处理而非炸(存储异常不该变成 500)。""" try: salt = bytes.fromhex(user.salt) expected = bytes.fromhex(user.password_hash) except ValueError: return False actual = hashlib.scrypt( password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P) return hmac.compare_digest(expected, actual) # ---- 超管引导 --------------------------------------------------------------- async def bootstrap_superuser( engine: AsyncEngine, username: str, password: str) -> None: """空表才建超管(tenant_id NULL, must_change_password=True);表非空 一律不动——不重复建、不改已有用户/密码(幂等:lifespan 每次启动安全 重入,运行期改密不会被启动覆盖)。""" async with AsyncSession(engine, expire_on_commit=False) as session: existing = (await session.execute( select(AdminUser.id).limit(1))).first() if existing is not None: return password_hash, salt = hash_password(password) session.add(AdminUser( tenant_id=None, username=username, password_hash=password_hash, salt=salt, must_change_password=True)) await session.commit() async def list_admin_users(engine: AsyncEngine) -> list[AdminUser]: """全部后台账号(按 id 稳序)——测试/管理列表用。""" async with AsyncSession(engine, expire_on_commit=False) as session: result = await session.execute(select(AdminUser).order_by(AdminUser.id)) return list(result.scalars().all())