Skip to content
Closed
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

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.

VITE_API_BASE_URL is documented (with a "未配置时启动直接报错" fail-fast guarantee) but not implemented: no VITE_API_BASE_URL, import.meta.env, or HTTP client reference exists anywhere under frontend/ (verified by grep). As written, following these steps produces a frontend build that is not actually wired to the backend. Either add the frontend wiring in this PR or flag this section as not-yet-implemented.

```

## 质量检查 / 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

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.

uv:latest is unpinned, which undercuts the reproducibility that uv.lock --frozen is trying to guarantee and can silently bust the dependency-install layer cache when upstream publishes. Pin to a specific uv version tag.


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
Comment thread
minorcell marked this conversation as resolved.

# ── 运行时 ──
FROM python:3.12-slim AS runtime

WORKDIR /app

COPY --from=builder /app/.venv /app/.venv

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 runtime stage never creates or switches to a non-root user, so uvicorn runs as root (UID 0). For a deployable image, consider dropping privileges to reduce blast radius if the app (e.g. the media-upload path) is compromised:

RUN useradd -r -u 1001 appuser && chown -R appuser /app
USER appuser

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

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.

urlopen(...) has no timeout=. Docker's --timeout=5s kills the probe process, so it's mitigated, but relying on that is fragile — a hung socket otherwise blocks on Python's default (none). Prefer making intent explicit: urllib.request.urlopen('http://localhost:8000/health', timeout=4).


CMD ["uvicorn", "windup_app.bootstrap.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]

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.

Single uvicorn process (default one worker). On a multi-core host this caps throughput and makes the service prone to head-of-line blocking on the sync Qiniu upload / Postgres paths. Consider env-driven concurrency (--workers/WEB_CONCURRENCY) for a deployable backend.

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
27 changes: 27 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,17 +4,44 @@
是整个 web 服务的唯一装配点(composition root)。
"""

import os

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from windup_app.web.api.agent import router as ai_router
from windup_app.web.api.generation import router as generation_router
from windup_app.web.api.media import router as media_router
from windup_app.web.api.workflow_run import router as workflow_run_router


def _cors_origins() -> list[str]:
"""开发阶段全放行;部署时用 ``WINDUP_CORS_ORIGINS``(逗号分隔)收窄。"""
raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip()
return [o.strip() for o in raw.split(",") if o.strip()] or ["*"]


def create_app() -> FastAPI:
app = FastAPI(title="windup", version="0.1.0")

# 鉴权走 Authorization 头不走 cookie,故关掉 credentials —— 这样 "*" 才合法。
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins(),
allow_credentials=False,
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)
app.include_router(generation_router)
Expand Down
31 changes: 31 additions & 0 deletions backend/tests/test_deployable_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""部署形态的两颗钉子:镜像装齐运行期依赖 + 探针可达。

两条都属于"容器能起来 ≠ 请求能成功"这一类问题,只在真实部署后才暴露,
所以在 CI 里各钉一颗。CORS 相关的断言在 ``test_cors.py``。
"""

import importlib.util

from fastapi.testclient import TestClient

from windup_app.bootstrap.app import create_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 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"

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.

Postgres is published to the host (default 0.0.0.0:7856). The backend reaches it over the internal windup-net bridge (POSTGRES_HOST: postgres), so this mapping isn't needed to run the app and exposes the DB on a cloud host without a strict firewall. Consider removing it, or binding to loopback: "127.0.0.1:${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:?}

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.

${POSTGRES_PASSWORD:?} here has an empty error message, unlike the helpful message on the postgres service (line 13). If service resolution order ever changes, the backend would fail with a blank, confusing error. Reuse the descriptive message for consistency.

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"