# 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" "net/url" "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 " ```