diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 89f7b43..8d60013 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -6,10 +6,19 @@ from fastapi import FastAPI +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 def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0") + + # 业务路由 app.include_router(media_router) + app.include_router(generation_router) + app.include_router(workflow_run_router) + app.include_router(ai_router) + return app diff --git a/backend/packages/app/src/windup_app/server/generation/__init__.py b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py similarity index 89% rename from backend/packages/app/src/windup_app/server/generation/__init__.py rename to backend/packages/app/src/windup_app/server/orchestrator/__init__.py index a6701de..c21a7b8 100644 --- a/backend/packages/app/src/windup_app/server/generation/__init__.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py @@ -1,6 +1,6 @@ """生成任务领域。""" -from windup_app.server.generation.model import ( +from windup_app.server.orchestrator.model import ( ActionType, CharacterActionFrame, CharacterActionInput, diff --git a/backend/packages/app/src/windup_app/server/generation/interface.py b/backend/packages/app/src/windup_app/server/orchestrator/interface.py similarity index 97% rename from backend/packages/app/src/windup_app/server/generation/interface.py rename to backend/packages/app/src/windup_app/server/orchestrator/interface.py index b43bace..83e38aa 100644 --- a/backend/packages/app/src/windup_app/server/generation/interface.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/interface.py @@ -22,7 +22,7 @@ from abc import ABC, abstractmethod -from windup_app.server.generation.model import ( +from windup_app.server.orchestrator.model import ( CharacterActionInput, CharacterImageInput, GenerationTask, diff --git a/backend/packages/app/src/windup_app/server/generation/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py similarity index 100% rename from backend/packages/app/src/windup_app/server/generation/model.py rename to backend/packages/app/src/windup_app/server/orchestrator/model.py diff --git a/backend/packages/app/src/windup_app/server/workflow_run/__init__.py b/backend/packages/app/src/windup_app/server/workflow_run/__init__.py new file mode 100644 index 0000000..18b7be4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/__init__.py @@ -0,0 +1 @@ +"""工作流执行记录领域。""" diff --git a/backend/packages/app/src/windup_app/server/workflow_run/interface.py b/backend/packages/app/src/windup_app/server/workflow_run/interface.py new file mode 100644 index 0000000..9efb97c --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/interface.py @@ -0,0 +1,89 @@ +"""工作流执行记录领域服务接口。 + +API 层只依赖本模块定义的抽象。具体实现在应用装配层继承后通过依赖注入提供。 + +职责 +---- +- 存储执行记录(含前端维护的节点树 JSONB) +- 支持版本管理(修改角色模板 → 新版本 run) +- 支持跨 run diff(新旧 run 对比) + +不做 +---- +- 不感知节点结构(前端自定义 nodes JSONB 内容) +- 不管节点拼装和推进(由前端负责) +- 不执行业务逻辑(由原子能力 API 负责) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + +from windup_app.server.workflow_run.model import ( + RunStatus, + WorkflowRun, +) + + +class WorkflowRunService(ABC): + """执行记录用例的抽象边界。""" + + # -- 执行记录 CRUD -------------------------------------------------------- + + @abstractmethod + def create_run( + self, + *, + project_id: int, + parent_run_id: int | None = None, + root_capability: str, + root_input: dict | None = None, + nodes: list | None = None, + ) -> WorkflowRun: + """创建执行记录。 + + 修改角色模板时传 parent_run_id,形成版本链。 + nodes 为前端定义的初始节点树(可选)。 + """ + + @abstractmethod + def get_run(self, run_id: int) -> WorkflowRun | None: + """获取执行记录详情(含 nodes JSONB)。""" + + @abstractmethod + def update_run( + self, + run_id: int, + *, + root_output: dict | None = None, + nodes: list | None = None, + status: RunStatus | None = None, + ) -> WorkflowRun: + """全量更新执行记录。 + + 前端维护节点树后,通过此接口全量写回。 + """ + + @abstractmethod + def delete_run(self, run_id: int) -> None: + """软删除执行记录。""" + + # -- Diff ----------------------------------------------------------------- + + @abstractmethod + def diff_runs(self, new_run_id: int, old_run_id: int) -> DiffResult: + """对比新旧 run,返回差异信息。""" + + +# -- Diff 结果模型 --------------------------------------------------------- + + +@dataclass +class DiffResult: + """跨 run diff 结果。""" + + old_nodes: list = field(default_factory=list) + new_nodes: list = field(default_factory=list) + root_input_changed: bool = False + root_capability_changed: bool = False diff --git a/backend/packages/app/src/windup_app/server/workflow_run/model.py b/backend/packages/app/src/windup_app/server/workflow_run/model.py new file mode 100644 index 0000000..27e3bce --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/model.py @@ -0,0 +1,44 @@ +"""工作流执行记录领域模型。 + +后端不感知节点结构,节点树由前端维护, +通过 workflow_run.nodes JSONB 字段全量读写。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum + + +# -- 枚举 ---------------------------------------------------------------- + + +class RunStatus(StrEnum): + """执行记录状态。""" + + ACTIVE = "active" + SOFT_DELETED = "soft_deleted" + + +# -- 执行记录 ------------------------------------------------------------- + + +@dataclass +class WorkflowRun: + """执行记录——一个角色的完整生命周期。 + + 修改角色模板时,创建新 run(parent_run_id 指向旧 run),形成版本链。 + nodes 字段存储前端定义的节点树结构,后端不校验其内容。 + """ + + id: int | None = None + project_id: int = 0 + parent_run_id: int | None = None + root_capability: str = "" # 根节点能力类型(如 "generate_images") + root_input: dict = field(default_factory=dict) + root_output: dict | None = None + nodes: list = field(default_factory=list) # 节点树(前端自定义结构) + status: RunStatus = RunStatus.ACTIVE + version: int = 1 + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/packages/app/src/windup_app/server/workflow_run/schema.py b/backend/packages/app/src/windup_app/server/workflow_run/schema.py new file mode 100644 index 0000000..5023c7e --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/schema.py @@ -0,0 +1,87 @@ +"""工作流执行记录 API Schema。 + +定义前端请求/响应的 Pydantic 模型,与 server 层解耦。 +前端团队参考此文件了解接口契约。 + +后端不感知 nodes 字段的内部结构,前端自定义。 +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +# ══════════════════════════════════════════════════════════════════════════════ +# 执行记录 +# ══════════════════════════════════════════════════════════════════════════════ + + +class WorkflowRunCreateRequest(BaseModel): + """创建执行记录。""" + + project_id: int = Field(description="关联项目 ID,项目约束从这里读取") + parent_run_id: int | None = Field( + default=None, + description="父执行记录 ID(修改角色模板时指向旧 run,形成版本链)", + ) + root_capability: str = Field( + description="根节点能力类型(如 'generate_images')", + ) + root_input: dict = Field( + default_factory=dict, + description="根节点输入参数", + ) + nodes: list = Field( + default_factory=list, + description="节点树(前端自定义结构,后端不校验)", + ) + + +class WorkflowRunUpdateRequest(BaseModel): + """全量更新执行记录。 + + 前端维护节点树后,通过此接口全量写回。 + """ + + root_output: dict | None = Field( + default=None, + description="根节点输出结果", + ) + nodes: list = Field( + default_factory=list, + description="节点树(前端自定义结构,后端不校验)", + ) + status: str | None = Field( + default=None, + description="状态:active / soft_deleted", + ) + + +class WorkflowRunOut(BaseModel): + """执行记录响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + parent_run_id: int | None = None + root_capability: str = Field(description="根节点能力类型") + root_input: dict = Field(default_factory=dict, description="根节点输入") + root_output: dict | None = Field(default=None, description="根节点输出") + nodes: list = Field(default_factory=list, description="节点树(前端自定义结构)") + status: str = Field(description="active / soft_deleted") + version: int = Field(description="版本号,从 1 递增") + + +# ══════════════════════════════════════════════════════════════════════════════ +# Diff 结果 +# ══════════════════════════════════════════════════════════════════════════════ + + +class DiffResultOut(BaseModel): + """跨 run diff 结果。""" + + old_nodes: list = Field(default_factory=list, description="旧 run 的节点树") + new_nodes: list = Field(default_factory=list, description="新 run 的节点树") + root_input_changed: bool = Field(description="根节点输入是否变化") + root_capability_changed: bool = Field(description="根节点能力类型是否变化") diff --git a/backend/packages/app/src/windup_app/web/api/agent.py b/backend/packages/app/src/windup_app/web/api/agent.py new file mode 100644 index 0000000..de721bd --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/agent.py @@ -0,0 +1,103 @@ +"""AI Proxy(LLM 代理)API。 + +后端是无状态 LLM 代理,不做 agent 编排。 +前端维护 conversation history,传完整 messages 数组。 +OpenAI 兼容格式,前端可用 OpenAI SDK 解析。 + +端点 +---- +POST /ai/chat LLM 代理(流式响应) + +认证 +---- +复用现有 JWT 体系,user_id 从 token 解析(用于日志/审计,不存对话)。 +""" + +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +logger = logging.getLogger("windup.ai.proxy") + +router = APIRouter(prefix="/ai", tags=["ai"]) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 请求/响应模型 +# ══════════════════════════════════════════════════════════════════════════════ + + +class ChatMessage(BaseModel): + """对话消息。""" + + role: str = Field(description="消息角色:system / user / assistant / tool") + content: str | None = Field(default=None, description="消息内容") + tool_calls: list[dict] | None = Field(default=None, description="工具调用(assistant 角色)") + tool_call_id: str | None = Field(default=None, description="工具结果关联的调用 ID(tool 角色)") + + +class ToolDefinition(BaseModel): + """工具定义(OpenAI function calling 格式)。""" + + type: str = Field(default="function", description="工具类型,固定为 function") + function: dict = Field(description="函数定义:{name, description, parameters}") + + +class ChatRequest(BaseModel): + """LLM 代理请求。""" + + system: str | None = Field(default=None, description="系统提示词") + messages: list[ChatMessage] = Field(description="对话历史(前端维护完整列表)") + model: str | None = Field(default=None, description="模型名称,省略则使用默认模型") + temperature: float | None = Field(default=None, ge=0, le=2, description="温度参数") + tools: list[ToolDefinition] | None = Field( + default=None, + description="可用工具列表(前端定义,后端透传给 LLM)", + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 端点 +# ══════════════════════════════════════════════════════════════════════════════ + + +@router.post("/chat") +async def chat( + body: ChatRequest, + request: Request, +) -> StreamingResponse: + """LLM 代理(流式响应)。 + + 后端职责: + 1. 从 JWT 解析 user_id(用于日志/审计) + 2. 调用 create_chat_model 工厂创建 LLM 实例 + 3. 流式转发 LLM 响应 + 4. 不解析 tool_calls、不执行工具、不存对话历史 + + 响应格式:OpenAI 兼容 SSE + ``` + data: {"choices": [{"delta": {"content": "好"}}]} + data: {"choices": [{"delta": {"tool_calls": [...]}}]} + data: [DONE] + ``` + """ + # TODO: 调用 create_chat_model 创建 LLM 实例,流式转发 + # 当前返回占位实现 + async def _placeholder(): + yield f'data: {json.dumps({"choices": [{"delta": {"content": "AI Proxy 尚未实现,请配置 LLM provider。"}}]}, ensure_ascii=False)}\n\n' + yield "data: [DONE]\n\n" + + return StreamingResponse( + _placeholder(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index be3a5e9..a3f80ca 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -2,12 +2,25 @@ 契约层:定义前端请求/响应的 Pydantic 模型,与 server 层解耦。 实际逻辑由 server 层实现,本文件只做参数校验和格式转换。 + +端点一览 +-------- +POST /generation/image 提交角色图片生成任务 +POST /generation/action 提交角色动作生成任务 +GET /generation/tasks/{task_id} 查询任务状态 +GET /generation/tasks/{task_id}/stream SSE 订阅任务进度 """ +from __future__ import annotations + +import asyncio import dataclasses +import json import logging +from collections import defaultdict from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session @@ -16,7 +29,7 @@ from windup_common.result import Response from windup_framework.db import get_session -from windup_app.server.generation.model import ( +from windup_app.server.orchestrator.model import ( ActionType, GenerationTask, ) @@ -26,7 +39,48 @@ router = APIRouter(prefix="/generation", tags=["generation"]) -# ── 请求模型 ───────────────────────────────────────────────────────────────── +# ══════════════════════════════════════════════════════════════════════════════ +# EventBus(任务进度推送) +# ══════════════════════════════════════════════════════════════════════════════ + +# 心跳间隔(秒) +_HEARTBEAT_TIMEOUT = 30.0 + +# 终态事件 +_TERMINAL_EVENTS = {"completed", "failed"} + + +class _EventBus: + """任务进度内存发布-订阅。""" + + def __init__(self) -> None: + self._queues: dict[str, list[asyncio.Queue]] = defaultdict(list) + + async def subscribe(self, task_id: int) -> asyncio.Queue: + queue: asyncio.Queue = asyncio.Queue() + self._queues[str(task_id)].append(queue) + return queue + + async def unsubscribe(self, task_id: int, queue: asyncio.Queue) -> None: + key = str(task_id) + subs = self._queues.get(key) + if subs and queue in subs: + subs.remove(queue) + if not subs: + del self._queues[key] + + def publish(self, task_id: int, event: str, data: dict) -> None: + for queue in self._queues.get(str(task_id), []): + queue.put_nowait((event, data)) + + +# 全局实例,挂到 app.state.event_bus +event_bus = _EventBus() + + +# ══════════════════════════════════════════════════════════════════════════════ +# 请求/响应模型 +# ══════════════════════════════════════════════════════════════════════════════ class CharacterImageGenerateRequest(BaseModel): @@ -55,9 +109,6 @@ class CharacterActionGenerateRequest(BaseModel): num_frames: int = 16 -# ── 响应模型 ───────────────────────────────────────────────────────────────── - - class GenerationTaskOut(BaseModel): """生成任务响应。""" @@ -90,7 +141,9 @@ def _task_to_out(task: GenerationTask) -> GenerationTaskOut: ) -# ── 端点 ───────────────────────────────────────────────────────────────────── +# ══════════════════════════════════════════════════════════════════════════════ +# 端点 +# ══════════════════════════════════════════════════════════════════════════════ def _validate_project_size(session: Session, project_id: int | None, width: int, height: int) -> None: @@ -141,3 +194,55 @@ def get_task( """查询生成任务状态与结果。""" # TODO: service.get_task raise BizException("接口待实现", code=BizCode.BAD_REQUEST) + + +@router.get("/tasks/{task_id}/stream") +async def stream_task( + task_id: int, + request: Request, + project_id: int = Query(..., gt=0), + session: Session = Depends(get_session), +) -> StreamingResponse: + """SSE:实时推送任务进度与最终结果。 + + 事件类型: + - ``progress``: 生成进度 (stage/current/total/note) + - ``completed``: 任务完成,携带最终结果 + - ``failed``: 任务失败,携带错误信息 + + 若客户端订阅时任务已处于终态,立即推送终态事件并关闭连接。 + """ + # TODO: 检查任务初始状态,若已终态立即推送 + queue = await event_bus.subscribe(task_id) + logger.debug("SSE 订阅: task_id=%d", task_id) + + async def _event_generator(): + try: + while True: + if await request.is_disconnected(): + logger.debug("SSE 客户端断开: task_id=%d", task_id) + break + try: + event, data = await asyncio.wait_for( + queue.get(), timeout=_HEARTBEAT_TIMEOUT, + ) + payload = json.dumps(data, ensure_ascii=False) + yield f"event: {event}\ndata: {payload}\n\n" + if event in _TERMINAL_EVENTS: + logger.debug("SSE 终态: task_id=%d event=%s", task_id, event) + break + except asyncio.TimeoutError: + yield ": heartbeat\n\n" + finally: + await event_bus.unsubscribe(task_id, queue) + logger.debug("SSE 取消订阅: task_id=%d", task_id) + + return StreamingResponse( + _event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/packages/app/src/windup_app/web/api/workflow_run.py b/backend/packages/app/src/windup_app/web/api/workflow_run.py new file mode 100644 index 0000000..46a548a --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/workflow_run.py @@ -0,0 +1,105 @@ +"""工作流执行记录 API。 + +契约层:定义端点和请求/响应模型,与 server 层解耦。 +实际逻辑由 server 层实现,本文件只做参数校验和格式转换。 + +端点一览 +-------- +POST /workflow-runs 创建执行记录 +GET /workflow-runs/{id} 获取执行记录(含 nodes) +PATCH /workflow-runs/{id} 全量更新(含 nodes) +DELETE /workflow-runs/{id} 软删除 +POST /workflow-runs/{id}/diff/{old_id} 对比新旧 run + +设计原则 +-------- +后端不感知节点结构,nodes 字段由前端自定义, +后端只做全量读写,不校验 nodes 内部结构。 +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from windup_common.result import Response +from windup_framework.db import get_session + +from windup_app.server.workflow_run.schema import ( + DiffResultOut, + WorkflowRunCreateRequest, + WorkflowRunOut, + WorkflowRunUpdateRequest, +) + +logger = logging.getLogger("windup.workflow_run.api") + +router = APIRouter(prefix="/workflow-runs", tags=["workflow-run"]) + + +# ── 执行记录 CRUD ─────────────────────────────────────────────────────────── + + +@router.post("", response_model=Response[WorkflowRunOut]) +def create_run( + body: WorkflowRunCreateRequest, + session: Session = Depends(get_session), +) -> Response[WorkflowRunOut]: + """创建执行记录。 + + 修改角色模板时传 parent_run_id,形成版本链。 + nodes 为前端定义的初始节点树(可选)。 + """ + # TODO: service.create_run + raise NotImplementedError + + +@router.get("/{run_id}", response_model=Response[WorkflowRunOut]) +def get_run( + run_id: int, + session: Session = Depends(get_session), +) -> Response[WorkflowRunOut]: + """获取执行记录详情(含 nodes JSONB)。""" + # TODO: service.get_run + raise NotImplementedError + + +@router.patch("/{run_id}", response_model=Response[WorkflowRunOut]) +def update_run( + run_id: int, + body: WorkflowRunUpdateRequest, + session: Session = Depends(get_session), +) -> Response[WorkflowRunOut]: + """全量更新执行记录。 + + 前端维护节点树后,通过此接口全量写回。 + 后端不校验 nodes 内部结构。 + """ + # TODO: service.update_run + raise NotImplementedError + + +@router.delete("/{run_id}", response_model=Response[None]) +def delete_run( + run_id: int, + session: Session = Depends(get_session), +) -> Response[None]: + """软删除执行记录。""" + # TODO: service.delete_run + raise NotImplementedError + + +@router.post("/{run_id}/diff/{old_run_id}", response_model=Response[DiffResultOut]) +def diff_runs( + run_id: int, + old_run_id: int, + session: Session = Depends(get_session), +) -> Response[DiffResultOut]: + """对比新旧 run,返回差异信息。 + + 前端可用于判断哪些节点可以复用,哪些需要重新执行。 + """ + # TODO: service.diff_runs + raise NotImplementedError diff --git a/backend/packages/app/src/windup_app/web/sse/.gitkeep b/backend/packages/app/src/windup_app/web/sse/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/module-split.md b/docs/module-split.md deleted file mode 100644 index 3dd8d74..0000000 --- a/docs/module-split.md +++ /dev/null @@ -1,230 +0,0 @@ -# 后端模块拆分 - -> 当前阶段:各模块定义抽象接口(ABC)+ Pydantic 领域模型。部分模块已有具体实现(media、project)。 -> 每个模块包含 `interface.py`(接口)、`model.py`(领域模型),有实现的模块额外包含 `service.py`。 - -## 项目层级 - -``` -backend/ -├── packages/ -│ ├── common/ # 共享:Response、BizException、BizCode 枚举 -│ ├── framework/ # 基础设施:KodoStorage、ChatProvider、DB 配置 -│ └── app/ # 业务应用 -│ └── src/windup_app/ -│ ├── web/api/ # FastAPI 路由 -│ └── server/ # 领域抽象 + 实现 -│ ├── user/ # 用户认证 -│ ├── project/ # 项目管理 -│ ├── character/ # 角色资产(隶属于项目) -│ ├── generation/ # AI 生成任务 -│ ├── media/ # 文件上传(对象存储) -│ ├── quota/ # 待实现 -│ ├── workflow/ # 待实现 -│ └── ... # 其他待实现 -``` - -> **已删除的模块:** `asset`(角色本身就是资产,不另建 Asset 表)、 -> `character/action`、`character/character_template`、`character/wearable` -> (造型/动作/帧存入 `character_data` JSONB,不另建子包和独立表)。 - ---- - -## 1. user — 用户认证 - -**对应表:** `windup_user` / `windup_user_oauth` **接口:** `UserService` - -| 方法 | 说明 | -|---|---| -| `register_by_email(input)` | 邮箱+密码注册,注册即登录 | -| `login_by_password(input)` | 邮箱+密码登录 | -| `send_verification_code(email)` | 发送邮箱验证码 | -| `login_by_code(input)` | 验证码登录,无账号自动注册 | -| `logout(session_token)` | 销毁会话 | -| `validate_session(token)` | 校验会话,返回 `User` 或 `None` | -| `refresh_session(token)` | 刷新会话 | -| `change_password(user_id, input)` | 修改密码(验证旧密码) | -| `get_by_id(id)` / `get_by_email(email)` | 按 ID/邮箱查用户 | - -> **暂不设计/实现:** OAuth 第三方认证(`get_oauth_authorize_url` / `login_by_oauth` / -> `bind_oauth` / `get_oauth_bindings`)及相关模型 `OAuthCallbackInput` / `UserOAuth` -> 均已注解掉,保留注释占位作为后续扩展点。 - ---- - -## 2. project — 项目管理 - -**对应表:** `windup_project` **接口:** `ProjectService` - -| 方法 | 说明 | -|---|---| -| `create_project(project)` | 创建项目 | -| `project_name_exists(user_id, name)` | 名称唯一性校验 | -| `get_project(id)` | 按 ID 查询 | -| `list_projects(page, page_size, user_id)` | 分页查询 | -| `delete_project(id)` | 删除 | - ---- - -## 3. character — 角色资产 - -**对应表:** `windup_character` **接口:** `CharacterService` - -角色是隶属于项目的资产。不再建立独立的 `Asset` 表、`CharacterTemplate` 表、 -`Outfit` 表或 `Action` 表。造型、动作、动作帧等完整数据统一存储在 -`character_data` JSONB 字段中。 - -**ORM 模型:** - -| 字段 | 类型 | 说明 | -|---|---|---| -| `id` | BigInteger | 主键自增 | -| `project_id` | BigInteger | 所属项目 ID | -| `description` | Text | 角色描述 | -| `reference_image_url` | Text | 角色参考图(即旧概念中的 Character Template) | -| `character_data` | JSONB | 造型→动作→帧 完整嵌套数据 | -| `status` | SmallInteger | 1 正常 / 0 禁用 | -| `create_at` | DateTime(tz) | 创建时间 | -| `update_at` | DateTime(tz) | 更新时间 | - -**`character_data` Pydantic 模型层级:** - -``` -CharacterData -└── outfits: list[CharacterOutfit] - ├── id: str # 造型稳定 ID - ├── name: str # 造型名称 - ├── description: str | None - ├── preview_url: str | None - └── actions: list[CharacterAction] - ├── id: str # 动作稳定 ID - ├── type: str # idle / walk / attack / custom - ├── name: str # 动作显示名称 - ├── loop: bool # 是否循环播放 - ├── fps: float # 播放帧率 - ├── frame_count: int # 帧数 - └── frames: list[CharacterFrame] - ├── index: int - ├── image_url: str - └── duration_ms: int | None -``` - -**接口方法:** - -| 方法 | 说明 | -|---|---| -| `create_character(session, **fields)` | 创建角色 | -| `get_character(session, character_id)` | 按 ID 查询 | -| `list_characters(session, *, project_id, page, page_size)` | 分页查询项目下的角色 | -| `update_character(session, character_id, **fields)` | 更新角色字段或 character_data | -| `delete_character(session, character_id)` | 删除角色 | - -> **与旧设计的差异:** 不再有 `name` 字段(角色无需名称)、不再有子领域包 -> (action / character_template / wearable)、不再有 `get_character_detail` -> 聚合方法。前端在 Workflow 中编辑 character_data,确认导出时一次性写回数据库。 - ---- - -## 4. generation — AI 生成任务 - -**接口:** `GenerationService` **传输:** SSE 推送任务状态 - -职责:管理生成任务生命周期,按任务类型区分入参和出参。前端通过 SSE 订阅任务 -状态变更,无需轮询。 - -**任务类型与出参对应关系:** - -| 任务类型 | 入参 | 出参 | 前端回填目标 | -|---|---|---|---| -| `CHARACTER_IMAGE` | `CharacterImageInput` | `CharacterImageOutput` | `Character.reference_image_url` | -| `CHARACTER_ACTION` | `CharacterActionInput` | `CharacterActionOutput` | `character_data.outfits[].actions[].frames[]` | - -**入参模型:** - -- `CharacterImageInput`:`reference_image_url`、`prompt`、`negative_prompt`、`width`、`height`、`num_images` -- `CharacterActionInput`:`character_id`、`action_type`、`custom_prompt`、`reference_video_url`、`reference_image_urls`、`num_frames` - -**出参模型:** - -- `CharacterImageOutput`:`image_url`(前端写入 `Character.reference_image_url`) -- `CharacterActionOutput`:`action_type` + `frames[]`(前端写入 `character_data.outfits[].actions[].frames[]`) - - `CharacterActionFrame`:`index`、`image_url`、`duration_ms` - -**接口方法:** - -| 方法 | 说明 | -|---|---| -| `generate_character_image(input)` | 提交角色图片生成任务 | -| `generate_character_action(input)` | 提交角色动作生成任务 | -| `get_task(project_id, task_id)` | 查询任务状态与结果 | - -**SSE 调用流程:** - -1. 前端 POST 提交任务,拿到 `task_id`。 -2. 前端连接 `GET /generation/tasks/{task_id}/stream`,服务端在任务状态变化时 - 推送 `task_update` 事件。事件 payload 包含 `task_id` / `task_type` / `status`, - 完成时附带 `result`,失败时附带 `error_message`。 -3. 前端从 `status` 判断完成,从 `result` 取出对应类型的出参,回填 character 模块。 - -> **与旧设计的差异:** 不再使用策略模式(`GenerationStrategy` / `register_strategy` / -> `submit(payload)`),改为按任务类型拆分明确的接口方法。不再使用泛化出参 -> `GenerationResult(urls, metadata)`,改为按任务类型细化出参 -> `CharacterImageOutput` / `CharacterActionOutput`。不再使用前端轮询,改为 SSE 推送。 - ---- - -## 5. media — 文件上传 - -**接口:** `MediaService` **实现:** `ObjectStorageMediaService`(使用 KodoStorage) - -职责:接收前端上传的文件 → 写入对象存储 → 返回公开 URL。前端拿到 URL 后 -回填 character 模块的相关字段(`reference_image_url` / `preview_url` / -`frames[].image_url`)。 - -**文件分类 `MediaCategory`:** - -| 枚举值 | 用途 | -|---|---| -| `REFERENCE_IMAGE` | 角色参考图 → `Character.reference_image_url` | -| `OUTFIT_PREVIEW` | 造型预览图 → `CharacterOutfit.preview_url` | -| `ACTION_FRAME` | 动作帧 → `CharacterFrame.image_url` | -| `GENERAL` | 通用文件 | - -**模型:** - -- `MediaUploadInput`:`filename` / `content_type` / `size` / `category` -- `MediaUploadResult`:`url` / `object_key` / `filename` / `content_type` / `size` - -**接口方法:** - -| 方法 | 说明 | -|---|---| -| `upload(data, metadata)` | 上传文件到对象存储,返回 `MediaUploadResult` | - -对象 key 格式:`media/{category}/{uuid}.{ext}`,不暴露用户原始文件名。 - -**API 端点:** - -``` -POST /media/upload?category=reference-image -Content-Type: multipart/form-data(字段名 file) -``` - -响应:`Response[MediaUploadResult]`,前端从 `data.url` 取值回填业务字段。 - -> **与旧设计的差异:** 不再使用策略模式(`MediaProcessor` / `register_processor` / -> `process(options)`)。当前阶段仅实现上传能力,缩略图/转码/元数据提取后续按需添加。 -> media 模块不与角色表耦合,同一上传服务可处理参考图、造型预览图和动作帧。 - ---- - -## 待实现模块 - -| 包 | 预计职责 | -|---|---| -| `execution` | 任务执行引擎(消费队列、调用 AI、回调) | -| `export` | 导出(GIF、序列帧、精灵图集、游戏引擎格式) | -| `playtest` | 预览与试玩 | -| `quota` | 积分套餐与配额管理 | -| `review` | 生成候选质检与人工审核 | -| `workflow` | 节点工作流编排 |