Files
ai-kefu-admin/app/platform/db.py
T
浅念 6839cb94d2 fix: 终审修复——站点停用接线/prefix查重补创建reset/默认凭据警示+取舍补录
- I-1 站点停用全链接线: Site 数据类加 status 字段; DB 源 _load_one 与
  文件源 read_tenant 均只输出 active 站点(停用站点域名不进 registry
  索引 → 该域名 404;全停用=合法运营状态输出空 sites;文件源无 status
  键默认 active 兼容)。E2E: PATCH 站点 disabled → 其域名 404、复启用
  恢复;源级直测两源过滤。
- I-3 prefix 跨租户查重补两口: db.other_push_prefixes 共用助手
  (MAX(version) 逐租户当前前缀,排除自身);创建向导(出厂档案前缀被占
  → 400 提示改 blank/改占用方)与 reset push(重置回出厂撞车 → 400 提示
  走 PUT)接入;PUT 原有查重改用同一助手。rollback 维持豁免(历史不可变
  语义优先,取舍清单记档)。既有测试第二租户改 blank profile 避撞。
- I-4 默认凭据警示: lifespan 启动检测 ADMIN_PASSWORD 仍为 Settings 字段
  默认值(非硬编码第二份,防双源漂移)→ logger.warning 醒目提示;
  CLAUDE.md Phase 4 部署记档补 ADMIN_PASSWORD 生产要求。
- I-2/M-2 取舍补录: 密钥掩码未实现(GET config/settings 明文返回
  api_key/smtp 密码,Phase 3 前须定 __SET__ 哨兵方案);站点管理权限现为
  仅超管(Phase 3 对接时决策放开或改 spec);取舍清单第 2 条同步刷新
  (rollback 现为唯一豁免口)。

全量回归: 571 passed(基线 566 + 新增 5)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:40:21 +08:00

279 lines
12 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 存版本化串
"scrypt$<n>$<r>$<p>$<salt_hex>$<hash_hex>"(参数与摘要同存,将来提参/
换算法有迁移路径,verify 解析兼容);salt 列同步存同一盐 hex(单一写方
hash_password,verify 以串内为准)。哈希实现 = hashlib.scrypt(n=2**14,
r=8, p=1)——Task 2 起上移至 platform/auth.py(单一事实源,签名纯参式),
本模块 re-export 兼容既有导入;偏差注记:spec §7.2 写 bcrypt/argon2,
用 stdlib scrypt 为有意取舍(零新依赖,抗 GPU 性能同级)。
测试/teardown 纪律:建过 platform engine 的测试结尾 await engine.dispose()
(Windows 文件锁,同 registry)。
"""
import datetime
import json
import pathlib
from sqlalchemy import (
JSON,
Boolean,
CheckConstraint,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
event,
func,
select,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# 密码哈希单一定义点:Task 2 上移至 platform/auth.py(纯参式签名,解 ORM
# 绑定——M-2b),此处 re-export 兼容既有 from app.platform.db import 路径
from app.platform.auth import hash_password, verify_password # noqa: F401
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")
# 创建向导选定的出厂档案(Task 6 起):重置出厂按它取对应档案组
# (auto/jiancai 档案 / blank = jiancai 去身份组)。缺省 blank——
# 正常路径一律经创建向导写入,缺省只是手工插行的兜底语义。
profile: Mapped[str] = mapped_column(String(16), default="blank")
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)。
CHECK(json_valid(config_json)):坏 JSON 在库层兜底拒收(当前零写入方,
加约束零破坏;Task 5+ 写入方还应前置 json.dumps——约束只兜底不代偿)。"""
__tablename__ = "tenant_config_versions"
__table_args__ = (
UniqueConstraint("tenant_id", "version"),
CheckConstraint(
"json_valid(config_json)", name="ck_tcv_config_json_valid"),
)
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)
# ---- 密码哈希:定义在 auth.py(见文件头 re-export)---------------------------
# ---- 配置版本写入(Task 6 抽公共:创建向导与配置 API 的单一版本写路径) --------
async def write_config_version(
session: AsyncSession, tenant_id: int, snapshot: dict, *,
updated_by: str = "", comment: str | None = None) -> int:
"""写新配置版本:MAX(version)+1,整份快照直存(spec §5.2 历史不可变,
当前生效 = MAX)。Task 5 创建向导(版本 1)与 Task 6 配置 API(PUT 分组
编辑/rollback 复制旧版/reset 重置出厂)的公共写入路径——两处各自
MAX+1 会漂移,收敛为单一事实源。调用方负责 commit;返回新版本号。"""
current = (await session.execute(
select(func.max(TenantConfigVersion.version))
.where(TenantConfigVersion.tenant_id == tenant_id))).scalar()
version = (current or 0) + 1
session.add(TenantConfigVersion(
tenant_id=tenant_id,
version=version,
config_json=json.dumps(snapshot, ensure_ascii=False),
updated_by=updated_by,
comment=comment))
return version
async def other_push_prefixes(
session: AsyncSession, exclude_tenant_id: int | None) -> frozenset:
"""各租户**当前版本**的 push.prefix 集合(spec E1 查重数据源;空/坏数据
不参战)。MAX(version) 子查询定位每租户当前版。
终审 I-3 收口:自 config_routes 的私有 helper 提取共用——PUT 分组编辑
(经 config_validation 的 ValidationContext)、创建向导(首版快照带出厂
prefix,同 profile 第二个租户必撞)、reset 重置出厂(重置回出厂 prefix
可能撞上现在的占用方)三口共用。exclude_tenant_id=None = 不排除任何
租户(创建向导:租户尚未落库)。rollback 仍不查(历史不可变语义优先,
挂账见 CLAUDE.md 取舍清单)。"""
max_versions = (
select(TenantConfigVersion.tenant_id.label("tid"),
func.max(TenantConfigVersion.version).label("mv"))
.group_by(TenantConfigVersion.tenant_id).subquery())
rows = (await session.execute(
select(TenantConfigVersion.tenant_id, TenantConfigVersion.config_json)
.join(max_versions, (TenantConfigVersion.tenant_id == max_versions.c.tid)
& (TenantConfigVersion.version == max_versions.c.mv)))).all()
prefixes = set()
for tenant_id, config_json in rows:
if tenant_id == exclude_tenant_id:
continue
try:
snapshot = json.loads(config_json)
except ValueError:
continue
push = snapshot.get("push") if isinstance(snapshot, dict) else None
prefix = push.get("prefix") if isinstance(push, dict) else None
if isinstance(prefix, str) and prefix:
prefixes.add(prefix)
return frozenset(prefixes)
# ---- 超管引导 ---------------------------------------------------------------
async def bootstrap_superuser(
engine: AsyncEngine, username: str, password: str) -> None:
"""空表才建超管(tenant_id NULL, must_change_password=True);表非空
一律不动——不重复建、不改已有用户/密码(幂等:lifespan 每次启动安全
重入,运行期改密不会被启动覆盖)。
多进程共享库竞态(I-1):两进程同时见空表同时插,UNIQUE(username)
让输方 IntegrityError——吞掉视为已引导(rollback 后返回),输方启动
不炸,库落一行赢家结果,语义等价。"""
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)
try:
session.add(AdminUser(
tenant_id=None,
username=username,
password_hash=password_hash,
salt=salt,
must_change_password=True))
await session.commit()
except IntegrityError:
await session.rollback() # 输方:另一进程已抢先建,视为已引导
async def list_admin_users(engine: AsyncEngine) -> list[AdminUser]:
"""全部后台账号(按 id 稳序)——测试/管理列表用。
警示(M-5):返回的是 ORM 实体,含凭据列(password_hash/salt)——
序列化出 API/日志前必须显式剔除这两列,凭据永不外泄。"""
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())