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
+23
View File
@@ -0,0 +1,23 @@
# 构建上下文瘦身 + 防泄(Phase 4 Task 1):
# - .env 必须排除(密钥不进镜像层);admin/.env.production 是构建输入,
# 不受根级 .env 规则影响(.dockerignore 模式锚定在上下文根)
# - tests/docs 不进生产镜像(镜像内不拷 tests,Dockerfile 与此保持一致)
.git
.gitignore
.gitattributes
.venv
venv
**/node_modules
admin/dist
.worktrees
tests
docs
data
tenants
platform.db*
.playwright-mcp
.claude
.pytest_cache
**/__pycache__
*.md
.env
+5
View File
@@ -0,0 +1,5 @@
# 部署关键文件钉 LF:Windows autocrlf 检出成 CRLF 会破坏 .dockerignore
# 模式匹配(行尾 \r)并污染 Dockerfile/compose 的跨平台一致性
Dockerfile text eol=lf
.dockerignore text eol=lf
docker-compose.yml text eol=lf
+37
View File
@@ -0,0 +1,37 @@
# 两阶段构建(Phase 4 Task 1):
# 阶段一 node:20-alpine 构建 admin 前端 → dist;
# 阶段二 python:3.13-slim 只带运行时所需(app/static/seed_profiles/
# scripts + 前端 dist),不带 node/tests/docs。
# ---- 阶段一: admin 前端构建 --------------------------------------------------
FROM node:20-alpine AS admin-build
WORKDIR /build
# 先拷清单再拷源码:依赖层缓存与源码改动解耦
COPY admin/package.json admin/pnpm-lock.yaml admin/pnpm-workspace.yaml ./
# corepack 按 package.json 的 packageManager 字段钉 pnpm 版本(与本地
# 一致的单一事实源);升级 corepack 规避 node20 镜像内置旧版的签名钥问题
RUN npm install -g corepack@latest && corepack enable
RUN pnpm install --frozen-lockfile
COPY admin/ ./
# VITE_BASE_URL 取 admin/.env.production(=/admin/):产物资源引用与后端
# 挂载路径同源;不靠构建期 env 注入(vite 的 env/文件优先级易漂移,
# 文件为单一事实源)
RUN pnpm build
# ---- 阶段二: 生产镜像 --------------------------------------------------------
FROM python:3.13-slim
WORKDIR /app
# tzdata: compose 设 TZ=Asia/Shanghai,推送窗口等业务时间依赖本地时区,
# slim 基镜像不带 tzdata 时 TZ 会被静默忽略(退 UTC)
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY static ./static
COPY seed_profiles ./seed_profiles
COPY scripts ./scripts
COPY --from=admin-build /build/dist ./admin/dist
EXPOSE 8018
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8018", "--workers", "1"]
+3 -1
View File
@@ -9,7 +9,9 @@
VITE_VERSION = 0.1.0
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
VITE_BASE_URL = /
# Phase 4: 后端把 admin/dist 挂载在 /admin(见 app/main.py AdminSPA),
# 生产产物资源引用必须同前缀;dev 保持 .env.development 的 /(走 Vite 代理)
VITE_BASE_URL = /admin/
# 权限模式【 frontend 前端模式 / backend 后端模式 】
VITE_ACCESS_MODE = frontend
+1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.7.1",
"engines": {
"node": ">=20.19.0",
"pnpm": ">=8.8.0"
+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)
+33
View File
@@ -0,0 +1,33 @@
# 生产编排(Phase 4 Task 1)——单容器全栈(uvicorn 直出,无反代层时
# 8018 对外;有反代时由反代转发)。数据全在 bind mount,容器可随手重建。
name: all-kefu
services:
app:
build: .
ports:
- "8018:8018"
env_file:
# 生产服务器放 .env(ADMIN_PASSWORD/ZHIPU_API_KEY/SMTP 等);本机
# 没有 .env 也能起(出厂默认+启动日志警示)——required: false 只是
# 容忍缺省,不改变变量优先级
- path: .env
required: false
environment:
TZ: Asia/Shanghai
volumes:
- ./data:/app/data # 头像上传等(data/uploads)
- ./tenants:/app/tenants # 每租户 SQLite 库
mem_limit: 768m
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
# slim 镜像无 curl——用容器内自带的 python urllib 探 /health
# (分级 health:单租户库损坏返回 200 degraded,不杀容器)
test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8018/health',timeout=3)\""]
interval: 30s
retries: 3
restart: unless-stopped
+179
View File
@@ -0,0 +1,179 @@
# Phase 4 Task 1: admin 生产服务(/admin SPA)+ /health 分级。
#
# dist 的测试纪律: 夹具造最小假 dist(index.html + assets/app.js),经
# ADMIN_DIST 环境变量注入——不依赖也不污染仓库 admin/dist(pnpm build
# 前后测试行为一致);真 dist 的浏览器冒烟归验收阶段,不在单测内。
#
# 覆盖:
# - /admin/ 静态 SPA 服务(html=True 语义:目录→index.html);
# - SPA fallback 只吞 /admin 内的非 /admin/api 404;/api 未知路径保持
# FastAPI 默认 JSON 404(不被 index.html 越界吞掉);
# - Host 中间件 /admin 豁免(未知 Host 可达);/admin-evil 仍走租户解析;
# - dist 缺失 → 503 说明页,应用其余功能照常(降级不炸);
# - /health 分级: ok / 懒建未探(engine_ok=None) / 单租户库损坏只
# degraded 不整体 500。
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.platform.registry import make_tenant_engine
from tests.conftest import (
AUTO_HOST,
JIANCAI_HOST,
NOPE_HOST,
SEED_DIR,
app_test_client,
build_app,
)
ADMIN_INDEX_HTML = (
"<!DOCTYPE html><html><head><title>admin</title>"
'<script type="module" src="/admin/assets/app.js"></script>'
"</head><body>admin-spa-marker</body></html>"
)
@pytest.fixture
def admin_client(tmp_path, monkeypatch):
"""app_client 变体: ADMIN_DIST 指向 tmp 假 dist。
/admin 挂载对 dist 请求期现势解析(见 app.main.AdminSPA)——env 覆盖
即换目录,测试与真实构建产物解耦。"""
dist = tmp_path / "admin-dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text(ADMIN_INDEX_HTML, encoding="utf-8")
(dist / "assets" / "app.js").write_text("// admin bundle", encoding="utf-8")
monkeypatch.setenv("ADMIN_DIST", str(dist))
a = build_app(SEED_DIR, tmp_path)
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
yield c
@pytest.fixture
def missing_dist_client(tmp_path, monkeypatch):
"""ADMIN_DIST 指向不存在路径——dist 缺失降级(503 说明页)的确定性
夹具:不依赖仓库 admin/dist 的有无(冒烟构建后它会真实存在)。"""
monkeypatch.setenv("ADMIN_DIST", str(tmp_path / "no-such-dist"))
a = build_app(SEED_DIR, tmp_path)
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
yield c
# ---- /admin 静态服务 ---------------------------------------------------------
def test_admin_index_served(admin_client):
# /admin(无斜杠)→ Mount 规范化重定向 → /admin/ 服务 index.html
r = admin_client.get("/admin", follow_redirects=True)
assert r.status_code == 200
assert "admin-spa-marker" in r.text
assert r.headers["content-type"].startswith("text/html")
# 直连 /admin/ 同样 200
r = admin_client.get("/admin/")
assert r.status_code == 200
assert "admin-spa-marker" in r.text
def test_admin_assets_served(admin_client):
r = admin_client.get("/admin/assets/app.js")
assert r.status_code == 200
assert "admin bundle" in r.text
# ---- SPA fallback 边界 -------------------------------------------------------
def test_admin_fallback_only_admin(admin_client):
# /admin 内未知路径 → index.html 200(hash 路由下的加固:直连/刷新深链
# 不 404 白屏)
r = admin_client.get("/admin/unknown/deep/link")
assert r.status_code == 200
assert "admin-spa-marker" in r.text
# /admin/api 子前缀不吞:未来 admin 侧 API 的路由 404 必须按默认 JSON
# 暴露,不被 index.html 掩盖
r = admin_client.get("/admin/api/nope")
assert r.status_code == 404
assert r.headers["content-type"].startswith("application/json")
assert r.json() == {"detail": "Not Found"}
# 其余路径保持 FastAPI 默认 JSON 404——SPA fallback 不得越界
r = admin_client.get("/api/definitely-not-a-route")
assert r.status_code == 404
assert r.headers["content-type"].startswith("application/json")
assert r.json() == {"detail": "Not Found"}
# ---- Host 中间件豁免 ---------------------------------------------------------
def test_admin_exempt_from_tenant_middleware(admin_client):
# 未知 Host 下 /admin 可达(不 404)——admin 是平台面,不依赖租户域名
# 解析(与 /api/platform/ 前缀同一安全模型:豁免≠放行)
r = admin_client.get("/admin/", headers={"host": NOPE_HOST})
assert r.status_code == 200
assert "admin-spa-marker" in r.text
# 前缀精确到斜杠:/admin-evil 仍走租户解析 → 中间件 404(text/plain)
r = admin_client.get("/admin-evil", headers={"host": NOPE_HOST})
assert r.status_code == 404
assert r.headers["content-type"].startswith("text/plain")
# ---- dist 缺失降级 ----------------------------------------------------------
def test_admin_dist_missing_degrades(missing_dist_client):
r = missing_dist_client.get("/admin/")
assert r.status_code == 503
assert "未构建" in r.text
assert "pnpm dev" in r.text
# 降级不炸应用:健康检查与租户功能照常
assert missing_dist_client.get("/health").status_code == 200
assert missing_dist_client.post(
"/api/chat/session", json={}).status_code == 200
# ---- /health 分级 -----------------------------------------------------------
def test_health_tiered_ok_and_lazy(app_client):
r = app_client.get("/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert {t["slug"] for t in body["tenants"]} == {"jiancai", "auto"}
# 懒建未就绪:engine_ok=None——健康检查不触发全租户建库
assert all(t["engine_ok"] is None for t in body["tenants"])
# 域名绑定数(jiancai 2 + auto 1,种子档案)
assert body["domains"] == 3
@pytest.fixture
def corrupt_jiancai_client(tmp_path):
"""/health 分级的坏库夹具: jiancai 的 engine 直接指向损坏库文件。
绕过 ensure_ready 的建库路径(那会因 ensure_schema 失败把 engine 留
None),手工把垃圾字节写进 db_path 再挂 engine——模拟"运行中库文件
损坏"的形态:engine 在,一探就炸。lifespan 启动清扫对该租户失败只记
日志(main.py 既有行为),不影响本夹具。"""
a = build_app(SEED_DIR, tmp_path)
rt = next(r for r in a.state.registry.runtimes()
if r.config.slug == "jiancai")
rt.db_path.parent.mkdir(parents=True, exist_ok=True)
rt.db_path.write_bytes(b"definitely not a sqlite database")
rt.engine = make_tenant_engine(rt.db_path)
rt.session_factory = async_sessionmaker(rt.engine, expire_on_commit=False)
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
yield c
def test_health_tiered_degraded_on_corrupt_db(corrupt_jiancai_client):
c = corrupt_jiancai_client
r = c.get("/health")
assert r.status_code == 200 # 单租户损坏 → degraded,不整体 500
body = r.json()
assert body["status"] == "degraded"
by_slug = {t["slug"]: t for t in body["tenants"]}
assert by_slug["jiancai"]["engine_ok"] is False
assert by_slug["auto"]["engine_ok"] is None
# 其余租户不受牵连:auto 懒建好库正常服务
r = c.post("/api/chat/session", json={}, headers={"host": AUTO_HOST})
assert r.status_code == 200