diff --git a/README.md b/README.md index 947814d..e22b7da 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 0000000..fb13388 --- /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 0000000..ce525c8 --- /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 43c573b..befde57 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 89f7b43..ff43025 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -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 diff --git a/backend/tests/test_deployable_backend.py b/backend/tests/test_deployable_backend.py new file mode 100644 index 0000000..64d4f09 --- /dev/null +++ b/backend/tests/test_deployable_backend.py @@ -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"} diff --git a/backend/uv.lock b/backend/uv.lock index cf241f3..63861ef 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 0000000..d0e0ca0 --- /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"