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:
+43
-5
@@ -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 中间件按请求
|
||||
|
||||
Reference in New Issue
Block a user