From ed2acc3268af7f55b91f1fcdc5d19be65b40d645 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Tue, 7 Apr 2026 23:52:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20MiniMax=20?= =?UTF-8?q?=E5=9B=BE=E7=94=9F=E5=9B=BE=E6=9C=8D=E5=8A=A1=E5=92=8C=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- backend/services/assetService/go.mod | 1 + backend/services/assetService/go.sum | 2 + .../assetService/service/minimax_service.go | 333 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 backend/services/assetService/service/minimax_service.go diff --git a/backend/services/assetService/go.mod b/backend/services/assetService/go.mod index 9576789..6c53eb4 100644 --- a/backend/services/assetService/go.mod +++ b/backend/services/assetService/go.mod @@ -91,6 +91,7 @@ require ( github.com/mschoch/smat v0.2.0 // indirect github.com/nacos-group/nacos-sdk-go/v2 v2.2.5 // indirect github.com/natefinch/lumberjack v2.0.0+incompatible // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 // indirect github.com/onsi/ginkgo/v2 v2.11.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect diff --git a/backend/services/assetService/go.sum b/backend/services/assetService/go.sum index 000d16b..fc9518b 100644 --- a/backend/services/assetService/go.sum +++ b/backend/services/assetService/go.sum @@ -621,6 +621,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= diff --git a/backend/services/assetService/service/minimax_service.go b/backend/services/assetService/service/minimax_service.go new file mode 100644 index 0000000..eafa386 --- /dev/null +++ b/backend/services/assetService/service/minimax_service.go @@ -0,0 +1,333 @@ +package service + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "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), + } + 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() + + 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 := 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() + } +} \ No newline at end of file