Files
ai-kefu-admin/scripts/bootstrap_platform.py
T

140 lines
5.9 KiB
Python
Raw Normal View History

"""平台引导脚本(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 不建父目录(仓库根默认存在,--db 指新目录时会炸——conftest
# build_app 同坑注记);照 registry._ensure_ready 纪律先建目录
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 / "platform.db"),
help="platform.db 路径(默认仓库根,与 app/main.py 的 PLATFORM_DB "
"同一锚定;测试/分环境可覆盖)")
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()