2d62108c73
Co-Authored-By: Claude Code <noreply@anthropic.com>
224 lines
8.9 KiB
Python
224 lines
8.9 KiB
Python
"""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())
|