package controller import ( "bytes" "context" "encoding/json" "fmt" "strings" "sync" "sync/atomic" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "github.com/topfans/backend/gateway/config" "github.com/topfans/backend/gateway/pkg/response" "github.com/topfans/backend/gateway/repository" "github.com/topfans/backend/gateway/service" "github.com/topfans/backend/gateway/service/compositor" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" ) // LaserCardPersister 镭射卡持久化最小接口 // controller 只通过此接口访问 DB,便于单元测试用 fake 注入。 // 生产实现:*repository.LaserCardRepository(已隐式实现,无需 adapter) type LaserCardPersister interface { FindTemplateByCode(code string) (*models.LaserCardTemplate, error) CreateInstance(inst *models.LaserCardInstance) error UpdateMaterialsSnapshot(instanceID int64, snapshot models.MaterialsSnapshot) error CreateOperationLogSimple(instanceID int64, instanceNo string, userID int64, action, statusBefore, statusAfter string) error } // LaserGenerateController 镭射卡 AI 生成控制器 type LaserGenerateController struct { minimaxClient *service.MinimaxClient difyClient *service.DifyClient openaiClient *service.OpenAIClient jobStore *jobStore laserRepo LaserCardPersister ossHelper *service.OssHelper provider string // "minimax" | "dify" | "openai" | "relay-dify",由 cfg.LaserGen.Provider 决定 } // jobStore 内存中维护 job 状态 type jobStore struct { mu sync.RWMutex jobs map[string]*GenerateJob } // GenerateJob 生成任务 type GenerateJob struct { ID string `json:"job_id"` Status string `json:"status"` Progress float64 `json:"progress"` CreatedAt time.Time `json:"created_at"` Variants []map[string]interface{} `json:"variants"` CutoutURL string `json:"cutout_url"` Warnings []string `json:"warnings"` Error string `json:"error"` // 持久化用 UserID int64 `json:"-"` StarID int64 `json:"-"` InstanceID int64 `json:"-"` InstanceNo string `json:"-"` } var globalJobStore = &jobStore{ jobs: make(map[string]*GenerateJob), } // NewLaserGenerateController 创建控制器 func NewLaserGenerateController(cfg *config.Config) *LaserGenerateController { provider := strings.ToLower(strings.TrimSpace(cfg.LaserGen.Provider)) if provider == "" { provider = "minimax" } logger.Logger.Info("LaserGenerateController initialized", zap.String("provider", provider), zap.String("openai_model", cfg.OpenAI.Model), zap.Bool("openai_key_set", cfg.OpenAI.APIKey != ""), zap.Bool("dify_key_set", cfg.Dify.APIKey != ""), zap.String("dify_workflow", cfg.Dify.Workflow), ) return &LaserGenerateController{ minimaxClient: service.NewMinimaxClient(cfg.Minimax.APIURL, cfg.Minimax.APIKey), difyClient: service.NewDifyClient(cfg.Dify.APIBase, cfg.Dify.APIKey), openaiClient: service.NewOpenAIClient(cfg.OpenAI.BaseURL, cfg.OpenAI.APIKey, cfg.OpenAI.Model), jobStore: globalJobStore, laserRepo: repository.NewLaserCardRepository(database.GetDB()), ossHelper: service.NewOssHelper(cfg.OSS), provider: provider, } } // variantConfig 单个 variant 的生成配置 type variantConfig struct { PresetID string `json:"preset_id"` BgPrompt string `json:"bg_prompt"` OverlayPrompt string `json:"overlay_prompt"` GratingConfig map[string]interface{} `json:"grating_config"` } // generateReq 镭射卡生成请求体 type generateReq struct { CutoutURL string `json:"cutout_url"` PresetCodes []string `json:"preset_codes"` RenderConfigs []map[string]interface{} `json:"render_configs"` UserPrompt string `json:"user_prompt"` // 用户自定义 prompt,注入到 AI 生图 } // CreateGenerateJob POST /api/v1/laser/generate func (ctrl *LaserGenerateController) CreateGenerateJob(c *gin.Context) { var req generateReq if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "参数错误: "+err.Error()) return } if len(req.RenderConfigs) == 0 { response.BadRequest(c, "render_configs 不能为空") return } userID, ok := c.Get("user_id") if !ok { response.Unauthorized(c, "未登录") return } starIDRaw, _ := c.Get("star_id") var starID int64 if starIDRaw != nil { starID = starIDRaw.(int64) } // Dify 提供方: 阻塞模式, 同步调 Dify 工作流, 直接返回 variants if ctrl.provider == "dify" { ctrl.handleDifyBlocking(c, userID.(int64), starID, req) return } // relay-dify 提供方: Dify LLM 增强 prompt → 中转站 edits → 落 OSS(单张调试) if ctrl.provider == "relay-dify" { ctrl.handleRelayDifySingle(c, userID.(int64), starID, req) return } // OpenAI 提供方: 阻塞模式, 5 路并发调 /v1/images/edits, 落 OSS 后直接返回 variants if ctrl.provider == "openai" { ctrl.handleOpenAIDirect(c, userID.(int64), starID, req) return } variants := parseVariants(req) jobID := uuid.New().String() // 持久化:先创建 laser_card_instances(status=rendered) // 从 presets 中取第一个作为默认 template_code templateCode := "default" if len(req.PresetCodes) > 0 { templateCode = req.PresetCodes[0] } // 创建 instance + 写创建日志(统一走 persistGeneratedInstance) var instanceID int64 var instanceNoResp string instanceID, instanceNoResp = ctrl.persistGeneratedInstance(userID.(int64), starID, templateCode, jobID) job := &GenerateJob{ ID: jobID, Status: "processing", Progress: 0, CreatedAt: time.Now(), CutoutURL: req.CutoutURL, UserID: userID.(int64), StarID: starID, InstanceID: instanceID, InstanceNo: instanceNoResp, } ctrl.jobStore.mu.Lock() ctrl.jobStore.jobs[jobID] = job ctrl.jobStore.mu.Unlock() go ctrl.runParallelGeneration(userID.(int64), starID, jobID, req.CutoutURL, req.UserPrompt, variants) response.Success(c, gin.H{ "job_id": jobID, "estimated_seconds": 90, "status": "processing", "variant_count": len(variants), "instance_no": instanceNoResp, }) } // GetGenerateJob GET /api/v1/laser/generate/:id func (ctrl *LaserGenerateController) GetGenerateJob(c *gin.Context) { jobID := c.Param("id") ctrl.jobStore.mu.RLock() job, exists := ctrl.jobStore.jobs[jobID] ctrl.jobStore.mu.RUnlock() if !exists { response.NotFound(c, "任务不存在") return } resp := gin.H{ "status": job.Status, "progress": job.Progress, } if job.Status == "succeeded" { resp["variants"] = job.Variants resp["cutout_url"] = job.CutoutURL resp["warnings"] = job.Warnings if job.InstanceNo != "" { resp["instance_no"] = job.InstanceNo } } else if job.Status == "failed" { resp["error"] = job.Error } response.Success(c, resp) } func parseVariants(req generateReq) []variantConfig { configMap := make(map[string]variantConfig) for _, rc := range req.RenderConfigs { if rc == nil { continue } pid, _ := rc["preset_id"].(string) bg, _ := rc["bg_prompt"].(string) ov, _ := rc["overlay_prompt"].(string) gc, _ := rc["grating_config"].(map[string]interface{}) if pid != "" { configMap[pid] = variantConfig{ PresetID: pid, BgPrompt: bg, OverlayPrompt: ov, GratingConfig: gc, } } } var variants []variantConfig for _, pc := range req.PresetCodes { if v, ok := configMap[pc]; ok { variants = append(variants, v) } } if len(variants) == 0 { for _, v := range configMap { variants = append(variants, v) } } return variants } // handleDifyBlocking 阻塞模式调 Dify 工作流 // 由 Dify 工作流内部完成抠图(可选)→ 5 variant 生成 → 合成, 返回完整结果 // 前端单次请求即可拿到 5 张图, 无需轮询 func (ctrl *LaserGenerateController) handleDifyBlocking(c *gin.Context, userID, starID int64, req generateReq) { ctx := c.Request.Context() // 构造 Dify 工作流 inputs // 注意: Dify 平台限制, preset_codes / render_configs 必须以 JSON 字符串传入 presetCodesJSON, err := json.Marshal(req.PresetCodes) if err != nil { response.InternalError(c, "序列化 preset_codes 失败: "+err.Error()) return } renderConfigsJSON, err := json.Marshal(req.RenderConfigs) if err != nil { response.InternalError(c, "序列化 render_configs 失败: "+err.Error()) return } // user_prompt 加权说明: // - 当前前端 (useLaserDifyGenerate.resolveRenderConfigs) 已把 userPrompt 加权后写进 // 每个 render_config.bg_prompt(头部「主题(必须遵循,不可偏离)」前缀 + 末尾再重复一次), // 同时把 user_prompt 字段传空,让后端不再二次拼接。 // - 此处保留 req.UserPrompt 兜底:若未来有其他调用方忘记前端加权,这里会以 // "末尾追加" 语义再追加一次 userPrompt 作为额外强化,避免 prompt 头部被污染。 // - 为避免与前端加权产生的两次重复叠加成三次,这里仅在 bg_prompt 不含 userPrompt 时才追加。 if req.UserPrompt != "" { renderConfigsJSON = enrichRenderConfigsWithUserPrompt(req.RenderConfigs, req.UserPrompt) } inputs := map[string]interface{}{ "cutout_url": req.CutoutURL, "preset_codes": string(presetCodesJSON), "render_configs": string(renderConfigsJSON), } logger.Logger.Info("Dify workflow invoke", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int("preset_count", len(req.PresetCodes)), zap.String("cutout_url_prefix", safePrefix(req.CutoutURL, 60)), // [DEBUG-TEMP-2] 再次排查:确认前端加权逻辑生效 & Dify 节点是否更新 zap.String("debug_bg0_full", extractFirstBgPrompt(req.RenderConfigs)), zap.String("debug_user_prompt_full", req.UserPrompt), ) output, err := ctrl.difyClient.RunWorkflow(ctx, inputs, fmt.Sprintf("%d", userID)) if err != nil { logger.Logger.Error("Dify workflow failed", zap.Int64("user_id", userID), zap.Error(err), ) response.InternalError(c, "Dify 工作流失败: "+err.Error()) return } // 解析 Dify 输出 (variants / warnings 是 JSON 字符串, 来自 yml 结束节点) variants := []map[string]interface{}{} warnings := []string{} cutoutURL := req.CutoutURL if v, ok := output.Outputs["variants"]; ok && v != nil { switch vt := v.(type) { case string: if err := json.Unmarshal([]byte(vt), &variants); err != nil { logger.Logger.Warn("parse dify variants string failed", zap.Error(err)) } case []interface{}: // 已被 dify_client 解析为数组 b, _ := json.Marshal(vt) _ = json.Unmarshal(b, &variants) } } if w, ok := output.Outputs["warnings"]; ok && w != nil { if ws, ok := w.(string); ok { _ = json.Unmarshal([]byte(ws), &warnings) } } if cu, ok := output.Outputs["cutout_oss_key"].(string); ok && cu != "" { cutoutURL = cu } else if cu, ok := output.Outputs["cutout_url"].(string); ok && cu != "" { cutoutURL = cu } if output.Status == "failed" { response.Success(c, gin.H{ "status": "failed", "error": output.Error, "warnings": warnings, }) return } // 持久化: 统一走 persistGeneratedInstance + attachMaterialsSnapshot templateCode := "default" if len(req.PresetCodes) > 0 { templateCode = req.PresetCodes[0] } var instanceID int64 var instanceNoResp string instanceID, instanceNoResp = ctrl.persistGeneratedInstance(userID, starID, templateCode, "") ctrl.attachMaterialsSnapshot(instanceID, instanceNoResp, userID, cutoutURL, "cutout", variants, "") logger.Logger.Info("Dify workflow completed", zap.Int64("user_id", userID), zap.Int("variant_count", len(variants)), zap.Int("warnings", len(warnings)), ) response.Success(c, gin.H{ "status": "succeeded", "variants": variants, "warnings": warnings, "cutout_url": cutoutURL, "instance_no": instanceNoResp, }) } // handleOpenAIDirect 阻塞模式调 OpenAI /v1/images/edits // // 流程(每路 variant 并发): // 1. 调 openaiClient.EditImage(cutoutURL, bgPrompt) → 透明 PNG bytes // 2. 直接落 OSS(走 RAM STS / AK 直连),key: laser-card/openai/{star}/{user}/{preset}_{rand}.png // 3. 生成 1h 预签名 URL // 4. 汇总 variants 数组,返回与 Dify 路径相同的契约 // // 与 Dify 路径区别: // - 不调 compositor 6 层合成(GPT 输出一张图,已经包含"假镭射"效果) // - 不调 MiniMax(完全绕过文生图链路) // - 失败时不重试,不降级,直接返回 error 写 warnings func (ctrl *LaserGenerateController) handleOpenAIDirect(c *gin.Context, userID, starID int64, req generateReq) { ctx := c.Request.Context() if req.CutoutURL == "" { response.BadRequest(c, "openai provider requires cutout_url (use image-to-image edit mode)") return } if len(req.RenderConfigs) == 0 { response.BadRequest(c, "render_configs 不能为空") return } logger.Logger.Info("OpenAI direct invoke", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int("variant_count", len(req.RenderConfigs)), zap.String("cutout_url_prefix", safePrefix(req.CutoutURL, 60)), ) type variantResult struct { Data map[string]interface{} Err string } resultCh := make(chan variantResult, len(req.RenderConfigs)) var wg sync.WaitGroup for i, rc := range req.RenderConfigs { if rc == nil { continue } wg.Add(1) go func(idx int, rc map[string]interface{}) { defer wg.Done() presetID, _ := rc["preset_id"].(string) bgPrompt, _ := rc["bg_prompt"].(string) if presetID == "" { presetID = fmt.Sprintf("variant_%d", idx) } if bgPrompt == "" { resultCh <- variantResult{Err: fmt.Sprintf("%s: bg_prompt is empty", presetID)} return } // Step 1: 调 OpenAI edit pngBytes, err := ctrl.openaiClient.EditImage(ctx, req.CutoutURL, bgPrompt) if err != nil { logger.Logger.Error("OpenAI edit failed", zap.String("preset", presetID), zap.Error(err), ) resultCh <- variantResult{Err: fmt.Sprintf("%s: %v", presetID, err)} return } // Step 2: 落 OSS ossKey := fmt.Sprintf("laser-card/openai/%d/%d/%s_%s.png", starID, userID, presetID, uuid.New().String()[:8]) if err := ctrl.ossHelper.PutObject(ossKey, bytes.NewReader(pngBytes), "image/png"); err != nil { logger.Logger.Error("OSS upload failed", zap.String("preset", presetID), zap.String("oss_key", ossKey), zap.Error(err), ) resultCh <- variantResult{Err: fmt.Sprintf("%s: OSS upload failed: %v", presetID, err)} return } // Step 3: 生成 1h 预签名 URL signedURL, err := ctrl.ossHelper.SignGetURL(ossKey, 3600) if err != nil { logger.Logger.Error("SignGetURL failed", zap.String("preset", presetID), zap.Error(err), ) resultCh <- variantResult{Err: fmt.Sprintf("%s: sign URL failed: %v", presetID, err)} return } resultCh <- variantResult{Data: map[string]interface{}{ "preset_id": presetID, "oss_key": ossKey, "signed_url": signedURL, }} logger.Logger.Info("OpenAI variant done", zap.String("preset", presetID), zap.String("oss_key", ossKey), zap.Int("bytes", len(pngBytes)), ) }(i, rc) } wg.Wait() close(resultCh) variants := []map[string]interface{}{} var warnings []string for r := range resultCh { if r.Err != "" { warnings = append(warnings, r.Err) } else if r.Data != nil { variants = append(variants, r.Data) } } if len(variants) == 0 { response.InternalError(c, "OpenAI 生成全部失败: "+strings.Join(warnings, "; ")) return } // 持久化(与 Dify 路径共用) templateCode := "default" if len(req.PresetCodes) > 0 { templateCode = req.PresetCodes[0] } instanceID, instanceNo := ctrl.persistGeneratedInstance(userID, starID, templateCode, "") // OpenAI 路径:源图是用户上传原图(没抠图),role 标记 "source_image" 区别于 Dify 的 "cutout" ctrl.attachMaterialsSnapshot(instanceID, instanceNo, userID, req.CutoutURL, "source_image", variants, "") logger.Logger.Info("OpenAI direct completed", zap.Int64("user_id", userID), zap.Int("variant_count", len(variants)), zap.Int("warning_count", len(warnings)), ) response.Success(c, gin.H{ "status": "succeeded", "variants": variants, "warnings": warnings, "cutout_url": req.CutoutURL, "instance_no": instanceNo, }) } // handleRelayDifySingle 单张调试:Dify 增强 prompt → 中转站 edits → 落 OSS // // 流程: // // 1. 取 render_configs[0] 的 bg_prompt // 2. 调 Dify laser_prompt_enhancer_v2 工作流增强 prompt(LLM 通过中转站) // 3. 调中转站 /v1/images/edits(复用 openaiClient,原图 + 增强 prompt) // 4. 落 OSS + 预签名 URL // 5. 返回单个 variant(后续可扩展为 5 路并发 callDifyEnhance → bulk edits) func (ctrl *LaserGenerateController) handleRelayDifySingle(c *gin.Context, userID, starID int64, req generateReq) { ctx := c.Request.Context() if len(req.RenderConfigs) == 0 { response.BadRequest(c, "render_configs 不能为空") return } if req.CutoutURL == "" { response.BadRequest(c, "cutout_url 不能为空(需原图 URL)") return } // 1. 取第一个 variant 的 bg_prompt rc := req.RenderConfigs[0] presetID, _ := rc["preset_id"].(string) if presetID == "" { presetID = "variant_0" } bgPrompt, _ := rc["bg_prompt"].(string) if bgPrompt == "" { response.BadRequest(c, "render_configs[0].bg_prompt 不能为空") return } logger.Logger.Info("RelayDify single start", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.String("preset_id", presetID), zap.String("bg_prompt_prefix", safePrefix(bgPrompt, 60)), ) // 2. 调 Dify 工作流增强 prompt enhancedPrompt := bgPrompt // 默认 fallback difyInputs := map[string]interface{}{ "bg_prompt": bgPrompt, "user_prompt": req.UserPrompt, } difyOutput, err := ctrl.difyClient.RunWorkflow(ctx, difyInputs, fmt.Sprintf("%d", userID)) if err != nil { logger.Logger.Warn("Dify prompt enhancer failed, use raw prompt", zap.Error(err), ) } else if enhanced, ok := difyOutput.Outputs["enhanced_prompt"].(string); ok && enhanced != "" { enhancedPrompt = enhanced logger.Logger.Info("Dify prompt enhanced", zap.Int("raw_len", len(bgPrompt)), zap.Int("enhanced_len", len(enhancedPrompt)), ) } else { // Dify 返回了但字段格式不对,用原始 prompt 兜底 logger.Logger.Warn("Dify returned no enhanced_prompt field, use raw", zap.Any("outputs", difyOutput.Outputs), ) } // 3. 调中转站 /v1/images/edits pngBytes, err := ctrl.openaiClient.EditImage(ctx, req.CutoutURL, enhancedPrompt) if err != nil { logger.Logger.Error("Relay edits failed", zap.String("preset", presetID), zap.Error(err), ) response.InternalError(c, fmt.Sprintf("AI 生图失败: %v", err)) return } if len(pngBytes) == 0 { response.InternalError(c, "AI 生图返回为空") return } // 4. 落 OSS(只取第一张) ossKey := fmt.Sprintf("laser-card/relay-dify/%d/%d/%s_%s.png", starID, userID, presetID, uuid.New().String()[:8]) if err := ctrl.ossHelper.PutObject(ossKey, bytes.NewReader(pngBytes), "image/png"); err != nil { logger.Logger.Error("OSS upload failed", zap.String("oss_key", ossKey), zap.Error(err), ) response.InternalError(c, fmt.Sprintf("OSS 上传失败: %v", err)) return } signedURL, err := ctrl.ossHelper.SignGetURL(ossKey, 3600) if err != nil { logger.Logger.Warn("Sign URL failed", zap.Error(err)) } // 5. 持久化 + 返回 templateCode := "default" if len(req.PresetCodes) > 0 { templateCode = req.PresetCodes[0] } instanceID, instanceNo := ctrl.persistGeneratedInstance(userID, starID, templateCode, "") variants := []map[string]interface{}{{ "preset_id": presetID, "oss_key": ossKey, "signed_url": signedURL, }} ctrl.attachMaterialsSnapshot(instanceID, instanceNo, userID, req.CutoutURL, "source_image", variants, "") logger.Logger.Info("RelayDify single succeeded", zap.String("preset", presetID), zap.Int("png_bytes", len(pngBytes)), zap.String("oss_key", ossKey), ) response.Success(c, gin.H{ "status": "succeeded", "variants": variants, "warnings": []string{}, "cutout_url": req.CutoutURL, "instance_no": instanceNo, }) } // runParallelGeneration 并行生成 5 个 variant:MiniMax(背景+装饰) → compositor 合成 func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64, jobID, cutoutURL, userPrompt string, variants []variantConfig) { ctx := context.Background() logger.Logger.Info("runParallelGeneration start", zap.String("job_id", jobID), zap.Int("variant_count", len(variants)), zap.String("cutout_url_prefix", safePrefix(cutoutURL, 60)), zap.String("user_prompt", safePrefix(userPrompt, 60)), ) var wg sync.WaitGroup type variantResult struct { PresetID string Data map[string]interface{} Err string } resultCh := make(chan variantResult, len(variants)) total := len(variants) var completed int32 updateProgress := func(done int) { pid := float64(done) / float64(total) if pid > 0.95 { pid = 0.95 } ctrl.jobStore.mu.Lock() if j, ok := ctrl.jobStore.jobs[jobID]; ok { j.Progress = pid } ctrl.jobStore.mu.Unlock() } for i, v := range variants { wg.Add(1) go func(idx int, vc variantConfig) { defer wg.Done() ossKey := fmt.Sprintf("%s_%s", vc.PresetID, jobID[:8]) // 合并用户 prompt 到背景/装饰模板 finalBgPrompt := service.BuildBgPrompt(vc.BgPrompt, userPrompt) finalOverlayPrompt := service.BuildOverlayPrompt(vc.OverlayPrompt, userPrompt) logger.Logger.Info("Prompt merged", zap.String("preset", vc.PresetID), zap.String("bg_preview", safePrefix(finalBgPrompt, 80)), zap.String("ol_preview", safePrefix(finalOverlayPrompt, 80)), zap.String("bg_full", finalBgPrompt), zap.String("ol_full", finalOverlayPrompt), zap.Int("bg_len", len(finalBgPrompt)), zap.Int("ol_len", len(finalOverlayPrompt)), ) // Step 1: MiniMax 生成背景图(纯文生图,不含人物) bgURL, err := ctrl.minimaxClient.GenerateImage(ctx, finalBgPrompt) if err != nil { logger.Logger.Error("MiniMax bg failed", zap.String("preset", vc.PresetID), zap.Error(err), ) resultCh <- variantResult{PresetID: vc.PresetID, Err: fmt.Sprintf("背景生成失败: %v", err)} done := int(atomic.AddInt32(&completed, 1)) updateProgress(done) return } // Step 2: MiniMax 生成装饰图 overlayURL, err := ctrl.minimaxClient.GenerateImage(ctx, finalOverlayPrompt) if err != nil { logger.Logger.Error("MiniMax overlay failed", zap.String("preset", vc.PresetID), zap.Error(err), ) // 装饰图失败不致命,继续合成 overlayURL = "" } // Step 3: compositor 6 层合成(人像由 compositor 叠加在金属层之上) logger.Logger.Info("Compositor request", zap.Int("idx", idx), zap.String("preset", vc.PresetID), zap.String("bg_url_prefix", safePrefix(bgURL, 50)), zap.String("cutout_url_prefix", safePrefix(cutoutURL, 50)), zap.String("overlay_url_prefix", safePrefix(overlayURL, 50)), ) compReq := compositor.ComposeRequest{ BackgroundURL: bgURL, CutoutURL: cutoutURL, OverlayURL: overlayURL, ExportWidth: 450, ExportHeight: 600, VariantIndex: idx, } pngData, compErr := compositor.Compose(compReq) if compErr != nil { logger.Logger.Error("Compositor failed", zap.String("preset", vc.PresetID), zap.Error(compErr), ) resultCh <- variantResult{PresetID: vc.PresetID, Err: fmt.Sprintf("合成失败: %v", compErr)} done := int(atomic.AddInt32(&completed, 1)) updateProgress(done) return } compOSSKet := fmt.Sprintf("laser-card/minimax-local/%d/%d/%s", starID, userID, ossKey) if err := ctrl.ossHelper.PutObject(compOSSKet, bytes.NewReader(pngData), "image/png"); err != nil { logger.Logger.Error("Compositor OSS upload failed", zap.String("preset", vc.PresetID), zap.Error(err), ) resultCh <- variantResult{PresetID: vc.PresetID, Err: fmt.Sprintf("OSS上传失败: %v", err)} done := int(atomic.AddInt32(&completed, 1)) updateProgress(done) return } compSignedURL, signErr := ctrl.ossHelper.SignGetURL(compOSSKet, 3600) if signErr != nil { logger.Logger.Warn("Compositor sign URL failed", zap.Error(signErr)) } data := map[string]interface{}{ "preset_id": vc.PresetID, "oss_key": compOSSKet, "signed_url": compSignedURL, "width": 450, "height": 600, } resultCh <- variantResult{PresetID: vc.PresetID, Data: data} done := int(atomic.AddInt32(&completed, 1)) updateProgress(done) }(i, v) } wg.Wait() close(resultCh) ctrl.jobStore.mu.Lock() defer ctrl.jobStore.mu.Unlock() job, ok := ctrl.jobStore.jobs[jobID] if !ok { return } var warnings []string hasError := false for r := range resultCh { if r.Err != "" { warnings = append(warnings, fmt.Sprintf("%s: %s", r.PresetID, r.Err)) hasError = true } else if r.Data != nil { job.Variants = append(job.Variants, r.Data) } } job.Warnings = warnings if hasError && len(job.Variants) == 0 { job.Status = "failed" if len(warnings) > 0 { job.Error = warnings[0] } } else { job.Status = "succeeded" job.Progress = 1.0 // 持久化:更新 materials_snapshot(统一走 attachMaterialsSnapshot) // MiniMax 异步路径:前端先抠图,job.CutoutURL 实际是抠过的人像 ctrl.attachMaterialsSnapshot(job.InstanceID, job.InstanceNo, job.UserID, job.CutoutURL, "cutout", job.Variants, jobID) } logger.Logger.Info("Generate job completed", zap.String("job_id", jobID), zap.Int("variants_count", len(job.Variants)), zap.Int("warnings", len(warnings)), ) } // extractFirstBgPrompt 调试辅助:返回 render_configs[0].bg_prompt func extractFirstBgPrompt(renderConfigs []map[string]interface{}) string { for _, rc := range renderConfigs { if rc == nil { continue } if bg, ok := rc["bg_prompt"].(string); ok { return bg } } return "" } func marshalToString(v interface{}) string { if v == nil { return "" } b, err := json.Marshal(v) if err != nil { return "" } return string(b) } // enrichRenderConfigsWithUserPrompt 把 userPrompt 追加到每个 render_config.bg_prompt 末尾。 // // 约定:前端 (useLaserDifyGenerate.resolveRenderConfigs) 已经把 userPrompt 加权后写进 // bg_prompt 头部(「主题(必须遵循,不可偏离)」前缀 + 末尾再重复一次);此处仅在 bg_prompt // 不包含 userPrompt 时追加,避免与前端加权叠加成 3 次重复。 // // 若 bg_prompt 为空,直接以「主题(必须遵循,不可偏离)」开头建立主题锚点。 // // 返回:序列化后的 JSON 字节,可直接放入 Dify inputs.render_configs。 func enrichRenderConfigsWithUserPrompt(renderConfigs []map[string]interface{}, userPrompt string) []byte { if userPrompt == "" { b, _ := json.Marshal(renderConfigs) return b } enriched := make([]map[string]interface{}, 0, len(renderConfigs)) for _, rc := range renderConfigs { enrichedOne := make(map[string]interface{}, len(rc)+1) for k, v := range rc { enrichedOne[k] = v } bg, _ := enrichedOne["bg_prompt"].(string) switch { case bg == "": enrichedOne["bg_prompt"] = "主题(必须遵循,不可偏离): " + userPrompt case strings.Contains(bg, userPrompt): // 前端已加权,跳过避免重复 default: enrichedOne["bg_prompt"] = bg + ". 主题强调: " + userPrompt } enriched = append(enriched, enrichedOne) } b, _ := json.Marshal(enriched) return b } func safePrefix(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } // persistGeneratedInstance 创建 status=rendered 的 laser_card_instance + 写一条创建操作日志 // // Dify 阻塞模式与 MiniMax 异步模式共用此方法。 // 即使 laserRepo 为 nil 或创建失败也不中断:返回 (0, ""),调用方可决定降级策略。 // // 返回值: // - instanceID > 0 表示创建成功;调用方应继续调用 attachMaterialsSnapshot // - instanceID == 0 表示创建失败(laserRepo 为 nil / DB 写入失败) func (ctrl *LaserGenerateController) persistGeneratedInstance(userID, starID int64, templateCode, jobID string) (int64, string) { if ctrl.laserRepo == nil { return 0, "" } renderedInst := &models.LaserCardInstance{ InstanceNo: "", // BeforeCreate 自动生成 InstanceUlid: "", // BeforeCreate 自动生成 TemplateID: 0, // 种子数据 id=1-5,按 code 查找 TemplateCode: templateCode, TemplateVersion: 1, OwnerUserID: userID, StarID: starID, Status: models.LaserCardInstanceStatusRendered, MaterialsSnapshot: models.MaterialsSnapshot{}, } // 尝试按 template_code 查到真实 template_id if tpl, err := ctrl.laserRepo.FindTemplateByCode(templateCode); err == nil { renderedInst.TemplateID = tpl.ID } if err := ctrl.laserRepo.CreateInstance(renderedInst); err != nil { logger.Logger.Warn("Failed to persist laser_card_instance on create", zap.String("job_id", jobID), zap.Error(err), ) return 0, "" } instanceID := renderedInst.ID instanceNo := renderedInst.InstanceNo logger.Logger.Info("Laser card instance persisted", zap.Int64("instance_id", instanceID), zap.String("instance_no", instanceNo), ) // 写创建操作日志(statusBefore="" → statusAfter="rendered") _ = ctrl.laserRepo.CreateOperationLogSimple( instanceID, instanceNo, userID, models.LaserCardActionGenerateVariants, "", models.LaserCardInstanceStatusRendered, ) return instanceID, instanceNo } // attachMaterialsSnapshot 把"源图" + variants 装配成 MaterialsSnapshot 并写库, // 写一条 snapshot 更新操作日志。 // // sourceRole: // - "cutout" : Dify / MiniMax 路径,源图是抠过的人像 // - "source_image" : OpenAI 路径,源图是用户上传原图(没经过 segment) // // instanceID <= 0 时直接返回(persistGeneratedInstance 创建失败,跳过 snapshot)。 // snapshot 写失败仅 warn,不影响调用方其他逻辑。 func (ctrl *LaserGenerateController) attachMaterialsSnapshot(instanceID int64, instanceNo string, userID int64, sourceURL, sourceRole string, variants []map[string]interface{}, jobID string) { if ctrl.laserRepo == nil || instanceID <= 0 { return } snapshot := buildMaterialsSnapshot(sourceURL, sourceRole, variants) if err := ctrl.laserRepo.UpdateMaterialsSnapshot(instanceID, snapshot); err != nil { logger.Logger.Warn("Failed to update materials_snapshot", zap.String("job_id", jobID), zap.Int64("instance_id", instanceID), zap.Error(err), ) return } // 写 snapshot 更新操作日志(statusBefore="rendered" → statusAfter="") _ = ctrl.laserRepo.CreateOperationLogSimple( instanceID, instanceNo, userID, models.LaserCardActionGenerateVariants, models.LaserCardInstanceStatusRendered, "", ) } // buildMaterialsSnapshot 把"源图" + variants 装配成 MaterialsSnapshot JSON。 // 顺序:源图 先,variants 后;过滤掉 oss_key 为空的项。 // // sourceRole 区分源图语义: // - "cutout" : Dify / MiniMax 路径,源图是抠过的人像(compositor 6 层合成用) // - "source_image" : OpenAI 路径,源图是用户上传原图(GPT-image 自己理解,不再扣图) func buildMaterialsSnapshot(sourceURL, sourceRole string, variants []map[string]interface{}) models.MaterialsSnapshot { snapshot := make(models.MaterialsSnapshot, 0, len(variants)+1) if sourceURL != "" { snapshot = append(snapshot, models.MaterialSnapshotItem{ Role: sourceRole, OssKey: sourceURL, }) } for _, v := range variants { ossKey, _ := v["oss_key"].(string) presetID, _ := v["preset_id"].(string) if ossKey != "" { snapshot = append(snapshot, models.MaterialSnapshotItem{ Role: "composite", OssKey: ossKey, PresetID: presetID, }) } } return snapshot }