Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions backend/.dockerignore
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
52 changes: 52 additions & 0 deletions backend/Dockerfile
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

Copy link
Copy Markdown
Contributor

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 qiniu lazily in windup_app.server.media.service, and neither backend/uv.lock nor any backend pyproject.toml declares that package. The container can start, but /media/upload will fail at request time with ModuleNotFoundError: qiniu. Please add the SDK to the appropriate package and update the lockfile so the deployable image contains the runtime dependency.

@johnnyzhang-eng johnnyzhang-eng Aug 5, 2026

Copy link
Copy Markdown
Author

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 函数体里延迟 import qiniu,pyproject 和 uv.lock 都没有它 —— 镜像能构建、能启动、/docs 也返回 200,只有第一次 POST /media/uploadModuleNotFoundError,正是你描述的路径。

  • 声明位置放在 app 包(import 发生在这里):packages/app/pyproject.tomlqiniu>=7.13
  • uv.lock 重新锁定,只新增 qiniu 7.18.0 一项(它唯一的依赖 requests 已在锁内)
  • CI 加断言 importlib.util.find_spec("qiniu") is not Nonetests/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 的话我再提。


# ── 运行时 ──
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"]
3 changes: 3 additions & 0 deletions backend/packages/app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
49 changes: 49 additions & 0 deletions backend/packages/app/src/windup_app/bootstrap/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,61 @@
是整个 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()]
# 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"]


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=_cors_origin_regex(),
allow_credentials=True,
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
94 changes: 94 additions & 0 deletions backend/tests/test_deployable_backend.py
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"}
14 changes: 14 additions & 0 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions docker-compose.yml
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"