diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..77df9d48 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ── Windup 环境变量配置示例 ─────────────────────────────────────────── +# 复制此文件为 .env 并填入实际值 +# cp .env.example .env + +# ── PostgreSQL 配置 ── +POSTGRES_USER=root +POSTGRES_PASSWORD=your-password-here +POSTGRES_DB=windup +POSTGRES_HOST=localhost +POSTGRES_PORT=7856 +# 宿主机访问数据库的端口(Docker 映射用) +POSTGRES_EXTERNAL_PORT=7856 +# 数据持久化目录(默认 ./data/postgres) +POSTGRES_DATA_DIR=./data/postgres + +# ── Redis 配置 ── +REDIS_URL=redis://localhost:6379/0 +# 数据持久化目录(默认 ./data/redis) +REDIS_DATA_DIR=./data/redis + +# ── LLM 配置 ── +LLM_API_KEY=your-api-key-here +LLM_MODEL_ID=doubao-seed-2.0-mini +LLM_BASE_URL=https://api.qnaigc.com/v1 +LLM_IMAGE_MODEL_ID=gemini-3.0-pro-image-preview +LLM_VIDEO_MODEL_ID= + +# ── 搜索 API ── +SERPAPI_API_KEY=your-serpapi-key + +# ── 七牛云存储 ── +QINIU_ACCESS_KEY=your-access-key +QINIU_SECRET_KEY=your-secret-key +QINIU_BUCKET_NAME=your-bucket +QINIU_BUCKET_DOMAIN=your-domain.com +QINIU_PRIVATE_SPACE=false + +# ── AI Provider ── +AI_BASE_URL=https://api.qnaigc.com/v1 +AI_API_KEY=your-ai-api-key + +# ── 服务配置 ── +WINDUP_HOST=127.0.0.1 +WINDUP_PORT=8000 +# 跨域来源(逗号分隔,默认覆盖 localhost:5173/3000) +WINDUP_CORS_ORIGINS= +# 跨域正则匹配(默认允许 *.vercel.app) +WINDUP_CORS_ORIGIN_REGEX=https://.*\.vercel\.app diff --git a/.gitignore b/.gitignore index 39bf5e20..6de0b26d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ venv/ # 敏感配置(切勿提交) .env .env.* +!.env.example + +# Docker 数据 +data/ # 运行产物 output/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..4761de95 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,57 @@ +# ── 后端 Dockerfile ────────────────────────────────────────────────── +# 多阶段构建:builder 装依赖 → runtime 只拷贝产物,镜像更小 + +# ── 阶段 1: 构建 ── +FROM python:3.12-slim AS builder + +# 安装 uv(比 pip 快 10x) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# ── 国内网络:走镜像源 + 拉长超时 ──────────────────────────────────────── +# 实测(2026-08-04,这台服务器):宿主机访问 pypi.org 需 8s,构建容器内默认超时 +# 会在下载大包(uvloop)时 "operation timed out" 直接失败。 +ENV UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ + UV_HTTP_TIMEOUT=180 + +# 必须与 runtime 阶段同路径 —— venv 内的 shebang/.pth 是绝对路径,跨路径拷贝会失效 +WORKDIR /app + +# 先拷贝依赖定义,利用 Docker 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/ + +# 安装依赖(不含 dev 依赖) +RUN uv sync --frozen --no-dev --no-install-workspace + +# 拷贝源码并安装 +COPY packages/ packages/ +RUN uv sync --frozen --no-dev + +# ── 阶段 2: 运行时 ── +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# 从 builder 拷贝虚拟环境和包 +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/packages /app/packages + +# 把 venv/bin 加入 PATH +ENV PATH="/app/.venv/bin:$PATH" + +# 默认环境变量(可被 docker-compose / .env 覆盖) +ENV WINDUP_HOST=0.0.0.0 +ENV WINDUP_PORT=8000 +ENV WINDUP_RELOAD=false + +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1 + +# 启动命令 +CMD ["uvicorn", "windup_app.bootstrap.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 8d600137..d3ad65eb 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -1,24 +1,96 @@ + """FastAPI 应用工厂与装配入口。 ``create_app`` 负责创建 FastAPI 实例并挂载路由 / 中间件 / 异常处理, 是整个 web 服务的唯一装配点(composition root)。 + +``main`` 是开发启动入口:``python -m windup_app`` 或 ``windup`` 命令。 """ +import os +from contextlib import asynccontextmanager + +import windup_framework.db # noqa: F401 组装时显式触发 DB engine/session 初始化 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 +from windup_app.web.handler.exception_handlers import register_exception_handlers -def create_app() -> FastAPI: - app = FastAPI(title="windup", version="0.1.0") +def _env_flag(name: str) -> bool: + """把环境变量解析为真正的布尔值:仅 1/true/yes/on(忽略大小写与空白)视为 True。""" + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + + +def _cors_origins() -> list[str]: + """允许跨域的前端来源,逗号分隔的 WINDUP_CORS_ORIGINS 覆盖。 + + 不配这个中间件的话,浏览器会把前端的**所有**请求拦在预检那一步 + (OPTIONS 返回 405、响应无 access-control-* 头),后端日志里连请求都看不到。 + 默认值覆盖本地 dev server 与 Vercel 预览域名。 + """ + raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip() + if raw: + return [o.strip() for o in raw.split(",") if o.strip()] + return ["http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:3000", "http://127.0.0.1:3000"] + + +def _cors_origin_regex() -> str | None: + """CORS 正则匹配的额外来源,WINDUP_CORS_ORIGIN_REGEX 覆盖。 + + 默认允许所有 Vercel 预览域名。 + """ + return os.getenv("WINDUP_CORS_ORIGIN_REGEX", r"https://.*\.vercel\.app").strip() or None + + +def print_banner() -> None: + """启动时打印 banner(占位实现,后续替换为正式 ASCII banner)。""" + print("windup 0.1.0 starting ...") - # 业务路由 + +@asynccontextmanager +async def _lifespan(app: FastAPI): + """应用启动时打印 banner,关闭时无特殊处理。""" + print_banner() + yield + + +def create_app() -> FastAPI: + app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins(), + allow_origin_regex=_cors_origin_regex(), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) app.include_router(media_router) app.include_router(generation_router) - app.include_router(workflow_run_router) - app.include_router(ai_router) - + register_exception_handlers(app) return app + + +def main() -> None: + """开发启动入口:用 uvicorn 跑 ``create_app``。 + + host/port/reload 可用 ``WINDUP_HOST`` / ``WINDUP_PORT`` / ``WINDUP_RELOAD`` 覆盖。 + """ + import uvicorn + + uvicorn.run( + "windup_app.bootstrap.app:create_app", + factory=True, + host=os.getenv("WINDUP_HOST", "127.0.0.1"), + port=int(os.getenv("WINDUP_PORT", "8000")), + reload=_env_flag("WINDUP_RELOAD"), + ) + + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b1eaf551 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +# ── Windup Docker Compose ───────────────────────────────────────────── +# 使用方式: +# 启动服务: docker compose up -d +# 查看日志: docker compose logs -f [service] +# 停止服务: docker compose down +# 清理数据: docker compose down -v (⚠️ 会删除数据库数据) +# 重新构建: docker compose up -d --build + +services: + # ── Redis 缓存(验证码 / refresh_token) ── + redis: + image: redis:7-alpine + container_name: windup-redis + restart: unless-stopped + volumes: + - ${REDIS_DATA_DIR:-./data/redis}:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - windup-net + + # ── PostgreSQL 数据库 ── + postgres: + image: postgres:16-alpine + container_name: windup-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-root} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-W1ndup@2026!Secure} + POSTGRES_DB: ${POSTGRES_DB:-windup} + ports: + - "${POSTGRES_EXTERNAL_PORT:-7856}:5432" + volumes: + - ${POSTGRES_DATA_DIR:-./data/postgres}:/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 + + # ── 后端 API 服务 ── + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: windup-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - .env + environment: + # 容器内部通信用 Docker 主机名,覆盖 .env 中的本地开发值 + REDIS_URL: redis://redis:6379/0 + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + ports: + - "${WINDUP_PORT:-8000}:8000" + networks: + - windup-net + +networks: + windup-net: + driver: bridge + # 宿主机链路 MTU 是 1480(eno1),compose 自建网络不会继承 daemon 的 mtu 设置, + # 默认仍是 1500 → 大包被丢,表现为 TLS 握手超时(七牛上传域名连不上、pip 下载卡死)。 + driver_opts: + com.docker.network.driver.mtu: "1450"