topfans/backend/gateway/controller/laser_generate_controller.go
Lenticular Studio Agent af7908e72e feat: 接入微达API中转站,重构镭射卡生图流程
- 替换中转站从 xbcl.link 到 weda.cc
- prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖)
- 4 路并发调用 + 原图展示 = 5 张 variant
- 前端提示词中译英支持
- 全局 Vue errorHandler
- WebSocket 鉴权失败跳登录
- 删除已弃用的 laserCompositor 微服务

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 22:43:49 +08:00

1020 lines
32 KiB
Go
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.

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_instancesstatus=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 工作流增强 promptLLM 通过中转站)
// 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 个 variantMiniMax(背景+装饰) → 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
}