Files
ai-kefu-admin/app/main.py
T
浅念 6100b4eb15 fix: P4T1审查修复(platform.db入data卷/tzdata注释/corepack钉版/compose版本注记)
- main.py: PLATFORM_DB 挪到 <repo>/data/platform.db——data/ 是 compose
  bind mount,容器/镜像重建不再丢租户注册表;lifespan 对父目录兜底
  mkdir(冷机 data/ 不存在,sqlite 不建父目录)。bootstrap 脚本 --db
  默认值同步锚定 data/。
- 新测试 test_platform_db_defaults_into_data_dir: 常量形状钉死(回退
  仓库根即红)+ 冷机形态下 lifespan 建库于 data/ 内(全量 603)。
- Dockerfile: corepack 钉实测通过的具体版本 0.36.0(不追 latest,构建
  可复现);tzdata 注释照实改写(trixie slim 实测已自带,安装留作基
  镜像漂移保险)。
- docker-compose: 头注释改准确(tenants/=租户库,data/=platform.db+
  uploads);env_file 注记对象式写法需 Docker Compose >= 2.24。
- 实证: compose down/up 重建容器后宿主 ./data/platform.db 仍在,
  healthcheck healthy,/admin/ 200;docker compose build 复验通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 08:42:50 +08:00

310 lines
15 KiB
Python

from contextlib import asynccontextmanager
from pathlib import Path
import logging
import os
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import FileResponse, PlainTextResponse
from starlette.types import Receive, Scope, Send
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
# 平台库落 data/ 子目录(P4T1 审查修复):data/ 是 compose 的 bind mount
# ——容器/镜像重建不丢租户注册表(租户库本就在挂载的 tenants/ 内);
# .gitignore 的 data/ 规则覆盖其运行产物。
PLATFORM_DB = BASE_DIR / "data" / "platform.db"
# ---- admin 前端生产服务(Phase 4 Task 1)---------------------------------------
ADMIN_NOT_BUILT_MSG = (
"admin 前端未构建(admin/dist 缺失)。生产镜像内由两阶段 Dockerfile 的"
" node 阶段产出;开发模式请用 cd admin && pnpm dev(Vite dev server,"
" API 经代理指向本服务)。")
def resolve_admin_dist() -> Path:
"""admin 构建产物目录:env ADMIN_DIST 覆盖(测试注入/非常规部署),
默认 <repo>/admin/dist(vite build 的 outDir)。请求期现势解析——
构建完成后无需重启进程即可生效,也让测试能逐用例换目录。"""
env = os.environ.get("ADMIN_DIST")
return Path(env) if env else BASE_DIR / "admin" / "dist"
class AdminSPA:
"""/admin 的 SPA 静态服务(纯 ASGI,整体作为 Mount 的 app)。
- dist 存在(以 index.html 为准):等价 StaticFiles(html=True)——
目录请求落 index.html、assets 按文件服务;
- dist 缺失:503 纯文本说明(等价"不 mount + 说明页"的可观测行为,
且免去模块导入期的双分支——导入时序决定挂载会让测试与"构建后
不重启生效"都不可行);
- SPA fallback:Mount 内 404(hash 路由下是加固——直连/刷新深链不
白屏)回 index.html 200;/api 子前缀除外(未来 admin 侧 API 的路由
404 必须按默认 JSON 暴露,不被 index.html 吞)。fallback 收在
Mount 内部而非全局 ExceptionHandler:不吃全局 404 语义(其余路径
的默认 JSON 404 原样保留,零越界面),子路径判 /api 即判
/admin/api(见 __call__ 内的 root_path 归一)。
StaticFiles 按目录缓存(ADMIN_DIST 切换/dist 重建时换新实例);
构造只查目录存在,并发首请求双建幂等,末次赋值胜出。
"""
def __init__(self) -> None:
self._cached_dir: Path | None = None
self._static: StaticFiles | None = None
def _static_for(self, dist: Path) -> StaticFiles:
if self._static is None or self._cached_dir != dist:
self._static = StaticFiles(directory=str(dist), html=True)
self._cached_dir = dist
return self._static
async def __call__(self, scope: Scope, receive: Receive,
send: Send) -> None:
dist = resolve_admin_dist()
if not (dist / "index.html").is_file():
await PlainTextResponse(
ADMIN_NOT_BUILT_MSG, status_code=503)(scope, receive, send)
return
try:
await self._static_for(dist)(scope, receive, send)
except StarletteHTTPException as exc:
# 余段子路径 = path 剥去 root_path 挂载段:Starlette 1.8 的
# Mount 不改写 scope["path"](仅追加 root_path),老版本语义则
# 直接给剥好的 path——两种形态下都归一出 "/xxx" 再判 /api。
full_path = scope.get("path") or ""
root_path = scope.get("root_path") or ""
sub = (full_path[len(root_path):]
if root_path and full_path.startswith(root_path)
else full_path)
if (exc.status_code != 404
or sub == "/api" or sub.startswith("/api/")):
raise
await FileResponse(str(dist / "index.html"))(scope, receive, send)
@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(临时库)则沿用——不预置则会在 <repo>/data/
# 下建出真实 platform.db(生产;测试必须预置或锚到 tmp)。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:
# sqlite 不建父目录——冷机 data/ 可能尚不存在(全新检出/新容器),
# 连接前兜底建目录(registry._ensure_ready 同纪律)
PLATFORM_DB.parent.mkdir(parents=True, exist_ok=True)
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)
# 默认凭据警示(终审 I-4):ADMIN_PASSWORD 仍是出厂默认时启动日志
# 醒目提示。比较对象取 Settings 字段定义的默认值(非硬编码第二份
# "admin123"——将来改默认值,警示判定自动跟随,不双源漂移)。
if settings.ADMIN_PASSWORD == type(
settings).model_fields["ADMIN_PASSWORD"].default:
logger.warning(
"!!! 安全警示: 超管账号 %s 正在使用默认密码 —— 生产环境必须"
"设置 ADMIN_PASSWORD 环境变量,且首登后立即改密 !!!",
settings.ADMIN_USERNAME)
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")
# admin SPA(Phase 4 Task 1): 与 API 路由前缀互斥,挂载次序无冲突;
# Host 中间件已豁免 /admin 与 /admin/ 前缀(平台面不依赖租户域名)。
app.mount("/admin", AdminSPA(), name="admin")
@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(request: Request):
"""分级健康检查(Phase 4 Task 1:Docker healthcheck/部署观测的事实源)。
- 整体 status:任一已建 engine 探库失败 → "degraded" 但仍 200——单
租户库损坏不该让编排器杀容器(其余租户还在服务),也不整体 500;
- tenants:逐租户 slug/status(引擎配置态)+ engine_ok:True=探活
通过 / False=库损坏 / None=engine 懒建未就绪(不探——健康检查
不应成为触发全租户建库的路径);
- domains:域名绑定总数(导入完整性的人工核对口径)。
探活走租户自己的 session_factory(SELECT 1),任何异常都收敛成该
租户 degraded,不炸整体。
"""
registry = getattr(request.app.state, "registry", None)
tenants = []
overall = "ok"
domains = 0
if registry is not None:
for rt in registry.runtimes():
domains += sum(len(site.domains) for site in rt.config.sites)
engine_ok = None
if rt.session_factory is not None:
try:
async with rt.session_factory() as session:
await session.execute(text("SELECT 1"))
engine_ok = True
except Exception:
engine_ok = False
overall = "degraded"
tenants.append({
"slug": rt.config.slug,
"status": rt.config.status,
"engine_ok": engine_ok,
})
return {"status": overall, "tenants": tenants, "domains": domains}