From 8c15ea634ba70c9dd2faf8327e891118518de0f9 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Wed, 5 Aug 2026 00:34:18 +0800 Subject: [PATCH 1/5] feat(deploy): add a deployable backend image, compose stack and CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 main 上的后端可以直接部署起来并被浏览器访问。三处内容都是在一台真实云主机上 部署时踩出来的,不是照搬模板: 1. backend/Dockerfile —— builder 与 runtime **必须同路径**。uv 装出来的 venv 里, 可执行脚本 shebang 与 workspace 包 .pth 都是绝对路径;builder 在 /build、 runtime 在 /app 时,容器起来会报 "exec /app/.venv/bin/uvicorn: no such file or directory" —— 报的不是脚本本身而是它 shebang 指向的解释器,同时 workspace 包 import 不到。两边都用 /app 即可,不需要任何 relocate 技巧。 另加国内镜像源与 UV_HTTP_TIMEOUT=180:实测宿主机访问 pypi.org 需 8s, 构建容器内默认超时会在下载 uvloop 时直接失败。 2. docker-compose.yml —— 网络显式设 mtu 1450。云主机链路 MTU 常小于 1500 (实测某部署机 eno1 为 1480),而 compose 自建网络**不继承** daemon.json 的 mtu 设置,默认仍是 1500,大包被丢,表现为 TLS 握手超时(对象存储上传挂死、 pip 下载卡死)而不是明确报错。这条是分两次才定位到的:先改 daemon 以为好了, 重建后容器里仍是 1500。 POSTGRES_PASSWORD 用 ${VAR:?} 强制显式提供,不给默认值。 3. CORS 中间件 —— 不挂的话浏览器会把前端**所有**请求拦在预检那一步 (OPTIONS 返回 405、响应无 access-control-* 头),且后端日志里连请求都看不到, 很容易被误判成前端问题。来源用 WINDUP_CORS_ORIGINS 覆盖,默认覆盖本地 dev 并放行 Vercel 预览域名。 本地验证:ruff 通过;pytest 1 passed;TestClient 实测 OPTIONS 预检返回 200 且 allow-origin 正确。 Co-Authored-By: Claude Opus 5 --- backend/Dockerfile | 51 ++++++++++++++ .../app/src/windup_app/bootstrap/app.py | 25 +++++++ docker-compose.yml | 66 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..a070ef53 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,51 @@ +# ── 后端 Dockerfile ────────────────────────────────────────────────── +# 多阶段构建:builder 装依赖 → runtime 只拷贝产物,镜像更小。 +# +# 两处不是随手写的,都是在部署服务器上实测踩出来的(2026-08-04): +# +# 1. builder 与 runtime **必须同路径**。uv 装出来的 venv 里,可执行脚本的 shebang +# 与 workspace 包的 .pth 都是**绝对路径**。若 builder 在 /build、runtime 在 /app, +# 拷过去之后 uvicorn 会报 "no such file or directory" —— 报的不是脚本本身, +# 而是它 shebang 指向的 /build/.venv/bin/python;同时 workspace 包 import 不到。 +# +# 2. 国内网络必须换源并拉长超时。实测宿主机访问 pypi.org 需 8s,构建容器内默认 +# 超时会在下载大包(uvloop)时 "operation timed out" 直接失败。 + +FROM python:3.12-slim AS builder + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +ENV UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ + UV_HTTP_TIMEOUT=180 + +# 与 runtime 同路径 —— 见文件头第 1 条 +WORKDIR /app + +# 先拷依赖定义,利用 layer cache +COPY pyproject.toml uv.lock ./ +COPY packages/common/pyproject.toml packages/common/ +COPY packages/framework/pyproject.toml packages/framework/ +COPY packages/ai_engine/pyproject.toml packages/ai_engine/ +COPY packages/app/pyproject.toml packages/app/ + +RUN uv sync --frozen --no-dev --no-install-workspace + +COPY packages/ packages/ +RUN uv sync --frozen --no-dev + +# ── 运行时 ── +FROM python:3.12-slim AS runtime + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/packages /app/packages + +ENV PATH="/app/.venv/bin:$PATH" + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1 + +CMD ["uvicorn", "windup_app.bootstrap.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 89f7b43d..5cefdeeb 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -4,12 +4,37 @@ 是整个 web 服务的唯一装配点(composition root)。 """ +import os + from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from windup_app.web.api.media import router as media_router +def _cors_origins() -> list[str]: + """允许跨域的前端来源;逗号分隔的 ``WINDUP_CORS_ORIGINS`` 覆盖。 + + 不挂这个中间件的话,浏览器会把前端的**所有**请求拦在预检那一步 + (OPTIONS 返回 405、响应无 access-control-* 头),而且后端日志里连请求都看不到, + 很容易被误判成前端问题。默认值覆盖本地 dev server。 + """ + raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip() + if raw: + return [o.strip() for o in raw.split(",") if o.strip()] + return ["http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:3000", "http://127.0.0.1:3000"] + + def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0") + app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins(), + allow_origin_regex=r"https://.*\.vercel\.app", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) app.include_router(media_router) return app diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..9c67ed04 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +# ── Windup 本地 / 服务器部署编排 ────────────────────────────────────── +# 启动: docker compose up -d --build +# 日志: docker compose logs -f backend +# 停止: docker compose down (加 -v 会删库数据) + +services: + postgres: + image: postgres:16-alpine + container_name: windup-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-root} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请在 .env 里设置,不要用默认值} + POSTGRES_DB: ${POSTGRES_DB:-windup} + ports: + - "${POSTGRES_EXTERNAL_PORT:-7856}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-root} -d ${POSTGRES_DB:-windup}"] + interval: 10s + timeout: 5s + retries: 5 + networks: [windup-net] + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: windup-backend + restart: unless-stopped + depends_on: + postgres: { condition: service_healthy } + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-root} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} + POSTGRES_DB: ${POSTGRES_DB:-windup} + # 七牛 Kodo(media 上传用) + QINIU_ACCESS_KEY: ${QINIU_ACCESS_KEY} + QINIU_SECRET_KEY: ${QINIU_SECRET_KEY} + QINIU_BUCKET_NAME: ${QINIU_BUCKET_NAME} + QINIU_BUCKET_DOMAIN: ${QINIU_BUCKET_DOMAIN} + QINIU_PRIVATE_SPACE: ${QINIU_PRIVATE_SPACE:-false} + # AI provider + AI_BASE_URL: ${AI_BASE_URL} + AI_API_KEY: ${AI_API_KEY} + # 允许跨域的前端来源,逗号分隔;不设则用代码里的开发默认值 + WINDUP_CORS_ORIGINS: ${WINDUP_CORS_ORIGINS:-} + ports: + - "${WINDUP_PORT:-8000}:8000" + networks: [windup-net] + +volumes: + postgres_data: + driver: local + +networks: + windup-net: + driver: bridge + # 云主机链路 MTU 常小于 1500(实测某部署机 eno1 为 1480)。compose 自建网络 + # **不继承** daemon.json 里的 mtu 设置,默认仍是 1500 → 大包被丢,表现为 + # TLS 握手超时(对象存储上传挂死、pip 下载卡死),而不是明确报错。 + driver_opts: + com.docker.network.driver.mtu: "1450" From 6333adb09b733a6ca1d141c36200880e6e17669d Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Wed, 5 Aug 2026 09:53:05 +0800 Subject: [PATCH 2/5] fix(deploy): declare the qiniu SDK and stop wildcarding CORS preview origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按评审意见修两处「容器起得来但请求会挂/放得太开」的问题。 1. qiniu 没声明依赖。server/media/service.py 在函数体里延迟 import qiniu, pyproject 与 uv.lock 都没有它 —— 镜像能构建、能启动、/docs 也正常, 直到第一次 POST /media/upload 才 ModuleNotFoundError。声明在 app 包 (import 发生在这里);uv.lock 只新增 qiniu 7.18.0 一项,requests 已在锁内。 2. CORS 不再写死 https://.*\.vercel\.app。starlette 用 fullmatch,那条正则 放行整个 vercel.app 域下的任意第三方应用,而这里 allow_credentials=True, 且显式配了 WINDUP_CORS_ORIGINS 也关不掉它。改为 WINDUP_CORS_ORIGIN_REGEX 提供、默认不开;compose 里透传该变量并注明只写自家项目的域名形态。 新增 tests/test_deployable_backend.py 把两条钉在 CI:find_spec("qiniu") 断言运行期依赖装齐;三条 CORS 预检断言(配置内来源放行 / 陌生来源默认拿不到 allow-origin / 预览正则需显式开启且只匹配自家形态)。 验证:ruff 通过;lint-imports 2 kept;pytest 5 passed。本机无 docker, 未重建镜像,改以 `uv export --frozen --no-dev` 确认 qiniu 在生产解析集内 (Dockerfile 装的正是这一集)。 Co-Authored-By: Claude Opus 5 --- backend/packages/app/pyproject.toml | 3 + .../app/src/windup_app/bootstrap/app.py | 15 +++- backend/tests/test_deployable_backend.py | 68 +++++++++++++++++++ backend/uv.lock | 14 ++++ docker-compose.yml | 4 ++ 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_deployable_backend.py diff --git a/backend/packages/app/pyproject.toml b/backend/packages/app/pyproject.toml index 43c573b0..befde579 100644 --- a/backend/packages/app/pyproject.toml +++ b/backend/packages/app/pyproject.toml @@ -12,6 +12,9 @@ dependencies = [ "pydantic>=2.7", "sqlalchemy>=2.0", "python-multipart>=0.0.9", + # server/media/service.py 用它上传 Kodo。函数内延迟 import,不声明的话 + # 镜像照样能起来,直到第一次 POST /media/upload 才 ModuleNotFoundError。 + "qiniu>=7.13", ] [project.scripts] diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 5cefdeeb..e3eaae2b 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -26,12 +26,25 @@ def _cors_origins() -> list[str]: "http://localhost:3000", "http://127.0.0.1:3000"] +def _cors_origin_regex() -> str | None: + """预览域名的来源正则;由 ``WINDUP_CORS_ORIGIN_REGEX`` 提供,默认不开。 + + 这里**不写死** ``https://.*\\.vercel\\.app``:下面 ``allow_credentials=True``, + 那条正则等于把带凭证的跨域请求放行给整个 vercel.app 域下的任意第三方应用, + 而且显式配了 ``WINDUP_CORS_ORIGINS`` 也关不掉它。预览域名形态随部署环境变, + 所以交给部署方自己配,例如 ``https://<项目名>-[a-z0-9-]+\\.vercel\\.app`` + (starlette 用 ``fullmatch``,不必自己加 ``^$``)。 + """ + raw = os.getenv("WINDUP_CORS_ORIGIN_REGEX", "").strip() + return raw or None + + def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=_cors_origins(), - allow_origin_regex=r"https://.*\.vercel\.app", + allow_origin_regex=_cors_origin_regex(), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/tests/test_deployable_backend.py b/backend/tests/test_deployable_backend.py new file mode 100644 index 00000000..735f76da --- /dev/null +++ b/backend/tests/test_deployable_backend.py @@ -0,0 +1,68 @@ +"""部署形态的两条保证:镜像装齐运行期依赖 + CORS 不放行陌生来源。 + +两条都属于"容器能起来 ≠ 请求能成功"这一类问题,只在真实请求时才暴露, +所以在 CI 里各钉一颗钉子。 +""" + +import importlib.util + +from fastapi.testclient import TestClient + +from windup_app.bootstrap.app import create_app + +PREVIEW_ORIGIN = "https://windup-git-main-preview.example.app" + + +def test_media_upload_dependency_is_declared(): + """``server/media/service.py`` 在函数体里延迟 import qiniu。 + + 不在 pyproject/uv.lock 里声明的话,镜像照样能构建、能启动、``/docs`` 也正常, + 直到第一次 ``POST /media/upload`` 才 ``ModuleNotFoundError: qiniu``。 + """ + assert importlib.util.find_spec("qiniu") is not None + + +def _preflight(client: TestClient, origin: str): + return client.options( + "/media/upload", + headers={"Origin": origin, "Access-Control-Request-Method": "POST"}, + ) + + +def test_configured_origin_passes_preflight(monkeypatch): + monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") + monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) + client = TestClient(create_app()) + + resp = _preflight(client, "https://windup.example.com") + + assert resp.status_code == 200 + assert resp.headers["access-control-allow-origin"] == "https://windup.example.com" + + +def test_unknown_origin_is_rejected_by_default(monkeypatch): + """默认不带任何平台通配 —— 后端开了 allow_credentials, + 通配一个托管平台的域等于把带凭证的跨域请求放行给平台上任意第三方应用。 + """ + monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") + monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) + client = TestClient(create_app()) + + resp = _preflight(client, "https://someone-elses-app.example.app") + + assert "access-control-allow-origin" not in resp.headers + + +def test_preview_regex_is_opt_in_and_scoped(monkeypatch): + """预览域名要放行就显式配正则,且只匹配自家项目的域名形态。""" + monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") + monkeypatch.setenv( + "WINDUP_CORS_ORIGIN_REGEX", r"https://windup-[a-z0-9-]+\.example\.app" + ) + client = TestClient(create_app()) + + allowed = _preflight(client, PREVIEW_ORIGIN) + assert allowed.headers["access-control-allow-origin"] == PREVIEW_ORIGIN + + stranger = _preflight(client, "https://someone-elses-app.example.app") + assert "access-control-allow-origin" not in stranger.headers diff --git a/backend/uv.lock b/backend/uv.lock index cf241f39..63861ef0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1039,6 +1039,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, ] +[[package]] +name = "qiniu" +version = "7.18.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/32/e5/82e5078de1204b641d6b24fdd15b3c5a87a7dd71f514e4a3cfb845a2d988/qiniu-7.18.0.tar.gz", hash = "sha256:d9edca3a1c5217c13638a08d9095cd1661f5ba6cf92ea3827949ff3d332ea4fa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1484,6 +1496,7 @@ dependencies = [ { name = "fastapi" }, { name = "pydantic" }, { name = "python-multipart" }, + { name = "qiniu" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, { name = "windup-ai-engine" }, @@ -1496,6 +1509,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "pydantic", specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "qiniu", specifier = ">=7.13" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "windup-ai-engine", editable = "packages/ai_engine" }, diff --git a/docker-compose.yml b/docker-compose.yml index 9c67ed04..d0e0ca09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,10 @@ services: AI_API_KEY: ${AI_API_KEY} # 允许跨域的前端来源,逗号分隔;不设则用代码里的开发默认值 WINDUP_CORS_ORIGINS: ${WINDUP_CORS_ORIGINS:-} + # 预览域名正则(可选)。只写自家项目的预览域名形态,别写成整个平台通配 —— + # 后端开了 allow_credentials,通配等于放行该平台下任意第三方应用。 + # 例: https://<项目名>-[a-z0-9-]+\.vercel\.app + WINDUP_CORS_ORIGIN_REGEX: ${WINDUP_CORS_ORIGIN_REGEX:-} ports: - "${WINDUP_PORT:-8000}:8000" networks: [windup-net] From 1d35597cbc11e916946de59070e0559f9ca77f9f Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Wed, 5 Aug 2026 10:15:06 +0800 Subject: [PATCH 3/5] fix(deploy): allow the vite preview port (4173) by default in CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 演示跑的是生产构建 `vite preview`,端口 4173,而默认放行列表只有 dev 的 5173 和 3000。实测已部署的后端对 `Origin: http://localhost:4173` 的预检直接 400、 不带 access-control-allow-origin —— 前端每个请求都会被浏览器拦在预检那一步, 后端日志里连请求都看不到,最容易被误判成后端挂了。 默认值补上 4173(localhost 与 127.0.0.1 各一条),并加一条断言钉住。 Co-Authored-By: Claude Opus 5 --- .../packages/app/src/windup_app/bootstrap/app.py | 2 ++ backend/tests/test_deployable_backend.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index e3eaae2b..aa7d196d 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -22,7 +22,9 @@ def _cors_origins() -> list[str]: raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip() if raw: return [o.strip() for o in raw.split(",") if o.strip()] + # 5173 = vite dev、4173 = vite preview(生产构建,演示走这个)、3000 = 备用 return ["http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:4173", "http://127.0.0.1:4173", "http://localhost:3000", "http://127.0.0.1:3000"] diff --git a/backend/tests/test_deployable_backend.py b/backend/tests/test_deployable_backend.py index 735f76da..1e17cfe6 100644 --- a/backend/tests/test_deployable_backend.py +++ b/backend/tests/test_deployable_backend.py @@ -40,6 +40,21 @@ def test_configured_origin_passes_preflight(monkeypatch): assert resp.headers["access-control-allow-origin"] == "https://windup.example.com" +def test_vite_preview_port_is_allowed_by_default(monkeypatch): + """演示走的是生产构建 `vite preview` 的 **4173**,不是 dev 的 5173。 + + 默认值漏掉 4173 的话,演示当天前端每个请求都会被浏览器拦在预检, + 而后端日志里连请求都看不到 —— 极易误判成后端挂了。 + """ + monkeypatch.delenv("WINDUP_CORS_ORIGINS", raising=False) + monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) + client = TestClient(create_app()) + + for origin in ("http://localhost:4173", "http://localhost:5173"): + resp = _preflight(client, origin) + assert resp.headers.get("access-control-allow-origin") == origin, origin + + def test_unknown_origin_is_rejected_by_default(monkeypatch): """默认不带任何平台通配 —— 后端开了 allow_credentials, 通配一个托管平台的域等于把带凭证的跨域请求放行给平台上任意第三方应用。 From a7bf752f3cd25e8a481945f538a1ae8b65c3e97b Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Wed, 5 Aug 2026 11:35:02 +0800 Subject: [PATCH 4/5] chore(deploy): add .dockerignore so the build context stays small MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 没有这个文件的话,`docker build ./backend` 会把本地开发产物一起送进 daemon: 实测 `backend/.venv` 单独就有 161MB,而它在镜像里由 builder 阶段的 uv sync 重新装一遍,送过去纯属浪费;`.git` 与各类缓存目录同理,还会让 layer cache 因无关文件变动而频繁失效。顺带把 `.env` 挡在镜像外。 Co-Authored-By: Claude Opus 5 --- backend/.dockerignore | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/.dockerignore diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..fb133885 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,35 @@ +# 构建上下文排除项。 +# +# 不加这个文件的话,`docker build ./backend` 会把本地开发产物一起送进 daemon: +# 实测 backend/.venv 单独就有 161MB,而它在镜像里会被 builder 阶段重新装一遍, +# 送过去纯属浪费;.git 与缓存目录同理,还会让 layer cache 因无关文件变动而失效。 + +# 本地虚拟环境(镜像内由 uv sync 重建) +.venv/ +venv/ + +# 版本库与编辑器 +.git/ +.gitignore +.idea/ +.vscode/ + +# Python 缓存与构建产物 +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ + +# 各类工具缓存 +.pytest_cache/ +.ruff_cache/ +.import_linter_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# 环境变量与密钥:绝不进镜像 +.env +.env.* +!.env.example From 9e5047625bf782a537400b55f351cdb3f0bd6406 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Wed, 5 Aug 2026 11:38:36 +0800 Subject: [PATCH 5/5] feat(deploy): add a /health probe and document the deployment env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处按评审预检补齐: 1. 容器 HEALTHCHECK 原本打 `/docs`。生产通常会关掉交互文档(`docs_url=None`), 那时探针永远失败、容器被反复判死。改打新增的 `GET /health`,并补断言。 2. `WINDUP_CORS_ORIGINS` / `WINDUP_CORS_ORIGIN_REGEX` 以及 compose 用到的 其余环境变量此前在 README 与 docs 里都查不到,部署方只能读代码。 README 补「部署」一节:一条命令起服务、环境变量表、以及前端构建期 `VITE_API_BASE_URL` 的写法(未配置时前端启动直接报错,不会静默连本机)。 Co-Authored-By: Claude Opus 5 --- README.md | 31 +++++++++++++++++++ backend/Dockerfile | 3 +- .../app/src/windup_app/bootstrap/app.py | 9 ++++++ backend/tests/test_deployable_backend.py | 11 +++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 947814de..e22b7da4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,37 @@ uv sync --frozen uv run uvicorn windup_app.bootstrap.app:create_app --factory --reload ``` +## 部署 / Deployment + +一条命令起后端与数据库(需要 Docker): + +```bash +docker compose up -d --build # 起服务 +docker compose logs -f backend # 看日志 +docker compose down # 停止(加 -v 会删库数据) +``` + +健康检查端点 `GET /health`,容器 HEALTHCHECK 用的就是它。 + +### 环境变量 / Environment Variables + +| 变量 | 必填 | 默认 | 说明 | +| --- | --- | --- | --- | +| `POSTGRES_PASSWORD` | 是 | 无 | 不给默认值,避免弱口令跟着编排进生产 | +| `POSTGRES_USER` / `POSTGRES_DB` | 否 | `root` / `windup` | | +| `POSTGRES_EXTERNAL_PORT` / `WINDUP_PORT` | 否 | `7856` / `8000` | 宿主机映射端口 | +| `QINIU_ACCESS_KEY` / `QINIU_SECRET_KEY` / `QINIU_BUCKET_NAME` / `QINIU_BUCKET_DOMAIN` | 是 | 无 | 对象存储;缺失时 `/media/upload` 会失败 | +| `AI_BASE_URL` / `AI_API_KEY` | 是 | 无 | 模型网关 | +| `WINDUP_CORS_ORIGINS` | 否 | 本地 dev 来源 | 逗号分隔的前端来源。默认放行 `localhost`/`127.0.0.1` 的 `5173`(vite dev)、`4173`(vite preview)、`3000` | +| `WINDUP_CORS_ORIGIN_REGEX` | 否 | 空(不启用) | 预览域名正则。**只写自家项目的域名形态**,例如 `https://<项目名>-[a-z0-9-]+\.vercel\.app`;写成整个平台通配等于把带凭证的跨域请求放行给该平台上任意第三方应用 | + +前端连后端靠构建期变量 `VITE_API_BASE_URL`(未配置时启动直接报错,不会静默连本机): + +```bash +cd frontend +VITE_API_BASE_URL=http://<后端地址>:8000 npm run build +``` + ## 质量检查 / Quality Checks ```bash diff --git a/backend/Dockerfile b/backend/Dockerfile index a070ef53..ce525c8c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -45,7 +45,8 @@ ENV PATH="/app/.venv/bin:$PATH" EXPOSE 8000 +# 打 /health 而不是 /docs:生产通常关掉交互文档(docs_url=None),那时探针会永远失败。 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1 + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 CMD ["uvicorn", "windup_app.bootstrap.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index aa7d196d..ff430252 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -51,5 +51,14 @@ def create_app() -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) + @app.get("/health", tags=["ops"]) + def health() -> dict[str, str]: + """存活探针。 + + 容器 HEALTHCHECK 不打 ``/docs`` —— 生产通常会关掉交互文档 + (``docs_url=None``),那时健康检查会永远失败,容器被反复判死。 + """ + return {"status": "ok"} + app.include_router(media_router) return app diff --git a/backend/tests/test_deployable_backend.py b/backend/tests/test_deployable_backend.py index 1e17cfe6..64d4f098 100644 --- a/backend/tests/test_deployable_backend.py +++ b/backend/tests/test_deployable_backend.py @@ -81,3 +81,14 @@ def test_preview_regex_is_opt_in_and_scoped(monkeypatch): stranger = _preflight(client, "https://someone-elses-app.example.app") assert "access-control-allow-origin" not in stranger.headers + + +def test_health_endpoint_is_reachable(): + """容器 HEALTHCHECK 打的是 /health,不是 /docs。 + + /docs 在生产会被关掉(``docs_url=None``),那时探针永远失败、容器被反复判死。 + """ + resp = TestClient(create_app()).get("/health") + + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"}