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}
+18 -7
View File
@@ -12,6 +12,8 @@ scope type,两条路都先过租户解析。
/health,Host 是 IP:端口必然未知;精确匹配,``/healthxxx`` 不豁免);
- path 前缀 ``/api/platform/`` 豁免(Phase 2 Task 3,平台后台不依赖租户
域名解析;安全模型详见 ``__call__`` 内注释——豁免≠放行);
- path 等于 ``/admin`` 或前缀 ``/admin/`` 豁免(Phase 4 Task 1,生产
admin SPA 静态面,同为平台级;``/admin-evil`` 不豁免);
- Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008);
租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013);
- 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state
@@ -80,23 +82,32 @@ class TenantHostMiddleware:
# websocket 两类 scope 同构生效——当前 /api/platform 前缀没有 WS
# 端点,保持与 /health 同构以防将来有):
# - /health 精确匹配(见模块 docstring);
# - /api/platform/ 前缀(Phase 2 Task 3)。
# - /api/platform/ 前缀(Phase 2 Task 3);
# - /admin 与 /admin/ 前缀(Phase 4 Task 1,生产 admin SPA 静态
# 面;hash 路由下直连的都是 /admin/ 一级,深链防御同构保留)。
#
# 安全模型——豁免≠放行:本豁免只跳过"租户解析"这一层,不打开
# 任何受保护端点:/api/platform/* 的认证由路由依赖守护
# (platform_user = get_current_user + enforce_password_changed,
# 未带有效 token 一律 401/409)。未知 Host 下 admin API 可达是
# **有意设计**:生产的 admin 域名与租户域名在反代层区分(见
# spec §4 边界规则与部署文档),应用层不按 Host 收紧 admin 面。
# 前缀精确到斜杠:startswith("/api/platform/"),/api/platform-evil
# 不豁免(仍走租户解析,测试 test_prefix_match_precise 钉住)。
# 未带有效 token 一律 401/409)。/admin 只是静态资源(公开的前端
# 产物),真正的数据面仍在 /api/platform/* 认证后。未知 Host 下
# admin 面可达是 **有意设计**:生产的 admin 域名与租户域名在反代
# 层区分(见 spec §4 边界规则与部署文档),应用层不按 Host 收紧
# admin 面。
# 前缀精确到斜杠:startswith("/api/platform/") 与 startswith(
# "/admin/"),/api/platform-evil、/admin-evil 不豁免(仍走租户
# 解析,测试 test_prefix_match_precise / test_admin_exempt_from_
# tenant_middleware 钉住)。
#
# 已知观察项(本任务仅注记不改实现):反代部署形态下登录限流键
# 取 request.client.host 不解析 XFF——所有请求同源(反代 IP),
# 键退化为按用户名(fail-safe:只更严不会更松,单账号爆破面
# 不变);可信代理头解析归部署批次统一处理。
path = scope.get("path") or ""
if path == "/health" or path.startswith("/api/platform/"):
if (path == "/health"
or path.startswith("/api/platform/")
or path == "/admin"
or path.startswith("/admin/")):
return await self.app(scope, receive, send)
registry = getattr(scope["app"].state, "registry", None)