feat: 生产装配切换DbTenantSource + bootstrap脚本(超管/两租户幂等导入)

- app/main.py: lifespan registry 装配按 env TENANT_SOURCE 切换(db|file,
  默认 db)——db 走 TenantRegistry(DbTenantSource(platform_engine)) +
  schema 就绪后 async load_all;file 回退旧 load_from_dir。空 platform.db
  冷启动不炸:显式预检 tenants 表(非捕 ValueError——坏数据同样抛
  ValueError,一揽子吞会把数据损坏静默成空平台),空表保持空注册表
  (域名全 404 直到 bootstrap 导入),清扫循环对空 runtimes() 安全。
  非法值在创建任何 engine 前显性炸。conftest 预置缝优先于 env 装配,
  既有 558 测试零改动全绿。
- scripts/bootstrap_platform.py: init(schema+超管,env/.env 凭据,空表
  才建)+ 出厂档案幂等导入(已存在 slug 跳过;FileTenantSource.read_tenant
  同构快照 → Tenant+sites+domains+版本1,版本走 write_config_version 单一
  路径;域名 _normalize_host 归一)。不建租户管理员——导入是存量迁移
  形态,租户管理员按需走后台创建向导补。CLI 照 extract_config.py 的
  argparse 风格(--db/--profiles/--seed)。
- tests/test_bootstrap_script.py: 幂等两遍零变化(行数/版本数逐项断言)、
  超管 env 凭据、未知档案显性炸、bootstrap→DbTenantSource→registry 两租户
  resolve 冒烟(jc 蓝/17 条/ai-kefu-jc,auto 橙/13 条/ai-kefu,es 覆盖层)、
  TENANT_SOURCE 三态装配(db 空/库冷启动、file 回退、非法值炸)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 12:05:42 +08:00
parent 71e11c317f
commit 7d16ae6318
3 changed files with 431 additions and 5 deletions
+43 -5
View File
@@ -1,18 +1,23 @@
from contextlib import asynccontextmanager
from pathlib import Path
import logging
import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.config import STATIC_DIR, settings
from app.platform.db import (
Tenant,
bootstrap_superuser,
ensure_platform_schema,
make_platform_engine,
)
from app.platform.host_middleware import TenantHostMiddleware
from app.platform.registry import TenantRegistry
from app.platform.tenant_source import DbTenantSource
logger = logging.getLogger(__name__)
@@ -25,22 +30,38 @@ PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 platform.db*
@asynccontextmanager
async def lifespan(app: FastAPI):
# 测试注入缝:conftest 预置过 app.state.registry(临时 data_dir 的独立
# Registry)则沿用,不再加载仓库 seed——测试租户库与生产互不污染
# registry 装配(Task 11 生产切换):测试注入缝优先——conftest 预置过
# app.state.registry 则沿用(既有测试全走预置,不受 env 默认切换影响),
# 预置路径完全不读 TENANT_SOURCE。未预置才按 env 装配:db(默认,生产
# 形态)DbTenantSource(platform.db);file 回退旧文件源(冷备/本地调试)。
# 非法值在创建任何 engine 之前显性炸——拼错名静默跑错数据源,与"空源
# 必须炸"同一纪律。
registry = getattr(app.state, "registry", None)
if registry is None:
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=TENANTS_DIR)
app.state.registry = registry
assemble_from_env = registry is None
tenant_source = os.environ.get("TENANT_SOURCE", "db").strip().lower()
if assemble_from_env and tenant_source not in ("db", "file"):
raise RuntimeError(
f"TENANT_SOURCE 只允许 db|file(实际 {tenant_source!r})")
# 平台库测试缝(Phase 2 Task 1,同 registry 缝模式):预置过
# app.state.platform_engine(临时库)则沿用——不预置则每个 TestClient
# 测试都会在仓库根建出真实 platform.db。schema+bootstrap 无条件跑
# (两者幂等):预置引擎同样被建到可用,Task 2 起认证测试直接可用。
# 装配次序(Task 11 起)engine 先于 registry:db 源 registry 构造即需
# 持有 platform_engine。
platform_engine = getattr(app.state, "platform_engine", None)
if platform_engine is None:
platform_engine = make_platform_engine(PLATFORM_DB)
app.state.platform_engine = platform_engine
if assemble_from_env:
if tenant_source == "db":
registry = TenantRegistry(
DbTenantSource(platform_engine), data_dir=TENANTS_DIR)
else:
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=TENANTS_DIR)
app.state.registry = registry
try:
await ensure_platform_schema(platform_engine)
await bootstrap_superuser(
@@ -48,6 +69,23 @@ async def lifespan(app: FastAPI):
username=settings.ADMIN_USERNAME,
password=settings.ADMIN_PASSWORD)
# db 源初始加载(Task 11):schema 就绪后现势加载(空库无表,先建
# schema 才能查)。空平台(platform.db 无租户——init 后未导入)不炸:
# 保持空注册表启动(域名全 404 直到 bootstrap 导入;下方清扫循环对
# runtimes() 空元组安全)。探空用显式预检而非捕 ValueError——
# load_all 对坏数据(缺版本/坏 JSON/缺配置组)同样抛 ValueError,
# 一揽子吞会把数据损坏静默成"空平台",必须区分。
if assemble_from_env and tenant_source == "db":
async with AsyncSession(platform_engine) as session:
has_tenant = (await session.execute(
select(Tenant.id).limit(1))).first() is not None
if has_tenant:
await registry.load_all()
else:
logger.warning(
"platform.db 无租户——空注册表启动(域名全 404);"
"跑 scripts/bootstrap_platform.py 导入出厂租户")
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed。逐租户、
# 用该租户自己的 session_factory;db 文件不存在的租户跳过——全新库
# 无遗留行,保持 engine 懒建纪律(租户 engine 由 Host 中间件按请求