diff --git a/README.md b/README.md index 45bc95cc..ae32aabc 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/.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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..ce525c8c --- /dev/null +++ b/backend/Dockerfile @@ -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"] 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 8d600137..7094f9ec 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -4,7 +4,10 @@ 是整个 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 @@ -12,9 +15,33 @@ 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) diff --git a/backend/tests/test_deployable_backend.py b/backend/tests/test_deployable_backend.py new file mode 100644 index 00000000..f634c702 --- /dev/null +++ b/backend/tests/test_deployable_backend.py @@ -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"} 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 new file mode 100644 index 00000000..d0e0ca09 --- /dev/null +++ b/docker-compose.yml @@ -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"