-
Notifications
You must be signed in to change notification settings - Fork 4
feat(deploy): 让 main 上的后端可直接部署并被浏览器访问 #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
johnnyzhang-eng
wants to merge
5
commits into
1024XEngineer:main
Choose a base branch
from
johnnyzhang-eng:feat/deployable-backend
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8c15ea6
feat(deploy): add a deployable backend image, compose stack and CORS
Soli22de 6333adb
fix(deploy): declare the qiniu SDK and stop wildcarding CORS preview …
Soli22de 1d35597
fix(deploy): allow the vite preview port (4173) by default in CORS
Soli22de a7bf752
chore(deploy): add .dockerignore so the build context stays small
Soli22de 9e50476
feat(deploy): add a /health probe and document the deployment env vars
Soli22de File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # ── 后端 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 | ||
|
|
||
| # 打 /health 而不是 /docs:生产通常关掉交互文档(docs_url=None),那时探针会永远失败。 | ||
| HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ | ||
| 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"] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """部署形态的两条保证:镜像装齐运行期依赖 + 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_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, | ||
| 通配一个托管平台的域等于把带凭证的跨域请求放行给平台上任意第三方应用。 | ||
| """ | ||
| 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 | ||
|
|
||
|
|
||
| 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"} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| # ── 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:-} | ||
| # 预览域名正则(可选)。只写自家项目的预览域名形态,别写成整个平台通配 —— | ||
| # 后端开了 allow_credentials,通配等于放行该平台下任意第三方应用。 | ||
| # 例: https://<项目名>-[a-z0-9-]+\.vercel\.app | ||
| WINDUP_CORS_ORIGIN_REGEX: ${WINDUP_CORS_ORIGIN_REGEX:-} | ||
| 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" |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The image installs only the locked production dependencies here, but the upload path imports
qiniulazily inwindup_app.server.media.service, and neitherbackend/uv.locknor any backendpyproject.tomldeclares that package. The container can start, but/media/uploadwill fail at request time withModuleNotFoundError: qiniu. Please add the SDK to the appropriate package and update the lockfile so the deployable image contains the runtime dependency.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
确认,已在 6333adb 修。
server/media/service.py函数体里延迟 importqiniu,pyproject 和 uv.lock 都没有它 —— 镜像能构建、能启动、/docs也返回 200,只有第一次POST /media/upload才ModuleNotFoundError,正是你描述的路径。packages/app/pyproject.toml加qiniu>=7.13uv.lock重新锁定,只新增qiniu 7.18.0一项(它唯一的依赖requests已在锁内)importlib.util.find_spec("qiniu") is not None(tests/test_deployable_backend.py),避免同类「延迟 import 未声明」再溜过去本机没有 docker,未重建镜像验证;改用
uv export --frozen --no-dev确认qiniu==7.18.0在生产解析集内 —— Dockerfile 里uv sync --frozen --no-dev装的正是这一集。另:对象存储配置(
StorageSettings)在 framework 层,SDK 却因 import 位置声明在 app 层,这处分层不一致本 PR 没动(属于 media 服务归属问题,与部署正交),需要一并归位到 framework 的话我再提。