topfans/docs/superpowers/plans/2026-04-07-minimax-image-generation-plan.md
zerosaturation 4bae8e9b64 fix: plan 添加 net/url import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 01:30:57 +08:00

766 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <token>" \
-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/<job_id> \
-H "Authorization: Bearer <token>"
```