diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ef4b96 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ec7af18 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8fe07b7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/admin/.env.production b/admin/.env.production index cdc67d4..1761828 100644 --- a/admin/.env.production +++ b/admin/.env.production @@ -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 diff --git a/admin/package.json b/admin/package.json index a46c6c0..ae6762f 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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" diff --git a/app/main.py b/app/main.py index d2260f3..6ae70c6 100644 --- a/app/main.py +++ b/app/main.py @@ -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 覆盖(测试注入/非常规部署), + 默认 /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} diff --git a/app/platform/host_middleware.py b/app/platform/host_middleware.py index 87443bd..9968e8e 100644 --- a/app/platform/host_middleware.py +++ b/app/platform/host_middleware.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..653bdd9 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/tests/test_admin_serving.py b/tests/test_admin_serving.py new file mode 100644 index 0000000..d317319 --- /dev/null +++ b/tests/test_admin_serving.py @@ -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 = ( + "admin" + '' + "admin-spa-marker" +) + + +@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