feat: admin生产服务(/admin SPA fallback+health分级) + Docker两阶段构建 + 生产compose

- app/main.py: AdminSPA 纯 ASGI 挂载 /admin(dist 存在=StaticFiles html
  + SPA fallback(非 /admin/api 的 404 回 index.html 200);缺失=503 说明页);
  ADMIN_DIST env 覆盖、请求期现势解析(构建后不重启生效/测试可注入)。
  /health 分级: {status, tenants:[{slug,status,engine_ok}], domains}——
  懒建未就绪 engine_ok=None 不探库;单租户库损坏只 degraded 仍 200,
  不让 healthcheck 杀容器。
- host_middleware: /admin 与 /admin/ 前缀豁免租户解析(平台面;/admin-evil
  仍 404,前缀精确到斜杠)。
- admin: .env.production VITE_BASE_URL=/admin/(产物资源引用与挂载同源,
  单一事实源,不靠构建期 env 注入);package.json 钉 packageManager
  pnpm@10.7.1(corepack 与本地同版本)。
- Dockerfile 两阶段: node:20-alpine 构建 admin/dist(corepack 按清单钉版)
  → python:3.13-slim 只带运行时(app/static/seed_profiles/scripts+dist,
  含 tzdata 使 TZ 生效)。
- docker-compose: 生产编排(8018/env_file 可缺省/TZ/数据 bind mount/
  mem_limit 768m/日志轮转/python urllib healthcheck/restart)。
- .dockerignore 防泄(.env 不进镜像)+ .gitattributes 钉部署文件 LF。
- tests/test_admin_serving.py: 假 dist 夹具 7 例(服务/fallback 边界/
  Host 豁免/缺失降级/health 分级含坏库 degraded)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 08:22:26 +08:00
parent 4829baf9c1
commit 4f38522759
9 changed files with 411 additions and 12 deletions
+112 -4
View File
@@ -3,10 +3,13 @@ from pathlib import Path
import logging
import os
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
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 (
@@ -28,6 +31,75 @@ TENANTS_DIR = BASE_DIR / "tenants" # .gitignore 已忽略 tenants/*.db
PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 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 预置过
@@ -165,6 +237,9 @@ app.include_router(platform_stats_router)
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")
@@ -191,5 +266,38 @@ async def index():
@app.get("/health")
async def health():
return {"status": "ok"}
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}