Files
ai-kefu-admin/app/main.py
T
浅念 7d16ae6318 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>
2026-10-13 12:05:42 +08:00

187 lines
8.9 KiB
Python

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__)
# 路径锚定(不随 CWD 漂移):seed_profiles 与 tenants 都在仓库根
BASE_DIR = Path(__file__).resolve().parents[1]
SEED_DIR = BASE_DIR / "seed_profiles"
TENANTS_DIR = BASE_DIR / "tenants" # .gitignore 已忽略 tenants/*.db
PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 platform.db*
@asynccontextmanager
async def lifespan(app: FastAPI):
# registry 装配(Task 11 生产切换):测试注入缝优先——conftest 预置过
# app.state.registry 则沿用(既有测试全走预置,不受 env 默认切换影响),
# 预置路径完全不读 TENANT_SOURCE。未预置才按 env 装配:db(默认,生产
# 形态)DbTenantSource(platform.db);file 回退旧文件源(冷备/本地调试)。
# 非法值在创建任何 engine 之前显性炸——拼错名静默跑错数据源,与"空源
# 必须炸"同一纪律。
registry = getattr(app.state, "registry", None)
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(
platform_engine,
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 中间件按请求
# 懒建,启动期只有已有库文件的租户会被扫描)。
# 清扫失败只记日志继续:一个租户的坏库不炸整个平台启动(其余租户
# 照常服务),该租户下轮重启还会重扫。
from app.engine.services.lead_push_service import sweep_stale_pending
for rt in registry.runtimes():
if rt.config.status != "active" or not rt.db_path.exists():
continue
try:
ready = await registry.ensure_ready(rt)
await sweep_stale_pending(session_factory=ready.session_factory)
except Exception:
logger.exception(
"启动清扫失败,跳过该租户(slug=%s, db=%s)",
rt.config.slug, rt.db_path)
yield
finally:
# 幂等于"更少的引擎":启动半途失败也把已建好的 engine 关掉
# (Windows 文件锁: 不关则库文件删不掉)。platform_engine 含预置的
# 测试引擎——dispose 后 engine 对象仍可重连,测试侧重置引用即可。
await registry.dispose_all()
await platform_engine.dispose()
app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)
from app.engine.routes.chat import router as chat_router
from app.engine.routes.chat_rest import router as chat_rest_router
from app.engine.routes.public import router as public_router
from app.platform.routes.auth_routes import router as platform_auth_router
from app.platform.routes.config_routes import (
router as platform_config_router,
settings_router as platform_settings_router,
)
from app.platform.routes.data_routes import router as platform_data_router
from app.platform.routes.stats_routes import router as platform_stats_router
from app.platform.routes.tenant_routes import router as platform_tenant_router
from app.platform.routes.upload_routes import router as platform_upload_router
app.include_router(chat_router)
app.include_router(chat_rest_router)
app.include_router(public_router)
# 租户管理 API(Task 5):创建向导/站点/域名/停用——同样只依赖路由层认证
# (require_super / require_tenant),Host 豁免面归 Task 3 的前缀规则覆盖。
app.include_router(platform_tenant_router)
# 配置 API(Task 6):分组校验管线/整份版本/回滚/试金石/重置出厂(鉴权
# require_tenant,租户全开)+ 平台设置(require_super)——/api/platform/
# 前缀的 Host 豁免由 Task 3 的中间件规则统一覆盖。
app.include_router(platform_config_router)
app.include_router(platform_settings_router)
# 头像上传(Task 7):multipart 魔数校验 + uuid 重命名 + 配置版本管线
# (require_tenant);/api/public/avatar 的三级解析链消费上传产物。
app.include_router(platform_upload_router)
# 数据视图(Task 8): 会话列表(筛选/分页)/详情/删除 + 推送日志——跑在租户
# runtime 的 session_factory 上(slug → runtimes() 匹配 + ensure_ready 懒建),
# 鉴权 _tenant_guard;查询语义自 jc admin.py 移植。
app.include_router(platform_data_router)
# 统计 API(Task 9): 卡片/漏斗/趋势/分布/推送健康——聚合在 app/platform/
# stats.py,逐库查询应用层合并(禁 ATTACH,spec §7.6);合并端点 require_super
# (?tenant= 单租户下钻),单租户端点 _tenant_guard。
app.include_router(platform_stats_router)
# 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下
# /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为
# /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析);
# 豁免≠放行——认证由路由依赖(get_current_user 系)守护。
app.include_router(platform_auth_router)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.middleware("http")
async def static_no_cache(request, call_next):
"""静态资源协商缓存(2026-09-30 线上事件): 无 Cache-Control 时浏览器对
widget.js 走启发式缓存,修复部署后用户端仍跑旧脚本。no-cache=每次用
etag 轻量协商,有更新即时生效,未变则 304 几乎零开销。"""
resp = await call_next(request)
if request.url.path.startswith("/static/"):
resp.headers["Cache-Control"] = "no-cache"
return resp
# 租户路由中间件(Task 7): 最后 add = 最外层——租户解析先于一切用户中间件。
# 纯 ASGI 类(http+websocket 双覆盖),registry 经 scope["app"] 延迟取,
# 因此可在 lifespan 挂 Registry 之前于模块级注册。
app.add_middleware(TenantHostMiddleware)
@app.get("/")
async def index():
from fastapi.responses import FileResponse
return FileResponse(str(STATIC_DIR / "chat.html"))
@app.get("/health")
async def health():
return {"status": "ok"}