diff --git a/.dockerignore b/.dockerignore
index 6054892..b97b965 100755
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,4 +2,5 @@ node_modules
frontend/node_modules
frontend/dist
backend/bin
+backend/.env
.git
diff --git a/.workflow/deploy.yml b/.workflow/deploy.yml
new file mode 100644
index 0000000..836c30b
--- /dev/null
+++ b/.workflow/deploy.yml
@@ -0,0 +1,32 @@
+version: '1.0'
+name: pipeline-20260525
+displayName: pipeline-20260525
+triggers:
+ trigger: auto
+ push:
+ branches:
+ precise:
+ - master
+stages:
+ - name: stage-b0e54961
+ displayName: deploy
+ strategy: naturally
+ trigger: auto
+ executor: []
+ steps:
+ - step: shell@agent
+ name: execute_shell
+ displayName: Shell 脚本执行
+ schedulerType: DISTRIBUTED
+ hostGroupID:
+ ID: i-f8zc4nf8y8u267zwn9vw
+ hostID:
+ - 0c13eac4-c35d-455e-a162-ce11a0ab9e37
+ scriptType: SH
+ script:
+ - cd ~/gen2d
+ - ./deploy.sh
+ gitClone: false
+ notify: []
+ strategy:
+ retry: '0'
diff --git a/README.md b/README.md
index d018005..ab566ea 100755
--- a/README.md
+++ b/README.md
@@ -1,49 +1,45 @@
# gen2d
-AI 驱动的 2D 游戏素材生成工具。通过文本提示词或可视化参数,快速生成风格一致、管线友好的 Sprite、背景、UI 元素与动画帧。支持 Unity / Godot 一键集成。
+AI 驱动的 2D 游戏素材生成工具。通过文本提示词或可视化参数,生成风格一致、管线友好的 Sprite、背景、UI 元素与动画帧,无缝融入 Unity / Godot 等主流 2D 游戏引擎工作流。
-[](https://go.dev/)
-[](https://gin-gonic.com/)
-[](https://react.dev/)
-[](https://www.typescriptlang.org/)
-[](https://vite.dev/)
+[](https://go.dev/)
+[](https://gin-gonic.com/)
+[](https://github.com/cloudwego/eino)
+[](https://react.dev/)
+[](https://www.typescriptlang.org/)
+[](https://vite.dev/)
+[](LICENSE)
### **[在线体验 http://47.121.181.112:10000](http://47.121.181.112:10000)**
-架构采用多智能体协作管线(PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter),详见 [docs/backend.md](docs/backend.md)。
+## 特性
-## 提示词优化链路
+- **多智能体协作管线** — PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter,基于 Eino `compose.Graph` 编排
+- **提示词优化** — Eino Chain 驱动的 PromptAgent,支持任意 OpenAI 兼容 API,无 key 时自动回退模板生成
+- **风格系统** — 预设美术风格、色调、线条、场景、光照、情绪等维度,支持工程级与任务级风格覆盖
+- **实时进度** — WebSocket 推送管线各阶段状态、进度与质检重试信息
+- **异步任务** — 内存队列 + 通道模型,支持并发控制、失败重试(最多 3 次)与降级输出
+- **请求去重** — 相同 `prompt + assetType + params` 的并发请求自动合并
+- **暗色主题** — 前端支持 Light / Dark 双主题,CSS 自定义属性驱动
+- **一键部署** — Docker Compose 双容器架构 + `deploy.sh` 脚本 + Gitee CI/CD 流水线
-前端用户输入标签和原始描述后,后端通过 Eino 驱动的 PromptAgent 调用 LLM 进行提示词优化,最终生成规范的三段式提示词(【主题】【风格】【技术】)。
+## 技术栈
-### 优化管线
-
-```
-用户输入 (标签 + 原始Prompt + 素材类型)
- │
- ▼
-formatMetaPrompt (Lambda)
- │ 构建元提示词:角色设定 + 输出格式要求 + 用户输入
- ▼
-llmRefine (Lambda → ChatModel API)
- │ 调用 OpenAI 兼容的 Chat Completions API
- │ 支持流式 (SSE) 与非流式响应
- │ 无 API key 时自动回退到模板生成
- ▼
-三段式规范提示词(【主题】【风格】【技术】)
- │
- ▼
-PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter(生成管线)
-```
-
-### 特性
-
-- **Eino Chain 编排**:`START → formatMetaPrompt → llmRefine → END`
-- **OpenAI 兼容 API**:支持任意兼容 OpenAI 接口的 LLM 服务(OpenAI / DeepSeek / 本地模型等)
-- **SSE 流式解析**:自动识别 `text/event-stream` 响应并拼接完整内容
-- **优雅降级**:API 调用失败或未配置 key 时回退到模板生成,不阻塞管线
-
-详见下方 [提示词优化 API](#提示词优化-api)。
+| 层 | 技术 |
+|---|------|
+| 后端语言 | Go 1.26 |
+| HTTP 框架 | Gin v1.12 |
+| 管线编排 | [Eino](https://github.com/cloudwego/eino) v0.8 — `compose.Graph` + `compose.Chain` |
+| ORM | GORM + SQLite |
+| 认证 | JWT (golang-jwt) + bcrypt |
+| 配置 | Viper + godotenv(环境变量 > config.yml > 默认值) |
+| 前端框架 | React 18 + TypeScript 5.6 |
+| 构建工具 | Vite 6 |
+| 状态管理 | Zustand 5 |
+| 路由 | react-router-dom v7 |
+| 样式 | CSS Modules + CSS 自定义属性 |
+| 容器化 | Docker 多阶段构建 + nginx 反向代理 |
+| CI/CD | Gitee Workflow + deploy.sh |
## 快速开始
@@ -51,6 +47,7 @@ PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter(生
```bash
cd backend
+cp .env.example .env # 编辑 .env 填入 API key
go mod tidy
go run cmd/main.go
```
@@ -65,36 +62,35 @@ npm install
npm run dev
```
-开发服务器默认运行在 `http://localhost:5173`,通过 Vite proxy 转发 API 请求到后端。
+开发服务器默认运行在 `http://localhost:5173`,通过 Vite proxy 转发 `/api` 和 `/auth` 请求到后端。
## 配置
-配置通过 Viper 读取,分为三个优先级(高→低):
+配置通过 Viper 读取,优先级从高到低:
| 优先级 | 来源 | 说明 |
|--------|------|------|
| 1 | 环境变量 | 适合 Docker / CI,敏感字段推荐使用 |
-| 2 | `config.yml` | 文件位于 `backend/internal/config/config.yml`,存放非敏感默认值 |
-| 3 | 默认值 | 代码内置,无需任何配置文件即可启动 |
+| 2 | `.env` 文件 | 位于 `backend/.env`,通过 godotenv 加载 |
+| 3 | `config.yml` | 位于 `backend/internal/config/config.yml`,存放非敏感默认值 |
+| 4 | 代码默认值 | 无需任何配置文件即可启动 |
-**环境变量会覆盖 YAML 文件中的同名配置。**
-
-### 大语言模型配置
+### 大语言模型
| 环境变量 | YAML 路径 | 默认值 | 说明 |
|----------|-----------|--------|------|
-| `GEN2D_LLM_BASE_URL` | `llm.base_url` | `https://api.openai.com/v1` | LLM API 地址(兼容 OpenAI 接口) |
-| `GEN2D_LLM_API_KEY` | `llm.api_key` | (空) | API 密钥,**请通过环境变量设置** |
-| `GEN2D_LLM_MODEL` | `llm.model` | `gpt-4o` | 模型名称 |
-| `GEN2D_LLM_TEMPERATURE` | `llm.temperature` | `0.7` | 生成温度 (0–2) |
+| `GEN2D_LLM_BASE_URL` | `llm.base_url` | `https://api.deepseek.com/v1` | LLM API 地址(兼容 OpenAI 接口) |
+| `GEN2D_LLM_API_KEY` | `llm.api_key` | (空) | API 密钥,请通过环境变量设置 |
+| `GEN2D_LLM_MODEL` | `llm.model` | `deepseek-v4-pro` | 模型名称 |
+| `GEN2D_LLM_TEMPERATURE` | `llm.temperature` | `0.7` | 生成温度 (0-2) |
| `GEN2D_LLM_MAX_TOKENS` | `llm.max_tokens` | `2048` | 最大输出 token 数 |
-### 文生图配置
+### 文生图
| 环境变量 | YAML 路径 | 默认值 | 说明 |
|----------|-----------|--------|------|
| `GEN2D_IMAGE_BASE_URL` | `image_gen.base_url` | `https://api.stability.ai/v1` | 文生图 API 地址 |
-| `GEN2D_IMAGE_API_KEY` | `image_gen.api_key` | (空) | API 密钥,**请通过环境变量设置** |
+| `GEN2D_IMAGE_API_KEY` | `image_gen.api_key` | (空) | API 密钥,请通过环境变量设置 |
| `GEN2D_IMAGE_MODEL` | `image_gen.model` | `stable-diffusion-xl` | 模型名称 |
| `GEN2D_IMAGE_WIDTH` | `image_gen.width` | `1024` | 生成图片宽度 |
| `GEN2D_IMAGE_HEIGHT` | `image_gen.height` | `1024` | 生成图片高度 |
@@ -102,7 +98,7 @@ npm run dev
| `GEN2D_IMAGE_STEPS` | `image_gen.steps` | `30` | 扩散步数 |
| `GEN2D_IMAGE_CFG_SCALE` | `image_gen.cfg_scale` | `7.0` | CFG 引导强度 |
-### 服务基础配置
+### 服务基础
| 环境变量 | YAML 路径 | 默认值 | 说明 |
|----------|-----------|--------|------|
@@ -112,100 +108,152 @@ npm run dev
| `GEN2D_JWT_SECRET` | `jwt.secret` | `gen2d-dev-secret` | JWT 签名密钥 |
| `GEN2D_JWT_EXPIRE` | `jwt.expire` | `7200` | JWT 过期时间(秒) |
-### 快速配置示例
+## 架构
-**方式一:直接使用环境变量**
+### 生成管线
-```bash
-export GEN2D_LLM_API_KEY=sk-your-openai-key
-export GEN2D_IMAGE_API_KEY=sk-your-stability-key
-go run cmd/main.go
+基于 Eino `compose.Graph` 的四阶段管线,支持质检重试与降级输出:
+
+```mermaid
+graph TD
+ START(( )) --> PromptOptimizer
+ PromptOptimizer["PromptOptimizer
合并工程风格 + 任务风格覆盖
调用 PromptAgent 生成三段式提示词"]
+ PromptOptimizer --> AssetGenerator
+ AssetGenerator["AssetGenerator
调用文生图 API 生成素材"]
+ AssetGenerator --> QualitySupervisor
+ QualitySupervisor{"QualitySupervisor
质检通过?"}
+ QualitySupervisor -->|pass| FormatAdapter
+ QualitySupervisor -->|fail, retry < 3| PromptOptimizer
+ QualitySupervisor -->|fail, retry ≥ 3| FormatAdapter
+ FormatAdapter["FormatAdapter
组装输出素材与元数据"]
+ FormatAdapter --> END(( ))
```
-**方式二:使用 `.env` 文件**
+### 提示词优化链路
-```bash
-# backend/.env
-GEN2D_LLM_API_KEY=sk-your-openai-key
-GEN2D_IMAGE_API_KEY=sk-your-stability-key
+```mermaid
+graph TD
+ A["用户输入
标签 + 原始 Prompt + 素材类型"] --> B["formatMetaPrompt
构建元提示词:角色设定 + 输出格式 + 用户输入"]
+ B --> C["llmRefine
调用 OpenAI 兼容 Chat API
支持 SSE 流式 / 非流式
无 key 自动回退模板"]
+ C --> D["三段式规范提示词
【主题】【风格】【技术】"]
```
-**方式三:修改 `config.yml`(仅非敏感字段)**
+### 预设风格键
-```yaml
-# backend/internal/config/config.yml
-llm:
- base_url: "https://api.openai.com/v1"
- model: "gpt-4o"
-image_gen:
- width: 512
- height: 512
-```
+| 分类 | 键名 | 可选值示例 |
+|------|------|-----------|
+| 美术风格 | `artStyle` | pixel, cartoon, hand-drawn, vector, flat |
+| 色调 | `palette` | warm, cool, neutral, vibrant, muted, monochrome |
+| 线条 | `lineWeight` | none, thin, medium, thick |
+| 场景 | `scene` | forest, dungeon, city, space, underwater, desert |
+| 光照 | `lighting` | bright, dim, dramatic, ambient, neon |
+| 情绪 | `mood` | cheerful, dark, mysterious, epic, calm |
-## 提示词优化 API
+详细架构说明见 [docs/_index.md](docs/_index.md)。
-### POST /api/v1/prompt/optimize
+## API
-接收用户标签和原始描述,调用 LLM 生成规范化的三段式提示词。
-
-**请求体:**
+接口统一前缀 `/api/v1/`,统一响应格式:
```json
-{
- "tags": ["像素", "战士"],
- "assetType": "sprite",
- "prompt": "我要一个拿着大剑的战士角色",
- "userNote": "需要发光特效"
-}
+{ "code": 0, "message": "ok", "data": {} }
```
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| `tags` | string[] | 是 | 用户选择的风格标签,至少 1 个 |
-| `assetType` | string | 是 | 素材类型:`sprite` / `background` / `ui` / `animation` |
-| `prompt` | string | 否 | 用户输入的原始提示词描述 |
-| `userNote` | string | 否 | 额外补充说明 |
+除健康检查和注册登录外,所有接口需通过 `Authorization: Bearer ` 认证。
-**响应:**
+### 已实现
-```json
-{
- "code": 0,
- "message": "ok",
- "data": {
- "prompt": "【主题】一个融合像素、战士元素的游戏角色精灵图...\n【风格】色彩鲜明;像素风格...\n【技术】输出格式: spritesheet;分辨率: 64x64...",
- "rawText": "【主题】一个融合像素...\n【风格】色彩鲜明..."
- }
-}
-```
-
-| 字段 | 类型 | 说明 |
+| 方法 | 路径 | 说明 |
|------|------|------|
-| `data.prompt` | string | 优化后的三段式提示词,可直接传入生成管线 |
-| `data.rawText` | string | LLM 原始返回文本 |
+| GET | `/api/v1/health` | 健康检查 |
+| POST | `/auth/register` | 用户注册 |
+| POST | `/auth/login` | 用户登录(返回 JWT Token) |
+| POST | `/api/v1/prompt/optimize` | 提示词优化 |
-接入真实 LLM 后(配置 `GEN2D_LLM_API_KEY`),LLM 会根据标签+原始描述+素材类型生成高质量、可执行的提示词。未配置 API key 时自动回退到模板生成。
+### 规划中
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET/POST | `/api/v1/projects` | 工程列表 / 创建工程 |
+| GET/DELETE | `/api/v1/projects/:id` | 工程详情 / 删除工程 |
+| GET/PUT | `/api/v1/projects/:id/style` | 工程风格读写 |
+| POST | `/api/v1/generate` | 提交生成任务 |
+| GET | `/api/v1/tasks/:id` | 任务状态与进度 |
+| GET | `/api/v1/tasks/:id/assets` | 生成结果素材列表 |
+| WS | `/api/v1/tasks/:id/ws` | WebSocket 实时进度推送 |
+
+完整 API 文档见 [docs/api.md](docs/api.md)。
+
+## 部署
+
+### Docker Compose
+
+```bash
+cd backend
+cp .env.example .env # 编辑填入 API key
+cd ..
+docker compose up -d --build
+```
+
+- 后端:Go 多阶段构建,暴露 8080 端口
+- 前端:Node 构建 + nginx 反向代理,默认映射到宿主机 10000 端口
+- 数据持久化:`backend-data` 卷挂载 SQLite 数据库与生成素材
+
+### 一键脚本
+
+```bash
+bash deploy.sh
+```
+
+自动完成仓库克隆/拉取、环境检查、`.env` 模板复制、`docker compose up`。
## 项目结构
```
gen2d/
-├── backend/ # Go + Gin API 服务
-├── frontend/ # Vite + React 前端
-└── docs/ # 项目文档
- ├── architecture.md # 架构设计
- └── api.md # API 文档
+├── backend/ # Go + Gin API 服务
+│ ├── cmd/main.go # 入口:加载配置 → 初始化 DB → 注册路由 → 启动
+│ ├── internal/
+│ │ ├── config/ # Viper 配置加载 + config.yml
+│ │ ├── db/ # SQLite + GORM 初始化
+│ │ ├── handler/ # HTTP 处理器(health, prompt, auth)
+│ │ ├── model/ # 数据模型(User, Response)
+│ │ └── service/ # 业务逻辑
+│ │ ├── pipeline.go # Eino Graph 四阶段管线
+│ │ ├── prompt_agent.go # Eino Chain 提示词优化
+│ │ ├── nodes.go # 管线节点实现
+│ │ ├── inference.go # 文生图推理(Mock)
+│ │ └── auth.go # 注册 / 登录 / JWT
+│ └── pkg/splitsprite/ # 精灵表拆分工具
+├── frontend/ # Vite + React 前端
+│ └── src/
+│ ├── api/ # HTTP 客户端与类型定义
+│ ├── components/ # UI 组件(StyleSelector, PromptEditor, GenerateForm...)
+│ ├── pages/ # 页面(Login, Register, Project, Generate, Result)
+│ ├── stores/ # Zustand 状态管理
+│ ├── hooks/ # 自定义 Hooks
+│ ├── router/ # 路由配置
+│ └── styles/ # 全局样式与主题变量
+├── docs/ # 项目文档
+│ ├── _index.md # 文档索引
+│ ├── api.md # API 设计
+│ ├── backend.md # 后端工程
+│ ├── frontend.md # 前端工程
+│ ├── database.md # 数据存储
+│ ├── async-tasks.md # 异步任务
+│ └── style-keys.md # 预设风格键
+├── docker-compose.yml # 容器编排
+├── deploy.sh # 一键部署脚本
+└── .workflow/deploy.yml # Gitee CI/CD 流水线
```
-详细架构说明见 [docs/architecture.md](docs/architecture.md)。
-
-## API
-
-接口统一前缀 `/api/v1/`,详见 [docs/api.md](docs/api.md)。
-
## 参与贡献
1. Fork 本仓库
-2. 新建 Feat_xxx 分支
+2. 新建 `Feat_xxx` 分支
3. 提交代码
4. 新建 Pull Request
+
+## License
+
+[MIT](LICENSE)
diff --git a/backend/.env.example b/backend/.env.example
index 28a254c..4926101 100755
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -5,6 +5,10 @@
GEN2D_PORT=8080
GEN2D_MODE=debug
+# 日志配置
+GEN2D_LOG_LEVEL=info # debug / info / warn / error
+GEN2D_LOG_FORMAT=text # text / json
+
# SQLite 数据库路径
GEN2D_DSN=data/gen2d.db
@@ -27,3 +31,16 @@ GEN2D_IMAGE_MODEL=gpt-image-2
GEN2D_IMAGE_WIDTH=1024
GEN2D_IMAGE_HEIGHT=1024
GEN2D_IMAGE_QUALITY=low
+GEN2D_IMAGE_TIMEOUT=120
+GEN2D_IMAGE_MAX_RETRIES=2
+GEN2D_IMAGE_RETRY_DELAY=5
+GEN2D_IMAGE_NUM_IMAGES=1
+GEN2D_IMAGE_STEPS=30
+GEN2D_IMAGE_CFG_SCALE=7.0
+
+# 七牛云对象存储
+GEN2D_QINIU_ACCESS_KEY=your-access-key
+GEN2D_QINIU_SECRET_KEY=your-secret-key
+GEN2D_QINIU_BUCKET=your-bucket-name
+GEN2D_QINIU_CDN_HOST=https://cdn.example.com
+GEN2D_QINIU_USE_HTTPS=true
diff --git a/backend/cmd/main.go b/backend/cmd/main.go
index 2a447dd..d4f15f0 100755
--- a/backend/cmd/main.go
+++ b/backend/cmd/main.go
@@ -3,12 +3,12 @@ package main
import (
"fmt"
- "log"
- "os"
+ "log/slog"
"gen2d/internal/config"
"gen2d/internal/db"
"gen2d/internal/handler"
+ "gen2d/internal/logger"
"gen2d/internal/mildware"
"gen2d/internal/model"
"gen2d/internal/service"
@@ -19,14 +19,15 @@ import (
func main() {
cfg := config.Load()
- gin.SetMode(cfg.Server.Mode)
+ // 初始化日志
+ logger.Init(cfg.Log.Level, cfg.Log.Format)
- // 确保 generation 输出目录存在(项目根级别)
- _ = os.MkdirAll("../generation", 0755)
+ gin.SetMode(cfg.Server.Mode)
// 初始化 SQLite 数据库
if err := db.Init(cfg.Database.DSN, &model.User{}); err != nil {
- log.Fatalf("db init failed: %v", err)
+ slog.Error("db init failed", "error", err)
+ return
}
// 初始化 AuthService,注入 JWT 配置
@@ -36,27 +37,30 @@ func main() {
service.InitLLMConfig(cfg.LLM)
service.InitImageGenConfig(cfg.ImageGen)
- r := gin.New()
- r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机
+ // 初始化七牛云存储服务
+ storageSvc := service.NewStorageService(cfg.Qiniu)
+ handler.InitStorageService(storageSvc)
- // 静态文件服务 — 生成的图片
- r.Static("/generation", "../generation")
+ r := gin.New()
+ r.Use(mildware.Logger())
+ r.Use(mildware.Recovery())
// API v1 路由组 — 公开接口
v1 := r.Group("/api/v1")
{
v1.GET("/health", handler.Health) // 健康检查
v1.POST("/prompt/optimize", handler.PromptOptimize) // 提示词优化
+ v1.GET("/assets/download", handler.DownloadAsset) // 素材下载
}
// API v1 路由组 — 需认证
v1Auth := r.Group("/api/v1")
v1Auth.Use(mildware.AuthMiddleware(cfg.JWT.Secret))
{
- v1Auth.POST("/generate", handler.Generate) // 素材生成管线
- v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务
- v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材
- v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑
+ v1Auth.POST("/generate", handler.Generate) // 素材生成管线
+ v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务
+ v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材
+ v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑
}
// Auth 路由组
@@ -67,8 +71,8 @@ func main() {
}
addr := fmt.Sprintf(":%d", cfg.Server.Port)
- log.Printf("gen2d backend starting on %s", addr)
+ slog.Info("gen2d backend starting", "addr", addr, "mode", cfg.Server.Mode)
if err := r.Run(addr); err != nil {
- log.Fatalf("server failed: %v", err)
+ slog.Error("server failed", "error", err)
}
}
diff --git a/backend/go.mod b/backend/go.mod
index 8b21941..0a2d76c 100755
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -7,6 +7,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
+ github.com/qiniu/go-sdk/v7 v7.26.12
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.48.0
gorm.io/driver/sqlite v1.6.0
@@ -14,6 +15,8 @@ require (
)
require (
+ github.com/BurntSushi/toml v1.3.2 // indirect
+ github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
@@ -24,6 +27,7 @@ require (
github.com/eino-contrib/jsonschema v1.0.3 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+ github.com/gammazero/toposort v0.1.1 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -31,6 +35,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/gofrs/flock v0.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@@ -65,8 +70,10 @@ require (
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
golang.org/x/net v0.51.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/fileutil v1.0.0 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index e6265f5..9ea4cb0 100755
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,4 +1,8 @@
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
+github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
+github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
@@ -32,6 +36,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg=
+github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
@@ -53,6 +59,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
+github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@@ -66,6 +74,8 @@ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
@@ -118,6 +128,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/qiniu/go-sdk/v7 v7.26.12 h1:AnWiKjBY62XpULoB/MySKdNmlUo9S9pQB7/0s7QueCo=
+github.com/qiniu/go-sdk/v7 v7.26.12/go.mod h1:ri7fGwbio0pRDFr8EK5TUpx0DbnpIMJ2bMSDxGWfCbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -189,6 +201,8 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -215,3 +229,5 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w=
+modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index a15d38a..000f62e 100755
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -12,8 +12,16 @@ type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
+ Log LogConfig `mapstructure:"log"`
LLM LLMConfig `mapstructure:"llm"`
ImageGen ImageGenConfig `mapstructure:"image_gen"`
+ Qiniu QiniuConfig `mapstructure:"qiniu"`
+}
+
+// LogConfig 日志配置。
+type LogConfig struct {
+ Level string `mapstructure:"level"` // debug / info / warn / error
+ Format string `mapstructure:"format"` // text / json
}
// ServerConfig HTTP 服务配置。
@@ -45,12 +53,24 @@ type LLMConfig struct {
// ImageGenConfig OpenAI 兼容文生图模型配置。
type ImageGenConfig struct {
- BaseURL string `mapstructure:"base_url"`
- APIKey string `mapstructure:"api_key"`
- Model string `mapstructure:"model"`
- Width int `mapstructure:"width"`
- Height int `mapstructure:"height"`
- Quality string `mapstructure:"quality"`
+ BaseURL string `mapstructure:"base_url"`
+ APIKey string `mapstructure:"api_key"`
+ Model string `mapstructure:"model"`
+ Width int `mapstructure:"width"`
+ Height int `mapstructure:"height"`
+ Quality string `mapstructure:"quality"`
+ Timeout int `mapstructure:"timeout"` // HTTP 请求超时秒数,默认 120
+ MaxRetries int `mapstructure:"max_retries"` // 5xx 错误重试次数,默认 2
+ RetryDelay int `mapstructure:"retry_delay"` // 重试间隔秒数,默认 5
+}
+
+// QiniuConfig 七牛云对象存储配置。
+type QiniuConfig struct {
+ AccessKey string `mapstructure:"access_key"`
+ SecretKey string `mapstructure:"secret_key"`
+ Bucket string `mapstructure:"bucket"`
+ CDNHost string `mapstructure:"cdn_host"`
+ UseHTTPS bool `mapstructure:"use_https"`
}
// Load 从 YAML 配置文件和环境变量加载配置。
@@ -90,6 +110,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("server.mode", "debug")
v.SetDefault("server.max_file_size", int64(10<<20))
+ v.SetDefault("log.level", "info")
+ v.SetDefault("log.format", "text")
+
v.SetDefault("database.dsn", "data/gen2d.db")
v.SetDefault("jwt.secret", "gen2d-dev-secret")
@@ -107,6 +130,18 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("image_gen.width", 1024)
v.SetDefault("image_gen.height", 1024)
v.SetDefault("image_gen.quality", "low")
+ v.SetDefault("image_gen.timeout", 120)
+ v.SetDefault("image_gen.max_retries", 2)
+ v.SetDefault("image_gen.retry_delay", 5)
+ v.SetDefault("image_gen.num_images", 1)
+ v.SetDefault("image_gen.steps", 30)
+ v.SetDefault("image_gen.cfg_scale", 7.0)
+
+ v.SetDefault("qiniu.access_key", "")
+ v.SetDefault("qiniu.secret_key", "")
+ v.SetDefault("qiniu.bucket", "")
+ v.SetDefault("qiniu.cdn_host", "")
+ v.SetDefault("qiniu.use_https", true)
}
func bindEnvVars(v *viper.Viper) {
@@ -114,6 +149,9 @@ func bindEnvVars(v *viper.Viper) {
v.BindEnv("server.mode", "GEN2D_MODE")
v.BindEnv("server.max_file_size", "GEN2D_MAX_FILE_SIZE")
v.BindEnv("database.dsn", "GEN2D_DSN")
+
+ v.BindEnv("log.level", "GEN2D_LOG_LEVEL")
+ v.BindEnv("log.format", "GEN2D_LOG_FORMAT")
v.BindEnv("jwt.secret", "GEN2D_JWT_SECRET")
v.BindEnv("jwt.expire", "GEN2D_JWT_EXPIRE")
@@ -129,4 +167,16 @@ func bindEnvVars(v *viper.Viper) {
v.BindEnv("image_gen.width", "GEN2D_IMAGE_WIDTH")
v.BindEnv("image_gen.height", "GEN2D_IMAGE_HEIGHT")
v.BindEnv("image_gen.quality", "GEN2D_IMAGE_QUALITY")
+ v.BindEnv("image_gen.timeout", "GEN2D_IMAGE_TIMEOUT")
+ v.BindEnv("image_gen.max_retries", "GEN2D_IMAGE_MAX_RETRIES")
+ v.BindEnv("image_gen.retry_delay", "GEN2D_IMAGE_RETRY_DELAY")
+ v.BindEnv("image_gen.num_images", "GEN2D_IMAGE_NUM_IMAGES")
+ v.BindEnv("image_gen.steps", "GEN2D_IMAGE_STEPS")
+ v.BindEnv("image_gen.cfg_scale", "GEN2D_IMAGE_CFG_SCALE")
+
+ v.BindEnv("qiniu.access_key", "GEN2D_QINIU_ACCESS_KEY")
+ v.BindEnv("qiniu.secret_key", "GEN2D_QINIU_SECRET_KEY")
+ v.BindEnv("qiniu.bucket", "GEN2D_QINIU_BUCKET")
+ v.BindEnv("qiniu.cdn_host", "GEN2D_QINIU_CDN_HOST")
+ v.BindEnv("qiniu.use_https", "GEN2D_QINIU_USE_HTTPS")
}
diff --git a/backend/internal/config/config.yml b/backend/internal/config/config.yml
index 5ffae66..74055ba 100755
--- a/backend/internal/config/config.yml
+++ b/backend/internal/config/config.yml
@@ -6,6 +6,10 @@ server:
mode: debug
max_file_size: 10485760 # 10MB
+log:
+ level: info # debug / info / warn / error
+ format: text # text / json
+
database:
dsn: "data/gen2d.db"
diff --git a/backend/internal/handler/edit.go b/backend/internal/handler/edit.go
index f77b05a..48af1af 100755
--- a/backend/internal/handler/edit.go
+++ b/backend/internal/handler/edit.go
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"net/http"
+ "gen2d/internal/logger"
"gen2d/internal/model"
"gen2d/internal/service"
@@ -12,9 +13,9 @@ import (
// EditImageRequest 图片编辑请求。
type EditImageRequest struct {
- Image string `json:"image" binding:"required"` // 底图 base64 编码
- Prompt string `json:"prompt" binding:"required"` // 编辑指令
- Count int `json:"count"` // 生成数量,默认 1
+ Image string `json:"image" binding:"required"` // 底图 base64 编码
+ Prompt string `json:"prompt" binding:"required"` // 编辑指令
+ Count int `json:"count"` // 生成数量,默认 1
}
// editAssetResponse 编辑结果素材(返回 base64)。
@@ -38,7 +39,7 @@ func EditImage(c *gin.Context) {
imageData, err := base64.StdEncoding.DecodeString(req.Image)
if err != nil {
- c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "图片 base64 解码失败: "+err.Error()))
+ c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "图片 base64 解码失败"))
return
}
@@ -49,7 +50,8 @@ func EditImage(c *gin.Context) {
images, err := service.EditImages(c.Request.Context(), imageData, req.Prompt, count)
if err != nil {
- c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "图片编辑失败: "+err.Error()))
+ logger.FromCtx(c.Request.Context()).Error("图片编辑失败", "error", err)
+ c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "图片编辑失败"))
return
}
diff --git a/backend/internal/handler/generate.go b/backend/internal/handler/generate.go
index c3ee063..a7cb7dc 100755
--- a/backend/internal/handler/generate.go
+++ b/backend/internal/handler/generate.go
@@ -3,13 +3,11 @@ package handler
import (
"context"
"fmt"
- "log"
"net/http"
- "os"
- "path/filepath"
"sync"
"time"
+ "gen2d/internal/logger"
"gen2d/internal/model"
"gen2d/internal/service"
@@ -58,8 +56,8 @@ type TaskResponse struct {
// AssetsResponse 素材列表响应。
type AssetsResponse struct {
- Assets []AssetResponse `json:"assets"`
- Metadata service.AssetMetadata `json:"metadata"`
+ Assets []AssetResponse `json:"assets"`
+ Metadata service.AssetMetadata `json:"metadata"`
}
// taskRecord 内存中的任务记录。
@@ -106,7 +104,7 @@ func Generate(c *gin.Context) {
c.JSON(http.StatusOK, model.OK(GenerateResponse{TaskID: taskID}))
// 后台执行管线
- go runPipelineBg(projectID, taskID, req)
+ go runPipelineBg(c.Request.Context(), projectID, taskID, req)
}
// runPipelineBg 后台执行生成管线,更新任务状态。
@@ -116,6 +114,8 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) {
updateTaskProgress(taskID, "running", stage, progress)
})
+ l := logger.With("task_id", taskID, "project_id", projectID)
+
updateTaskProgress(taskID, "running", "prompt_builder", 5)
in := service.PipelineInput{
@@ -139,30 +139,24 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) {
output, err := service.RunPipeline(ctx, in)
if err != nil {
- log.Printf("[generate] task %s failed: %v", taskID, err)
- updateFailed(taskID, err.Error())
+ l.Error("task pipeline failed", "error", err)
+ updateFailed(taskID, "生成管线执行失败")
return
}
updateTaskProgress(taskID, "saving", "format_adapter", 90)
- // 保存图片到 ../generation/{projectId}/{taskId}/
- genDir := filepath.Join("..", "generation", projectID, taskID)
- if err := os.MkdirAll(genDir, 0755); err != nil {
- updateFailed(taskID, "创建输出目录失败: "+err.Error())
- return
- }
-
assets := make([]AssetResponse, len(output.Assets))
for i, a := range output.Assets {
- filename := fmt.Sprintf("%d.%s", i, a.Format)
- filePath := filepath.Join(genDir, filename)
- if err := os.WriteFile(filePath, a.Data, 0644); err != nil {
- updateFailed(taskID, "保存图片失败: "+err.Error())
+ key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format)
+ cdnURL, err := storageSvc.Upload(ctx, key, a.Data)
+ if err != nil {
+ l.Error("upload asset failed", "index", i, "error", err)
+ updateFailed(taskID, "上传素材失败")
return
}
assets[i] = AssetResponse{
- URL: fmt.Sprintf("/generation/%s/%s/%s", projectID, taskID, filename),
+ URL: cdnURL,
Format: a.Format,
}
}
@@ -182,7 +176,7 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) {
metadata: output.Metadata,
})
- log.Printf("[generate] task %s completed, %d assets", taskID, len(assets))
+ l.Info("task completed", "asset_count", len(assets))
}
func updateTaskProgress(taskID, status, stage string, progress int) {
diff --git a/backend/internal/handler/login.go b/backend/internal/handler/login.go
index e839664..35b7dfe 100755
--- a/backend/internal/handler/login.go
+++ b/backend/internal/handler/login.go
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
+ "gen2d/internal/logger"
"gen2d/internal/model"
"github.com/gin-gonic/gin"
@@ -31,6 +32,7 @@ func Login(c *gin.Context) {
token, expiresIn, user, err := authSvc.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
+ logger.FromCtx(c.Request.Context()).Warn("登录失败", "username", req.Username, "error", err)
c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, err.Error()))
return
}
diff --git a/backend/internal/handler/prompt.go b/backend/internal/handler/prompt.go
index 1910e79..6a09b86 100755
--- a/backend/internal/handler/prompt.go
+++ b/backend/internal/handler/prompt.go
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
+ "gen2d/internal/logger"
"gen2d/internal/model"
"gen2d/internal/service"
@@ -34,7 +35,8 @@ func PromptOptimize(c *gin.Context) {
output, err := service.RunPromptAgent(c.Request.Context(), in)
if err != nil {
- c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "提示词优化失败: "+err.Error()))
+ logger.FromCtx(c.Request.Context()).Error("提示词优化失败", "error", err)
+ c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "提示词优化失败"))
return
}
diff --git a/backend/internal/handler/register.go b/backend/internal/handler/register.go
index bca2108..802d3f3 100755
--- a/backend/internal/handler/register.go
+++ b/backend/internal/handler/register.go
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
+ "gen2d/internal/logger"
"gen2d/internal/model"
"gen2d/internal/service"
@@ -39,6 +40,7 @@ func Register(c *gin.Context) {
user, err := authSvc.Register(c.Request.Context(), req.Username, req.Password, req.Email)
if err != nil {
+ logger.FromCtx(c.Request.Context()).Warn("注册失败", "username", req.Username, "error", err)
c.JSON(http.StatusConflict, model.Fail(http.StatusConflict, err.Error()))
return
}
diff --git a/backend/internal/handler/storage.go b/backend/internal/handler/storage.go
new file mode 100644
index 0000000..6270982
--- /dev/null
+++ b/backend/internal/handler/storage.go
@@ -0,0 +1,37 @@
+package handler
+
+import (
+ "net/http"
+
+ "gen2d/internal/logger"
+ "gen2d/internal/model"
+ "gen2d/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+var storageSvc *service.StorageService
+
+// InitStorageService 由 main 在启动时调用,注入七牛云存储服务。
+func InitStorageService(svc *service.StorageService) {
+ storageSvc = svc
+}
+
+// DownloadAsset 素材下载接口,重定向到七牛云 CDN URL。
+// GET /api/v1/assets/download?key=...
+func DownloadAsset(c *gin.Context) {
+ key := c.Query("key")
+ if key == "" {
+ c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "缺少 key 参数"))
+ return
+ }
+
+ downloadURL, err := storageSvc.GetDownloadURL(c.Request.Context(), key)
+ if err != nil {
+ logger.FromCtx(c.Request.Context()).Error("生成下载链接失败", "key", key, "error", err)
+ c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "生成下载链接失败"))
+ return
+ }
+
+ c.Redirect(http.StatusFound, downloadURL)
+}
diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go
new file mode 100644
index 0000000..01650cf
--- /dev/null
+++ b/backend/internal/logger/logger.go
@@ -0,0 +1,71 @@
+package logger
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "strings"
+)
+
+type ctxKey struct{}
+
+// L 全局 logger,Init 之前使用默认 text 输出。
+var L *slog.Logger
+
+func init() {
+ L = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+}
+
+// Init 初始化全局 logger。
+// level: debug / info / warn / error
+// format: text / json
+func Init(level, format string) {
+ var lvl slog.Level
+ switch strings.ToLower(level) {
+ case "debug":
+ lvl = slog.LevelDebug
+ case "warn":
+ lvl = slog.LevelWarn
+ case "error":
+ lvl = slog.LevelError
+ default:
+ lvl = slog.LevelInfo
+ }
+
+ opts := &slog.HandlerOptions{Level: lvl}
+
+ var h slog.Handler
+ switch strings.ToLower(format) {
+ case "json":
+ h = slog.NewJSONHandler(os.Stdout, opts)
+ default:
+ h = slog.NewTextHandler(os.Stdout, opts)
+ }
+
+ L = slog.New(h)
+ slog.SetDefault(L)
+}
+
+// With 创建带附加字段的 logger。
+func With(args ...any) *slog.Logger {
+ return L.With(args...)
+}
+
+// IntoCtx 将 logger 存入 context。
+func IntoCtx(ctx context.Context, l *slog.Logger) context.Context {
+ return context.WithValue(ctx, ctxKey{}, l)
+}
+
+// FromCtx 从 context 取出 logger,不存在时返回全局 L。
+func FromCtx(ctx context.Context) *slog.Logger {
+ if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
+ return l
+ }
+ return L
+}
+
+// WithRequestID 创建带 request_id 的 logger 并存入 context。
+func WithRequestID(ctx context.Context, requestID string) (context.Context, *slog.Logger) {
+ l := L.With("request_id", requestID)
+ return IntoCtx(ctx, l), l
+}
diff --git a/backend/internal/mildware/auth.go b/backend/internal/mildware/auth.go
index 9a0226a..550da71 100755
--- a/backend/internal/mildware/auth.go
+++ b/backend/internal/mildware/auth.go
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
+ "gen2d/internal/logger"
"gen2d/internal/model"
"github.com/gin-gonic/gin"
@@ -15,12 +16,14 @@ func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
+ logger.FromCtx(c.Request.Context()).Warn("auth failed: no token")
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未提供认证令牌"))
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
+ logger.FromCtx(c.Request.Context()).Warn("auth failed: invalid format")
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "认证格式错误,需为 Bearer "))
return
}
@@ -29,12 +32,14 @@ func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
+ logger.FromCtx(c.Request.Context()).Warn("auth failed: invalid token", "error", err)
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "令牌无效或已过期"))
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
+ logger.FromCtx(c.Request.Context()).Warn("auth failed: parse claims")
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "令牌解析失败"))
return
}
diff --git a/backend/internal/mildware/logger.go b/backend/internal/mildware/logger.go
new file mode 100644
index 0000000..d6e3c65
--- /dev/null
+++ b/backend/internal/mildware/logger.go
@@ -0,0 +1,81 @@
+package mildware
+
+import (
+ "crypto/rand"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "runtime/debug"
+ "time"
+
+ "gen2d/internal/logger"
+
+ "github.com/gin-gonic/gin"
+)
+
+// Logger 返回 HTTP 请求日志中间件。
+// 为每个请求生成 request_id,记录 method、path、status、latency、client_ip。
+func Logger() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+ requestID := generateRequestID()
+
+ c.Set("request_id", requestID)
+ ctx, _ := logger.WithRequestID(c.Request.Context(), requestID)
+ c.Request = c.Request.WithContext(ctx)
+
+ c.Header("X-Request-ID", requestID)
+
+ c.Next()
+
+ latency := time.Since(start)
+ status := c.Writer.Status()
+
+ l := logger.FromCtx(ctx)
+ attrs := []slog.Attr{
+ slog.String("method", c.Request.Method),
+ slog.String("path", c.Request.URL.Path),
+ slog.Int("status", status),
+ slog.Duration("latency", latency),
+ slog.String("client_ip", c.ClientIP()),
+ }
+
+ if status >= 500 {
+ l.LogAttrs(ctx, slog.LevelError, "request completed", attrs...)
+ } else if status >= 400 {
+ l.LogAttrs(ctx, slog.LevelWarn, "request completed", attrs...)
+ } else {
+ l.LogAttrs(ctx, slog.LevelInfo, "request completed", attrs...)
+ }
+ }
+}
+
+// Recovery 返回自定义 panic 恢复中间件。
+// panic 时记录完整的 request 上下文和堆栈,返回 500。
+func Recovery() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ defer func() {
+ if r := recover(); r != nil {
+ ctx := c.Request.Context()
+ l := logger.FromCtx(ctx)
+ l.Error("panic recovered",
+ "error", fmt.Sprintf("%v", r),
+ "method", c.Request.Method,
+ "path", c.Request.URL.Path,
+ "request_id", c.GetString("request_id"),
+ "stack", string(debug.Stack()),
+ )
+
+ c.AbortWithStatusJSON(http.StatusInternalServerError,
+ gin.H{"code": http.StatusInternalServerError, "message": "服务器内部错误"})
+ }
+ }()
+ c.Next()
+ }
+}
+
+func generateRequestID() string {
+ b := make([]byte, 8)
+ _, _ = rand.Read(b)
+ return fmt.Sprintf("%d-%x", time.Now().UnixMilli(), b)
+}
diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go
index 4f96abe..db4a2ba 100755
--- a/backend/internal/service/auth.go
+++ b/backend/internal/service/auth.go
@@ -6,6 +6,7 @@ import (
"time"
"gen2d/internal/db"
+ "gen2d/internal/logger"
"gen2d/internal/model"
"github.com/golang-jwt/jwt/v5"
@@ -29,8 +30,11 @@ func NewAuthService(jwtSecret string, jwtExpire int64) *AuthService {
// Register 注册新用户,密码使用 bcrypt 加密存储。
func (s *AuthService) Register(ctx context.Context, username, password, email string) (*model.User, error) {
+ l := logger.FromCtx(ctx)
+
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
+ l.Error("bcrypt failed", "error", err)
return nil, err
}
@@ -42,25 +46,33 @@ func (s *AuthService) Register(ctx context.Context, username, password, email st
if err := db.DB.WithContext(ctx).Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
+ l.Warn("register failed: duplicate username", "username", username)
return nil, errors.New("用户名已存在")
}
+ l.Error("register db failed", "username", username, "error", err)
return nil, err
}
+ l.Info("user registered", "username", username, "user_id", user.ID)
return &user, nil
}
// Login 校验用户名密码,成功返回 JWT token 及用户信息。
func (s *AuthService) Login(ctx context.Context, username, password string) (string, int64, *model.User, error) {
+ l := logger.FromCtx(ctx)
+
var user model.User
if err := db.DB.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
+ l.Warn("login failed: user not found", "username", username)
return "", 0, nil, errors.New("用户名或密码错误")
}
+ l.Error("login db query failed", "username", username, "error", err)
return "", 0, nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
+ l.Warn("login failed: wrong password", "username", username)
return "", 0, nil, errors.New("用户名或密码错误")
}
@@ -72,8 +84,10 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (str
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
if err != nil {
+ l.Error("jwt sign failed", "error", err)
return "", 0, nil, err
}
+ l.Info("login succeeded", "username", username, "user_id", user.ID)
return token, int64(s.jwtExpire.Seconds()), &user, nil
}
diff --git a/backend/internal/service/inference.go b/backend/internal/service/inference.go
index f334a34..37d28d2 100755
--- a/backend/internal/service/inference.go
+++ b/backend/internal/service/inference.go
@@ -11,20 +11,29 @@ import (
"image/color"
"image/png"
"io"
- "log"
"mime/multipart"
"net/http"
"strings"
+ "time"
"gen2d/internal/config"
+ "gen2d/internal/logger"
)
// imgCfg 保存文生图配置,由 main 通过 InitImageGenConfig 注入。
var imgCfg config.ImageGenConfig
+// imageHTTPClient 带超时的 HTTP 客户端,由 InitImageGenConfig 初始化。
+var imageHTTPClient *http.Client
+
// InitImageGenConfig 注入文生图配置。
func InitImageGenConfig(cfg config.ImageGenConfig) {
imgCfg = cfg
+ timeout := time.Duration(cfg.Timeout) * time.Second
+ if timeout <= 0 {
+ timeout = 120 * time.Second
+ }
+ imageHTTPClient = &http.Client{Timeout: timeout}
}
// ======================== OpenAI 兼容 Images API 类型 ========================
@@ -55,25 +64,37 @@ func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]G
count = params.Frames.Directions * params.Frames.FramesPerDirection
}
+ l := logger.FromCtx(ctx)
+
if imgCfg.APIKey != "" {
width, height := imgCfg.Width, imgCfg.Height
if params.Resolution > 0 {
width = params.Resolution
height = params.Resolution
}
- log.Println("[inference] calling image gen API")
- return callImageAPI(ctx, prompt, count, width, height)
+ l.Info("calling image gen API",
+ "model", imgCfg.Model,
+ "count", count,
+ "size", fmt.Sprintf("%dx%d", width, height),
+ )
+ images, err := callImageAPI(ctx, prompt, count, width, height)
+ if err != nil {
+ l.Error("image gen API failed", "error", err)
+ return nil, err
+ }
+ l.Info("image gen API succeeded", "image_count", len(images))
+ return images, nil
}
size := params.Resolution
if size <= 0 {
size = 64
}
- log.Println("[inference] image API key not configured, using mock")
+ l.Warn("image API key not configured, using mock")
return generateMockImages(size, count)
}
-// callImageAPI 调用 OpenAI 兼容 Images API。
+// callImageAPI 调用 OpenAI 兼容 Images API,5xx 错误自动重试。
func callImageAPI(ctx context.Context, prompt string, count, width, height int) ([]GeneratedImage, error) {
reqBody := imageGenRequest{
Model: imgCfg.Model,
@@ -89,25 +110,70 @@ func callImageAPI(ctx context.Context, prompt string, count, width, height int)
}
url := strings.TrimRight(imgCfg.BaseURL, "/") + "/images/generations"
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
- if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
+ maxRetries := imgCfg.MaxRetries
+ if maxRetries <= 0 {
+ maxRetries = 2
}
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("send request: %w", err)
+ retryDelay := time.Duration(imgCfg.RetryDelay) * time.Second
+ if retryDelay <= 0 {
+ retryDelay = 5 * time.Second
}
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
+ l := logger.FromCtx(ctx)
+
+ var lastErr error
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ if attempt > 0 {
+ l.Warn("retrying image API",
+ "attempt", attempt,
+ "max_retries", maxRetries,
+ )
+ select {
+ case <-ctx.Done():
+ return nil, fmt.Errorf("context cancelled during retry: %w", ctx.Err())
+ case <-time.After(retryDelay):
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
+
+ resp, err := imageHTTPClient.Do(req)
+ if err != nil {
+ lastErr = fmt.Errorf("send request: %w", err)
+ l.Warn("image API request failed", "error", lastErr)
+ continue
+ }
+
+ if resp.StatusCode == http.StatusOK {
+ return parseImageResponse(ctx, resp.Body, width, height)
+ }
+
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ resp.Body.Close()
+
+ if resp.StatusCode >= 500 {
+ lastErr = fmt.Errorf("image api error %d: %s", resp.StatusCode, string(b))
+ l.Error("image API server error",
+ "status", resp.StatusCode,
+ "body", string(b),
+ "attempt", attempt,
+ )
+ continue
+ }
+ // 4xx 不重试
+ l.Error("image API client error",
+ "status", resp.StatusCode,
+ "body", string(b),
+ )
return nil, fmt.Errorf("image api error %d: %s", resp.StatusCode, string(b))
}
- return parseImageResponse(ctx, resp.Body, width, height)
+ return nil, lastErr
}
// parseImageResponse 解析 OpenAI 兼容图片响应(b64_json 或 url)。
@@ -152,7 +218,7 @@ func downloadImage(ctx context.Context, url string) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
- resp, err := http.DefaultClient.Do(req)
+ resp, err := imageHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
@@ -167,10 +233,17 @@ func downloadImage(ctx context.Context, url string) ([]byte, error) {
// EditImages 图片编辑接口,调用 OpenAI 兼容 Images Edits API(multipart/form-data)。
func EditImages(ctx context.Context, imageData []byte, prompt string, count int) ([]GeneratedImage, error) {
+ l := logger.FromCtx(ctx)
+
if imgCfg.APIKey == "" {
return nil, fmt.Errorf("image API key not configured")
}
+ l.Info("calling image edit API",
+ "model", imgCfg.Model,
+ "count", count,
+ )
+
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
@@ -199,18 +272,27 @@ func EditImages(ctx context.Context, imageData []byte, prompt string, count int)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
- resp, err := http.DefaultClient.Do(req)
+ resp, err := imageHTTPClient.Do(req)
if err != nil {
+ l.Error("image edit API request failed", "error", err)
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ l.Error("image edit API error", "status", resp.StatusCode, "body", string(b))
return nil, fmt.Errorf("image edit api error %d: %s", resp.StatusCode, string(b))
}
- return parseImageResponse(ctx, resp.Body, imgCfg.Width, imgCfg.Height)
+ images, err := parseImageResponse(ctx, resp.Body, imgCfg.Width, imgCfg.Height)
+ if err != nil {
+ l.Error("image edit response parse failed", "error", err)
+ return nil, err
+ }
+
+ l.Info("image edit API succeeded", "image_count", len(images))
+ return images, nil
}
// ======================== 质检 ========================
diff --git a/backend/internal/service/pipeline.go b/backend/internal/service/pipeline.go
index f37823e..e377b85 100755
--- a/backend/internal/service/pipeline.go
+++ b/backend/internal/service/pipeline.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
+ "gen2d/internal/logger"
+
"github.com/cloudwego/eino/compose"
)
@@ -101,20 +103,32 @@ func NewGenerateGraph() (*compose.Graph[PipelineInput, PipelineOutput], error) {
// RunPipeline 编译并执行生成管线。
func RunPipeline(ctx context.Context, in PipelineInput) (*PipelineOutput, error) {
+ l := logger.FromCtx(ctx)
+
+ l.Info("pipeline started",
+ "asset_type", in.AssetType,
+ "tags", in.Tags,
+ "resolution", in.Params.Resolution,
+ )
+
g, err := NewGenerateGraph()
if err != nil {
+ l.Error("pipeline create graph failed", "error", err)
return nil, fmt.Errorf("create graph: %w", err)
}
r, err := g.Compile(ctx, compose.WithMaxRunSteps(20))
if err != nil {
+ l.Error("pipeline compile failed", "error", err)
return nil, fmt.Errorf("compile graph: %w", err)
}
output, err := r.Invoke(ctx, in)
if err != nil {
+ l.Error("pipeline invoke failed", "error", err)
return nil, fmt.Errorf("invoke pipeline: %w", err)
}
+ l.Info("pipeline completed", "asset_count", len(output.Assets))
return &output, nil
}
diff --git a/backend/internal/service/prompt_agent.go b/backend/internal/service/prompt_agent.go
index 813ade2..f3e64ce 100755
--- a/backend/internal/service/prompt_agent.go
+++ b/backend/internal/service/prompt_agent.go
@@ -7,11 +7,11 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
"net/http"
"strings"
"gen2d/internal/config"
+ "gen2d/internal/logger"
"github.com/cloudwego/eino/compose"
)
@@ -130,8 +130,10 @@ type chatResponse struct {
// callLLMRefine 调用 LLM 生成规范化提示词。未配置 API key 时回退到模板生成。
func callLLMRefine(ctx context.Context, metaPrompt string) (PromptAgentOutput, error) {
+ l := logger.FromCtx(ctx)
+
if llmCfg.APIKey == "" {
- log.Println("[prompt_agent] LLM API key not configured, using template fallback")
+ l.Warn("LLM API key not configured, using template fallback")
return fallbackRefine(metaPrompt), nil
}
@@ -140,12 +142,14 @@ func callLLMRefine(ctx context.Context, metaPrompt string) (PromptAgentOutput, e
{Role: "user", Content: metaPrompt},
}
+ l.Info("calling LLM API", "model", llmCfg.Model)
result, err := chatCompletion(ctx, messages)
if err != nil {
- log.Printf("[prompt_agent] LLM API call failed: %v, using template fallback", err)
+ l.Error("LLM API call failed, using template fallback", "error", err)
return fallbackRefine(metaPrompt), nil
}
+ l.Info("LLM API succeeded", "response_length", len(result))
return PromptAgentOutput{
Prompt: result,
RawText: result,
@@ -175,6 +179,8 @@ func chatCompletion(ctx context.Context, messages []chatMessage) (string, error)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+llmCfg.APIKey)
+ l := logger.FromCtx(ctx)
+
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
@@ -183,6 +189,7 @@ func chatCompletion(ctx context.Context, messages []chatMessage) (string, error)
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ l.Error("LLM API error", "status", resp.StatusCode, "body", string(b))
return "", fmt.Errorf("llm api error %d: %s", resp.StatusCode, string(b))
}
diff --git a/backend/internal/service/storage.go b/backend/internal/service/storage.go
new file mode 100644
index 0000000..dbd43d0
--- /dev/null
+++ b/backend/internal/service/storage.go
@@ -0,0 +1,96 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "gen2d/internal/config"
+ "gen2d/internal/logger"
+
+ "github.com/qiniu/go-sdk/v7/storagev2/credentials"
+ "github.com/qiniu/go-sdk/v7/storagev2/objects"
+ "github.com/qiniu/go-sdk/v7/storagev2/uptoken"
+ "github.com/qiniu/go-sdk/v7/storagev2/uploader"
+)
+
+// StorageService 七牛云对象存储服务。
+type StorageService struct {
+ creds *credentials.Credentials
+ cfg config.QiniuConfig
+ objectsMgr *objects.ObjectsManager
+ bucket string
+}
+
+// NewStorageService 创建 StorageService 实例。
+func NewStorageService(cfg config.QiniuConfig) *StorageService {
+ creds := credentials.NewCredentials(cfg.AccessKey, cfg.SecretKey)
+ objectsMgr := objects.NewObjectsManager(&objects.ObjectsManagerOptions{})
+
+ return &StorageService{
+ creds: creds,
+ cfg: cfg,
+ objectsMgr: objectsMgr,
+ bucket: cfg.Bucket,
+ }
+}
+
+// Upload 上传图片字节到七牛云,返回 CDN 访问 URL。
+func (s *StorageService) Upload(ctx context.Context, key string, data []byte) (string, error) {
+ l := logger.FromCtx(ctx)
+
+ putPolicy, err := uptoken.NewPutPolicy(s.bucket, time.Now().Add(1*time.Hour))
+ if err != nil {
+ l.Error("qiniu put policy failed", "key", key, "error", err)
+ return "", fmt.Errorf("qiniu put policy: %w", err)
+ }
+ upToken := uptoken.NewSigner(putPolicy, s.creds)
+
+ uploadMgr := uploader.NewUploadManager(&uploader.UploadManagerOptions{
+ UpTokenProvider: upToken,
+ })
+
+ l.Info("uploading to qiniu", "key", key, "size", len(data))
+ err = uploadMgr.UploadReader(ctx, bytes.NewReader(data), &uploader.ObjectOptions{
+ BucketName: s.bucket,
+ ObjectName: &key,
+ }, nil)
+ if err != nil {
+ l.Error("qiniu upload failed", "key", key, "error", err)
+ return "", fmt.Errorf("qiniu upload: %w", err)
+ }
+
+ cdnURL := s.buildURL(key)
+ l.Info("upload succeeded", "key", key, "url", cdnURL)
+ return cdnURL, nil
+}
+
+// GetDownloadURL 生成素材的下载 URL。
+// 公开 bucket 直接返回 CDN URL;私有 bucket 生成带签名的临时 URL。
+func (s *StorageService) GetDownloadURL(_ context.Context, key string) (string, error) {
+ return s.buildURL(key), nil
+}
+
+// Delete 从七牛云删除指定对象。
+func (s *StorageService) Delete(ctx context.Context, key string) error {
+ l := logger.FromCtx(ctx)
+
+ bucket := s.objectsMgr.Bucket(s.bucket)
+ err := bucket.Object(key).Delete().Call(ctx)
+ if err != nil {
+ l.Error("qiniu delete failed", "key", key, "error", err)
+ return err
+ }
+
+ l.Info("delete succeeded", "key", key)
+ return nil
+}
+
+// buildURL 根据配置构建完整的 CDN URL。
+func (s *StorageService) buildURL(key string) string {
+ host := strings.TrimRight(s.cfg.CDNHost, "/")
+ return fmt.Sprintf("%s/%s", host, url.PathEscape(key))
+}
diff --git a/deploy.sh b/deploy.sh
index cfd59fc..45f3d3b 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -31,7 +31,22 @@ else
cd "$INSTALL_DIR"
fi
-# 3. 构建并启动
+# 3. 环境变量检查
+ENV_FILE="$INSTALL_DIR/backend/.env"
+ENV_EXAMPLE="$INSTALL_DIR/backend/.env.example"
+if [ ! -f "$ENV_FILE" ]; then
+ if [ -f "$ENV_EXAMPLE" ]; then
+ echo "==> 未找到 backend/.env,已从 .env.example 复制模板"
+ cp "$ENV_EXAMPLE" "$ENV_FILE"
+ echo " 请编辑 $ENV_FILE 填入实际配置后重新运行本脚本"
+ exit 0
+ else
+ echo "错误: backend/.env 和 .env.example 均不存在,请手动创建"
+ exit 1
+ fi
+fi
+
+# 4. 构建并启动
echo "==> 构建并启动 Docker 容器..."
if docker compose version &>/dev/null; then
COMPOSE_CMD="docker compose"
@@ -55,3 +70,4 @@ echo "常用命令:"
echo " 查看日志: cd $INSTALL_DIR && $COMPOSE_CMD logs -f"
echo " 停止服务: cd $INSTALL_DIR && $COMPOSE_CMD down"
echo " 重启服务: cd $INSTALL_DIR && $COMPOSE_CMD restart"
+exit 0
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 83e764d..aa15cf3 100755
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,9 +3,10 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
+ env_file:
+ - ./backend/.env
environment:
- GEN2D_MODE=release
- - GEN2D_PORT=8080
volumes:
- backend-data:/data
expose:
diff --git a/docs/_index.md b/docs/_index.md
index 2526eb6..b1a5453 100755
--- a/docs/_index.md
+++ b/docs/_index.md
@@ -4,13 +4,13 @@ gen2d — AI 驱动的 2D 游戏素材生成工具。通过文本提示词生成
## 技术栈
-Go + Gin + Eino / Vite + React + TypeScript + zustand / SQLite / 可替换 AI 推理模型
+Go + Gin + Eino / Vite + React + TypeScript + zustand / SQLite / 七牛云对象存储 / 可替换 AI 推理模型
## 文档索引
- [后端工程](backend.md) — 分层结构、管线设计(PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter)、风格模型
- [前端工程](frontend.md) — 组件树、状态管理、路由、WebSocket 通信
- [异步任务](async-tasks.md) — 任务队列、状态机、并发控制、失败重试
-- [数据存储](database.md) — SQLite 选型、表结构、本地文件存储
+- [数据存储](database.md) — SQLite 选型、表结构、七牛云对象存储
- [API 设计](api.md) — 接口列表、请求/响应示例、实现状态
- [预设风格键](style-keys.md) — 美术风格、色调、线条等风格键分类与可选值
diff --git a/docs/api.md b/docs/api.md
index 74bf155..9d9f91f 100755
--- a/docs/api.md
+++ b/docs/api.md
@@ -300,7 +300,7 @@ POST /api/v1/prompt/optimize
### DELETE /api/v1/projects/:projectId
-级联删除工程下的所有任务、素材及本地文件。
+级联删除工程下的所有任务、素材及七牛云对象。
响应:
@@ -368,10 +368,11 @@ POST /api/v1/prompt/optimize
| 状态 | 方法 | 路径 | 说明 |
|------|------|------|------|
-| [ ] | POST | `/api/v1/generate` | 提交生成任务,返回 taskId |
-| [ ] | GET | `/api/v1/tasks/:taskId` | 查询任务状态与进度 |
-| [ ] | GET | `/api/v1/tasks/:taskId/assets` | 获取生成结果(素材列表 + 元数据) |
+| [x] | POST | `/api/v1/generate` | 提交生成任务,返回 taskId |
+| [x] | GET | `/api/v1/tasks/:taskId` | 查询任务状态与进度 |
+| [x] | GET | `/api/v1/tasks/:taskId/assets` | 获取生成结果(素材列表 + 元数据) |
| [ ] | WS | `/api/v1/tasks/:taskId/ws` | WebSocket 实时进度推送 |
+| [x] | GET | `/api/v1/assets/download` | 下载素材(重定向到 CDN) |
### POST /api/v1/generate
@@ -483,7 +484,7 @@ POST /api/v1/prompt/optimize
"assets": [
{
"id": "asset_001",
- "url": "users/user_a1B2c3D4/projects/proj_abc123/tasks/task_xyz789/output/spritesheet.png",
+ "url": "https://cdn.example.com/users/user_a1B2c3D4/projects/proj_abc123/tasks/task_xyz789/output/spritesheet.png",
"format": "png",
"width": 256,
"height": 64,
@@ -499,6 +500,19 @@ POST /api/v1/prompt/optimize
}
```
+### GET /api/v1/assets/download
+
+需认证。返回 302 重定向到七牛云 CDN URL。
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `key` | string | 对象存储 Key,查询参数 |
+
+响应:
+- HTTP 302,`Location` 头指向 CDN URL
+- 400:缺少 key 参数
+- 500:生成下载链接失败
+
### WebSocket 消息格式
连接路径:`ws://host/api/v1/tasks/:taskId/ws`(通过 httpOnly Cookie 自动携带认证信息)
diff --git a/docs/backend.md b/docs/backend.md
index 9a50263..63be3ff 100755
--- a/docs/backend.md
+++ b/docs/backend.md
@@ -14,32 +14,32 @@ backend/internal/
│ ├── health.go # [已有] 健康检查
│ ├── register.go # [已有] 用户注册(handler 骨架 + 参数校验)
│ ├── login.go # [已有] 用户登录(handler 骨架 + 参数校验)
-│ ├── project.go # 工程 CRUD + 任务列表
-│ ├── generate.go # 生成任务提交 / 查询 / WebSocket
-│ └── project_style.go # 工程风格 CRUD
+│ ├── 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 调用封装(当前为 mock 实现)
+│ ├── inference.go # [已有] AI 推理 API 调用封装(文生图 + 图片编辑)
+│ ├── prompt_agent.go # [已有] LLM 提示词优化 Agent
│ ├── pipeline_test.go # [已有] 管线测试(happy path / 重试 / 降级 / 风格合并)
-│ ├── auth.go # 用户认证:注册、登录、JWT 签发与校验
-│ └── project_style.go # 工程风格管理 & 风格合并逻辑
+│ ├── auth.go # [已有] 用户认证:注册、登录、JWT 签发与校验
+│ └── storage.go # [已有] 七牛云对象存储:上传、下载 URL 生成、删除
├── model/ # 数据模型 / DTO
│ ├── response.go # [已有] 统一响应
-│ ├── user.go # [已有] 用户模型
-│ ├── task.go # 生成任务 & 素材
-│ └── style.go # 工程风格 & 任务风格覆盖
-├── middleware/ # 中间件
-│ ├── logger.go
-│ ├── auth.go # JWT 认证中间件(从 Cookie 读取 Token)
-│ └── ratelimit.go
+│ └── user.go # [已有] 用户模型
+├── mildware/ # 中间件
+│ ├── logger.go # [已有] 请求日志 + Panic 恢复中间件(基于 slog)
+│ └── auth.go # [已有] JWT 认证中间件
+├── logger/ # [已有] 日志包(基于 log/slog)
+│ └── logger.go # 日志初始化、全局 logger、context 注入
├── config/ # [已有] 配置
-│ └── config.go
-├── queue/ # 异步任务队列
-│ └── jobqueue.go
-└── cache/ # 缓存层(请求去重)
- └── cache.go
+│ ├── config.go
+│ └── config.yml
+└── db/ # [已有] 数据库初始化
+ └── db.go
```
## 分层原则
@@ -48,6 +48,46 @@ backend/internal/
- **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` 编排的三阶段管线,含质检不通过时的重优化分支。
@@ -106,7 +146,7 @@ finalStyle = merge(projectStyle.KVPairs, taskStyle.KVPairs)
- **输入**:通过质检的图片 + 素材类型
- **输出**:游戏引擎可用的素材文件 + 元数据
-- **职责**:格式转换、spritesheet 打包、元数据生成。
+- **职责**:格式转换、spritesheet 打包、元数据生成、上传素材到七牛云对象存储。
- **示例**:输入 4 张 64×64 的角色行走帧 PNG → 输出一张 256×64 的 spritesheet + JSON 元数据(帧尺寸、帧数、锚点偏移),可直接拖入 Unity 2D Animation 使用。
---
diff --git a/docs/database.md b/docs/database.md
index 1658709..c5b7589 100755
--- a/docs/database.md
+++ b/docs/database.md
@@ -5,7 +5,7 @@
| 用途 | 方案 | 说明 |
|------|------|------|
| 持久化存储 | SQLite 3 | 工程、任务、素材元数据、风格配置全部落库,单文件部署 |
-| 文件存储 | 本地文件系统 | 生成的图片与 spritesheet 文件保存到本地目录,通过 Gin 静态文件服务访问 |
+| 文件存储 | 七牛云对象存储 | 生成的图片与 spritesheet 文件上传到七牛云 Bucket,通过 CDN URL 访问 |
| 认证 | JWT + Cookie | 简单注册登录,httpOnly Cookie 鉴权 |
| 缓存 / 去重 | 应用层内存 | 请求去重通过应用层 `sync.Map` 实现短期去重窗口,无需外部依赖 |
@@ -133,44 +133,33 @@ task 1 ── N asset
## 文件存储
-生成的素材文件保存到本地文件系统,通过 Gin 静态文件服务访问。
+生成的素材文件上传到七牛云对象存储,通过 CDN URL 访问。
-### 目录结构
+### 存储结构
+
+对象 Key 命名规则(与原有本地路径保持一致):
```
-{dataDir}/
-└── users/
- └── {userId}/
- └── projects/
- └── {projectId}/
- └── tasks/
- └── {taskId}/
- ├── raw/ # AssetGenerator 输出的原始图片
- │ ├── 0.png
- │ ├── 1.png
- │ └── ...
- ├── output/ # FormatAdapter 输出的最终素材
- │ ├── spritesheet.png
- │ └── metadata.json
- └── pipeline.log # 管线执行日志
+users/{userId}/projects/{projectId}/tasks/{taskId}/output/{filename}
```
-`dataDir` 通过环境变量 `GEN2D_DATA_DIR` 配置,默认 `./data`。
+示例:
+```
+users/user_a1B2c3/projects/proj_abc/tasks/task_xyz/output/spritesheet.png
+```
### 访问方式
-Gin 静态文件服务,路由 `/files/*` 映射到 `{dataDir}/` 目录:
+素材上传后返回 CDN URL,前端直接通过该 URL 访问图片。
-```go
-router.Static("/files", dataDir)
-```
+下载接口 `GET /api/v1/assets/download?key=...` 返回 302 重定向到 CDN URL。
### asset 表的 url 字段
-存储相对路径,前端通过 `/files/{url}` 拼接访问:
+存储七牛云 CDN 完整 URL,如:
+`https://cdn.example.com/users/user_a1B2c3/projects/proj_abc/tasks/task_xyz/output/spritesheet.png`
-- 如 `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`
+前端直接使用该 URL 加载图片,无需拼接路径。
---
@@ -194,9 +183,13 @@ router.Static("/files", dataDir)
|----------|--------|------|
| `GEN2D_SERVER_PORT` | `8080` | HTTP 服务监听端口 |
| `GEN2D_DB_PATH` | `./data/gen2d.db` | SQLite 数据库文件路径 |
-| `GEN2D_DATA_DIR` | `./data` | 本地文件存储根目录 |
| `GEN2D_JWT_SECRET` | — | JWT 签名密钥(必填) |
| `GEN2D_JWT_EXPIRE` | `7200` | Token 过期时间(秒) |
+| `GEN2D_QINIU_ACCESS_KEY` | — | 七牛云 AccessKey |
+| `GEN2D_QINIU_SECRET_KEY` | — | 七牛云 SecretKey |
+| `GEN2D_QINIU_BUCKET` | — | 七牛云 Bucket 名称 |
+| `GEN2D_QINIU_CDN_HOST` | — | CDN 域名(如 `https://cdn.example.com`) |
+| `GEN2D_QINIU_USE_HTTPS` | `true` | 是否使用 HTTPS |
在 `config.go` 中扩展字段即可,无需额外依赖。