- 替换中转站从 xbcl.link 到 weda.cc - prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖) - 4 路并发调用 + 原图展示 = 5 张 variant - 前端提示词中译英支持 - 全局 Vue errorHandler - WebSocket 鉴权失败跳登录 - 删除已弃用的 laserCompositor 微服务 Co-Authored-By: Claude <noreply@anthropic.com>
1020 lines
32 KiB
Go
1020 lines
32 KiB
Go
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
|
||
}
|