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
+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)