topfans/docs/superpowers/plans/2026-04-07-minimax-image-generation-plan.md
zerosaturation 575233b51f docs: 标记 Task 6 已完成(使用os.Getenv替代)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 01:30:57 +08:00

21 KiB
Raw Blame History

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 文件

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"` // MiniMax API 为数组
    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
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: 添加依赖

cd backend/services/assetService && go get github.com/nfnt/resize
  • Step 2: 创建 minimax_service.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
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 添加:

minimaxService service.MinimaxService

NewAssetController 添加:

ctrl.minimaxService = service.NewMinimaxService(nil) // TODO: 传入 config

添加两个方法:

// 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
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", ...) 附近添加:

assets.POST("/mints/image/generation", assetCtrl.ImageGeneration)     // 图生图
assets.GET("/mints/image/generation/:job_id", assetCtrl.GetImageJob) // 查询任务
  • Step 2: Commit
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 函数改为:

// 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
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 配置读取方法:

// 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
git add backend/services/assetService/config/
git commit -m "feat: 添加 MiniMax 配置读取"

Task 7: 验证测试

  • Step 1: 启动后端服务
cd backend/gateway && go run main.go
  • Step 2: 测试创建任务
curl -X POST http://localhost:8080/api/v1/assets/mints/image/generation \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"model":"image-01","prompt":"test","aspect_ratio":"16:9","n":1,"subject_reference":[]}'
  • Step 3: 测试轮询任务状态
curl http://localhost:8080/api/v1/assets/mints/image/generation/<job_id> \
  -H "Authorization: Bearer <token>"