docs: 适配单机场景,简化存储与认证方案

- 数据库:MySQL → SQLite,去掉连接池配置和 golang-migrate
- 文件存储:OSS 对象存储 → 本地文件系统,统一 Gin 静态文件服务
- ID 生成:Sonyflake → 前缀+随机字符串
- 认证:Bearer Token → httpOnly Cookie,WebSocket 同步改为 Cookie 认证
- 质检重试:PromptBuilder 重试时注入 RejectReason 改进提示词
- 合并 multi-agent-pipeline.md 到 backend.md,消除文档重复
- 移除缓存管理 API(暴露内部实现细节)
This commit is contained in:
2026-05-24 10:56:40 +08:00
parent f4c1a10403
commit cbcda82515
7 changed files with 206 additions and 255 deletions
+3 -4
View File
@@ -4,14 +4,13 @@ gen2d — AI 驱动的 2D 游戏素材生成工具。通过文本提示词生成
## 技术栈
Go + Gin + Eino / Vite + React + TypeScript + zustand / 可替换 AI 推理模型
Go + Gin + Eino / Vite + React + TypeScript + zustand / SQLite / 可替换 AI 推理模型
## 文档索引
- [多智能体生成管线](multi-agent-pipeline.md) — PromptBuilder → AssetGenerator → QualitySupervisor → FormatAdapter 四阶段流水线
- [后端工程](backend.md) — 分层结构、目录组织、分层原则
- [后端工程](backend.md) — 分层结构、管线设计(PromptBuilder → AssetGenerator → QualitySupervisor → FormatAdapter)、风格模型
- [前端工程](frontend.md) — 组件树、状态管理、路由、WebSocket 通信
- [异步任务](async-tasks.md) — 任务队列、状态机、并发控制、失败重试
- [数据存储](database.md) — 数据库选型、表结构、素材文件存储
- [数据存储](database.md) — SQLite 选型、表结构、本地文件存储
- [API 设计](api.md) — 接口列表、请求/响应示例、实现状态
- [预设风格键](style-keys.md) — 美术风格、色调、线条等风格键分类与可选值
+16 -55
View File
@@ -8,13 +8,16 @@
## 认证
除健康检查和注册登录外,所有接口需在请求头携带 Bearer Token:
除健康检查和注册登录外,所有接口需通过 httpOnly Cookie 携带 JWT。登录/注册成功后,服务端通过 `Set-Cookie` 响应头写入 Token,后续请求自动携带。
```
Authorization: Bearer <token>
```
Cookie 属性:
- `Name`: `token`
- `HttpOnly`: true
- `SameSite`: Lax
- `Path`: `/`
- `MaxAge`: 由 `GEN2D_JWT_EXPIRE` 控制(默认 7200 秒)
Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默认 7200 秒)。Token 过期或无效时返回:
Token 过期或无效时返回:
```json
{ "code": 401, "message": "未登录或 Token 已过期", "data": null }
@@ -77,7 +80,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
| `email` | string | 是 | 邮箱地址 |
| `password` | string | 是 | 8-128 字符 |
响应:
响应(Token 通过 `Set-Cookie` 响应头写入,不在 body 中返回):
```json
{
@@ -86,9 +89,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
"data": {
"id": "user_a1B2c3D4",
"username": "player1",
"email": "player1@example.com",
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresAt": "2026-05-24T12:00:00Z"
"email": "player1@example.com"
}
}
```
@@ -121,7 +122,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
}
```
响应:
响应(Token 通过 `Set-Cookie` 响应头写入,不在 body 中返回):
```json
{
@@ -130,9 +131,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
"data": {
"id": "user_a1B2c3D4",
"username": "player1",
"email": "player1@example.com",
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresAt": "2026-05-24T12:00:00Z"
"email": "player1@example.com"
}
}
```
@@ -173,7 +172,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
## 工程管理
需认证。以下接口均需携带 Bearer Token,工程归属于当前登录用户。
需认证。以下接口均需通过 Cookie 携带 Token,工程归属于当前登录用户。
| 状态 | 方法 | 路径 | 说明 |
|------|------|------|------|
@@ -260,7 +259,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
### DELETE /api/v1/projects/:projectId
级联删除工程下的所有任务、素材及 OSS 文件。
级联删除工程下的所有任务、素材及本地文件。
响应:
@@ -443,7 +442,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
"assets": [
{
"id": "asset_001",
"url": "https://cdn.example.com/users/user_a1B2c3D4/projects/proj_abc123/tasks/task_xyz789/output/spritesheet.png",
"url": "users/user_a1B2c3D4/projects/proj_abc123/tasks/task_xyz789/output/spritesheet.png",
"format": "png",
"width": 256,
"height": 64,
@@ -461,7 +460,7 @@ Token 通过登录接口获取,过期时间由 `GEN2D_JWT_EXPIRE` 控制(默
### WebSocket 消息格式
连接路径:`ws://host/api/v1/tasks/:taskId/ws?token=<jwt>`(通过 query 参数传递 Token)
连接路径:`ws://host/api/v1/tasks/:taskId/ws`(通过 httpOnly Cookie 自动携带认证信息)
```typescript
interface PipelineProgress {
@@ -502,41 +501,3 @@ interface PipelineProgress {
{"stage":"format_adapter","status":"completed","progress":100,"result":{"assets":[...]}}
```
## 缓存管理
需认证。用于管理应用层去重缓存(详见 [数据存储 — 去重策略](database.md#去重策略)),主要用于调试和运维场景。
| 状态 | 方法 | 路径 | 说明 |
|------|------|------|------|
| [ ] | DELETE | `/api/v1/cache/:key` | 清除特定去重缓存条目 |
| [ ] | POST | `/api/v1/cache/clear` | 批量清除所有去重缓存 |
### DELETE /api/v1/cache/:key
清除指定去重键的缓存条目。`:key` 为 `hash(prompt + assetType + params)` 生成的去重键,仅在需要手动解除去重锁定时使用。
响应:
```json
{
"code": 0,
"message": "ok",
"data": null
}
```
### POST /api/v1/cache/clear
清除所有去重缓存条目,不影响正在执行的任务。
响应:
```json
{
"code": 0,
"message": "ok",
"data": {
"cleared": 5
}
}
```
+7 -7
View File
@@ -20,7 +20,7 @@ stateDiagram-v2
|------|------|--------|
| `pending` | 已入队,等待 Worker 领取 | `task.status = 'pending'` |
| `running` | 管线执行中,`task.stage` 记录当前阶段 | `task.status = 'running'` |
| `completed` | 管线完成,素材已上传至 OSS | `task.status = 'completed'`, 写入 `asset` 表 |
| `completed` | 管线完成,素材已保存到本地 | `task.status = 'completed'`, 写入 `asset` 表 |
| `failed` | 执行失败或超过重试上限 | `task.status = 'failed'`, `task.error` 记录原因 |
## 任务队列
@@ -79,7 +79,7 @@ JobQueue.Pop() 取出 taskId
│ ├── AssetGenerator → stage 更新
│ ├── QualitySupervisor → stage 更新(可能触发重试分支)
│ └── FormatAdapter → stage 更新
├── 5. 上传素材至 OSS,写入 asset 表
├── 5. 保存素材到本地,写入 asset 表
├── 6. 更新 task.status = completed
└── 7. 异常时更新 task.status = failed, task.error = 错误信息
```
@@ -128,7 +128,7 @@ func (s *TaskService) StartWorkers(n int) {
| 提示词构建 | `prompt_builder` | 0-20% | 合并风格,生成三段式提示词 |
| 素材生成 | `asset_generator` | 20-60% | 调用 AI 推理 API 出图 |
| 质量检查 | `quality_supervisor` | 60-80% | 视觉模型质检 |
| 格式适配 | `format_adapter` | 80-100% | 格式转换、spritesheet 打包、上传 OSS |
| 格式适配 | `format_adapter` | 80-100% | 格式转换、spritesheet 打包、保存素材 |
进度更新通过 WebSocket 实时推送给前端(参见 [API 设计 - WebSocket 消息格式](api.md))。
@@ -152,7 +152,7 @@ QualitySupervisor -- pass --> FormatAdapter
### 任务级重试(管线外重试)
管线执行过程中发生不可恢复的错误(如 AI API 超时、OSS 上传失败):
管线执行过程中发生不可恢复的错误(如 AI API 超时、文件写入失败):
| 参数 | 默认值 | 说明 |
|------|--------|------|
@@ -176,10 +176,10 @@ GET /api/v1/tasks/:taskId
### WebSocket
```
ws://host/api/v1/tasks/:taskId/ws?token=<jwt>
ws://host/api/v1/tasks/:taskId/ws
```
长连接实时推送,每个阶段的状态变更立即通知前端。消息格式参见 [API 设计](api.md)。
长连接实时推送,通过 httpOnly Cookie 自动认证,每个阶段的状态变更立即通知前端。消息格式参见 [API 设计](api.md)。
## 去重
@@ -187,4 +187,4 @@ ws://host/api/v1/tasks/:taskId/ws?token=<jwt>
## 文件存储
任务完成后,FormatAdapter 将素材上传至 OSS。素材文件的存储结构与访问方式详见 [数据存储 — 文件存储](database.md#文件存储)。
任务完成后,FormatAdapter 将素材保存到本地文件系统。素材文件的存储结构与访问方式详见 [数据存储 — 文件存储](database.md#文件存储)。
+75 -10
View File
@@ -30,13 +30,13 @@ backend/internal/
│ └── style.go # 工程风格 & 任务风格覆盖
├── middleware/ # 中间件
│ ├── logger.go
│ ├── auth.go # JWT 认证中间件
│ ├── auth.go # JWT 认证中间件(从 Cookie 读取 Token)
│ └── ratelimit.go
├── config/ # [已有] 配置
│ └── config.go
├── queue/ # 异步任务队列
│ └── jobqueue.go
└── cache/ # 缓存层(请求去重 + 结果缓存)
└── cache/ # 缓存层(请求去重)
└── cache.go
```
@@ -46,17 +46,71 @@ backend/internal/
- **service**:承载所有业务逻辑。`pipeline.go` 通过 Eino `compose.Graph` 编排四个节点;`nodes.go` 实现各节点逻辑;`types.go` 定义显式状态结构体。
- **model**:纯数据结构,不含业务逻辑。
## 管线设计(Eino compose.Graph)
## 多阶段生成管线
四阶段管线,含质检不通过时的重生成分支:
gen2d 的核心生成流程采用 Eino `compose.Graph` 编排的四阶段管线,含质检不通过时的重生成分支。
```mermaid
flowchart LR
START --> PromptBuilder --> AssetGenerator --> QualitySupervisor
QualitySupervisor -- pass --> FormatAdapter --> END
QualitySupervisor -- fail --> PromptBuilder
A["PromptBuilder\n(提示词工程)"] --> B["AssetGenerator\n(AI 出图)"]
B --> C["QualitySupervisor\n(质检)"]
C -- pass --> D["FormatAdapter\n(格式适配)"]
C -- fail --> A
```
### 各阶段职责
#### 1. PromptBuilder
- **输入**:用户文本 + 素材类型标签 + 工程风格 + 任务风格覆盖 + 技术参数
- **输出**:三段式完整生成提示词
- **职责**:
1. 解析用户文本,提取核心内容作为【主题】
2. 合并工程风格与任务风格覆盖(任务同名键覆盖工程),生成【约束】(含负面提示词)
3. 注入素材类型、分辨率等技术参数作为【内容】
4. 拼接为最终提示词
5. **重试时**:将上次质检的 RejectReason 注入【约束】段,指导 AI 修正问题
**风格合并**:
```go
finalStyle = merge(projectStyle.KVPairs, taskStyle.KVPairs)
```
工程风格保证同一工程下所有素材风格一致;任务风格仅覆盖需要差异化的键。
**三段式提示词结构**:
```
【主题】用户的原始描述核心内容
【约束】风格键值对生成的约束条件 + 负面提示词 + [重试时] 上次质检问题:{rejectReason}
【内容】素材类型、分辨率、帧数等技术参数
```
#### 2. AssetGenerator
- **输入**:PromptBuilder 的输出
- **输出**:原始生成图片(单张或多张)
- **职责**:调用 AI 推理 API 出图。不同素材类型的差异化需求已在上一步注入提示词中。
- **接口规范**:统一采用 OpenAI 兼容的 `/v1/images/generations` 格式。多张生成通过 `n` 参数请求,实际支持数量取决于后端模型(如 DALL-E 3 仅支持 `n=1`,需多次调用模拟多张)。
#### 3. QualitySupervisor
- **输入**:原始生成图片 + 风格配置
- **输出**:通过 / 不通过 + 不通过的原因
- **职责**:通过视觉模型检查素材是否符合提示词要求与风格约束。不通过时,Graph 分支将 RejectReason 回填到 PipelineState,路由回 PromptBuilder 重新生成(最多 3 次,超过则降级输出)。
#### 4. FormatAdapter
- **输入**:通过质检的图片 + 素材类型
- **输出**:游戏引擎可用的素材文件 + 元数据
- **职责**:格式转换、spritesheet 打包、元数据生成。
- **示例**:输入 4 张 64×64 的角色行走帧 PNG → 输出一张 256×64 的 spritesheet + JSON 元数据(帧尺寸、帧数、锚点偏移),可直接拖入 Unity 2D Animation 使用。
---
## 管线设计(Eino compose.Graph)
### 类型定义(types.go)
```go
@@ -105,6 +159,7 @@ func NewGenerateGraph() (*compose.Graph[PipelineInput, PipelineOutput], error) {
// 添加节点
_ = g.AddLambdaNode(nodePromptBuilder, promptBuilderNode,
compose.WithStatePreHandler(promptBuilderPreHandler), // 重试时注入 RejectReason
compose.WithStatePostHandler(promptBuilderPostHandler))
_ = g.AddLambdaNode(nodeAssetGenerator, assetGeneratorNode,
compose.WithStatePostHandler(assetGeneratorPostHandler))
@@ -142,7 +197,7 @@ func NewGenerateGraph() (*compose.Graph[PipelineInput, PipelineOutput], error) {
| 节点 | Lambda 输入→输出 | State 交互 | 职责 |
|------|-----------------|-----------|------|
| PromptBuilder | `PipelineInput → string` | PostHandler 写入 FinalPrompt | 合并风格 → 生成三段式提示词 |
| PromptBuilder | `PipelineInput → string` | PreHandler 读取 RejectReason,PostHandler 写入 FinalPrompt | 合并风格 → 生成三段式提示词(重试时注入质检问题) |
| AssetGenerator | `string → []GeneratedImage` | PostHandler 写入 RawImages | 调用 AI 推理 API 出图 |
| QualitySupervisor | `[]GeneratedImage → bool` | PostHandler 写入 PassQuality + RejectReason + RetryCount | 视觉模型质检 |
| FormatAdapter | `[]GeneratedImage → PipelineOutput` | 无 | 格式转换、spritesheet 打包 |
@@ -150,10 +205,18 @@ func NewGenerateGraph() (*compose.Graph[PipelineInput, PipelineOutput], error) {
```go
// PromptBuilder 节点:接收输入,输出提示词
var promptBuilderNode = compose.InvokableLambda(func(ctx context.Context, in PipelineInput) (string, error) {
// 合并 projectStyle + taskStyle,生成三段式提示词
return buildPrompt(in), nil
})
// StatePreHandler:重试时将 RejectReason 注入输入
func promptBuilderPreHandler(ctx context.Context, in PipelineInput, state *PipelineState) (PipelineInput, error) {
if state.RetryCount > 0 && state.RejectReason != "" {
// 将上次质检问题附加到输入中,供 buildPrompt 注入【约束】段
in.RejectReason = state.RejectReason
}
return in, nil
}
// StatePostHandler:将提示词写入全局状态
func promptBuilderPostHandler(ctx context.Context, out string, state *PipelineState) (string, error) {
state.FinalPrompt = out
@@ -189,6 +252,8 @@ func RunPipeline(ctx context.Context, in PipelineInput) (*PipelineOutput, error)
}
```
---
## 关键实体
| 实体 | 说明 | 关系 |
@@ -242,4 +307,4 @@ finalStyle = merge(projectStyle.KVPairs, taskStyle.KVPairs)
- **数据结构**:`sync.Map`,key 为 `hash(prompt + assetType + params)`,value 为 taskId
- **生命周期**:任务提交时写入,任务完成或失败后清除;可设置 TTL 过期兜底
- **作用域**:单实例内存,不跨实例共享
- **接口**:提供 `Get`/`Set`/`Delete`/`Clear` 方法,同时供 handler 层(缓存管理 API)和 service 层(去重检查)调用
- **接口**:提供 `Get`/`Set`/`Delete`/`Clear` 方法,供 service 层(去重检查)调用
+100 -111
View File
@@ -4,12 +4,12 @@
| 用途 | 方案 | 说明 |
|------|------|------|
| 持久化存储 | MySQL 8.0+ | 工程、任务、素材元数据、风格配置全部落库 |
| 文件存储 | OSS 对象存储 | 生成的图片与 spritesheet 文件上传至 S3 兼容存储桶,通过 CDN URL 访问 |
| 认证 | JWT | 简单注册登录,Bearer Token 鉴权 |
| 缓存 / 去重 | MySQL + 应用层内存 | 请求去重通过数据库唯一约束 + 应用层 sync.Map 实现短期去重窗口,不引入 Redis |
| 持久化存储 | SQLite 3 | 工程、任务、素材元数据、风格配置全部落库,单文件部署 |
| 文件存储 | 本地文件系统 | 生成的图片与 spritesheet 文件保存到本地目录,通过 Gin 静态文件服务访问 |
| 认证 | JWT + Cookie | 简单注册登录,httpOnly Cookie 鉴权 |
| 缓存 / 去重 | 应用层内存 | 请求去重通过应用层 `sync.Map` 实现短期去重窗口,无需外部依赖 |
**不用 Redis 的理由**:gen2d 是单实例部署的工具型应用,并发量有限,任务状态轮询即可满足实时性需求。MySQL 完全能覆盖缓存、去重、队列语义,引入 Redis 增加运维复杂度但收益不大。
**选用 SQLite 的理由**:gen2d 是单实例部署的工具型应用,SQLite 零配置、单文件、性能足够,无需额外数据库服务。
---
@@ -18,32 +18,30 @@
### user — 用户
```sql
CREATE TABLE `user` (
`id` CHAR(20) NOT NULL, -- 用户 ID,如 user_a1B2c3
`username` VARCHAR(64) NOT NULL, -- 登录用户名
`email` VARCHAR(128) NOT NULL, -- 邮箱
`password_hash` VARCHAR(128) NOT NULL, -- bcrypt 哈希
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user (
id TEXT NOT NULL PRIMARY KEY, -- 用户 ID,如 user_a1B2c3
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL, -- bcrypt 哈希
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX uk_username ON user (username);
CREATE UNIQUE INDEX uk_email ON user (email);
```
### project — 工程
```sql
CREATE TABLE `project` (
`id` CHAR(20) NOT NULL, -- 项目 ID,如 proj_abc123
`user_id` CHAR(20) NOT NULL, -- 所属用户
`name` VARCHAR(128) NOT NULL, -- 工程名称
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`, `created_at`),
CONSTRAINT `fk_project_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE project (
id TEXT NOT NULL PRIMARY KEY, -- 项目 ID,如 proj_abc123
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE INDEX idx_project_user ON project (user_id, created_at);
```
### project_style — 工程风格
@@ -51,58 +49,55 @@ CREATE TABLE `project` (
工程级键值对,保证同一工程下所有素材风格一致。每个工程恰好一行。
```sql
CREATE TABLE `project_style` (
`id` CHAR(20) NOT NULL,
`project_id` CHAR(20) NOT NULL,
`kv_pairs` JSON NOT NULL, -- {"artStyle":"pixel","palette":"warm",...}
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_project` (`project_id`),
CONSTRAINT `fk_style_project` FOREIGN KEY (`project_id`) REFERENCES `project` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE project_style (
id TEXT NOT NULL PRIMARY KEY,
project_id TEXT NOT NULL,
kv_pairs TEXT NOT NULL, -- JSON: {"artStyle":"pixel","palette":"warm",...}
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX uk_style_project ON project_style (project_id);
```
### task — 生成任务
```sql
CREATE TABLE `task` (
`id` CHAR(20) NOT NULL, -- 任务 ID,如 task_xyz789
`project_id` CHAR(20) NOT NULL,
`prompt` TEXT NOT NULL, -- 用户原始文本
`asset_type` VARCHAR(32) NOT NULL, -- sprite / background / ui / animation
`task_style` JSON NULL, -- 任务级风格覆盖,可为 NULL
`params` JSON NOT NULL DEFAULT '{}', -- {"resolution":64,"frames":{...},"format":"spritesheet"}
`status` VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending / running / completed / failed
`stage` VARCHAR(32) NULL, -- 当前管线阶段:prompt_builder / asset_generator / quality_supervisor / format_adapter
`progress` TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0-100
`retry_count` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`error` TEXT NULL, -- 失败原因
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_project` (`project_id`, `created_at`),
KEY `idx_status` (`status`),
CONSTRAINT `fk_task_project` FOREIGN KEY (`project_id`) REFERENCES `project` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE task (
id TEXT NOT NULL PRIMARY KEY, -- 任务 ID,如 task_xyz789
project_id TEXT NOT NULL,
prompt TEXT NOT NULL, -- 用户原始文本
asset_type TEXT NOT NULL, -- sprite / background / ui / animation
task_style TEXT, -- JSON: 任务级风格覆盖,可为 NULL
params TEXT NOT NULL DEFAULT '{}', -- JSON: {"resolution":64,"frames":{...},"format":"spritesheet"}
status TEXT NOT NULL DEFAULT 'pending', -- pending / running / completed / failed
stage TEXT, -- 当前管线阶段
progress INTEGER NOT NULL DEFAULT 0, -- 0-100
retry_count INTEGER NOT NULL DEFAULT 0,
error TEXT, -- 失败原因
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE
);
CREATE INDEX idx_task_project ON task (project_id, created_at);
CREATE INDEX idx_task_status ON task (status);
```
### asset — 生成素材
```sql
CREATE TABLE `asset` (
`id` CHAR(20) NOT NULL, -- 素材 ID,如 asset_001
`task_id` CHAR(20) NOT NULL,
`url` VARCHAR(512) NOT NULL, -- OSS 完整 URL 或相对路径
`format` VARCHAR(16) NOT NULL DEFAULT 'png', -- png / json
`width` INT NOT NULL DEFAULT 0,
`height` INT NOT NULL DEFAULT 0,
`metadata` JSON NULL, -- {"frameWidth":64,"frameHeight":64,"frameCount":4,"directions":1}
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_task` (`task_id`),
CONSTRAINT `fk_asset_task` FOREIGN KEY (`task_id`) REFERENCES `task` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE asset (
id TEXT NOT NULL PRIMARY KEY, -- 素材 ID,如 asset_001
task_id TEXT NOT NULL,
url TEXT NOT NULL, -- 相对路径,如 tasks/task_xyz/output/spritesheet.png
format TEXT NOT NULL DEFAULT 'png', -- png / json
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
metadata TEXT, -- JSON: {"frameWidth":64,"frameHeight":64,"frameCount":4,"directions":1}
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE
);
CREATE INDEX idx_asset_task ON asset (task_id);
```
---
@@ -122,26 +117,28 @@ task 1 ── N asset
## ID 生成
使用 **Sonyflake**(或类似分布式 ID 方案)生成 20 字符的字符串 ID,格式为 `{前缀}_{base62}`:
使用前缀 + 随机字符串生成 ID,格式为 `{prefix}_{random}`:
| 实体 | 前缀 | 示例 |
|------|------|------|
| user | `user` | `user_a1B2c3D4` |
| project | `proj` | `proj_3kF9a2Bc` |
| project_style | `sty` | `sty_7xM2pQ1d` |
| task | `task` | `task_9bN4vR8e` |
| asset | `asset` | `asset_2wK6tY5f` |
| user | `user` | `user_a1B2c3` |
| project | `proj` | `proj_3kF9a2` |
| project_style | `sty` | `sty_7xM2pQ` |
| task | `task` | `task_9bN4vR` |
| asset | `asset` | `asset_2wK6tY` |
随机部分使用 `crypto/rand` 生成 6 字节 base62 编码,兼顾可读性与唯一性。
---
## 文件存储
使用 S3 兼容对象存储(如阿里云 OSS、MinIO),用户生成的素材持久化保存,便于重复利用。
生成的素材文件保存到本地文件系统,通过 Gin 静态文件服务访问。
### OSS Key 结构
### 目录结构
```
{bucket}/
{dataDir}/
└── users/
└── {userId}/
└── projects/
@@ -158,15 +155,22 @@ task 1 ── N asset
└── pipeline.log # 管线执行日志
```
`dataDir` 通过环境变量 `GEN2D_DATA_DIR` 配置,默认 `./data`。
### 访问方式
- **开发环境**:Gin 静态文件服务,路由 `/files/*` 映射到本地 `data/` 目录,无需 OSS。
- **生产环境**:文件上传至 OSS 存储桶,`asset.url` 存储完整 CDN URL,前端直接访问。
Gin 静态文件服务,路由 `/files/*` 映射到 `{dataDir}/` 目录:
```go
router.Static("/files", dataDir)
```
### asset 表的 url 字段
- 开发环境:相对路径,如 `projects/proj_abc/tasks/task_xyz/output/spritesheet.png`,前端通过 `/files/{url}` 拼接。
- 生产环境:完整 URL,如 `https://cdn.example.com/users/user_xxx/projects/proj_abc/tasks/task_xyz/output/spritesheet.png`。
存储相对路径,前端通过 `/files/{url}` 拼接访问:
- 如 `users/user_a1B2c3/projects/proj_abc/tasks/task_xyz/output/spritesheet.png`
- 前端拼接为 `/files/users/user_a1B2c3/projects/proj_abc/tasks/task_xyz/output/spritesheet.png`
---
@@ -180,8 +184,6 @@ task 1 ── N asset
- 若不存在,写入内存并提交任务。
3. 任务完成或失败后,从内存中清除(可设置 TTL 过期兜底)。
不依赖数据库唯一约束做去重,因为相同输入在不同时间点应该允许重新生成。
---
## 配置
@@ -191,17 +193,10 @@ task 1 ── N asset
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `GEN2D_SERVER_PORT` | `8080` | HTTP 服务监听端口 |
| `GEN2D_DB_DSN` | `root:@tcp(127.0.0.1:3306)/gen2d?charset=utf8mb4&parseTime=True&loc=Local` | MySQL DSN |
| `GEN2D_DB_MAX_OPEN` | `25` | 最大连接数 |
| `GEN2D_DB_MAX_IDLE` | `5` | 最大空闲连接数 |
| `GEN2D_DB_MAX_LIFETIME` | `300` | 连接最大存活时间(秒) |
| `GEN2D_DB_PATH` | `./data/gen2d.db` | SQLite 数据库文件路径 |
| `GEN2D_DATA_DIR` | `./data` | 本地文件存储根目录 |
| `GEN2D_JWT_SECRET` | — | JWT 签名密钥(必填) |
| `GEN2D_JWT_EXPIRE` | `7200` | Token 过期时间(秒) |
| `GEN2D_OSS_ENDPOINT` | — | OSS Endpoint(生产环境必填) |
| `GEN2D_OSS_BUCKET` | — | 存储桶名称 |
| `GEN2D_OSS_ACCESS_KEY` | — | Access Key ID |
| `GEN2D_OSS_SECRET_KEY` | — | Access Key Secret |
| `GEN2D_OSS_CDN_DOMAIN` | — | CDN 域名(可选,用于拼接公开访问 URL) |
在 `config.go` 中扩展字段即可,无需额外依赖。
@@ -209,23 +204,17 @@ task 1 ── N asset
## 迁移
使用 [golang-migrate](https://github.com/golang-migrate/migrate) 管理 schema 版本:
应用启动时自动执行建表 SQL,保证 schema 与代码版本一致:
```
backend/internal/migrations/
├── 000001_init_user.up.sql
├── 000001_init_user.down.sql
├── 000002_init_project.up.sql
├── 000002_init_project.down.sql
├── 000003_init_task.up.sql
├── 000003_init_task.down.sql
├── 000004_init_asset.up.sql
└── 000004_init_asset.down.sql
```go
// 启动时执行
db.Exec(createUserTableSQL)
db.Exec(createProjectTableSQL)
db.Exec(createTaskTableSQL)
db.Exec(createAssetTableSQL)
```
启动时自动执行 `migrate.Up()`,保证 schema 与代码版本一致。
> 注:`project_style` 表的创建包含在 `000002_init_project.up.sql` 中,与 `project` 表同批迁移。
使用 `CREATE TABLE IF NOT EXISTS` 保证幂等性。
---
@@ -235,8 +224,8 @@ backend/internal/migrations/
|----|------|------|
| `user` | `uk_username (username)` | 用户名唯一约束 |
| `user` | `uk_email (email)` | 邮箱唯一约束 |
| `project` | `idx_user (user_id, created_at)` | 用户下工程列表分页查询 |
| `project_style` | `uk_project (project_id)` | 一个工程一个风格,唯一约束 |
| `task` | `idx_project (project_id, created_at)` | 工程下任务列表分页查询 |
| `task` | `idx_status (status)` | 按状态筛选任务(如查找所有 pending 任务) |
| `asset` | `idx_task (task_id)` | 任务下素材列表查询 |
| `project` | `idx_project_user (user_id, created_at)` | 用户下工程列表分页查询 |
| `project_style` | `uk_style_project (project_id)` | 一个工程一个风格,唯一约束 |
| `task` | `idx_task_project (project_id, created_at)` | 工程下任务列表分页查询 |
| `task` | `idx_task_status (status)` | 按状态筛选任务 |
| `asset` | `idx_asset_task (task_id)` | 任务下素材列表查询 |
+5 -4
View File
@@ -10,7 +10,7 @@ Vite 6 + React 18 + TypeScript + zustand + react-router-dom
| UI 框架 | React 18 | 函数组件 + Hooks |
| 状态管理 | zustand | 轻量,支持 devtools 中间件 |
| 路由 | react-router-dom v6 | SPA 模式 |
| 实时通信 | 原生 WebSocket | 连接后端 `/api/v1/tasks/:taskId/ws` |
| 实时通信 | 原生 WebSocket | 连接后端 `/api/v1/tasks/:taskId/ws`(Cookie 认证) |
| HTTP 请求 | fetch + 封装层 | 统一错误处理、响应解包 |
## 目录结构
@@ -97,7 +97,7 @@ sequenceDiagram
User->>GP: 点击提交
GP->>API: POST /api/v1/generate
API-->>GP: 返回 taskId
GP->>WS: ws://host/api/v1/tasks/:taskId/ws
GP->>WS: ws://host/api/v1/tasks/:taskId/ws(Cookie 自动携带)
loop 管线执行中
WS-->>GP: PipelineProgress(stage + progress)
GP->>GP: ProgressBar 更新阶段和进度
@@ -141,7 +141,7 @@ sequenceDiagram
### API 请求错误
- `api/client.ts` 统一拦截 `code !== 0` 的响应,抛出业务异常
- 401:自动清除本地 Token,跳转到登录页(或提示重新登录)
- 401:跳转到登录页(Token 已过期或无效,Cookie 会被服务端清除)
- 403:提示无权访问,不自动跳转
- 429:提示请求过于频繁,稍后重试
- 500:展示通用错误提示
@@ -224,9 +224,10 @@ type PipelineStage =
`api/client.ts` 统一封装:
- baseURL 从环境变量读取,开发模式默认 `/api/v1`
- 所有 fetch 请求设置 `credentials: 'include'`,自动携带 Cookie
- 所有响应按 `{ code, message, data }` 解包,`code !== 0` 时抛错
- 统一 401/403/500 错误处理
- WebSocket 连接封装为 `createTaskSocket(taskId)` 返回可订阅对象
- WebSocket 连接封装为 `createTaskSocket(taskId)` 返回可订阅对象(浏览器自动携带 Cookie)
### API 类型定义(api/types.ts)
-64
View File
@@ -1,64 +0,0 @@
# 多阶段生成管线
gen2d 的核心生成流程采用 Eino `compose.Graph` 编排的四阶段管线,含质检不通过时的重生成分支。管线编排与节点实现详见 [后端工程 — 管线设计](backend.md#管线设计eino-composegraph)。
```mermaid
flowchart LR
A["PromptBuilder\n(提示词工程)"] --> B["AssetGenerator\n(AI 出图)"]
B --> C["QualitySupervisor\n(质检)"]
C -- pass --> D["FormatAdapter\n(格式适配)"]
C -- fail --> A
```
---
## 1. PromptBuilder
- **输入**:用户文本 + 素材类型标签 + 工程风格 + 任务风格覆盖 + 技术参数
- **输出**:三段式完整生成提示词
- **职责**:
1. 解析用户文本,提取核心内容作为【主题】
2. 合并工程风格与任务风格覆盖(任务同名键覆盖工程),生成【约束】(含负面提示词)
3. 注入素材类型、分辨率等技术参数作为【内容】
4. 拼接为最终提示词
### 风格合并
```go
finalStyle = merge(projectStyle.KVPairs, taskStyle.KVPairs)
```
工程风格保证同一工程下所有素材风格一致;任务风格仅覆盖需要差异化的键。
### 三段式提示词结构
```
【主题】用户的原始描述核心内容
【约束】风格键值对生成的约束条件 + 负面提示词
【内容】素材类型、分辨率、帧数等技术参数
```
## 2. AssetGenerator
- **输入**:PromptBuilder 的输出
- **输出**:原始生成图片(单张或多张)
- **职责**:调用 AI 推理 API 出图。不同素材类型的差异化需求已在上一步注入提示词中。
- **接口规范**:统一采用 OpenAI 兼容的 `/v1/images/generations` 格式。多张生成通过 `n` 参数请求,实际支持数量取决于后端模型(如 DALL-E 3 仅支持 `n=1`,需多次调用模拟多张)。
## 3. QualitySupervisor
- **输入**:原始生成图片 + 风格配置
- **输出**:通过 / 不通过 + 不通过的原因
- **职责**:通过视觉模型检查素材是否符合提示词要求与风格约束。不通过时,Graph 分支将 RejectReason 回填到 PipelineState,路由回 PromptBuilder 重新生成(最多 3 次,超过则降级输出)。
## 4. FormatAdapter
- **输入**:通过质检的图片 + 素材类型
- **输出**:游戏引擎可用的素材文件 + 元数据
- **职责**:格式转换、spritesheet 打包、元数据生成。
- **示例**:输入 4 张 64×64 的角色行走帧 PNG → 输出一张 256×64 的 spritesheet + JSON 元数据(帧尺寸、帧数、锚点偏移),可直接拖入 Unity 2D Animation 使用。
---
管线执行过程中的进度推送与重试机制详见 [异步任务](async-tasks.md)。