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 89f7b43d..4c7f484f 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -1,15 +1,97 @@ + """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.server.orchestrator.executor import run_action_task, run_image_task +from windup_app.web.api.character import router as character_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.project import router as project_router +from windup_app.web.handler.exception_handlers import register_exception_handlers + + +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 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") + app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins(), + allow_origin_regex=r"https://.*\.vercel\.app", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(project_router) + app.include_router(character_router) app.include_router(media_router) + app.include_router(generation_router) + # 生成后台调度器注入 app.state:bootstrap(composition root)持有 ai_engine 依赖, + # web 端运行期从 request.app.state 取,避免 web 静态 import ai_engine(入口层门禁)。 + app.state.run_action_task = run_action_task + app.state.run_image_task = run_image_task + 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/db/init.sql b/db/init.sql new file mode 100644 index 00000000..cee115ee --- /dev/null +++ b/db/init.sql @@ -0,0 +1,128 @@ +-- ────────────────────────────────────────────────────────────── +-- Windup 数据库初始化脚本 +-- 首次启动时自动执行,创建所有表结构 +-- 数据库已通过 POSTGRES_DB 环境变量自动创建 +-- ────────────────────────────────────────────────────────────── + +/*项目表,全局约束角色人物。*/ +CREATE TABLE windup_project ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + workflow_id BIGINT, + project_name varchar(20) NOT NULL, + character_perspective SMALLINT NOT NULL, + directional_movement SMALLINT NOT NULL, + sprite_width SMALLINT NOT NULL, + sprite_height SMALLINT NOT NULL, + game_style TEXT, + sprite_sample_url TEXT, + create_at TIMESTAMPTZ, + update_at TIMESTAMPTZ, + + CONSTRAINT uq_windup_project_user_name UNIQUE (user_id, project_name) +); + +COMMENT ON TABLE windup_project IS '项目表'; +COMMENT ON COLUMN windup_project.id IS '项目表主键'; +COMMENT ON COLUMN windup_project.user_id IS '创建者 ID'; +COMMENT ON COLUMN windup_project.workflow_id IS '工作流 ID'; +COMMENT ON COLUMN windup_project.project_name IS '项目名称'; +COMMENT ON COLUMN windup_project.character_perspective IS '游戏视角 :1 = 横版视角,2 = 俯视 ,3 = 2.5D '; +COMMENT ON COLUMN windup_project.directional_movement IS '移动方向 :1 = 单向 ,2 = 四向,3 = 八向'; +COMMENT ON COLUMN windup_project.sprite_width IS '角色尺寸 宽:32 、64 、128、256、512、1024、2048'; +COMMENT ON COLUMN windup_project.sprite_height IS '角色尺寸 高:32 、64 、128、256、512、1024、2048'; +COMMENT ON COLUMN windup_project.game_style IS '游戏风格'; +COMMENT ON COLUMN windup_project.sprite_sample_url IS '参考图URL'; +COMMENT ON COLUMN windup_project.create_at IS '创建时间'; +COMMENT ON COLUMN windup_project.update_at IS '修改时间'; + +/*用户表*/ +CREATE TABLE windup_user ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email VARCHAR(255) UNIQUE, + password_hash VARCHAR(255) NOT NULL, + nickname VARCHAR(255), + email_verified_at TIMESTAMPTZ, + status SMALLINT DEFAULT 0, + last_login_at TIMESTAMPTZ, + create_at TIMESTAMPTZ, + update_at TIMESTAMPTZ +); + +COMMENT ON TABLE windup_user IS '用户表'; +COMMENT ON COLUMN windup_user.id IS '用户表主键'; +COMMENT ON COLUMN windup_user.email IS '邮箱地址'; +COMMENT ON COLUMN windup_user.password_hash IS '用户密码(加密存储)'; +COMMENT ON COLUMN windup_user.nickname IS '用户昵称'; +COMMENT ON COLUMN windup_user.email_verified_at IS '邮箱校验时间'; +COMMENT ON COLUMN windup_user.status IS '用户状态 默认0 正常,1 封禁'; +COMMENT ON COLUMN windup_user.last_login_at IS '上次登录时间'; +COMMENT ON COLUMN windup_user.create_at IS '创建时间'; +COMMENT ON COLUMN windup_user.update_at IS '修改时间'; + +/*角色资产表*/ +CREATE TABLE windup_character ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + project_id BIGINT NOT NULL, + name varchar(20), + description TEXT NULL, + reference_image_url TEXT NULL, + character_data JSONB NOT NULL DEFAULT '{}'::jsonb, + status SMALLINT NOT NULL DEFAULT 1, + create_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT ck_windup_character_status CHECK (status IN (0, 1)), + CONSTRAINT ck_windup_character_data_object CHECK (jsonb_typeof(character_data) = 'object') +); + +CREATE INDEX idx_windup_character_project_id + ON windup_character (project_id); + +CREATE INDEX idx_windup_character_project_update_at + ON windup_character (project_id, update_at DESC); + +COMMENT ON TABLE windup_character IS '角色资产表;角色隶属于项目,是资产库中的基本资产'; +COMMENT ON COLUMN windup_character.id IS '角色资产主键'; +COMMENT ON COLUMN windup_character.project_id IS '所属项目 ID'; +COMMENT ON COLUMN windup_character.name IS '角色名称'; +COMMENT ON COLUMN windup_character.description IS '角色描述'; +COMMENT ON COLUMN windup_character.reference_image_url IS '角色参考图 URL;角色模板仅作为角色的参考图属性,不单独建表'; +COMMENT ON COLUMN windup_character.character_data IS '角色完整数据 JSON;包含造型、动作及动作帧等信息'; +COMMENT ON COLUMN windup_character.status IS '角色状态: 1-正常, 0-禁用'; +COMMENT ON COLUMN windup_character.create_at IS '创建时间'; +COMMENT ON COLUMN windup_character.update_at IS '最后更新时间'; +COMMENT ON CONSTRAINT ck_windup_character_status ON windup_character IS '角色状态只能为 0 或 1'; +COMMENT ON CONSTRAINT ck_windup_character_data_object ON windup_character IS '角色完整数据必须为 JSON 对象'; + +/*生成任务表*/ +CREATE TABLE IF NOT EXISTS windup_generation_task ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + project_id BIGINT, + task_type TEXT NOT NULL DEFAULT 'character_image', + status TEXT NOT NULL DEFAULT 'pending', + input_payload JSONB NOT NULL DEFAULT '{}', + result_type TEXT, + result JSONB, + error_message TEXT, + create_at TIMESTAMPTZ NOT NULL DEFAULT now(), + update_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE windup_generation_task IS '生成任务表——记录每次AI生成任务'; +COMMENT ON COLUMN windup_generation_task.id IS '任务主键'; +COMMENT ON COLUMN windup_generation_task.user_id IS '发起用户 ID'; +COMMENT ON COLUMN windup_generation_task.project_id IS '关联项目 ID'; +COMMENT ON COLUMN windup_generation_task.task_type IS '任务类型:character_image = 角色图片生成'; +COMMENT ON COLUMN windup_generation_task.status IS '任务状态:pending = 待执行,running = 执行中,completed = 完成,failed = 失败'; +COMMENT ON COLUMN windup_generation_task.input_payload IS '任务输入参数 JSON'; +COMMENT ON COLUMN windup_generation_task.result_type IS '结果类型'; +COMMENT ON COLUMN windup_generation_task.result IS '任务结果 JSON'; +COMMENT ON COLUMN windup_generation_task.error_message IS '错误信息(失败时记录)'; +COMMENT ON COLUMN windup_generation_task.create_at IS '创建时间'; +COMMENT ON COLUMN windup_generation_task.update_at IS '修改时间'; + +CREATE INDEX IF NOT EXISTS idx_generation_task_user_id ON windup_generation_task (user_id); +CREATE INDEX IF NOT EXISTS idx_generation_task_project_id ON windup_generation_task (project_id); +CREATE INDEX IF NOT EXISTS idx_generation_task_status ON windup_generation_task (status); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..ab4a7ae6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,98 @@ +# ── 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 + 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:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + 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 + environment: + # Redis(验证码 / refresh_token) + REDIS_URL: redis://redis:6379/0 + # 数据库连接(容器内部通信用 postgres 主机名,端口 5432) + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-root} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin123} + POSTGRES_DB: ${POSTGRES_DB:-windup} + # LLM 配置 + LLM_API_KEY: ${LLM_API_KEY} + LLM_MODEL_ID: ${LLM_MODEL_ID:-doubao-seed-2.0-mini} + LLM_BASE_URL: ${LLM_BASE_URL:-https://api.qnaigc.com/v1} + LLM_IMAGE_MODEL_ID: ${LLM_IMAGE_MODEL_ID:-gemini-3.0-pro-image-preview} + LLM_VIDEO_MODEL_ID: ${LLM_VIDEO_MODEL_ID} + # 搜索 + SERPAPI_API_KEY: ${SERPAPI_API_KEY} + # 七牛云存储 + 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:-https://api.qnaigc.com/v1} + AI_API_KEY: ${AI_API_KEY} + ports: + - "${WINDUP_PORT:-8000}:8000" + networks: + - windup-net + +volumes: + postgres_data: + driver: local + +networks: + windup-net: + driver: bridge + # 宿主机链路 MTU 是 1480(eno1),compose 自建网络不会继承 daemon 的 mtu 设置, + # 默认仍是 1500 → 大包被丢,表现为 TLS 握手超时(七牛上传域名连不上、pip 下载卡死)。 + driver_opts: + com.docker.network.driver.mtu: "1450"