Files

353 lines
14 KiB
Markdown
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 后端工程
## 技术选型
- **HTTP 框架**:Gin
- **管线编排**:[Eino](https://github.com/cloudwego/eino) `compose.Graph` — 三阶段管线,含质检→重优化分支
- **AI 组件**:Eino 的 model / tool 组件,统一接口便于替换模型提供商
## 分层结构
```
backend/internal/
├── handler/ # HTTP handlers(薄层,只做参数绑定 + 调用 service)
│ ├── health.go # [已有] 健康检查
│ ├── register.go # [已有] 用户注册(handler 骨架 + 参数校验)
│ ├── login.go # [已有] 用户登录(handler 骨架 + 参数校验)
│ ├── generate.go # [已有] 素材生成(异步管线 + 任务查询)
│ ├── edit.go # [已有] 图片编辑
│ ├── prompt.go # [已有] 提示词优化
│ └── storage.go # [已有] 素材下载(重定向到七牛云 CDN)
├── service/ # 业务逻辑层
│ ├── pipeline.go # [已有] Eino compose.Graph 编排:PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter
│ ├── nodes.go # [已有] 管线四个节点的实现(每个节点单一职责)
│ ├── types.go # [已有] 管线输入/状态/输出的显式结构体定义
│ ├── inference.go # [已有] AI 推理 API 调用封装(文生图 + 图片编辑)
│ ├── prompt_agent.go # [已有] LLM 提示词优化 Agent
│ ├── pipeline_test.go # [已有] 管线测试(happy path / 重试 / 降级 / 风格合并)
│ ├── auth.go # [已有] 用户认证:注册、登录、JWT 签发与校验
│ └── storage.go # [已有] 七牛云对象存储:上传、下载 URL 生成、删除
├── model/ # 数据模型 / DTO
│ ├── response.go # [已有] 统一响应
│ └── user.go # [已有] 用户模型
├── mildware/ # 中间件
│ ├── logger.go # [已有] 请求日志 + Panic 恢复中间件(基于 slog)
│ └── auth.go # [已有] JWT 认证中间件
├── logger/ # [已有] 日志包(基于 log/slog)
│ └── logger.go # 日志初始化、全局 logger、context 注入
├── config/ # [已有] 配置
│ ├── config.go
│ └── config.yml
└── db/ # [已有] 数据库初始化
└── db.go
```
## 分层原则
- **handler**:只做参数绑定、校验、调用 service、返回响应。一个 handler 对应一组 API 路由。
- **service**:承载所有业务逻辑。`pipeline.go` 通过 Eino `compose.Graph` 编排四个节点;`nodes.go` 实现各节点逻辑;`types.go` 定义显式状态结构体。
- **model**:纯数据结构,不含业务逻辑。
## 日志系统
基于 Go 标准库 `log/slog`,无需第三方依赖。
### 核心组件
- **`internal/logger/logger.go`**:日志初始化与 context 注入
- `Init(level, format)` — 初始化全局 logger(level: debug/info/warn/error,format: text/json)
- `FromCtx(ctx)` — 从 context 提取带 request_id 的 logger
- `WithRequestID(ctx, id)` — 创建带 request_id 的 logger 并存入 context
- **`internal/mildware/logger.go`**:HTTP 请求日志 + Panic 恢复中间件
- `Logger()` — 为每个请求生成 request_id(注入 header `X-Request-ID`),记录 method/path/status/latency/client_ip
- `Recovery()` — 自定义 panic 恢复,记录 request 上下文和堆栈
### 日志级别使用规范
| 级别 | 场景 |
|------|------|
| Debug | 开发调试信息 |
| Info | 正常业务流程(API 调用、任务完成等) |
| Warn | 可恢复的异常(认证失败、LLM 回退模板等) |
| Error | 不可恢复的错误(API 调用失败、数据库错误等) |
### 配置
```yaml
log:
level: info # debug / info / warn / error
format: text # text(开发)/ json(生产)
```
环境变量:`GEN2D_LOG_LEVEL`、`GEN2D_LOG_FORMAT`
### 错误处理原则
- 服务端错误(5xx):先用 `slog.Error` 记录完整错误(含 request_id),返回客户端通用描述
- 客户端错误(4xx):用 `slog.Warn` 记录,返回具体提示
- 内部错误细节不泄露给客户端
## 多阶段生成管线
gen2d 的核心生成流程采用 Eino `compose.Graph` 编排的三阶段管线,含质检不通过时的重优化分支。
```mermaid
flowchart LR
A["PromptOptimizer\n(提示词优化 + 风格合并)"] --> B["AssetGenerator\n(AI 出图)"]
B --> C["QualitySupervisor\n(质检)"]
C -- pass --> D["FormatAdapter\n(格式适配)"]
C -- fail --> A
```
### 各阶段职责
#### 1. PromptOptimizer
- **输入**:用户文本 + 素材类型标签 + 工程风格 + 任务风格覆盖 + 技术参数 + Tags
- **输出**:三段式完整生成提示词
- **职责**:
1. 合并工程风格与任务风格覆盖(任务同名键覆盖工程),转为自然语言风格描述
2. 有标签时调用 PromptAgent(LLM)生成规范化三段式提示词
3. 无标签时直接拼接原始 Prompt + 技术参数
4. **重试时**:将上次质检的 RejectReason 注入输入,指导 LLM 修正问题
5. 未配置 LLM API key 时自动回退到模板生成,不阻塞管线
**风格合并**:
```go
finalStyle = merge(projectStyle.KVPairs, taskStyle.KVPairs)
```
工程风格保证同一工程下所有素材风格一致;任务风格仅覆盖需要差异化的键。
**三段式提示词结构**(由 PromptAgent / LLM 生成):
```
【主题】用户的原始描述核心内容
【风格】风格描述与视觉特征
【技术】素材类型、分辨率、帧数等技术参数
```
#### 2. AssetGenerator
- **输入**:PromptOptimizer 的输出
- **输出**:原始生成图片(单张或多张)
- **职责**:调用 AI 推理 API 出图。不同素材类型的差异化需求已在上一步注入提示词中。
- **接口规范**:统一采用 OpenAI 兼容的 `/v1/images/generations` 格式。多张生成通过 `n` 参数请求,实际支持数量取决于后端模型(如 DALL-E 3 仅支持 `n=1`,需多次调用模拟多张)。
#### 3. QualitySupervisor
- **输入**:原始生成图片 + 风格配置
- **输出**:通过 / 不通过 + 不通过的原因
- **职责**:通过视觉模型检查素材是否符合提示词要求与风格约束。不通过时,Graph 分支将 RejectReason 回填到 PipelineState,路由回 PromptOptimizer 重新生成(最多 3 次,超过则降级输出)。
#### 4. FormatAdapter
- **输入**:通过质检的图片 + 素材类型
- **输出**:游戏引擎可用的素材文件 + 元数据
- **职责**:格式转换、spritesheet 打包、元数据生成、上传素材到七牛云对象存储。
- **示例**:输入 4 张 64×64 的角色行走帧 PNG → 输出一张 256×64 的 spritesheet + JSON 元数据(帧尺寸、帧数、锚点偏移),可直接拖入 Unity 2D Animation 使用。
---
## 管线设计(Eino compose.Graph)
### 类型定义(types.go)
```go
// PipelineInput 管线入口输入
type PipelineInput struct {
Prompt string // 用户原始文本
AssetType string // 素材类型:sprite / background / ui / animation
ProjectStyle map[string]string // 工程风格键值对
TaskStyle map[string]string // 任务风格覆盖
Params AssetParams // 技术参数(分辨率、帧数、格式等)
}
// PipelineState Graph 全局状态,通过 WithGenLocalState 注入
type PipelineState struct {
Input PipelineInput
FinalPrompt string // PromptOptimizer 输出的三段式提示词
RawImages []GeneratedImage // AssetGenerator 输出的原始图片
PassQuality bool // QualitySupervisor 质检结果
RejectReason string // 质检不通过原因
RetryCount int // 重试次数
}
// PipelineOutput 管线最终输出
type PipelineOutput struct {
Assets []Asset // 生成结果素材列表
Metadata AssetMetadata // 元数据(分辨率、帧数、格式)
}
```
### 编排入口(pipeline.go)
```go
const (
nodePromptOptimizer = "prompt_optimizer"
nodeAssetGenerator = "asset_generator"
nodeQualitySupervisor = "quality_supervisor"
nodeFormatAdapter = "format_adapter"
)
func NewGenerateGraph() (*compose.Graph[PipelineInput, PipelineOutput], error) {
g := compose.NewGraph[PipelineInput, PipelineOutput](
compose.WithGenLocalState(func(ctx context.Context) *PipelineState {
return &PipelineState{}
}),
)
// 添加节点
_ = g.AddLambdaNode(nodePromptOptimizer, promptBuilderNode,
compose.WithStatePreHandler(promptBuilderPreHandler), // 重试时注入 RejectReason
compose.WithStatePostHandler(promptBuilderPostHandler))
_ = g.AddLambdaNode(nodeAssetGenerator, assetGeneratorNode,
compose.WithStatePostHandler(assetGeneratorPostHandler))
_ = g.AddLambdaNode(nodeQualitySupervisor, qualitySupervisorNode,
compose.WithStatePostHandler(qualitySupervisorPostHandler))
_ = g.AddLambdaNode(nodeFormatAdapter, formatAdapterNode)
// 连线:正常路径
_ = g.AddEdge(compose.START, nodePromptOptimizer)
_ = g.AddEdge(nodePromptOptimizer, nodeAssetGenerator)
_ = g.AddEdge(nodeAssetGenerator, nodeQualitySupervisor)
_ = g.AddEdge(nodeFormatAdapter, compose.END)
// 连线:质检分支
_ = g.AddBranch(nodeQualitySupervisor, compose.NewGraphBranch(
func(ctx context.Context, state *PipelineState) (string, error) {
if state.PassQuality {
return nodeFormatAdapter, nil
}
if state.RetryCount >= 3 {
return nodeFormatAdapter, nil // 超过重试次数,降级输出
}
return nodePromptOptimizer, nil // 重生成
},
map[string]bool{nodePromptOptimizer: true, nodeFormatAdapter: true},
))
return g, nil
}
```
### 节点实现(nodes.go)
每个节点是 Graph 中的 Lambda,通过 `StatePreHandler` / `StatePostHandler` 读写全局状态:
| 节点 | Lambda 输入→输出 | State 交互 | 职责 |
|------|-----------------|-----------|------|
| PromptOptimizer | `PipelineInput → string` | PreHandler 读取 RejectReason,PostHandler 写入 FinalPrompt | 合并风格 → 生成三段式提示词(重试时注入质检问题) |
| AssetGenerator | `string → []GeneratedImage` | PostHandler 写入 RawImages | 调用 AI 推理 API 出图 |
| QualitySupervisor | `[]GeneratedImage → bool` | PostHandler 写入 PassQuality + RejectReason + RetryCount | 视觉模型质检 |
| FormatAdapter | `[]GeneratedImage → PipelineOutput` | 无 | 格式转换、spritesheet 打包 |
```go
// PromptOptimizer 节点:接收输入,输出提示词
var promptBuilderNode = compose.InvokableLambda(func(ctx context.Context, in PipelineInput) (string, error) {
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
return out, nil
}
// QualitySupervisor StatePostHandler:写入质检结果和重试计数
func qualitySupervisorPostHandler(ctx context.Context, out bool, state *PipelineState) (bool, error) {
state.PassQuality = out
if !out {
state.RetryCount++
state.RejectReason = "风格不一致" // 实际由视觉模型返回
}
return out, nil
}
```
### 运行入口
```go
func RunPipeline(ctx context.Context, in PipelineInput) (*PipelineOutput, error) {
g, err := NewGenerateGraph()
if err != nil {
return nil, err
}
r, err := g.Compile(ctx)
if err != nil {
return nil, err
}
return r.Invoke(ctx, in)
}
```
---
## 关键实体
| 实体 | 说明 | 关系 |
|------|------|------|
| Project | 顶层容器,用户创建的项目 | 1──1 ProjectStyle, 1──N Task |
| ProjectStyle | 工程级键值对风格配置,保证同一工程下所有素材风格一致 | 属于 Project |
| Task | 工程下的单次生成请求,包含用户文本、素材类型、任务风格覆盖、技术参数、状态、结果 | 属于 Project, 1──N Asset |
| Prompt | PromptOptimizer 输出的三段式提示词(主题+约束+内容),管线中间产物,不持久化 | 由 Task 生成 |
| Asset | 生成结果素材,关联到任务,包含 URL、元数据(分辨率、帧数、格式) | 属于 Task |
ER 关系图与外键约束详见 [数据存储 — ER 关系](database.md#er-关系)。
## 风格模型
### 工程风格(Project Style)
工程级别,保证同一工程下所有素材风格一致:
```go
type ProjectStyle struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
KVPairs map[string]string `json:"kvPairs"` // 如 {"artStyle":"pixel","palette":"warm"}
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
```
### 任务风格覆盖(Task Style Override)
任务级别,仅覆盖需要差异化的键:
```go
type TaskStyle struct {
KVPairs map[string]string `json:"kvPairs"` // 仅记录与工程风格不同的部分
}
```
### 合并逻辑
```
finalStyle = merge(projectStyle.KVPairs, taskStyle.KVPairs)
```
任务同名键覆盖工程风格,由 PromptOptimizer 节点在生成提示词时执行合并。
## 缓存层
`cache/cache.go` 提供应用内存级缓存,主要用于请求去重(详见 [数据存储 — 去重策略](database.md#去重策略)):
- **数据结构**:`sync.Map`,key 为 `hash(prompt + assetType + params)`,value 为 taskId
- **生命周期**:任务提交时写入,任务完成或失败后清除;可设置 TTL 过期兜底
- **作用域**:单实例内存,不跨实例共享
- **接口**:提供 `Get`/`Set`/`Delete`/`Clear` 方法,供 service 层(去重检查)调用