"""平台引导脚本(Phase 2 Task 11):生产装配(platform.db 源)的一键落地。 两步,全部幂等,可安全反复跑: 1. init:make_platform_engine + ensure_platform_schema + bootstrap_superuser (凭据取 env/.env 的 ADMIN_USERNAME/ADMIN_PASSWORD,空表才建,与 main.lifespan 同一 bootstrap——谁先跑结果一致); 2. 导入:遍历出厂档案(--profiles,默认 auto jiancai)—— FileTenantSource.read_tenant(slug) 产出 LoadedTenant(配置组⊕wall⊕ prompts 同构快照,Task 4 起 registry 单一消费形态)→ tenants 表已有 该 slug 则跳过 → 写 Tenant+sites+domains+配置版本 1(版本写入走 db.write_config_version,与创建向导/配置 API 同一条 MAX+1 路径)。 **不建租户管理员**(计划裁定):导入是"存量迁移形态"——线上超管已由 init 步骤引导,各租户管理员按需走后台创建向导(Task 5)补,脚本不造 任何租户账号。 用法(Windows 须 PYTHONUTF8=1,输出含中文): PYTHONUTF8=1 python scripts/bootstrap_platform.py [--db PATH] [--profiles SLUG ...] [--seed DIR] """ from __future__ import annotations import argparse import asyncio import sys from pathlib import Path # 仓库根入 sys.path(照 extract_config.py 锚定纪律:脚本可从任意 CWD 跑) sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.engine.config import settings from app.platform.db import ( Site, SiteDomain, Tenant, bootstrap_superuser, ensure_platform_schema, make_platform_engine, write_config_version, ) # 域名归一与路由层同一事实源(registry._normalize_host;tenant_routes 的 # _normalize_domain 即它的 HTTP 包装) from app.platform.registry import _normalize_host from app.platform.tenant_source import FileTenantSource REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_PROFILES = ("auto", "jiancai") async def _import_profile(engine, seed_dir: Path, slug: str) -> str: """单档案导入:已存在跳过(幂等),否则写 Tenant+sites+domains+版本1。 返回结果文案(导入明细/跳过原因);档案缺失 → LookupError 由调用方 转 SystemExit(拼错档案名静默空平台 = 全量 404 的部署事故,必须显性炸)。 """ loaded = FileTenantSource(seed_dir).read_tenant(slug) # LookupError 显性抛 async with AsyncSession(engine, expire_on_commit=False) as session: if (await session.execute( select(Tenant.id).where(Tenant.slug == slug))).first() is not None: return f"{slug}: 跳过(tenants 已存在)" # profile 列 = 档案名(重置出厂按它取对应档案组;导入的租户即其 # 出厂档案的原生形态) tenant = Tenant(slug=loaded.slug, name=loaded.name, status=loaded.status, profile=slug) session.add(tenant) await session.flush() domain_count = 0 for site_dict in loaded.sites: site = Site(tenant_id=tenant.id, slug=site_dict["slug"], name=site_dict.get("name") or site_dict["slug"], brand_overlay=dict(site_dict.get("brand") or {})) session.add(site) await session.flush() for domain in site_dict.get("domains") or []: normalized = _normalize_host(domain) if normalized: # 空串跳过(与域名索引同判) session.add(SiteDomain(domain=normalized, site_id=site.id)) domain_count += 1 version = await write_config_version( session, tenant.id, loaded.snapshot, updated_by="bootstrap", comment=f"bootstrap 导入: profile={slug}") assert version == 1 # 新租户无历史版本,必为 1 await session.commit() return (f"{slug}: 导入(站点 {len(loaded.sites)}," f"域名 {domain_count},版本 {version})") async def run(args) -> None: db_path = Path(args.db) # sqlite 不建父目录(默认的 data/ 在冷机可能尚不存在,--db 指新目录 # 同炸——conftest build_app 同坑注记);照 registry._ensure_ready 纪律 # 先建目录(app/main.py 的 lifespan 对 PLATFORM_DB 同兜底) db_path.parent.mkdir(parents=True, exist_ok=True) engine = make_platform_engine(db_path) try: # ---- init(schema + 超管,均幂等)------------------------------------ await ensure_platform_schema(engine) await bootstrap_superuser( engine, username=settings.ADMIN_USERNAME, password=settings.ADMIN_PASSWORD) print(f"platform.db: {db_path}") print(f"超管: {settings.ADMIN_USERNAME}(空表才建,已存在则不动)") # ---- 导入(逐档案;缺档案显性炸)------------------------------------- seed_dir = Path(args.seed) results = [] for slug in args.profiles: try: results.append(await _import_profile(engine, seed_dir, slug)) except LookupError as exc: raise SystemExit(f"导入失败: 出厂档案缺失: {exc}") from exc for line in results: print(line) finally: await engine.dispose() # Windows 文件锁纪律 def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--db", default=str(REPO_ROOT / "data" / "platform.db"), help="platform.db 路径(默认 /data/,与 app/main.py 的 " "PLATFORM_DB 同一锚定——data/ 是 compose bind mount,重建" "容器不丢;测试/分环境可覆盖)") parser.add_argument( "--profiles", nargs="+", default=list(DEFAULT_PROFILES), help=f"导入的出厂档案 slug 列表(默认 {' '.join(DEFAULT_PROFILES)})") parser.add_argument( "--seed", default=str(REPO_ROOT / "seed_profiles"), help="出厂档案目录(默认仓库根 seed_profiles)") args = parser.parse_args() asyncio.run(run(args)) if __name__ == "__main__": main()