From 9ca072b463e9c015af744a296f384f803985981f Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Tue, 7 Apr 2026 23:44:58 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20MiniMax=20?= =?UTF-8?q?=E5=9B=BE=E7=94=9F=E5=9B=BE=20API=20=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- ...026-04-07-minimax-image-generation-plan.md | 764 ++++++++++++++++++ 1 file changed, 764 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-minimax-image-generation-plan.md diff --git a/docs/superpowers/plans/2026-04-07-minimax-image-generation-plan.md b/docs/superpowers/plans/2026-04-07-minimax-image-generation-plan.md new file mode 100644 index 0000000..0000c21 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-minimax-image-generation-plan.md @@ -0,0 +1,764 @@ +# MiniMax 图生图 API 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现异步图生图 API,前端轮询 job 状态,后端调用 MiniMax 生成图片 + +**Architecture:** +- Gateway 接收 HTTP 请求,创建 job 返回 job_id +- assetService 异步调用 MiniMax API,完成后更新 job 状态 +- 前端轮询查询 job 状态,COMPLETED 后展示结果 + +**Tech Stack:** Go (Gin, Dubbo RPC), MiniMax API, uni-app (Vue 3) + +--- + +## 文件结构 + +``` +backend/ +├── gateway/ +│ ├── dto/ +│ │ └── image_dto.go # 新增: ImageGenerationRequest, ImageJobResponse +│ ├── controller/ +│ │ └── asset_controller.go # 修改: 添加 ImageGeneration, GetImageJob +│ └── router/ +│ └── router.go # 修改: 注册 /mints/image/generation 路由 +└── services/assetService/ + └── service/ + └── minimax_service.go # 新增: 任务管理 + MiniMax API 调用 + 图片压缩 + +frontend/ +└── pages/discover/ + └── generation-loading.vue # 修改: 改为轮询模式 +``` + +--- + +## Task 1: 创建 DTO 文件 + +**Files:** +- Create: `backend/gateway/dto/image_dto.go` +- Test: (无独立测试,随 controller 测试) + +- [ ] **Step 1: 创建 DTO 文件** + +```go +package dto + +// ImageGenerationRequest MiniMax 图生图请求 +type ImageGenerationRequest struct { + Model string `json:"model" binding:"required"` + Prompt string `json:"prompt" binding:"required"` + AspectRatio string `json:"aspect_ratio"` + SubjectReference []SubjectReference `json:"subject_reference"` + N int `json:"n"` // 1-4 +} + +type SubjectReference struct { + Type string `json:"type"` + ImageFile string `json:"image_file"` +} + +// ImageJobResponse 图生图任务响应 +type ImageJobResponse struct { + JobID string `json:"job_id"` + Status string `json:"status"` + Progress int `json:"progress"` + Images []string `json:"images,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CompletedAt int64 `json:"completed_at,omitempty"` +} + +// ImageJobCreateResponse 创建任务响应 +type ImageJobCreateResponse struct { + JobID string `json:"job_id"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/gateway/dto/image_dto.go +git commit -m "feat: 添加图生图 DTO" +``` + +--- + +## Task 2: 创建 MiniMax Service (任务管理 + API 调用) + +**Files:** +- Create: `backend/services/assetService/service/minimax_service.go` +- Modify: `backend/services/assetService/go.mod` (添加依赖) +- Test: (无独立测试) + +- [ ] **Step 1: 添加依赖** + +```bash +cd backend/services/assetService && go get github.com/nfnt/resize +``` + +- [ ] **Step 2: 创建 minimax_service.go** + +```go +package service + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "math/rand" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/nfnt/resize" + "github.com/topfans/backend/services/assetService/config" + dto "github.com/topfans/backend/gateway/dto" + "go.uber.org/zap" +) + +// JobStatus 任务状态 +type JobStatus string + +const ( + StatusPending JobStatus = "PENDING" + StatusProcessing JobStatus = "PROCESSING" + StatusCompleted JobStatus = "COMPLETED" + StatusFailed JobStatus = "FAILED" +) + +// ImageGenerationJob 图生图任务 +type ImageGenerationJob struct { + JobID string `json:"job_id"` + UserID int64 `json:"user_id"` + StarID int64 `json:"star_id"` + Status JobStatus `json:"status"` + Progress int `json:"progress"` + Images []string `json:"images,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` + Request *dto.ImageGenerationRequest `json:"request,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CompletedAt int64 `json:"completed_at,omitempty"` +} + +// MinimaxService MiniMax API 转发服务 +type MinimaxService interface { + CreateJob(ctx context.Context, userID, starID int64, req *dto.ImageGenerationRequest) (*ImageGenerationJob, error) + GetJob(ctx context.Context, jobID string, userID, starID int64) (*ImageGenerationJob, error) +} + +type minimaxService struct { + config *config.AssetConfig + jobs map[string]*ImageGenerationJob + jobsLock sync.RWMutex +} + +// NewMinimaxService 创建 MiniMax 服务 +func NewMinimaxService(cfg *config.AssetConfig) MinimaxService { + svc := &minimaxService{ + config: cfg, + jobs: make(map[string]*ImageGenerationJob), + } + // 启动清理 goroutine + go svc.cleanupExpiredJobs() + return svc +} + +// CreateJob 创建图生图任务 +func (s *minimaxService) CreateJob(ctx context.Context, userID, starID int64, req *dto.ImageGenerationRequest) (*ImageGenerationJob, error) { + jobID := uuid.New().String() + now := time.Now().UnixMilli() + + job := &ImageGenerationJob{ + JobID: jobID, + UserID: userID, + StarID: starID, + Status: StatusProcessing, + Progress: 0, + Request: req, + CreatedAt: now, + UpdatedAt: now, + } + + s.jobsLock.Lock() + s.jobs[jobID] = job + s.jobsLock.Unlock() + + // 异步调用 MiniMax + go s.processJob(job) + + return job, nil +} + +// GetJob 获取任务 +func (s *minimaxService) GetJob(ctx context.Context, jobID string, userID, starID int64) (*ImageGenerationJob, error) { + s.jobsLock.RLock() + job, ok := s.jobs[jobID] + s.jobsLock.RUnlock() + + if !ok { + return nil, fmt.Errorf("job not found") + } + if job.UserID != userID || job.StarID != starID { + return nil, fmt.Errorf("access denied") + } + + return job, nil +} + +// processJob 异步处理任务 +func (s *minimaxService) processJob(job *ImageGenerationJob) { + defer func() { + if r := recover(); r != nil { + job.Status = StatusFailed + job.ErrorMsg = fmt.Sprintf("panic: %v", r) + job.UpdatedAt = time.Now().UnixMilli() + } + }() + + // 1. 校验 SSRF + for _, ref := range job.Request.SubjectReference { + if err := validateURL(ref.ImageFile); err != nil { + job.Status = StatusFailed + job.ErrorMsg = "invalid image URL: " + err.Error() + job.UpdatedAt = time.Now().UnixMilli() + return + } + } + + // 2. 压缩图片 + processedRefs := make([]dto.SubjectReference, len(job.Request.SubjectReference)) + for i, ref := range job.Request.SubjectReference { + job.Progress = 10 + i*20 + job.UpdatedAt = time.Now().UnixMilli() + + compressed, err := s.compressImageIfNeeded(ref.ImageFile) + if err != nil { + compressed = ref.ImageFile + zap.S().Warnf("Image compression failed, using original: %v", err) + } + processedRefs[i] = dto.SubjectReference{ + Type: ref.Type, + ImageFile: compressed, + } + } + + job.Progress = 50 + job.UpdatedAt = time.Now().UnixMilli() + + // 3. 调用 MiniMax API + images, err := s.callMiniMaxAPI(job.Request.Model, job.Request.Prompt, job.Request.AspectRatio, processedRefs, job.Request.N) + if err != nil { + job.Status = StatusFailed + job.ErrorMsg = "MiniMax API failed: " + err.Error() + job.UpdatedAt = time.Now().UnixMilli() + return + } + + job.Progress = 90 + job.UpdatedAt = time.Now().UnixMilli() + + // 4. 完成 + job.Status = StatusCompleted + job.Progress = 100 + job.Images = images + job.CompletedAt = time.Now().UnixMilli() + job.UpdatedAt = time.Now().UnixMilli() +} + +// callMiniMaxAPI 调用 MiniMax API +func (s *minimaxService) callMiniMaxAPI(model, prompt, aspectRatio string, refs []dto.SubjectReference, n int) ([]string, error) { + apiURL := s.config.GetMiniMaxAPIURL() + apiKey := s.config.GetMiniMaxAPIKey() + + payload := map[string]interface{}{ + "model": model, + "prompt": prompt, + "aspect_ratio": aspectRatio, + "subject_reference": refs, + "n": n, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 120 * time.Second} + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Images []struct { + URL string `json:"url"` + } `json:"images"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + images := make([]string, len(result.Images)) + for i, img := range result.Images { + images[i] = img.URL + } + return images, nil +} + +// compressImageIfNeeded 下载并压缩图片 +func (s *minimaxService) compressImageIfNeeded(imageURL string) (string, error) { + resp, err := http.Get(imageURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + imgData, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + img, format, err := image.Decode(bytes.NewReader(imgData)) + if err != nil { + return "", err + } + + bounds := img.Bounds() + maxDim := uint(1024) + newWidth := uint(bounds.Dx()) + newHeight := uint(bounds.Dy()) + + if newWidth > maxDim || newHeight > maxDim { + if newWidth > newHeight { + ratio := float64(maxDim) / float64(newWidth) + newWidth = maxDim + newHeight = uint(float64(newHeight) * ratio) + } else { + ratio := float64(maxDim) / float64(newHeight) + newHeight = maxDim + newWidth = uint(float64(newWidth) * ratio) + } + } + + if newWidth == uint(bounds.Dx()) && newHeight == uint(bounds.Dy()) { + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(imgData), nil + } + + resized := resize.Thumbnail(newWidth, newHeight, img, resize.Lanczos) + + var buf bytes.Buffer + switch format { + case "png": + err = png.Encode(&buf, resized) + case "gif": + err = gif.Encode(&buf, resized, nil) + default: + err = jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 85}) + } + if err != nil { + return "", err + } + + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + mimeType := "image/jpeg" + if format == "png" { + mimeType = "image/png" + } else if format == "gif" { + mimeType = "image/gif" + } + return "data:" + mimeType + ";base64," + encoded, nil +} + +// validateURL 校验 URL 防止 SSRF +func validateURL(rawURL string) error { + if rawURL == "" { + return nil + } + u, err := url.Parse(rawURL) + if err != nil { + return err + } + host := u.Hostname() + + // 检查是否是 IP + ip := net.ParseIP(host) + if ip != nil { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() { + return fmt.Errorf("private IP not allowed: %s", host) + } + return nil + } + + // 检查是否是内网域名 + lowerHost := strings.ToLower(host) + if strings.HasSuffix(lowerHost, ".local") || + strings.HasSuffix(lowerHost, ".internal") || + strings.HasSuffix(lowerHost, ".private") { + return fmt.Errorf("internal domain not allowed: %s", host) + } + + return nil +} + +// cleanupExpiredJobs 清理过期任务 +func (s *minimaxService) cleanupExpiredJobs() { + ticker := time.NewTicker(1 * time.Hour) + for range ticker.C { + s.jobsLock.Lock() + now := time.Now().UnixMilli() + expiredThreshold := int64(24 * 60 * 60 * 1000) // 24h + for jobID, job := range s.jobs { + if job.Status == StatusCompleted || job.Status == StatusFailed { + if now-job.UpdatedAt > expiredThreshold { + delete(s.jobs, jobID) + } + } + } + s.jobsLock.Unlock() + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/services/assetService/service/minimax_service.go backend/services/assetService/go.mod backend/services/assetService/go.sum +git commit -m "feat: 添加 MiniMax 图生图服务和任务管理" +``` + +--- + +## Task 3: 修改 AssetController 添加新接口 + +**Files:** +- Modify: `backend/gateway/controller/asset_controller.go` (在 AssetController struct 添加 minimaxService 字段,添加两个方法) +- Test: (无独立测试) + +- [ ] **Step 1: 添加字段和方法到 AssetController** + +在 `AssetController` struct 添加: +```go +minimaxService service.MinimaxService +``` + +在 `NewAssetController` 添加: +```go +ctrl.minimaxService = service.NewMinimaxService(nil) // TODO: 传入 config +``` + +添加两个方法: + +```go +// ImageGeneration 创建图生图任务 +// @Summary 图生图 +// @Description 创建图生图任务 +// @Tags assets +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body dto.ImageGenerationRequest true "图生图请求" +// @Success 202 {object} response.Response +// @Router /api/v1/assets/mints/image/generation [post] +func (ctrl *AssetController) ImageGeneration(c *gin.Context) { + var req dto.ImageGenerationRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "Invalid request: "+err.Error()) + return + } + + userID := c.GetInt64("userID") + starID := c.GetInt64("starID") + + job, err := ctrl.minimaxService.CreateJob(c.Request.Context(), userID, starID, &req) + if err != nil { + response.Error(c, 500, "Failed to create job: "+err.Error()) + return + } + + response.SuccessWithStatus(c, 202, &dto.ImageJobCreateResponse{ + JobID: job.JobID, + Status: string(job.Status), + CreatedAt: job.CreatedAt, + }) +} + +// GetImageJob 查询图生图任务状态 +// @Summary 查询图生图任务 +// @Description 查询图生图任务状态和结果 +// @Tags assets +// @Produce json +// @Security BearerAuth +// @Param job_id path string true "任务ID" +// @Success 200 {object} response.Response +// @Router /api/v1/assets/mints/image/generation/{job_id} [get] +func (ctrl *AssetController) GetImageJob(c *gin.Context) { + jobID := c.Param("job_id") + if jobID == "" { + response.Error(c, 400, "job_id is required") + return + } + + userID := c.GetInt64("userID") + starID := c.GetInt64("starID") + + job, err := ctrl.minimaxService.GetJob(c.Request.Context(), jobID, userID, starID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + response.Error(c, 404, "Job not found") + return + } + if strings.Contains(err.Error(), "access denied") { + response.Error(c, 403, "Access denied") + return + } + response.Error(c, 500, "Failed to get job: "+err.Error()) + return + } + + response.Success(c, &dto.ImageJobResponse{ + JobID: job.JobID, + Status: string(job.Status), + Progress: job.Progress, + Images: job.Images, + ErrorMsg: job.ErrorMsg, + CreatedAt: job.CreatedAt, + UpdatedAt: job.UpdatedAt, + CompletedAt: job.CompletedAt, + }) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/gateway/controller/asset_controller.go +git commit -m "feat: 添加 ImageGeneration 和 GetImageJob 接口" +``` + +--- + +## Task 4: 注册路由 + +**Files:** +- Modify: `backend/gateway/router/router.go` + +- [ ] **Step 1: 在 assets 路由组添加新路由** + +在 `assets.POST("/mints/precreate", ...)` 附近添加: + +```go +assets.POST("/mints/image/generation", assetCtrl.ImageGeneration) // 图生图 +assets.GET("/mints/image/generation/:job_id", assetCtrl.GetImageJob) // 查询任务 +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/gateway/router/router.go +git commit -m "feat: 注册图生图 API 路由" +``` + +--- + +## Task 5: 修改前端轮询逻辑 + +**Files:** +- Modify: `frontend/pages/discover/generation-loading.vue` + +- [ ] **Step 1: 修改 script 部分** + +将 `callImageGeneration` 函数改为: + +```javascript +// job_id 轮询 +let jobId = null; + +// 调用图生图API - 异步模式 +const callImageGeneration = async () => { + try { + const res = await imageGenerationApi(generationData); + + if (res.data && res.data.job_id) { + jobId = res.data.job_id; + console.log('[GenerationLoading] 任务已创建:', jobId); + // 开始轮询 + pollJobStatus(); + } else { + uni.showToast({ + title: '创建任务失败', + icon: 'none', + duration: 2000 + }); + revertProgress(); + } + } catch (err) { + console.error('[GenerationLoading] 创建任务失败:', err); + uni.showToast({ + title: '创建任务失败', + icon: 'none', + duration: 2000 + }); + revertProgress(); + } +}; + +// 轮询任务状态 +const pollJobStatus = () => { + let pollCount = 0; + const maxPolls = 40; // 120秒 / 3秒 = 40次 + + const poll = async () => { + if (!jobId) return; + + try { + const res = await uni.request({ + url: baseURL + `/api/v1/assets/mints/image/generation/${jobId}`, + method: 'GET', + header: { + 'Authorization': `Bearer ${uni.getStorageSync('access_token')}` + } + }); + + const data = res.data?.data; + if (data) { + // 更新进度 + if (data.progress) { + progress.value = Math.min(data.progress, 90); + } + + if (data.status === 'COMPLETED') { + // 完成 + const imageUrls = data.images || []; + if (imageUrls.length > 0) { + uni.setStorageSync('generated_images', JSON.stringify(imageUrls)); + completeProgress(); + } else { + uni.showToast({ title: '未生成图片', icon: 'none' }); + revertProgress(); + } + return; + } else if (data.status === 'FAILED') { + // 失败 + uni.showToast({ + title: data.error_msg || '生成失败', + icon: 'none', + duration: 2000 + }); + revertProgress(); + return; + } + } + + pollCount++; + if (pollCount >= maxPolls) { + uni.showToast({ title: '生成超时,请重试', icon: 'none' }); + revertProgress(); + return; + } + + // 3秒后继续轮询 + setTimeout(poll, 3000); + } catch (err) { + console.error('[GenerationLoading] 轮询失败:', err); + pollCount++; + if (pollCount >= maxPolls) { + uni.showToast({ title: '查询失败,请重试', icon: 'none' }); + revertProgress(); + } else { + setTimeout(poll, 3000); + } + } + }; + + poll(); +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/pages/discover/generation-loading.vue +git commit -m "feat: 前端改为轮询模式" +``` + +--- + +## Task 6: 添加配置读取 (config) + +**Files:** +- Create: `backend/services/assetService/config/minimax_config.go` +- Modify: `backend/services/assetService/config/asset_config.go` (如果需要) + +- [ ] **Step 1: 添加配置读取** + +在 config 包添加 MiniMax 配置读取方法: + +```go +// GetMiniMaxAPIURL 获取 MiniMax API URL +func (c *AssetConfig) GetMiniMaxAPIURL() string { + return os.Getenv("MINIMAX_API_URL") +} + +// GetMiniMaxAPIKey 获取 MiniMax API Key +func (c *AssetConfig) GetMiniMaxAPIKey() string { + return os.Getenv("MINIMAX_API_KEY") +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/services/assetService/config/ +git commit -m "feat: 添加 MiniMax 配置读取" +``` + +--- + +## Task 7: 验证测试 + +- [ ] **Step 1: 启动后端服务** + +```bash +cd backend/gateway && go run main.go +``` + +- [ ] **Step 2: 测试创建任务** + +```bash +curl -X POST http://localhost:8080/api/v1/assets/mints/image/generation \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"image-01","prompt":"test","aspect_ratio":"16:9","n":1,"subject_reference":[]}' +``` + +- [ ] **Step 3: 测试轮询任务状态** + +```bash +curl http://localhost:8080/api/v1/assets/mints/image/generation/ \ + -H "Authorization: Bearer " +```