Skip to content
Open
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
57 changes: 57 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion backend/packages/app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ dependencies = [
"windup-ai-engine",
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"pydantic>=2.7",
"pydantic[email]>=2.7",
"sqlalchemy>=2.0",
"python-multipart>=0.0.9",
]
Expand Down
91 changes: 90 additions & 1 deletion backend/packages/app/src/windup_app/bootstrap/app.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,104 @@

"""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.auth import router as auth_router
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
from windup_app.web.middleware.auth import AuthMiddleware
from windup_app.web.middleware.ratelimit import RateLimitMiddleware


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)

# 中间件(执行顺序:请求先进 RateLimit,再进 Auth,最后到路由)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(AuthMiddleware)

# 路由
app.include_router(auth_router)
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
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``。"""
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()
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from datetime import datetime, timezone

from pydantic import BaseModel, Field
from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, Text
from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

Expand All @@ -60,6 +60,8 @@ class Character(Base):

project_id: Mapped[int] = mapped_column(BigInteger, nullable=False)

name: Mapped[str | None] = mapped_column(String(20), nullable=True)

description: Mapped[str | None] = mapped_column(Text, nullable=True)

reference_image_url: Mapped[str | None] = mapped_column(Text, nullable=True)
Expand Down
69 changes: 69 additions & 0 deletions backend/packages/app/src/windup_app/server/character/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""角色领域服务的 SQLAlchemy 实现。

:class:`SqlAlchemyCharacterService` 继承 :class:`CharacterService` 接口,用同步
SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作
模块级单例(:data:`service`)。

事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常
rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。
"""

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from windup_app.server.character.interface import CharacterService
from windup_app.server.character.model import Character


class SqlAlchemyCharacterService(CharacterService):
"""基于 SQLAlchemy session 的角色 CRUD 实现。"""

def create_character(self, session: Session, **fields) -> Character:
character = Character(**fields)
session.add(character)
session.flush()
return character

def get_character(self, session: Session, character_id: int) -> Character | None:
return session.get(Character, character_id)

def list_characters(
self, session: Session, *, project_id: int, page: int, page_size: int,
) -> tuple[list[Character], int]:
count_stmt = (
select(func.count())
.select_from(Character)
.where(Character.project_id == project_id)
)
stmt = (
select(Character)
.where(Character.project_id == project_id)
.order_by(Character.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
total = session.scalar(count_stmt) or 0
items = list(session.scalars(stmt))
return items, total

def update_character(
self, session: Session, character_id: int, **fields,
) -> Character | None:
character = session.get(Character, character_id)
if character is None:
return None
for key, value in fields.items():
setattr(character, key, value)
session.flush()
return character

def delete_character(self, session: Session, character_id: int) -> bool:
character = session.get(Character, character_id)
if character is None:
return False
session.delete(character)
session.flush()
return True


service = SqlAlchemyCharacterService()
24 changes: 17 additions & 7 deletions backend/packages/app/src/windup_app/server/project/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,44 @@

项目 API 只依赖本模块定义的抽象接口。数据库、缓存或其他具体实现应在
应用装配层继承 :class:`ProjectService` 后通过依赖注入提供。

约定为 session-per-call:``session`` 由调用方(FastAPI 的 ``get_session`` 依赖)
按请求传入,具体实现(如 :mod:`windup_app.server.project.service`)保持无状态,
可作为模块级单例。
"""

from abc import ABC, abstractmethod

from sqlalchemy.orm import Session

from windup_app.server.project.model import Project


class ProjectService(ABC):
"""项目 CRUD 用例的抽象边界。"""

@abstractmethod
def create_project(self, project: Project) -> Project:
"""创建项目。"""
def create_project(self, session: Session, **fields) -> Project:
"""创建项目。

``fields`` 为项目字段(对齐 ``ProjectCreate`` 的字段集),由实现组装成
:class:`Project` 后持久化。
"""

@abstractmethod
def project_name_exists(self, *, user_id: int, project_name: str) -> bool:
def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool:
"""判断用户下的项目名称是否已存在。"""

@abstractmethod
def get_project(self, project_id: int) -> Project | None:
def get_project(self, session: Session, project_id: int) -> Project | None:
"""按 ID 查询项目。"""

@abstractmethod
def list_projects(
self, *, page: int, page_size: int, user_id: int | None = None
self, session: Session, *, page: int, page_size: int, user_id: int | None = None
) -> tuple[list[Project], int]:
"""分页查询项目。"""
"""分页查询项目,返回 (当前页数据, 总数)。"""

@abstractmethod
def delete_project(self, project_id: int) -> bool:
def delete_project(self, session: Session, project_id: int) -> bool:
"""删除项目并返回是否找到。"""
10 changes: 8 additions & 2 deletions backend/packages/app/src/windup_app/server/project/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from datetime import datetime, timezone

from sqlalchemy import BigInteger, DateTime, SmallInteger, String, Text, UniqueConstraint
from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column

from windup_framework.db import Base
Expand All @@ -16,7 +16,13 @@ class Project(Base):
UniqueConstraint("user_id", "project_name", name="uq_windup_project_user_name"),
)

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# Postgres 上 BigInteger 自增;variant 到 Integer 让 SQLite(测试库)走
# INTEGER PRIMARY KEY 自增(SQLite 仅对该声明自动分配 rowid)。
id: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer, "sqlite"),
primary_key=True,
autoincrement=True,
)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
workflow_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
project_name: Mapped[str] = mapped_column(String(20), nullable=False)
Expand Down
60 changes: 60 additions & 0 deletions backend/packages/app/src/windup_app/server/project/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""项目领域服务的 SQLAlchemy 实现。

:class:`SqlAlchemyProjectService` 继承 :class:`ProjectService` 接口,用同步
SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作
模块级单例(:data:`service`)。

事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常
rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。
"""

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from windup_app.server.project.interface import ProjectService
from windup_app.server.project.model import Project


class SqlAlchemyProjectService(ProjectService):
"""基于 SQLAlchemy session 的项目 CRUD 实现。"""

def create_project(self, session: Session, **fields) -> Project:
project = Project(**fields)
session.add(project)
session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at)
return project

def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool:
stmt = (
select(Project.id)
.where(Project.user_id == user_id, Project.project_name == project_name)
.limit(1)
)
return session.scalar(stmt) is not None

def get_project(self, session: Session, project_id: int) -> Project | None:
return session.get(Project, project_id)

def list_projects(
self, session: Session, *, page: int, page_size: int, user_id: int | None = None
) -> tuple[list[Project], int]:
count_stmt = select(func.count()).select_from(Project)
stmt = select(Project)
if user_id is not None:
count_stmt = count_stmt.where(Project.user_id == user_id)
stmt = stmt.where(Project.user_id == user_id)
total = session.scalar(count_stmt) or 0
stmt = stmt.order_by(Project.id.desc()).offset((page - 1) * page_size).limit(page_size)
items = list(session.scalars(stmt))
return items, total

def delete_project(self, session: Session, project_id: int) -> bool:
project = session.get(Project, project_id)
if project is None:
return False
session.delete(project)
session.flush()
return True


service = SqlAlchemyProjectService()
Loading