From 2c5f83f64e5c32518e4d6813573c822fecf94f70 Mon Sep 17 00:00:00 2001 From: wonder Date: Sun, 24 May 2026 21:39:49 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat(deploy):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=9A=E8=BF=87=20.env=20=E6=96=87=E4=BB=B6=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=90=8E=E7=AB=AF=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml 添加 env_file 加载 backend/.env - deploy.sh 自动检测并从 .env.example 复制模板 - .dockerignore 排除 .env 防止密钥泄露到镜像 --- .dockerignore | 1 + deploy.sh | 17 ++++++++++++++++- docker-compose.yml | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 6054892..b97b965 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ node_modules frontend/node_modules frontend/dist backend/bin +backend/.env .git diff --git a/deploy.sh b/deploy.sh index cfd59fc..32683da 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" diff --git a/docker-compose.yml b/docker-compose.yml index 83e764d..aa15cf3 100644 --- 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: From 62544337289f67dd35568ec3c10470e9a4709ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=9C=9D=E6=99=96?= Date: Mon, 25 May 2026 02:35:55 +0000 Subject: [PATCH 02/17] =?UTF-8?q?CI/CD:=20=E6=B5=81=E6=B0=B4=E7=BA=BF?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E8=87=AA=E5=8A=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workflow/deploy.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .workflow/deploy.yml diff --git a/.workflow/deploy.yml b/.workflow/deploy.yml new file mode 100644 index 0000000..84aa8a5 --- /dev/null +++ b/.workflow/deploy.yml @@ -0,0 +1,33 @@ +version: '1.0' +name: pipeline-20260525 +displayName: pipeline-20260525 +triggers: + trigger: auto + push: + branches: + prefix: + - '' +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' From 6932b20152e41439b1d36658b60947acc954f547 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 11:18:23 +0800 Subject: [PATCH 03/17] =?UTF-8?q?docs:=20=E5=85=A8=E9=9D=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于仓库代码与 docs/ 文档综合重写 README: - 新增特性概览、技术栈表格、架构管线图、风格系统、部署说明 - 补充 Eino / License 徽章,修正版本号 - 配置表默认值与 config.yml 实际值对齐(DeepSeek) - 项目结构树反映实际目录与关键文件 - API 部分区分已实现与规划中,补充 WebSocket 说明 --- README.md | 293 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 176 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index d018005..f02745b 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,45 @@ # gen2d -AI 驱动的 2D 游戏素材生成工具。通过文本提示词或可视化参数,快速生成风格一致、管线友好的 Sprite、背景、UI 元素与动画帧。支持 Unity / Godot 一键集成。 +AI 驱动的 2D 游戏素材生成工具。通过文本提示词或可视化参数,生成风格一致、管线友好的 Sprite、背景、UI 元素与动画帧,无缝融入 Unity / Godot 等主流 2D 游戏引擎工作流。 -[![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white)](https://go.dev/) -[![Gin](https://img.shields.io/badge/Gin-008ECF?logo=gin&logoColor=white)](https://gin-gonic.com/) -[![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black)](https://react.dev/) -[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)](https://vite.dev/) +[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev/) +[![Gin](https://img.shields.io/badge/Gin-1.12-008ECF?logo=gin&logoColor=white)](https://gin-gonic.com/) +[![Eino](https://img.shields.io/badge/Eino-0.8-blue)](https://github.com/cloudwego/eino) +[![React](https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=black)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Vite](https://img.shields.io/badge/Vite-6-646CFF?logo=vite&logoColor=white)](https://vite.dev/) +[![License](https://img.shields.io/badge/License-MIT-green)](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,163 @@ 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` 的四阶段管线,支持质检重试与降级输出: + +``` +START + │ + ▼ +PromptOptimizer 合并工程风格 + 任务风格覆盖,调用 PromptAgent 生成三段式提示词 + │ (Eino Chain: formatMetaPrompt → llmRefine → 三段式规范提示词) + ▼ +AssetGenerator 调用文生图 API 生成素材 + │ + ▼ +QualitySupervisor ─────────┬─────────────────────────────┐ + │ │ pass │ fail (retry < 3) + ▼ │ ▼ +FormatAdapter │ 回到 PromptOptimizer 重试 + │ │ │ + ▼ │ ▼ (retry >= 3) +END └───────────────────── FormatAdapter (降级输出) ``` -**方式二:使用 `.env` 文件** +### 提示词优化链路 -```bash -# backend/.env -GEN2D_LLM_API_KEY=sk-your-openai-key -GEN2D_IMAGE_API_KEY=sk-your-stability-key +``` +用户输入 (标签 + 原始 Prompt + 素材类型) + │ + ▼ +formatMetaPrompt (Lambda) 构建元提示词:角色设定 + 输出格式要求 + 用户输入 + │ + ▼ +llmRefine (Lambda → ChatModel) 调用 OpenAI 兼容 Chat Completions API + │ 支持流式 (SSE) 与非流式响应 + │ 无 API key 时自动回退到模板生成 + ▼ +三段式规范提示词(【主题】【风格】【技术】) ``` -**方式三:修改 `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) From 97d9c7974c89bc2f1c83db46b2ae4f3cb33a2393 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 11:19:47 +0800 Subject: [PATCH 04/17] =?UTF-8?q?docs:=20README=20=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=9B=BE=E4=BB=8E=20ASCII=20=E6=9B=BF=E6=8D=A2=E4=B8=BA=20Merm?= =?UTF-8?q?aid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 47 ++++++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f02745b..fe63b06 100644 --- a/README.md +++ b/README.md @@ -114,39 +114,28 @@ npm run dev 基于 Eino `compose.Graph` 的四阶段管线,支持质检重试与降级输出: -``` -START - │ - ▼ -PromptOptimizer 合并工程风格 + 任务风格覆盖,调用 PromptAgent 生成三段式提示词 - │ (Eino Chain: formatMetaPrompt → llmRefine → 三段式规范提示词) - ▼ -AssetGenerator 调用文生图 API 生成素材 - │ - ▼ -QualitySupervisor ─────────┬─────────────────────────────┐ - │ │ pass │ fail (retry < 3) - ▼ │ ▼ -FormatAdapter │ 回到 PromptOptimizer 重试 - │ │ │ - ▼ │ ▼ (retry >= 3) -END └───────────────────── FormatAdapter (降级输出) +```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(( )) ``` ### 提示词优化链路 -``` -用户输入 (标签 + 原始 Prompt + 素材类型) - │ - ▼ -formatMetaPrompt (Lambda) 构建元提示词:角色设定 + 输出格式要求 + 用户输入 - │ - ▼ -llmRefine (Lambda → ChatModel) 调用 OpenAI 兼容 Chat Completions API - │ 支持流式 (SSE) 与非流式响应 - │ 无 API key 时自动回退到模板生成 - ▼ -三段式规范提示词(【主题】【风格】【技术】) +```mermaid +graph LR + A["用户输入
标签 + 原始 Prompt + 素材类型"] --> B["formatMetaPrompt
构建元提示词:角色设定 + 输出格式 + 用户输入"] + B --> C["llmRefine
调用 OpenAI 兼容 Chat API
支持 SSE 流式 / 非流式
无 key 自动回退模板"] + C --> D["三段式规范提示词
【主题】【风格】【技术】"] ``` ### 预设风格键 From d0ad68c3092fe8ab9f929fecb2313979c9fc1ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=9C=9D=E6=99=96?= Date: Mon, 25 May 2026 03:23:17 +0000 Subject: [PATCH 05/17] =?UTF-8?q?CI/CD:=20=E6=9B=B4=E6=96=B0=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=B5=81=E6=B0=B4=E7=BA=BF=EF=BC=8C=E4=BB=85=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=88=B0master=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workflow/pipeline-20260525.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .workflow/pipeline-20260525.yml diff --git a/.workflow/pipeline-20260525.yml b/.workflow/pipeline-20260525.yml new file mode 100644 index 0000000..f7473b4 --- /dev/null +++ b/.workflow/pipeline-20260525.yml @@ -0,0 +1,33 @@ +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' From d66ac373dd597234b063e16ee13ac96fc4a47949 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 11:34:47 +0800 Subject: [PATCH 06/17] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9A=84deploy=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workflow/deploy.yml | 5 ++--- .workflow/pipeline-20260525.yml | 33 --------------------------------- 2 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 .workflow/pipeline-20260525.yml diff --git a/.workflow/deploy.yml b/.workflow/deploy.yml index 84aa8a5..836c30b 100644 --- a/.workflow/deploy.yml +++ b/.workflow/deploy.yml @@ -5,8 +5,8 @@ triggers: trigger: auto push: branches: - prefix: - - '' + precise: + - master stages: - name: stage-b0e54961 displayName: deploy @@ -24,7 +24,6 @@ stages: - 0c13eac4-c35d-455e-a162-ce11a0ab9e37 scriptType: SH script: - - '# 请在此输入您想执行的脚本' - cd ~/gen2d - ./deploy.sh gitClone: false diff --git a/.workflow/pipeline-20260525.yml b/.workflow/pipeline-20260525.yml deleted file mode 100644 index f7473b4..0000000 --- a/.workflow/pipeline-20260525.yml +++ /dev/null @@ -1,33 +0,0 @@ -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' From af9d19d22f2a6cd8258cb271c08b98cb2b61e023 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 11:39:21 +0800 Subject: [PATCH 07/17] =?UTF-8?q?docs:=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=93=BE=E8=B7=AF=E5=9B=BE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=8E=92=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fe63b06..ab566ea 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ graph TD ### 提示词优化链路 ```mermaid -graph LR +graph TD A["用户输入
标签 + 原始 Prompt + 素材类型"] --> B["formatMetaPrompt
构建元提示词:角色设定 + 输出格式 + 用户输入"] B --> C["llmRefine
调用 OpenAI 兼容 Chat API
支持 SSE 流式 / 非流式
无 key 自动回退模板"] C --> D["三段式规范提示词
【主题】【风格】【技术】"] From d0937c2c35a3a8a134886367d4ed8ec5a72ce43b Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 12:56:58 +0800 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=B8=83?= =?UTF-8?q?=E7=89=9B=E4=BA=91=20SDK=20=E4=BE=9D=E8=B5=96=E4=B8=8E=20QiniuC?= =?UTF-8?q?onfig=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go.mod 新增 github.com/qiniu/go-sdk/v7 v7.26.12 - config.go 新增 QiniuConfig 结构体,绑定 GEN2D_QINIU_* 环境变量 - .env.example 补充七牛云环境变量模板 --- backend/.env.example | 7 +++++++ backend/go.mod | 7 +++++++ backend/go.sum | 16 ++++++++++++++++ backend/internal/config/config.go | 22 ++++++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/backend/.env.example b/backend/.env.example index 97bf0b8..3bdd87e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -28,3 +28,10 @@ GEN2D_IMAGE_HEIGHT=1024 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/go.mod b/backend/go.mod index 8b21941..0a2d76c 100644 --- 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 100644 --- 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 62359f3..38d90b2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` LLM LLMConfig `mapstructure:"llm"` ImageGen ImageGenConfig `mapstructure:"image_gen"` + Qiniu QiniuConfig `mapstructure:"qiniu"` } // ServerConfig HTTP 服务配置。 @@ -55,6 +56,15 @@ type ImageGenConfig struct { CFGScale float64 `mapstructure:"cfg_scale"` } +// 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 配置文件和环境变量加载配置。 // 优先级:环境变量 > YAML 文件 > 默认值。 func Load() *Config { @@ -111,6 +121,12 @@ func setDefaults(v *viper.Viper) { 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) { @@ -135,4 +151,10 @@ func bindEnvVars(v *viper.Viper) { 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") } From 30a46ff74b7e24e293ccd6509cc76ea317ea8371 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 12:57:13 +0800 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20=E4=B8=83=E7=89=9B=E4=BA=91?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E6=9C=8D=E5=8A=A1=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service/storage.go: StorageService 提供 Upload/GetDownloadURL/Delete - handler/storage.go: GET /api/v1/assets/download 重定向到 CDN URL - main.go: 注入 StorageService 并注册下载路由 --- backend/cmd/main.go | 9 +++- backend/internal/handler/storage.go | 35 +++++++++++++ backend/internal/service/storage.go | 78 +++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 backend/internal/handler/storage.go create mode 100644 backend/internal/service/storage.go diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 0d4c328..07b34bf 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -31,14 +31,19 @@ func main() { service.InitLLMConfig(cfg.LLM) service.InitImageGenConfig(cfg.ImageGen) + // 初始化七牛云存储服务 + storageSvc := service.NewStorageService(cfg.Qiniu) + handler.InitStorageService(storageSvc) + r := gin.New() r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机 // API v1 路由组 v1 := r.Group("/api/v1") { - v1.GET("/health", handler.Health) // 健康检查 - v1.POST("/prompt/optimize", handler.PromptOptimize) // 提示词优化 + v1.GET("/health", handler.Health) // 健康检查 + v1.POST("/prompt/optimize", handler.PromptOptimize) // 提示词优化 + v1.GET("/assets/download", handler.DownloadAsset) // 素材下载 } // Auth 路由组 diff --git a/backend/internal/handler/storage.go b/backend/internal/handler/storage.go new file mode 100644 index 0000000..2a7251d --- /dev/null +++ b/backend/internal/handler/storage.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + + "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 { + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "生成下载链接失败")) + return + } + + c.Redirect(http.StatusFound, downloadURL) +} diff --git a/backend/internal/service/storage.go b/backend/internal/service/storage.go new file mode 100644 index 0000000..f99e0d1 --- /dev/null +++ b/backend/internal/service/storage.go @@ -0,0 +1,78 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "net/url" + "strings" + "time" + + "gen2d/internal/config" + + "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) { + putPolicy, err := uptoken.NewPutPolicy(s.bucket, time.Now().Add(1*time.Hour)) + if err != nil { + return "", fmt.Errorf("qiniu put policy: %w", err) + } + upToken := uptoken.NewSigner(putPolicy, s.creds) + + uploadMgr := uploader.NewUploadManager(&uploader.UploadManagerOptions{ + UpTokenProvider: upToken, + }) + + err = uploadMgr.UploadReader(ctx, bytes.NewReader(data), &uploader.ObjectOptions{ + BucketName: s.bucket, + ObjectName: &key, + }, nil) + if err != nil { + return "", fmt.Errorf("qiniu upload: %w", err) + } + return s.buildURL(key), 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 { + bucket := s.objectsMgr.Bucket(s.bucket) + return bucket.Object(key).Delete().Call(ctx) +} + +// 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)) +} From f61508a24964b51ade22b86bc4bf5b08a4d7e165 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 12:57:41 +0800 Subject: [PATCH 10/17] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E6=96=87=E6=A1=A3=EF=BC=8C=E6=96=87=E4=BB=B6=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=88=87=E6=8D=A2=E4=B8=BA=E4=B8=83=E7=89=9B=E4=BA=91?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _index.md: 技术栈加入七牛云对象存储 - api.md: 新增素材下载接口,asset URL 改为 CDN URL - backend.md: 分层结构新增 storage.go,FormatAdapter 职责增加上传 - database.md: 文件存储改为七牛云,配置表新增 GEN2D_QINIU_* 环境变量 --- docs/_index.md | 4 ++-- docs/api.md | 18 ++++++++++++++++-- docs/backend.md | 8 +++++--- docs/database.md | 47 ++++++++++++++++++++--------------------------- 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/docs/_index.md b/docs/_index.md index 2526eb6..b1a5453 100644 --- 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..c06ffa7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -300,7 +300,7 @@ POST /api/v1/prompt/optimize ### DELETE /api/v1/projects/:projectId -级联删除工程下的所有任务、素材及本地文件。 +级联删除工程下的所有任务、素材及七牛云对象。 响应: @@ -372,6 +372,7 @@ POST /api/v1/prompt/optimize | [ ] | GET | `/api/v1/tasks/:taskId` | 查询任务状态与进度 | | [ ] | 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..947357e 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -16,7 +16,8 @@ backend/internal/ │ ├── login.go # [已有] 用户登录(handler 骨架 + 参数校验) │ ├── project.go # 工程 CRUD + 任务列表 │ ├── generate.go # 生成任务提交 / 查询 / WebSocket -│ └── project_style.go # 工程风格 CRUD +│ ├── project_style.go # 工程风格 CRUD +│ └── storage.go # [已有] 素材下载(重定向到七牛云 CDN) ├── service/ # 业务逻辑层 │ ├── pipeline.go # [已有] Eino compose.Graph 编排:PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter │ ├── nodes.go # [已有] 管线四个节点的实现(每个节点单一职责) @@ -24,7 +25,8 @@ backend/internal/ │ ├── inference.go # [已有] AI 推理 API 调用封装(当前为 mock 实现) │ ├── pipeline_test.go # [已有] 管线测试(happy path / 重试 / 降级 / 风格合并) │ ├── auth.go # 用户认证:注册、登录、JWT 签发与校验 -│ └── project_style.go # 工程风格管理 & 风格合并逻辑 +│ ├── project_style.go # 工程风格管理 & 风格合并逻辑 +│ └── storage.go # [已有] 七牛云对象存储:上传、下载 URL 生成、删除 ├── model/ # 数据模型 / DTO │ ├── response.go # [已有] 统一响应 │ ├── user.go # [已有] 用户模型 @@ -106,7 +108,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 100644 --- 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` 中扩展字段即可,无需额外依赖。 From 1b06652f17966015560fbb65e06750b5a212cb19 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 13:01:12 +0800 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20=E6=98=BE=E5=BC=8F=E8=BF=94?= =?UTF-8?q?=E5=9B=9Eexit=200=EF=BC=8C=E9=81=BF=E5=85=8D=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E7=BA=BF=E4=B8=AD=E6=97=A0=E6=B3=95=E8=AF=86=E5=88=AB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=B7=B2=E5=AE=8C=E6=88=90=E7=9A=84=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy.sh b/deploy.sh index 32683da..45f3d3b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -70,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 From 83d3d2663d111659fab03b3858b391220bfbf381 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 13:48:37 +0800 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E4=B8=83?= =?UTF-8?q?=E7=89=9B=E4=BA=91=E5=AD=98=E5=82=A8=EF=BC=8C=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate handler 中的本地文件写入替换为 storageSvc.Upload() 调用 - 移除 main.go 中的本地 generation 目录创建和静态文件服务 - 更新 api.md 接口状态标记 --- backend/cmd/main.go | 7 ------- backend/internal/handler/generate.go | 19 +++++-------------- docs/api.md | 6 +++--- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 8815037..c2be817 100755 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -4,7 +4,6 @@ package main import ( "fmt" "log" - "os" "gen2d/internal/config" "gen2d/internal/db" @@ -21,9 +20,6 @@ func main() { gin.SetMode(cfg.Server.Mode) - // 确保 generation 输出目录存在(项目根级别) - _ = os.MkdirAll("../generation", 0755) - // 初始化 SQLite 数据库 if err := db.Init(cfg.Database.DSN, &model.User{}); err != nil { log.Fatalf("db init failed: %v", err) @@ -43,9 +39,6 @@ func main() { r := gin.New() r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机 - // 静态文件服务 — 生成的图片 - r.Static("/generation", "../generation") - // API v1 路由组 — 公开接口 v1 := r.Group("/api/v1") { diff --git a/backend/internal/handler/generate.go b/backend/internal/handler/generate.go index 4f54ddc..3372cdb 100755 --- a/backend/internal/handler/generate.go +++ b/backend/internal/handler/generate.go @@ -5,8 +5,6 @@ import ( "fmt" "log" "net/http" - "os" - "path/filepath" "sync" "time" @@ -141,23 +139,16 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) { updateStatus(taskID, "saving", 80) - // 保存图片到 ../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 { + updateFailed(taskID, "上传素材失败: "+err.Error()) return } assets[i] = AssetResponse{ - URL: fmt.Sprintf("/generation/%s/%s/%s", projectID, taskID, filename), + URL: cdnURL, Format: a.Format, } } diff --git a/docs/api.md b/docs/api.md index c06ffa7..9d9f91f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -368,9 +368,9 @@ 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) | From 50f95adb9885f10e69201fd736ca728aae8547a5 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 14:05:14 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20=E4=B8=BA=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=20API=20=E6=B7=BB=E5=8A=A0=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E4=B8=8E=205xx=20=E9=87=8D=E8=AF=95=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageGenConfig 新增 timeout/max_retries/retry_delay 配置项(默认 120s/2次/5s) - 替换 http.DefaultClient 为带超时的 imageHTTPClient - callImageAPI 对 5xx 错误自动重试,避免 504 等瞬时故障直接失败 --- backend/.env.example | 3 ++ backend/internal/config/config.go | 21 ++++++--- backend/internal/service/inference.go | 68 +++++++++++++++++++++------ 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index cb4279c..3e656b5 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,6 +26,9 @@ GEN2D_IMAGE_MODEL=gpt-image-2-token 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 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0810193..31d545f 100755 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -46,12 +46,15 @@ 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 七牛云对象存储配置。 @@ -117,6 +120,9 @@ 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) @@ -148,6 +154,9 @@ 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") diff --git a/backend/internal/service/inference.go b/backend/internal/service/inference.go index f334a34..b29db7b 100755 --- a/backend/internal/service/inference.go +++ b/backend/internal/service/inference.go @@ -15,6 +15,7 @@ import ( "mime/multipart" "net/http" "strings" + "time" "gen2d/internal/config" ) @@ -22,9 +23,17 @@ import ( // 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 类型 ======================== @@ -73,7 +82,7 @@ func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]G 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 +98,54 @@ 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 { + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + log.Printf("[inference] retrying image API (attempt %d/%d)", attempt, 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) + 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)) + continue + } 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 +190,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) } @@ -199,7 +237,7 @@ 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 { return nil, fmt.Errorf("send request: %w", err) } From fb0d83251fdd8366d0565dd7c93dcde63d5836b9 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 14:30:35 +0800 Subject: [PATCH 14/17] =?UTF-8?q?feat(log):=20=E6=96=B0=E5=BB=BA=20logger?= =?UTF-8?q?=20=E5=8C=85=EF=BC=8C=E5=9F=BA=E4=BA=8E=20log/slog=20=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E7=BB=93=E6=9E=84=E5=8C=96=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 internal/logger/logger.go,提供 Init/FromCtx/WithRequestID 方法 - Config 新增 LogConfig(level/format),支持 GEN2D_LOG_LEVEL/GEN2D_LOG_FORMAT 环境变量 - config.yml 和 .env.example 添加日志配置示例 --- backend/.env.example | 4 ++ backend/internal/config/config.go | 13 ++++++ backend/internal/config/config.yml | 4 ++ backend/internal/logger/logger.go | 71 ++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 backend/internal/logger/logger.go diff --git a/backend/.env.example b/backend/.env.example index 3e656b5..402d14b 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 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 31d545f..000f62e 100755 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -12,11 +12,18 @@ 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 服务配置。 type ServerConfig struct { Port int `mapstructure:"port"` @@ -103,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") @@ -139,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") diff --git a/backend/internal/config/config.yml b/backend/internal/config/config.yml index aa29e74..7f1a7b2 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/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 +} From 681bc8f47287d3d7edd57063bf1474f002f53b4f Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 14:30:44 +0800 Subject: [PATCH 15/17] =?UTF-8?q?feat(middleware):=20=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97=E4=B8=8E=20Panic=20?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=AD=E9=97=B4=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logger 中间件:生成 request_id(注入 X-Request-ID header),记录 method/path/status/latency/client_ip - Recovery 中间件:自定义 panic 恢复,记录 request 上下文和堆栈 --- backend/internal/mildware/logger.go | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 backend/internal/mildware/logger.go 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) +} From 25c11ed649ed1e6852100a365c6f4d45dc5525d7 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 14:31:04 +0800 Subject: [PATCH 16/17] =?UTF-8?q?refactor:=20=E5=85=A8=E9=9D=A2=E9=9B=86?= =?UTF-8?q?=E6=88=90=20slog=20=E6=97=A5=E5=BF=97=E5=88=B0=20handler?= =?UTF-8?q?=E3=80=81service=E3=80=81middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/main.go: 集成 logger.Init 和日志中间件,替换标准 log 包 - handler 层: 5xx 错误记录完整日志,返回通用消息(防内部信息泄露) - service 层: LLM/图片生成/存储/认证等关键操作补充结构化日志 - auth 中间件: 记录认证失败原因 - generate.go: 后台管线任务使用带 task_id 的 logger --- backend/cmd/main.go | 30 ++++++++----- backend/internal/handler/edit.go | 12 ++--- backend/internal/handler/generate.go | 22 +++++----- backend/internal/handler/login.go | 2 + backend/internal/handler/prompt.go | 4 +- backend/internal/handler/register.go | 2 + backend/internal/handler/storage.go | 2 + backend/internal/mildware/auth.go | 5 +++ backend/internal/service/auth.go | 14 ++++++ backend/internal/service/inference.go | 56 +++++++++++++++++++++--- backend/internal/service/pipeline.go | 14 ++++++ backend/internal/service/prompt_agent.go | 13 ++++-- backend/internal/service/storage.go | 22 +++++++++- 13 files changed, 159 insertions(+), 39 deletions(-) diff --git a/backend/cmd/main.go b/backend/cmd/main.go index c2be817..d4f15f0 100755 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -3,11 +3,12 @@ package main import ( "fmt" - "log" + "log/slog" "gen2d/internal/config" "gen2d/internal/db" "gen2d/internal/handler" + "gen2d/internal/logger" "gen2d/internal/mildware" "gen2d/internal/model" "gen2d/internal/service" @@ -18,11 +19,15 @@ import ( func main() { cfg := config.Load() + // 初始化日志 + logger.Init(cfg.Log.Level, cfg.Log.Format) + 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 配置 @@ -37,24 +42,25 @@ func main() { handler.InitStorageService(storageSvc) r := gin.New() - r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机 + 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) // 素材下载 + 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 路由组 @@ -65,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/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 3372cdb..e592b1e 100755 --- a/backend/internal/handler/generate.go +++ b/backend/internal/handler/generate.go @@ -3,11 +3,11 @@ package handler import ( "context" "fmt" - "log" "net/http" "sync" "time" + "gen2d/internal/logger" "gen2d/internal/model" "gen2d/internal/service" @@ -55,8 +55,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 内存中的任务记录。 @@ -103,11 +103,13 @@ 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 后台执行生成管线,更新任务状态。 -func runPipelineBg(projectID, taskID string, req GenerateRequest) { +func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRequest) { + l := logger.With("task_id", taskID, "project_id", projectID) + updateStatus(taskID, "running", 10) in := service.PipelineInput{ @@ -129,11 +131,10 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) { }, } - ctx := context.Background() 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 } @@ -144,7 +145,8 @@ func runPipelineBg(projectID, taskID string, req GenerateRequest) { key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format) cdnURL, err := storageSvc.Upload(ctx, key, a.Data) if err != nil { - updateFailed(taskID, "上传素材失败: "+err.Error()) + l.Error("upload asset failed", "index", i, "error", err) + updateFailed(taskID, "上传素材失败") return } assets[i] = AssetResponse{ @@ -168,7 +170,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 updateStatus(taskID, status string, progress int) { diff --git a/backend/internal/handler/login.go b/backend/internal/handler/login.go index e839664..35b7dfe 100644 --- 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 100644 --- 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 100644 --- 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 index 2a7251d..6270982 100644 --- a/backend/internal/handler/storage.go +++ b/backend/internal/handler/storage.go @@ -3,6 +3,7 @@ package handler import ( "net/http" + "gen2d/internal/logger" "gen2d/internal/model" "gen2d/internal/service" @@ -27,6 +28,7 @@ func DownloadAsset(c *gin.Context) { 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 } diff --git a/backend/internal/mildware/auth.go b/backend/internal/mildware/auth.go index 9a0226a..550da71 100644 --- 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/service/auth.go b/backend/internal/service/auth.go index 4f96abe..db4a2ba 100644 --- 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 b29db7b..37d28d2 100755 --- a/backend/internal/service/inference.go +++ b/backend/internal/service/inference.go @@ -11,13 +11,13 @@ import ( "image/color" "image/png" "io" - "log" "mime/multipart" "net/http" "strings" "time" "gen2d/internal/config" + "gen2d/internal/logger" ) // imgCfg 保存文生图配置,由 main 通过 InitImageGenConfig 注入。 @@ -64,21 +64,33 @@ 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) } @@ -107,10 +119,15 @@ func callImageAPI(ctx context.Context, prompt string, count, width, height int) retryDelay = 5 * time.Second } + l := logger.FromCtx(ctx) + var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { - log.Printf("[inference] retrying image API (attempt %d/%d)", attempt, maxRetries) + 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()) @@ -128,6 +145,7 @@ func callImageAPI(ctx context.Context, prompt string, count, width, height int) resp, err := imageHTTPClient.Do(req) if err != nil { lastErr = fmt.Errorf("send request: %w", err) + l.Warn("image API request failed", "error", lastErr) continue } @@ -140,8 +158,18 @@ func callImageAPI(ctx context.Context, prompt string, count, width, height int) 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)) } @@ -205,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) @@ -239,16 +274,25 @@ func EditImages(ctx context.Context, imageData []byte, prompt string, count int) 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 6ce8a23..9134104 100644 --- 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" ) @@ -82,20 +84,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 100644 --- 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 index f99e0d1..dbd43d0 100644 --- a/backend/internal/service/storage.go +++ b/backend/internal/service/storage.go @@ -9,6 +9,7 @@ import ( "time" "gen2d/internal/config" + "gen2d/internal/logger" "github.com/qiniu/go-sdk/v7/storagev2/credentials" "github.com/qiniu/go-sdk/v7/storagev2/objects" @@ -39,8 +40,11 @@ func NewStorageService(cfg config.QiniuConfig) *StorageService { // 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) @@ -49,14 +53,19 @@ func (s *StorageService) Upload(ctx context.Context, key string, data []byte) (s 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) } - return s.buildURL(key), nil + + cdnURL := s.buildURL(key) + l.Info("upload succeeded", "key", key, "url", cdnURL) + return cdnURL, nil } // GetDownloadURL 生成素材的下载 URL。 @@ -67,8 +76,17 @@ func (s *StorageService) GetDownloadURL(_ context.Context, key string) (string, // Delete 从七牛云删除指定对象。 func (s *StorageService) Delete(ctx context.Context, key string) error { + l := logger.FromCtx(ctx) + bucket := s.objectsMgr.Bucket(s.bucket) - return bucket.Object(key).Delete().Call(ctx) + 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。 From 2471f970662dbf4c0fb4c7a535989399c69b1ed7 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 14:31:09 +0800 Subject: [PATCH 17/17] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=96=87=E6=A1=A3=EF=BC=8C=E8=A1=A5=E5=85=85=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=B3=BB=E7=BB=9F=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend.md | 74 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/docs/backend.md b/docs/backend.md index 947357e..63be3ff 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -14,34 +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 ``` ## 分层原则 @@ -50,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` 编排的三阶段管线,含质检不通过时的重优化分支。