diff --git a/backend/.env.example b/backend/.env.example
index 2e579c4..8600dd4 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -81,9 +81,26 @@ WS_AI_CHAT_PATH=/ai-chat
# ==================== Dify AI Workflow ====================
# Dify API 地址(自部署或云服务)
-DIFY_API_BASE=https://api.dify.ai/v1
+DIFY_API_BASE=http://localhost/v1
# Dify App API Key(laser_card_variants_v1 工作流)
-DIFY_API_KEY=
+DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
+# Dify 工作流名称(relay-dify 模式时使用的 Dify app,用于 prompt 增强)
+DIFY_WORKFLOW=laser_prompt_enhancer_v2
+# ==================== 镭射卡生成器 ====================
+# LASER_GEN_PROVIDER:
+# minimax (默认) - 后端直连 MiniMax, 并行 5 variant 生成背景+装饰, 再调 compositor 合成
+# dify - 调 Dify laser_card_variants_v1 工作流, 由 Dify 内部分发
+# openai - 后端直连 OpenAI /v1/images/edits, 5 路并发 + 直接落 OSS (不调 compositor)
+# relay-dify - Dify 增强 prompt → 中转站 edits → 落 OSS(单张调试,可扩展)
+LASER_GEN_PROVIDER=openai
+
+# ==================== OpenAI Images API (LASER_GEN_PROVIDER=openai 时使用) ====================
+# 必填:OpenAI API Key(去 https://platform.openai.com/api-keys 生成)
+OPENAI_API_KEY=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
+# 可选:API 端点(默认官方地址;如使用代理或自建兼容服务可改)
+OPENAI_BASE_URL=https://api.openai.com/v1
+# 可选:模型名(默认 gpt-image-1.5;可选 gpt-image-1 / dall-e-3)
+OPENAI_MODEL=gpt-image-1.5
# laser-compositor 内网地址
# 注意:7000 端口在 macOS 上被 AirPlay Receiver 占用,因此改用 7002
LASER_COMPOSITOR_URL=http://127.0.0.1:7002
diff --git a/backend/gateway/config/config.go b/backend/gateway/config/config.go
index dcdc4ad..c92793e 100644
--- a/backend/gateway/config/config.go
+++ b/backend/gateway/config/config.go
@@ -15,6 +15,8 @@ type Config struct {
Segment SegmentConfig
Dify DifyConfig
Minimax MinimaxConfig
+ OpenAI OpenAIConfig
+ LaserGen LaserGenConfig
LaserCompositor LaserCompositorConfig
Redis RedisConfig
DB DBConfig
@@ -22,6 +24,15 @@ type Config struct {
Root string
}
+// LaserGenConfig 镭射卡生成器配置
+// Provider:
+// "minimax" (默认) - 后端直连 MiniMax + 并行 5 variant + compositor 合成
+// "dify" - 调 Dify 工作流, 由 Dify 内部分发与合成, 阻塞模式
+// "openai" - 后端直连 OpenAI Images API /v1/images/edits, 5 路并发 + 直接落 OSS
+type LaserGenConfig struct {
+ Provider string
+}
+
// MinimaxConfig MiniMax 图像生成配置
type MinimaxConfig struct {
APIKey string // MiniMax API 密钥
@@ -35,8 +46,17 @@ type LaserCompositorConfig struct {
// DifyConfig Dify 工作流配置
type DifyConfig struct {
- APIBase string
- APIKey string
+ APIBase string
+ APIKey string
+ Workflow string // 工作流名称/ID,默认 laser_prompt_enhancer_v2
+}
+
+// OpenAIConfig OpenAI Images API 配置
+// 仅在 LASER_GEN_PROVIDER=openai 时使用
+type OpenAIConfig struct {
+ APIKey string // 必填
+ BaseURL string // 默认 https://api.openai.com/v1
+ Model string // 默认 gpt-image-1.5
}
// RedisConfig Redis 配置
@@ -155,13 +175,22 @@ func Load() *Config {
InferenceURL: getEnv("SEGMENT_INFERENCE_URL", ""),
},
Dify: DifyConfig{
- APIBase: getEnv("DIFY_API_BASE", ""),
- APIKey: getEnv("DIFY_API_KEY", ""),
+ APIBase: getEnv("DIFY_API_BASE", ""),
+ APIKey: getEnv("DIFY_API_KEY", ""),
+ Workflow: getEnv("DIFY_WORKFLOW", "laser_prompt_enhancer_v2"),
},
Minimax: MinimaxConfig{
APIKey: getEnv("MINIMAX_API_KEY", ""),
APIURL: getEnv("MINIMAX_API_URL", "https://api.minimaxi.com/v1/image_generation"),
},
+ OpenAI: OpenAIConfig{
+ APIKey: getEnv("OPENAI_API_KEY", ""),
+ BaseURL: getEnv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
+ Model: getEnv("OPENAI_MODEL", "gpt-image-1.5"),
+ },
+ LaserGen: LaserGenConfig{
+ Provider: getEnv("LASER_GEN_PROVIDER", "minimax"),
+ },
LaserCompositor: LaserCompositorConfig{
URL: getEnv("LASER_COMPOSITOR_URL", "http://127.0.0.1:7000"),
},
diff --git a/backend/gateway/controller/laser_generate_controller.go b/backend/gateway/controller/laser_generate_controller.go
index e16d063..a809005 100644
--- a/backend/gateway/controller/laser_generate_controller.go
+++ b/backend/gateway/controller/laser_generate_controller.go
@@ -1,9 +1,11 @@
package controller
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -16,17 +18,31 @@ import (
"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
- compositorClient *service.CompositorClient
+ difyClient *service.DifyClient
+ openaiClient *service.OpenAIClient
jobStore *jobStore
- laserRepo *repository.LaserCardRepository
+ laserRepo LaserCardPersister
+ ossHelper *service.OssHelper
+ provider string // "minimax" | "dify" | "openai" | "relay-dify",由 cfg.LaserGen.Provider 决定
}
// jobStore 内存中维护 job 状态
@@ -58,11 +74,26 @@ var globalJobStore = &jobStore{
// 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),
- compositorClient: service.NewCompositorClient(cfg.LaserCompositor.URL),
- jobStore: globalJobStore,
- laserRepo: repository.NewLaserCardRepository(database.GetDB()),
+ 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,
}
}
@@ -107,6 +138,24 @@ func (ctrl *LaserGenerateController) CreateGenerateJob(c *gin.Context) {
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()
@@ -117,43 +166,10 @@ func (ctrl *LaserGenerateController) CreateGenerateJob(c *gin.Context) {
if len(req.PresetCodes) > 0 {
templateCode = req.PresetCodes[0]
}
+ // 创建 instance + 写创建日志(统一走 persistGeneratedInstance)
var instanceID int64
var instanceNoResp string
- if ctrl.laserRepo != nil {
- renderedInst := &models.LaserCardInstance{
- InstanceNo: "", // BeforeCreate 自动生成
- InstanceUlid: "", // BeforeCreate 自动生成
- TemplateID: 0, // 种子数据 id=1-5,按 code 查找
- TemplateCode: templateCode,
- TemplateVersion: 1,
- OwnerUserID: userID.(int64),
- 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),
- )
- } else {
- instanceID = renderedInst.ID
- instanceNoResp = renderedInst.InstanceNo
- _ = ctrl.laserRepo.CreateOperationLogSimple(
- renderedInst.ID, renderedInst.InstanceNo, userID.(int64),
- models.LaserCardActionGenerateVariants, "", models.LaserCardInstanceStatusRendered,
- )
- logger.Logger.Info("Laser card instance persisted",
- zap.Int64("instance_id", instanceID),
- zap.String("instance_no", renderedInst.InstanceNo),
- )
- }
- }
+ instanceID, instanceNoResp = ctrl.persistGeneratedInstance(userID.(int64), starID, templateCode, jobID)
job := &GenerateJob{
ID: jobID,
@@ -248,6 +264,396 @@ func parseVariants(req generateReq) []variantConfig {
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()
@@ -335,38 +741,49 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64,
zap.String("cutout_url_prefix", safePrefix(cutoutURL, 50)),
zap.String("overlay_url_prefix", safePrefix(overlayURL, 50)),
)
- compReq := service.ComposeRequest{
+ compReq := compositor.ComposeRequest{
BackgroundURL: bgURL,
CutoutURL: cutoutURL,
OverlayURL: overlayURL,
- GratingConfig: vc.GratingConfig,
ExportWidth: 450,
ExportHeight: 600,
VariantIndex: idx,
- OutputOSSKey: ossKey,
}
- compResp, err := ctrl.compositorClient.Compose(ctx, compReq)
- if err != nil {
+ pngData, compErr := compositor.Compose(compReq)
+ if compErr != nil {
logger.Logger.Error("Compositor failed",
zap.String("preset", vc.PresetID),
- zap.Error(err),
+ zap.Error(compErr),
)
- resultCh <- variantResult{PresetID: vc.PresetID, Err: fmt.Sprintf("合成失败: %v", err)}
+ 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": compResp.OSSKey,
- "signed_url": compResp.SignedURL,
- "width": compResp.Width,
- "height": compResp.Height,
- }
- if compResp.Warning != "" {
- data["warning"] = compResp.Warning
+ "oss_key": compOSSKet,
+ "signed_url": compSignedURL,
+ "width": 450,
+ "height": 600,
}
resultCh <- variantResult{PresetID: vc.PresetID, Data: data}
@@ -408,40 +825,9 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64,
} else {
job.Status = "succeeded"
job.Progress = 1.0
- // 持久化:更新 materials_snapshot
- if ctrl.laserRepo != nil && job.InstanceID > 0 {
- snapshot := make(models.MaterialsSnapshot, 0, len(job.Variants)+1)
- // 用户原图/抠图记录
- if job.CutoutURL != "" {
- snapshot = append(snapshot, models.MaterialSnapshotItem{
- Role: "cutout",
- OssKey: job.CutoutURL,
- })
- }
- for _, v := range job.Variants {
- ossKey, _ := v["oss_key"].(string)
- presetID, _ := v["preset_id"].(string)
- if ossKey != "" {
- snapshot = append(snapshot, models.MaterialSnapshotItem{
- Role: "composite",
- OssKey: ossKey,
- PresetID: presetID,
- })
- }
- }
- if err := ctrl.laserRepo.UpdateMaterialsSnapshot(job.InstanceID, snapshot); err != nil {
- logger.Logger.Warn("Failed to update materials_snapshot",
- zap.Int64("instance_id", job.InstanceID),
- zap.Error(err),
- )
- }
- _ = ctrl.laserRepo.CreateOperationLogSimple(
- job.InstanceID, job.InstanceNo, job.UserID,
- models.LaserCardActionGenerateVariants,
- models.LaserCardInstanceStatusRendered,
- "",
- )
- }
+ // 持久化:更新 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",
@@ -451,6 +837,19 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64,
)
}
+// 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 ""
@@ -462,9 +861,159 @@ func marshalToString(v interface{}) string {
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
+}
diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go
index 9e754be..62dbc9d 100644
--- a/backend/gateway/router/router.go
+++ b/backend/gateway/router/router.go
@@ -11,6 +11,7 @@ import (
"github.com/topfans/backend/gateway/config"
"github.com/topfans/backend/gateway/controller"
"github.com/topfans/backend/gateway/middleware"
+ "github.com/topfans/backend/gateway/service"
"github.com/topfans/backend/gateway/socket"
)
@@ -104,6 +105,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
segmentCtrl := controller.NewSegmentController()
laserGenCtrl := controller.NewLaserGenerateController(config.Load())
+ composeCtrl := controller.NewComposeController(service.NewOssHelper(config.Load().OSS))
aiChatCtrl, err := controller.NewAIChatController(aiChatClient)
if err != nil {
return nil, err
@@ -244,6 +246,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
{
laser.POST("/generate", laserGenCtrl.CreateGenerateJob)
laser.GET("/generate/:id", laserGenCtrl.GetGenerateJob)
+ laser.POST("/compose", composeCtrl.ComposeSingle)
}
// 资产相关路由(需要认证)
diff --git a/backend/gateway/service/dify_client.go b/backend/gateway/service/dify_client.go
index 000407a..eae0899 100644
--- a/backend/gateway/service/dify_client.go
+++ b/backend/gateway/service/dify_client.go
@@ -26,7 +26,7 @@ func NewDifyClient(baseURL, apiKey string) *DifyClient {
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
- Timeout: 120 * time.Second, // Dify 工作流最长等待 120s
+ Timeout: 600 * time.Second, // Dify 工作流(MiniMax 文生图 + 合成), 最长约 300s, 留 300s 余量
},
}
}
diff --git a/backend/gateway/service/openai_client.go b/backend/gateway/service/openai_client.go
new file mode 100644
index 0000000..8d217a7
--- /dev/null
+++ b/backend/gateway/service/openai_client.go
@@ -0,0 +1,321 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/topfans/backend/pkg/logger"
+ "go.uber.org/zap"
+)
+
+// OpenAIClient OpenAI Images API 客户端
+//
+// 当前仅实现 /v1/images/edits 端点(图生图,镭射卡场景):
+// - 输入:远程图片 URL(由本服务先下载) + 风格 prompt
+// - 输出:PNG 字节流(透明背景,直接可上传 OSS)
+//
+// 不接 /v1/images/generations(文生图),不接 variations(已弃用)。
+// 失败时直接 error,无任何 fallback,由 controller 决定如何处理。
+type OpenAIClient struct {
+ BaseURL string // 默认 https://api.openai.com/v1
+ APIKey string
+ Model string // 默认 gpt-image-1.5
+ HTTPClient *http.Client
+}
+
+// NewOpenAIClient 创建 OpenAI 客户端
+func NewOpenAIClient(baseURL, apiKey, model string) *OpenAIClient {
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+ if model == "" {
+ model = "gpt-image-1.5"
+ }
+ return &OpenAIClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ HTTPClient: &http.Client{
+ Timeout: 360 * time.Second, // gpt-image-2 通过中转站时可能达 5min+, 设 6min
+ },
+ }
+}
+
+// OpenAIEditResponse /v1/images/edits 响应结构
+type OpenAIEditResponse struct {
+ Created int64 `json:"created"`
+ Data []struct {
+ B64JSON string `json:"b64_json,omitempty"`
+ URL string `json:"url,omitempty"`
+ RevisedPrompt string `json:"revised_prompt,omitempty"`
+ } `json:"data"`
+ Error *struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error,omitempty"`
+}
+
+// EditImage 调用 OpenAI /v1/images/edits 端点
+//
+// 参数:
+// - imageURL: 远程图片 URL(必须是 GET 可访问的图片,OpenAI 服务端会拉取)
+// - prompt: 风格描述(<= 32000 字符)
+//
+// 返回:解码后的 PNG 字节流;失败返回 error
+func (c *OpenAIClient) EditImage(ctx context.Context, imageURL, prompt string) ([]byte, error) {
+ if c.APIKey == "" {
+ return nil, fmt.Errorf("OpenAI API key not configured (set OPENAI_API_KEY)")
+ }
+ if imageURL == "" {
+ return nil, fmt.Errorf("imageURL is required for edit endpoint")
+ }
+ if prompt == "" {
+ return nil, fmt.Errorf("prompt is required")
+ }
+
+ // 基底镭射卡模板 + 拼接用户提示词
+ // 用户提示词建议传入英文,中文 GPT-image 理解效果略差
+ prompt = `Transform the input image into a premium holographic visual artwork.
+
+Preserve the original subject identity, pose, and composition.
+
+This is a high-end artistic re-imagining of the photograph, not a card, not a poster.
+
+Randomly apply combinations of:
+holographic light refraction, prismatic color grading, iridescent reflections,
+laser light dispersion, metallic sheen overlays, glass-like distortion,
+aurora color bleeding, spectral glow enhancement, cinematic post-processing,
+abstract particle integration.
+
+Random holographic styles:
+oil-slick sheen, prism refraction, aurora diffusion, laser scattering,
+liquid metal reflection, fractured spectrum, holographic bloom,
+rainbow diffraction, crystal refraction, spectral distortion.
+
+Visual direction may vary between:
+luxury editorial photography, experimental fine art, cinematic portrait enhancement,
+museum-grade print, surreal holographic art, avant-garde visual experiment.
+
+Background may subtly transform into abstract light fields, cosmic gradients,
+or studio-like luxury atmospheres.
+
+Constraints:
+Do not add frames, borders, cards, labels, or typography.
+Do not convert into trading card or poster layout.
+Do not alter subject identity.
+
+Result should feel like a luxury holographic fine art photograph.
+Ultra detailed, high-end commercial imaging quality.
+
+User direction: ` + prompt
+
+ // 1. 下载远程图片到 []byte
+ imgData, err := downloadRemoteImage(ctx, imageURL)
+ if err != nil {
+ return nil, fmt.Errorf("download image: %w", err)
+ }
+
+ // 2. 构造 multipart/form-data body
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ // image[]: PNG 文件
+ // ⚠️ 不能用 CreateFormFile —— 它不设置 Content-Type, multipart 默认会给二进制 part
+ // 加 application/octet-stream, OpenAI 后端只接受 image/{jpeg,png,webp},会直接 400
+ // 改用 CreatePart 手动探测真实 MIME 并显式设置
+ contentType := detectImageContentType(imgData, "input.png")
+ h := make(textproto.MIMEHeader)
+ h.Set("Content-Disposition", `form-data; name="image[]"; filename="input.png"`)
+ h.Set("Content-Type", contentType)
+ part, err := writer.CreatePart(h)
+ if err != nil {
+ return nil, fmt.Errorf("create form file: %w", err)
+ }
+ if _, err := part.Write(imgData); err != nil {
+ return nil, fmt.Errorf("write image data: %w", err)
+ }
+
+ // 表单字段
+ fields := buildEditFields(c.Model, prompt)
+ for k, v := range fields {
+ if err := writer.WriteField(k, v); err != nil {
+ return nil, fmt.Errorf("write field %s: %w", k, err)
+ }
+ }
+
+ if err := writer.Close(); err != nil {
+ return nil, fmt.Errorf("close multipart writer: %w", err)
+ }
+
+ // 3. 发送请求
+ req, err := http.NewRequestWithContext(ctx, "POST",
+ c.BaseURL+"/images/edits", body)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("openai request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, _ := io.ReadAll(resp.Body)
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, safePrefixBytes(respBody, 500))
+ }
+
+ // 4. 解析响应
+ var edResp OpenAIEditResponse
+ if err := json.Unmarshal(respBody, &edResp); err != nil {
+ return nil, fmt.Errorf("parse response: %w, body=%s", err, safePrefixBytes(respBody, 500))
+ }
+ if edResp.Error != nil {
+ return nil, fmt.Errorf("openai error: %s - %s", edResp.Error.Code, edResp.Error.Message)
+ }
+ if len(edResp.Data) == 0 {
+ return nil, fmt.Errorf("openai returned no data array")
+ }
+
+ // 5. 解析图片:优先 b64_json,fallback 到 url
+ pngBytes, source, err := decodeImageFromResponse(ctx, &edResp.Data[0])
+ if err != nil {
+ return nil, err
+ }
+
+ logger.Logger.Info("OpenAI edit image succeeded",
+ zap.String("model", c.Model),
+ zap.String("source", source),
+ zap.Int("bytes", len(pngBytes)),
+ zap.String("revised_prompt", safePrefixBytes([]byte(edResp.Data[0].RevisedPrompt), 80)),
+ )
+
+ return pngBytes, nil
+}
+
+// downloadRemoteImage 下载远程图片到 []byte
+// 失败时返回 error,无 fallback
+func downloadRemoteImage(ctx context.Context, imageURL string) ([]byte, error) {
+ cli := &http.Client{Timeout: 30 * time.Second}
+ req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := cli.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("download HTTP %d (url prefix: %s)", resp.StatusCode, safePrefixStr(imageURL, 60))
+ }
+ return io.ReadAll(resp.Body)
+}
+
+// safePrefixBytes 截断字节用于日志(避免巨大 body 撑爆日志)
+func safePrefixBytes(b []byte, n int) string {
+ if len(b) <= n {
+ return string(b)
+ }
+ return string(b[:n]) + "...(truncated)"
+}
+
+// safePrefixStr 截断字符串
+func safePrefixStr(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "..."
+}
+
+// buildEditFields 根据 model 名前缀构造 /v1/images/edits 的 multipart 字段集。
+//
+// gpt-image-*: OpenAI 新版,支持 transparent 背景 + 1024x1536 竖版 + 高级 quality
+// 其他(包括 dall-e-* / image-*): 旧版 / 中转站,只接受基础字段,size 限定 square
+func buildEditFields(model, prompt string) map[string]string {
+ fields := map[string]string{
+ "model": model,
+ "prompt": prompt,
+ "n": "1",
+ "size": "1024x1536",
+ }
+ return fields
+}
+
+// decodeImageFromResponse 从 OpenAI 响应里解出 PNG 字节。
+//
+// 优先 b64_json(OpenAI 官方 / 中转站都常用),fallback 到 url(要再发一次 GET)。
+// 返回 (bytes, source, err) — source 写日志用,标识数据来自 b64_json 还是 url 下载。
+func decodeImageFromResponse(ctx context.Context, item *struct {
+ B64JSON string `json:"b64_json,omitempty"`
+ URL string `json:"url,omitempty"`
+ RevisedPrompt string `json:"revised_prompt,omitempty"`
+}) ([]byte, string, error) {
+ if item.B64JSON != "" {
+ pngBytes, err := base64.StdEncoding.DecodeString(item.B64JSON)
+ if err != nil {
+ return nil, "", fmt.Errorf("base64 decode: %w", err)
+ }
+ return pngBytes, "b64_json", nil
+ }
+
+ if item.URL != "" {
+ // 走 downloadRemoteImage 拉 URL 字节(dall-e-2 旧版经常返这种)
+ // 但 image URL 通常 1h 过期,务必立即下载
+ imgData, err := downloadRemoteImage(ctx, item.URL)
+ if err != nil {
+ return nil, "", fmt.Errorf("download openai output url: %w", err)
+ }
+ return imgData, "url", nil
+ }
+
+ return nil, "", fmt.Errorf("openai response item has neither b64_json nor url")
+}
+
+// detectImageContentType 探测图片真实 MIME,供 multipart Content-Type 使用。
+//
+// 策略:
+// 1. 先按 magic bytes(http.DetectContentType) —— 不依赖文件名/扩展名,准
+// 2. 检测失败(空文件/未知格式)时, fallback 到文件名扩展名(应该是 input.png → image/png)
+// 3. 兜底 application/octet-stream
+//
+// 返回值限定 OpenAI 接受的 image/{jpeg,png,webp} 集合,其他格式也走 octet-stream 让 OpenAI 直接拒绝
+// (而不是上传个错的格式,后端报个更离谱的错)
+func detectImageContentType(data []byte, filename string) string {
+ detected := http.DetectContentType(data) // 返回 "image/png" 等
+ switch detected {
+ case "image/jpeg", "image/png", "image/webp":
+ return detected
+ }
+
+ // 探测不到或不在 OpenAI 白名单 → 用文件名扩展名兜底
+ if ext := strings.ToLower(filepath.Ext(filename)); ext != "" {
+ if mt := mime.TypeByExtension(ext); mt != "" {
+ // mime.TypeByExtension 返回值如 "image/png; charset=utf-8",需要去掉 charset
+ if i := strings.Index(mt, ";"); i > 0 {
+ mt = strings.TrimSpace(mt[:i])
+ }
+ switch mt {
+ case "image/jpeg", "image/png", "image/webp":
+ return mt
+ }
+ }
+ }
+
+ // 兜底 —— OpenAI 会以 "unsupported mimetype" 拒绝,但请求能正常发出去便于诊断
+ return "application/octet-stream"
+}
diff --git a/backend/gateway/service/segment_service.go b/backend/gateway/service/segment_service.go
index 1e24db0..59eae3c 100644
--- a/backend/gateway/service/segment_service.go
+++ b/backend/gateway/service/segment_service.go
@@ -24,13 +24,19 @@ const (
func MaxSegmentImageBytes() int { return maxSegmentImageBytes }
// SegmentPortraitResult 人像抠图结果
+//
+// 字段语义:
+// - CutoutOssKey/URLSigned: 抠过的透明人像 PNG(给 compositor 6 层合成人像层用)
+// - OriginalOssKey/URLSigned: 用户上传的原图(给 OpenAI 直接喂原图用,不再二次抠图)
type SegmentPortraitResult struct {
- Success bool `json:"success"`
- ErrorCode string `json:"error_code,omitempty"`
- Message string `json:"message,omitempty"`
- CutoutOssKey string `json:"cutout_oss_key,omitempty"`
- CutoutURLSigned string `json:"cutout_url_signed,omitempty"`
- Provider string `json:"provider,omitempty"`
+ Success bool `json:"success"`
+ ErrorCode string `json:"error_code,omitempty"`
+ Message string `json:"message,omitempty"`
+ CutoutOssKey string `json:"cutout_oss_key,omitempty"`
+ CutoutURLSigned string `json:"cutout_url_signed,omitempty"`
+ OriginalOssKey string `json:"original_oss_key,omitempty"`
+ OriginalURLSigned string `json:"original_url_signed,omitempty"`
+ Provider string `json:"provider,omitempty"`
}
// SegmentService 服务端人像抠图(imageseg / IVPD / 自部署 HTTP)
@@ -136,10 +142,15 @@ func (s *SegmentService) doPortrait(ctx context.Context, starID, userID int64, f
cutout, err := s.inferCutout(ctx, starID, userID, fileID, ext, imageData, contentType)
if err != nil {
logger.Logger.Warn("segment infer failed", zap.Error(err))
+ // 即使抠图失败,原图已上传到 OSS,返回原图 URL 供 OpenAI 路径使用
+ inKey := BuildSegmentTempInputKey(starID, userID, fileID, ext)
+ inSigned, _ := s.oss.SignGetURL(inKey, 3600)
return &SegmentPortraitResult{
- Success: false,
- ErrorCode: SegmentErrorCodeFailed,
- Message: err.Error(),
+ Success: false,
+ ErrorCode: SegmentErrorCodeFailed,
+ Message: "抠图失败: " + err.Error(),
+ OriginalOssKey: inKey,
+ OriginalURLSigned: inSigned,
}, nil
}
@@ -155,24 +166,30 @@ func (s *SegmentService) doPortrait(ctx context.Context, starID, userID int64, f
outSigned, err := s.oss.SignGetURL(outKey, 3600)
if err != nil {
return &SegmentPortraitResult{
- Success: true,
- CutoutOssKey: outKey,
- Provider: cutout.Provider,
- Message: "抠图成功但签名 URL 生成失败",
+ Success: true,
+ CutoutOssKey: outKey,
+ OriginalOssKey: cutout.InKey,
+ OriginalURLSigned: cutout.InURLSigned,
+ Provider: cutout.Provider,
+ Message: "抠图成功但签名 URL 生成失败",
}, nil
}
return &SegmentPortraitResult{
- Success: true,
- CutoutOssKey: outKey,
- CutoutURLSigned: outSigned,
- Provider: cutout.Provider,
+ Success: true,
+ CutoutOssKey: outKey,
+ CutoutURLSigned: outSigned,
+ OriginalOssKey: cutout.InKey,
+ OriginalURLSigned: cutout.InURLSigned,
+ Provider: cutout.Provider,
}, nil
}
type cutoutInferResult struct {
- Bytes []byte
- Provider string
+ Bytes []byte
+ Provider string
+ InKey string // 额外:原图 OSS key(供 OpenAI 模式直接拿原图,不再二次抠图)
+ InURLSigned string // 额外:原图签名 URL
}
func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64, fileID, ext string, imageData []byte, contentType string) (*cutoutInferResult, error) {
@@ -203,7 +220,7 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64,
if err != nil {
return nil, err
}
- return &cutoutInferResult{Bytes: raw, Provider: "imageseg"}, nil
+ return &cutoutInferResult{Bytes: raw, Provider: "imageseg", InKey: inKey, InURLSigned: inSigned}, nil
}
tryIVPD := func() (*cutoutInferResult, error) {
@@ -218,7 +235,7 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64,
if err != nil {
return nil, err
}
- return &cutoutInferResult{Bytes: raw, Provider: "ivpd"}, nil
+ return &cutoutInferResult{Bytes: raw, Provider: "ivpd", InKey: inKey, InURLSigned: inSigned}, nil
}
tryHTTP := func() (*cutoutInferResult, error) {
@@ -226,7 +243,8 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64,
if err != nil {
return nil, err
}
- return &cutoutInferResult{Bytes: raw, Provider: "http"}, nil
+ // tryHTTP 不走 OSS 中转,但原图已经被 line 181 上传,这里也带上
+ return &cutoutInferResult{Bytes: raw, Provider: "http", InKey: inKey, InURLSigned: inSigned}, nil
}
var lastErr error
diff --git a/backend/go.work b/backend/go.work
index 15dc81a..501720e 100644
--- a/backend/go.work
+++ b/backend/go.work
@@ -7,7 +7,6 @@ use (
./services/aiChatService
./services/assetService
./services/galleryService
- ./services/laserCompositor
./services/socialService
./services/statisticService
./services/taskService
diff --git a/docker/.env.local b/docker/.env.local
index bc158f2..a40d907 100644
--- a/docker/.env.local
+++ b/docker/.env.local
@@ -23,3 +23,16 @@ OSS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD
OSS_AVATAR_DIR=avatar/
OSS_ASSET_DIR=asset/
OSS_TOKEN_EXPIRE_TIME=3600
+
+# ==================== 镭射卡生成器 ====================
+# minimax (默认) | dify | openai
+LASER_GEN_PROVIDER=minimax
+
+# ==================== OpenAI Images API (LASER_GEN_PROVIDER=openai 时使用) ====================
+OPENAI_API_KEY=
+OPENAI_BASE_URL=https://api.openai.com/v1
+OPENAI_MODEL=gpt-image-1.5
+
+# ==================== Dify Workflow (laser_card_variants_v1) ====================
+DIFY_API_BASE=https://api.dify.ai/v1
+DIFY_API_KEY=
diff --git a/docker/.env.local.dev b/docker/.env.local.dev
new file mode 100644
index 0000000..efc7e33
--- /dev/null
+++ b/docker/.env.local.dev
@@ -0,0 +1,52 @@
+# ===================================================================
+# TopFans Docker - 本机开发环境真实凭据(untracked,不进 git)
+# 由 docker-compose --env-file .env.local --env-file .env.local.dev 叠加读入
+# ===================================================================
+
+# 宿主机手起的 topfans-postgres
+DB_USER=haihuizhu
+DB_PASSWORD=admin
+DB_NAME=top-fans
+DB_PORT=5432
+DB_HOST=host.docker.internal
+
+# 宿主机手起的 topfans-redis(重建后通过容器名 topfans-redis:6379 直连)
+REDIS_HOST=topfans-redis
+REDIS_PORT=6379
+REDIS_PASSWORD=123456
+
+# ==================== 镭射卡 OpenAI 中转站路径(覆盖 .env.local 的默认值)====================
+# 前端 VITE_API_BASE_URL=http://localhost:18090 → 打到 docker gateway (18090)
+# 所以 LASER_GEN_PROVIDER 必须在这里改,docker-compose.override.yml 第 92 行
+# LASER_GEN_PROVIDER: ${LASER_GEN_PROVIDER:-minimax}
+# 会把这里注入到容器,覆盖 .env.local 的 minimax 默认值
+LASER_GEN_PROVIDER=openai
+# 中转站 BaseURL —— XBCL 必须含 /v1 后缀(代码会拼成 https://xbcl.link/v1/images/edits)
+# 直连 OpenAI 官方会被墙,这里走 XBCL 中转
+# XBCL 的 chat / image / 其他端点都挂在 /v1/... 下,这是 OpenAI 兼容中转的标准路径
+OPENAI_BASE_URL=https://xbcl.link/v1
+# 中转站 model:XBCL /v1/models 实际暴露的 image 类模型是 gpt-image-1 / 1.5 / 2
+# openai_client.go 的 buildEditFields() 根据 model 名前缀自动选参数集
+# - gpt-image-* → 完整参数(transparent + 1024x1536 竖版)
+# - 其他 → 基础参数(1024x1024 square,无 transparent,保守路径)
+OPENAI_MODEL=gpt-image-2
+# ⚠️ 中转站 API key —— ⚠️ 务必先在 https://xbcl.link 撤销旧 key 再填新的
+# 因为之前 OpenAI 官方 key 已在对话历史里泄漏过,直接换中转站独立 key
+# 撤销旧 key → 生成新 key → 把值贴到下面这一行
+OPENAI_API_KEY=sk-b1f01c1ebc177e8fbd8e19ca3edeb542b521c39039977ef2974ce06c5d4cc18d
+
+# ==================== Dify 配置保留(暂未切回 dify,留着方便回滚)====================
+# gateway 容器的 DIFY_API_BASE 默认是 https://api.dify.ai/v1(生产 SaaS)
+# 本机要走自起的 Dify(project=dify,nginx 暴露在 host:80 → /v1 路由到 api:5001)
+#
+# ⚠️ 不能用 host.docker.internal!Docker Desktop 的 com.docker.backend.exe
+# 抢占了 host 的 0.0.0.0:80,host.docker.internal (192.168.65.254) 走不通。
+# 用 host 真实 IP 172.23.0.1 直连 host:80(Dify nginx bind 在这)
+DIFY_API_BASE=http://172.23.0.1/v1
+# 从 Dify 数据库里 laser_card_variants_v1 这个 app 的 api_tokens 表里取出来的
+DIFY_API_KEY=app-Ibs7reARanyuYGZ7zrLyiM6e
+
+# ==================== JWT_SECRET ====================
+# 本机用生产同款 secret,让生产签发的 token 在本机 gateway 也能验签通过
+# ⚠️ 本地开发用,绝对不要把生产 secret 提交到 git
+JWT_SECRET=topfans-secret-key-please-change-in-production
diff --git a/docker/.env.prod b/docker/.env.prod
index 5e7ecf6..dcba348 100644
--- a/docker/.env.prod
+++ b/docker/.env.prod
@@ -22,7 +22,7 @@ OSS_ASSET_DIR=asset/
OSS_TOKEN_EXPIRE_TIME=3600
# ==================== MiniMax API Configuration ====================
-MINIMAX_API_KEY=sk-cp-Fffv8Bg8zeFD929_KUAZq9EKet64Nkxgu7t1ibZEngqmyPKaOOa7U8U_gtg3VICfUQyGPn8c5XR4hxmWzjKC4wO6DxKh5ipN36Yv5jsFzZWMEPh6NKV2qAE
+MINIMAX_API_KEY=sk-api-oezuuNMr5iwPdlJ1JgTJTSzhMhGtaUR5Odjjg0ZqVQ7MoMIqLuE_ginMWRkNiAiDgMY6MvTVkYCWSQ8SK1-LuldrFmohCHxCgZIbxsFYr9zxA8z08Eb8nbo
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation
# Redis Configuration
@@ -31,11 +31,23 @@ REDIS_PORT=6379
REDIS_PASSWORD=123456
REDIS_DB=0
+# ==================== 镭射卡生成器 ====================
+# LASER_GEN_PROVIDER:
+# minimax (默认) - 后端直连 MiniMax
+# dify - 调 Dify laser_card_variants_v1 工作流 (阻塞)
+# openai - 后端直连 OpenAI /v1/images/edits (阻塞, 5 路并发 + 直接落 OSS)
+LASER_GEN_PROVIDER=minimax
+
+# ==================== OpenAI Images API (LASER_GEN_PROVIDER=openai 时使用) ====================
+# 生产环境必须设置,否则 LASER_GEN_PROVIDER=openai 启动会直接报错
+OPENAI_API_KEY=
+OPENAI_BASE_URL=https://api.openai.com/v1
+OPENAI_MODEL=gpt-image-1.5
+
# ==================== Dify Workflow (laser_card_v1) ====================
-# 注意:当前 laser 卡生成走的是 gateway 内的 MiniMax 直接调用(不是 Dify),
-# 这两个变量仅为 Dify 工作流调用保留,目前未使用
-DIFY_API_BASE=https://api.dify.ai/v1
-DIFY_API_KEY=
+# Dify API 入口 (laser_card_variants_v1 工作流, 仅在 LASER_GEN_PROVIDER=dify 时使用)
+DIFY_API_BASE=http://localhost/v1
+DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
# ==================== Laser Card ====================
# 镭射卡 6 层合成服务(gateway 通过容器名 + 端口访问)
diff --git a/docker/Dockerfile.services b/docker/Dockerfile.services
index 7f1a727..c77bb00 100644
--- a/docker/Dockerfile.services
+++ b/docker/Dockerfile.services
@@ -53,9 +53,6 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/aichatservice services/aiChatService/main.go && \
echo "Built aichatservice" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
- -o /tmp/lasercompositor services/laserCompositor/main.go && \
- echo "Built lasercompositor" && \
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/statisticservice services/statisticService/main.go && \
echo "Built statisticservice"
@@ -196,20 +193,7 @@ HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
ENTRYPOINT ["/app/aichatservice"]
-# ---- Runtime Stage: LaserCompositor (镭射卡 6 层合成服务) ----
-FROM --platform=linux/amd64 alpine:3.19 AS lasercompositor
-RUN apk add --no-cache ca-certificates tzdata
-
-WORKDIR /app
-COPY --from=builder /tmp/lasercompositor /app/lasercompositor
-
-EXPOSE 7002
-
-HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:7002/health || exit 1
-
-ENTRYPOINT ["/app/lasercompositor"]
# ---- Runtime Stage: StatisticService (数据看板微服务) ----
FROM --platform=linux/amd64 alpine:3.19 AS statisticservice
diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml
index 1d7125e..f1b21a1 100644
--- a/docker/docker-compose.local.yml
+++ b/docker/docker-compose.local.yml
@@ -298,40 +298,6 @@ services:
reservations:
memory: 256M
- # ==================== Laser Compositor Service (镭射卡 6 层合成) ====================
- lasercompositor:
- image: topfans/lasercompositor:latest
- build:
- context: ..
- dockerfile: docker/Dockerfile.services
- target: lasercompositor
- # 跳过 pull(本地无 push 到 registry),直接走 build
- pull_policy: never
- container_name: topfans-lasercompositor
- restart: unless-stopped
- environment:
- <<: *common-env
- COMPOSITOR_PORT: 7002
- OSS_REGION: ${OSS_REGION:-cn-shanghai}
- OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-top-fans-test}
- OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-}
- OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-}
- OSS_ASSET_DIR: ${OSS_ASSET_DIR:-asset/}
- extra_hosts:
- - "host.docker.internal:host-gateway"
- networks:
- - topfans-net
- expose:
- - "7002"
- healthcheck:
- test: ["CMD-SHELL", "nc -z localhost 7002 || exit 1"]
- <<: *healthcheck
- deploy:
- resources:
- limits:
- memory: 512M
- reservations:
- memory: 256M
# ==================== Statistic Service (数据看板微服务) ====================
statisticservice:
@@ -405,7 +371,6 @@ services:
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
MINIMAX_API_URL: ${MINIMAX_API_URL:-https://api.minimaxi.com/v1/image_generation}
# 镭射卡 6 层合成微服务(容器内通过 service name 访问)
- LASER_COMPOSITOR_URL: http://lasercompositor:7002
# 抠图(人像扣底)
SEGMENT_PROVIDER: ${SEGMENT_PROVIDER:-imageseg}
# OSS 配置(gateway 用于签名 + 抠图上传)
@@ -436,8 +401,6 @@ services:
condition: service_healthy
statisticservice:
condition: service_healthy
- lasercompositor:
- condition: service_healthy
networks:
topfans-net:
aliases:
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index e26e2e4..e1ccb51 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -429,39 +429,6 @@ services:
memory: 256M
cpus: '0.5'
- # ==================== Laser Compositor Service (镭射卡 6 层合成) ====================
- lasercompositor:
- image: topfans/lasercompositor:latest
- build:
- context: ..
- dockerfile: docker/Dockerfile.services
- target: lasercompositor
- # 跳过 pull(本地无 push 到 registry),直接走 build
- pull_policy: never
- container_name: topfans-lasercompositor
- restart: always
- env_file:
- - .env.prod
- environment:
- <<: *common-env
- COMPOSITOR_PORT: 7002
- # OSS_* 全部走 env_file: .env.prod
- networks:
- - topfans-net
- expose:
- - "7002"
- healthcheck:
- test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7002/health || exit 1"]
- <<: *healthcheck
- deploy:
- resources:
- limits:
- memory: 300M
- cpus: '0.5'
- reservations:
- memory: 128M
- cpus: '0.25'
-
# ==================== Statistic Service (数据看板微服务) ====================
statisticservice:
image: topfans/statisticservice:latest
@@ -535,7 +502,6 @@ services:
DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20005
DUBBO_AI_CHAT_SERVICE_URL: tri://aichatservice:20008
DUBBO_STATISTIC_SERVICE_URL: tri://statisticservice:20009
- LASER_COMPOSITOR_URL: http://lasercompositor:7002
# 抠图(人像扣底)、OSS、Dify、JWT、Redis 全部走 env_file: .env.prod
REDIS_HOST: topfans-redis
REDIS_PORT: 6379
@@ -559,7 +525,6 @@ services:
condition: service_started
statisticservice:
condition: service_started
- lasercompositor:
condition: service_started
redis:
condition: service_healthy
diff --git a/docs/dify/laser_card_variant_v1.yml b/docs/dify/laser_card_variant_v1.yml
index 63a4f79..2b26e93 100644
--- a/docs/dify/laser_card_variant_v1.yml
+++ b/docs/dify/laser_card_variant_v1.yml
@@ -225,7 +225,7 @@ workflow:
max_write_timeout: 20
title: 合成镭射卡
type: http-request
- url: '{{#env.LASER_COMPOSITOR_HOST#}}/compose'
+ url: '{{#env.LASER_COMPOSITOR_HOST#}}'
variables: []
height: 93
id: compositor
diff --git a/docs/dify/laser_card_variants_v1.yml b/docs/dify/laser_card_variants_v1.yml
index 5d98d9c..82fbd31 100644
--- a/docs/dify/laser_card_variants_v1.yml
+++ b/docs/dify/laser_card_variants_v1.yml
@@ -6,18 +6,18 @@ app:
name: laser_card_variants_v1
use_icon_as_answer_icon: false
kind: app
-version: 0.1.4
+version: 0.1.6
workflow:
conversation_variables: []
environment_variables:
- description: 'laser-compositor 合成服务地址'
name: LASER_COMPOSITOR_HOST
- value: 'http://host.docker.internal:7000'
+ value: 'http://host.docker.internal:18090/api/v1/laser'
value_type: string
- description: 'MiniMax API 密钥'
name: MINIMAX_API_KEY
- value: ''
- value_type: secret
+ value: 'sk-api-oezuuNMr5iwPdlJ1JgTJTSzhMhGtaUR5Odjjg0ZqVQ7MoMIqLuE_ginMWRkNiAiDgMY6MvTVkYCWSQ8SK1-LuldrFmohCHxCgZIbxsFYr9zxA8z08Eb8nbo'
+ value_type: string
features:
file_upload:
image:
@@ -42,7 +42,7 @@ workflow:
voice: ''
graph:
edges:
- # start → 参数展开
+ # start → code-param
- data:
isInIteration: false
isInLoop: false
@@ -55,20 +55,85 @@ workflow:
targetHandle: target
type: custom
zIndex: 0
- # 参数展开 → 循环
+ # code-param → http-bg-all(一次生成 5 张背景图)
+ - data:
+ isInIteration: false
+ isInLoop: false
+ sourceType: code
+ targetType: http-request
+ id: code-param-source-http-bg-all-target
+ source: code-param
+ sourceHandle: source
+ target: http-bg-all
+ targetHandle: target
+ type: custom
+ zIndex: 0
+ # code-param → http-overlay-all(一次生成 5 张装饰图)
+ - data:
+ isInIteration: false
+ isInLoop: false
+ sourceType: code
+ targetType: http-request
+ id: code-param-source-http-overlay-all-target
+ source: code-param
+ sourceHandle: source
+ target: http-overlay-all
+ targetHandle: target
+ type: custom
+ zIndex: 0
+ # code-param → code-prepare(传递 variants 元数据)
+ - data:
+ isInIteration: false
+ isInLoop: false
+ sourceType: code
+ targetType: code
+ id: code-param-source-code-prepare-target
+ source: code-param
+ sourceHandle: source
+ target: code-prepare
+ targetHandle: target
+ type: custom
+ zIndex: 0
+ # http-bg-all → code-prepare
+ - data:
+ isInIteration: false
+ isInLoop: false
+ sourceType: http-request
+ targetType: code
+ id: http-bg-all-source-code-prepare-target
+ source: http-bg-all
+ sourceHandle: source
+ target: code-prepare
+ targetHandle: target
+ type: custom
+ zIndex: 0
+ # http-overlay-all → code-prepare
+ - data:
+ isInIteration: false
+ isInLoop: false
+ sourceType: http-request
+ targetType: code
+ id: http-overlay-all-source-code-prepare-target
+ source: http-overlay-all
+ sourceHandle: source
+ target: code-prepare
+ targetHandle: target
+ type: custom
+ zIndex: 0
+ # code-prepare → loop-variants
- data:
isInIteration: false
isInLoop: false
sourceType: code
targetType: iteration
- id: code-param-source-loop-variants-target
- source: code-param
+ id: code-prepare-source-loop-variants-target
+ source: code-prepare
sourceHandle: source
target: loop-variants
targetHandle: target
type: custom
zIndex: 0
- # 循环 → 聚合
+ # loop-variants → code-agg
- data:
isInIteration: false
isInLoop: false
@@ -81,7 +146,7 @@ workflow:
targetHandle: target
type: custom
zIndex: 0
- # 聚合 → 结束
+ # code-agg → end
- data:
isInIteration: false
isInLoop: false
@@ -95,45 +160,17 @@ workflow:
type: custom
zIndex: 0
# === 循环内部 ===
- # iter-start → MiniMax 背景
+ # iter-start → code-call-compositor(直接合成,bg/overlay 已预生成)
- data:
isInIteration: true
iteration_id: loop-variants
isInLoop: false
sourceType: start
- targetType: http-request
- id: iter-start-source-http-bg-target
+ targetType: code
+ id: iter-start-source-code-call-compositor-target
source: iter-start
sourceHandle: source
- target: http-bg
- targetHandle: target
- type: custom
- zIndex: 1000
- # MiniMax 背景 → MiniMax 装饰
- - data:
- isInIteration: true
- iteration_id: loop-variants
- isInLoop: false
- sourceType: http-request
- targetType: http-request
- id: http-bg-source-http-overlay-target
- source: http-bg
- sourceHandle: source
- target: http-overlay
- targetHandle: target
- type: custom
- zIndex: 1000
- # MiniMax 装饰 → compositor
- - data:
- isInIteration: true
- iteration_id: loop-variants
- isInLoop: false
- sourceType: http-request
- targetType: http-request
- id: http-overlay-source-http-compositor-target
- source: http-overlay
- sourceHandle: source
- target: http-compositor
+ target: code-call-compositor
targetHandle: target
type: custom
zIndex: 1000
@@ -180,13 +217,13 @@ workflow:
type: custom
width: 244
# ================================================================
- # 代码节点 — 参数展开
+ # 代码节点 — 参数展开(同时输出合并的 prompt 字符串)
# ================================================================
- data:
- code: "import json\n\ndef main(preset_codes: str, render_configs: str) -> dict:\n preset_list = []\n if preset_codes and preset_codes.strip():\n try:\n parsed = json.loads(preset_codes)\n if isinstance(parsed, list):\n preset_list = parsed\n except:\n pass\n if not preset_list:\n preset_list = [\"dream\", \"classic\", \"holoFull\", \"ice\", \"sunset\"]\n configs = []\n if render_configs and render_configs.strip():\n try:\n parsed = json.loads(render_configs)\n if isinstance(parsed, list):\n configs = parsed\n except:\n pass\n config_map = {}\n for rc in configs:\n if isinstance(rc, dict) and \"preset_id\" in rc:\n config_map[rc[\"preset_id\"]] = rc\n variants = []\n for pc in preset_list:\n if pc in config_map:\n cfg = config_map[pc]\n variants.append({\n \"preset_id\": pc,\n \"grating_config\": cfg.get(\"grating_config\", {}),\n \"bg_prompt\": cfg.get(\"bg_prompt\", \"\"),\n \"overlay_prompt\": cfg.get(\"overlay_prompt\", \"\"),\n })\n return {\n \"variants\": variants,\n \"variant_count\": len(variants),\n }\n"
+ code: "import json\n\ndef main(preset_codes: str, render_configs: str) -> dict:\n preset_list = []\n if preset_codes and preset_codes.strip():\n try:\n parsed = json.loads(preset_codes)\n if isinstance(parsed, list):\n preset_list = parsed\n except:\n pass\n if not preset_list:\n preset_list = [\"dream\", \"classic\", \"holoFull\", \"ice\", \"sunset\"]\n configs = []\n if render_configs and render_configs.strip():\n try:\n parsed = json.loads(render_configs)\n if isinstance(parsed, list):\n configs = parsed\n except:\n pass\n config_map = {}\n for rc in configs:\n if isinstance(rc, dict) and \"preset_id\" in rc:\n config_map[rc[\"preset_id\"]] = rc\n variants = []\n for idx, pc in enumerate(preset_list):\n if pc in config_map:\n cfg = config_map[pc]\n variants.append({\n \"preset_id\": pc,\n \"variant_index\": idx,\n \"grating_config\": cfg.get(\"grating_config\", {}),\n \"bg_prompt\": cfg.get(\"bg_prompt\", \"\"),\n \"overlay_prompt\": cfg.get(\"overlay_prompt\", \"\"),\n })\n bg_parts = [v.get(\"bg_prompt\", \"\") or \"\" for v in variants]\n ov_parts = [v.get(\"overlay_prompt\", \"\") or \"\" for v in variants]\n style_names = {\"dream\": \"梦幻\", \"classic\": \"经典\", \"holoFull\": \"全息炫彩\", \"ice\": \"冰晶\", \"sunset\": \"落日\"}\n style_list = [style_names.get(v[\"preset_id\"], v[\"preset_id\"]) for v in variants]\n bg_descs = [bp if bp else f\"{sn}风格渐变背景\" for sn, bp in zip(style_list, bg_parts)]\n ov_descs = [op if op else f\"柔和光晕\" for sn, op in zip(style_list, ov_parts)]\n bg_prompts_all = \" | \".join(bg_descs)\n overlay_prompts_all = \" | \".join(ov_descs)\n return {\n \"variants\": variants,\n \"variant_count\": len(variants),\n \"bg_prompts_all\": bg_prompts_all,\n \"overlay_prompts_all\": overlay_prompts_all,\n }\n"
code_language: python3
dependencies: []
- desc: '解析 JSON 字符串 → 构建 variants 列表'
+ desc: '解析 JSON 字符串 → 构建 variants 列表 + 合并 prompt'
outputs:
variant_count:
children: null
@@ -194,6 +231,12 @@ workflow:
variants:
children: null
type: array[object]
+ bg_prompts_all:
+ children: null
+ type: string
+ overlay_prompts_all:
+ children: null
+ type: string
selected: false
title: 参数展开
type: code
@@ -206,7 +249,7 @@ workflow:
- start
- render_configs
variable: render_configs
- height: 84
+ height: 120
id: code-param
position:
x: 320
@@ -220,34 +263,161 @@ workflow:
type: custom
width: 244
# ================================================================
- # 循环节点
+ # 前置 HTTP — 一次生成 5 张背景图 (n=5)
# ================================================================
- data:
- desc: '对每个 variant 分别生成背景图、装饰图、合成最终镭射卡'
+ authorization:
+ config:
+ api_key: '{{#env.MINIMAX_API_KEY#}}'
+ type: bearer
+ type: api-key
+ body:
+ data: '{"model": "image-01", "prompt": "Generate 5 distinct background images based on these style directions. Each background must be a clean empty backdrop (no people, no products, no holographic card objects, no product photography). Laser holographic effects will be applied in post-processing, so focus only on the artistic background direction. Background directions: {{#code-param.bg_prompts_all#}}", "aspect_ratio": "3:4", "n": 5, "prompt_optimizer": true, "response_format": "url"}'
+ type: json
+ desc: '一次生成 5 种不同风格的背景图(干净背景,镭射效果后处理)'
+ headers: 'Content-Type: application/json'
+ isInIteration: false
+ method: post
+ params: ''
+ selected: false
+ timeout:
+ max_connect_timeout: 10
+ max_read_timeout: 200
+ max_write_timeout: 30
+ title: MiniMax 批量背景图
+ type: http-request
+ url: 'https://api.minimaxi.com/v1/image_generation'
+ variables:
+ - value_selector:
+ - code-param
+ - bg_prompts_all
+ variable: bg_prompts_all
+ height: 93
+ id: http-bg-all
+ position:
+ x: 610
+ y: 180
+ positionAbsolute:
+ x: 610
+ y: 180
+ selected: false
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ width: 244
+ # ================================================================
+ # 前置 HTTP — 一次生成 5 张装饰图 (n=5)
+ # ================================================================
+ - data:
+ authorization:
+ config:
+ api_key: '{{#env.MINIMAX_API_KEY#}}'
+ type: bearer
+ type: api-key
+ body:
+ data: '{"model": "image-01", "prompt": "Generate 5 distinct transparent overlay decoration layers. Each overlay must be a clean transparent decoration on empty background (no people, no products, no holographic card objects). Soft glows, micro-particles, light flow effects that enhance atmosphere without occluding main subject. Overlay directions: {{#code-param.overlay_prompts_all#}}", "aspect_ratio": "3:4", "n": 5, "prompt_optimizer": true, "response_format": "url"}'
+ type: json
+ desc: '一次生成 5 种不同风格的装饰层(透明叠加,不承载主题,镭射效果由 compositor 保证)'
+ headers: 'Content-Type: application/json'
+ isInIteration: false
+ method: post
+ params: ''
+ selected: false
+ timeout:
+ max_connect_timeout: 10
+ max_read_timeout: 200
+ max_write_timeout: 30
+ title: MiniMax 批量装饰图
+ type: http-request
+ url: 'https://api.minimaxi.com/v1/image_generation'
+ variables:
+ - value_selector:
+ - code-param
+ - overlay_prompts_all
+ variable: overlay_prompts_all
+ height: 93
+ id: http-overlay-all
+ position:
+ x: 610
+ y: 420
+ positionAbsolute:
+ x: 610
+ y: 420
+ selected: false
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ width: 244
+ # ================================================================
+ # 代码节点 — 将 bg/overlay URLs 与 variants 配对
+ # ================================================================
+ - data:
+ code: "import json\n\ndef main(bg_body: str, overlay_body: str, variants: list) -> dict:\n # 解析 MiniMax 批量响应,提取 5 张图 URL\n bg_data = json.loads(bg_body) if isinstance(bg_body, str) else bg_body\n bg_urls = (bg_data.get(\"data\") or {}).get(\"image_urls\", [])\n ov_data = json.loads(overlay_body) if isinstance(overlay_body, str) else overlay_body\n overlay_urls = (ov_data.get(\"data\") or {}).get(\"image_urls\", [])\n\n enriched = []\n for i, v in enumerate(variants):\n enriched.append({\n \"preset_id\": v.get(\"preset_id\", f\"v_{i}\"),\n \"variant_index\": v.get(\"variant_index\", i),\n \"grating_config\": v.get(\"grating_config\", {}),\n \"bg_prompt\": v.get(\"bg_prompt\", \"\"),\n \"overlay_prompt\": v.get(\"overlay_prompt\", \"\"),\n \"bg_url\": bg_urls[i] if i < len(bg_urls) else \"\",\n \"overlay_url\": overlay_urls[i] if i < len(overlay_urls) else \"\",\n })\n return {\"enriched_variants\": enriched}\n"
+ code_language: python3
+ dependencies: []
+ desc: '解析 MiniMax 批量响应 → 将 5 bg_urls + 5 overlay_urls 配对到每个 variant'
+ outputs:
+ enriched_variants:
+ children: null
+ type: array[object]
+ selected: false
+ title: 批量结果配对
+ type: code
+ variables:
+ - value_selector:
+ - http-overlay-all
+ - body
+ variable: overlay_body
+ - value_selector:
+ - http-bg-all
+ - body
+ variable: bg_body
+ - value_selector:
+ - code-param
+ - variants
+ variable: variants
+ height: 84
+ id: code-prepare
+ position:
+ x: 900
+ y: 300
+ positionAbsolute:
+ x: 900
+ y: 300
+ selected: false
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ width: 244
+ # ================================================================
+ # 循环节点(并行 5)
+ # ================================================================
+ - data:
+ desc: '对每个 variant 并行合成最终镭射卡'
error_handle_mode: terminated
height: 351
iterator_selector:
- - code-param
- - variants
- is_parallel: false
+ - code-prepare
+ - enriched_variants
+ is_parallel: true
output_selector:
- - http-compositor
- - body
+ - code-call-compositor
+ - compose_result
output_type: array[string]
- parallel_nums: 1
+ parallel_nums: 5
selected: false
startNodeType: start
start_node_id: iter-start
- title: 遍历 Variants
+ title: 遍历 Variants(并行5)
type: iteration
width: 953
height: 351
id: loop-variants
position:
- x: 610
+ x: 1200
y: 300
positionAbsolute:
- x: 610
+ x: 1200
y: 300
selected: false
sourcePosition: right
@@ -259,7 +429,7 @@ workflow:
# 循环内部 — 开始
# ================================================================
- data:
- desc: 当前 variant_item
+ desc: 当前 variant_item(含预生成的 bg_url / overlay_url)
isInIteration: true
iteration_id: loop-variants
selected: false
@@ -274,7 +444,7 @@ workflow:
x: 87
y: 120
positionAbsolute:
- x: 697
+ x: 1287
y: 420
selected: false
sourcePosition: right
@@ -283,122 +453,45 @@ workflow:
width: 244
zIndex: 1001
# ================================================================
- # 循环内部 — MiniMax 背景
+ # 循环内部 — Code 节点:调 compositor 合成
+ # 直接从 enriched item 读取 bg_url / overlay_url,不再调 MiniMax
# ================================================================
- data:
- authorization:
- config:
- api_key: '{{#env.MINIMAX_API_KEY#}}'
- type: bearer-api-key
- body:
- data: '{"model": "image-01", "prompt": "{{#loop-variants.item.bg_prompt#}}", "aspect_ratio": "3:4", "n": 1, "prompt_optimizer": true, "response_format": "url"}'
- type: json
- desc: '根据 bg_prompt 生成 3:4 背景'
- headers: 'Content-Type: application/json'
- isInIteration: true
- iteration_id: loop-variants
- method: post
- params: ''
+ code: "import json\nimport requests\n\ndef main(\n cutout_url: str,\n item: dict,\n compositor_host: str\n) -> dict:\n \"\"\"\n 从 enriched item 读取预生成的 bg_url / overlay_url,\n 调 laser-compositor /compose 合成镭射卡。\n \"\"\"\n preset_id = item.get(\"preset_id\", \"unknown\")\n variant_index = item.get(\"variant_index\", 0)\n grating_config = item.get(\"grating_config\", {})\n bg_url = item.get(\"bg_url\", \"\")\n overlay_url = item.get(\"overlay_url\", \"\")\n\n compose_body = {\n \"background_url\": bg_url,\n \"cutout_url\": cutout_url,\n \"overlay_url\": overlay_url,\n \"grating_config\": grating_config,\n \"export_width\": 450,\n \"export_height\": 600,\n \"variant_index\": variant_index,\n \"output_oss_key\": f\"laser-card/dify/{preset_id}\",\n }\n\n try:\n resp = requests.post(\n f\"{compositor_host}/compose\",\n json=compose_body,\n timeout=60,\n )\n resp.raise_for_status()\n compose_resp = resp.json()\n except Exception as e:\n return {\"compose_result\": json.dumps({\n \"status\": \"failed\",\n \"error\": f\"compositor call failed: {e}\",\n \"preset_id\": preset_id,\n \"variant_index\": variant_index,\n \"width\": 450,\n \"height\": 600,\n }, ensure_ascii=False)}\n\n compose_resp[\"preset_id\"] = preset_id\n return {\"compose_result\": json.dumps(compose_resp, ensure_ascii=False)}\n"
+ code_language: python3
+ dependencies:
+ - name: requests
+ version: 2.32.3
+ desc: '用预生成的 bg_url/overlay_url → 调 compositor 合成 → 注入 preset_id'
+ outputs:
+ compose_result:
+ children: null
+ type: string
selected: false
- timeout:
- max_connect_timeout: 10
- max_read_timeout: 60
- max_write_timeout: 20
- title: MiniMax 背景图
- type: http-request
- url: 'https://api.minimaxi.com/v1/image_generation'
- variables: []
+ title: 调 compositor 合成(批量优化版)
+ type: code
+ variables:
+ - value_selector:
+ - start
+ - cutout_url
+ variable: cutout_url
+ - value_selector:
+ - loop-variants
+ - item
+ variable: item
+ - value_selector:
+ - env
+ - LASER_COMPOSITOR_HOST
+ variable: compositor_host
extent: parent
height: 93
- id: http-bg
- parentId: loop-variants
- position:
- x: 377
- y: 120
- positionAbsolute:
- x: 987
- y: 420
- selected: false
- sourcePosition: right
- targetPosition: left
- type: custom
- width: 244
- zIndex: 1001
- # ================================================================
- # 循环内部 — MiniMax 装饰
- # ================================================================
- - data:
- authorization:
- config:
- api_key: '{{#env.MINIMAX_API_KEY#}}'
- type: bearer-api-key
- body:
- data: '{"model": "image-01", "prompt": "{{#loop-variants.item.overlay_prompt#}}", "aspect_ratio": "3:4", "n": 1, "prompt_optimizer": true, "response_format": "url"}'
- type: json
- desc: '根据 overlay_prompt 生成透明装饰层'
- headers: 'Content-Type: application/json'
- isInIteration: true
- iteration_id: loop-variants
- method: post
- params: ''
- selected: false
- timeout:
- max_connect_timeout: 10
- max_read_timeout: 60
- max_write_timeout: 20
- title: MiniMax 装饰图
- type: http-request
- url: 'https://api.minimaxi.com/v1/image_generation'
- variables: []
- extent: parent
- height: 93
- id: http-overlay
- parentId: loop-variants
- position:
- x: 667
- y: 120
- positionAbsolute:
- x: 1277
- y: 420
- selected: false
- sourcePosition: right
- targetPosition: left
- type: custom
- width: 244
- zIndex: 1001
- # ================================================================
- # 循环内部 — compositor 合成
- # ================================================================
- - data:
- authorization:
- type: no-auth
- body:
- data: '{"background_url": "{{#http-bg.body.data.image_urls[0]#}}", "cutout_url": "{{#start.cutout_url#}}", "overlay_url": "{{#http-overlay.body.data.image_urls[0]#}}", "grating_config": {{#loop-variants.item.grating_config#}}, "export_width": 450, "export_height": 600, "output_oss_key": "laser-card/dify/{{#loop-variants.item.preset_id#}}"}'
- type: json
- desc: '调用 laser-compositor /compose 6层合成'
- headers: 'Content-Type: application/json'
- isInIteration: true
- iteration_id: loop-variants
- method: post
- params: ''
- selected: false
- timeout:
- max_connect_timeout: 10
- max_read_timeout: 60
- max_write_timeout: 20
- title: 合成 6 层镭射卡
- type: http-request
- url: '{{#env.LASER_COMPOSITOR_HOST#}}/compose'
- variables: []
- extent: parent
- height: 93
- id: http-compositor
+ id: code-call-compositor
parentId: loop-variants
position:
x: 957
y: 120
positionAbsolute:
- x: 1567
+ x: 1767
y: 420
selected: false
sourcePosition: right
@@ -410,7 +503,7 @@ workflow:
# 代码节点 — 聚合输出
# ================================================================
- data:
- code: "import json\n\ndef main(loop_output: list) -> dict:\n variants = []\n warnings = []\n for i, item in enumerate(loop_output or []):\n if isinstance(item, str):\n try:\n item = json.loads(item)\n except:\n pass\n if isinstance(item, dict) and item.get(\"status\") == \"succeeded\":\n variants.append({\n \"preset_id\": item.get(\"preset_id\", f\"variant_{i}\"),\n \"oss_key\": item.get(\"oss_key\", \"\"),\n \"signed_url\": item.get(\"signed_url\", \"\"),\n \"width\": item.get(\"width\", 450),\n \"height\": item.get(\"height\", 600),\n })\n else:\n warnings.append(f\"variant_{i} 合成失败\")\n return {\n \"status\": \"succeeded\" if len(variants) > 0 else \"failed\",\n \"variants\": json.dumps(variants),\n \"warnings\": json.dumps(warnings),\n }\n"
+ code: "import json\n\ndef main(loop_output: list) -> dict:\n variants = []\n warnings = []\n for i, item in enumerate(loop_output or []):\n if isinstance(item, str):\n try:\n item = json.loads(item)\n except:\n pass\n if isinstance(item, dict) and item.get(\"status\") == \"succeeded\":\n variants.append({\n \"preset_id\": item.get(\"preset_id\", f\"variant_{i}\"),\n \"oss_key\": item.get(\"oss_key\", \"\"),\n \"signed_url\": item.get(\"signed_url\", \"\"),\n \"width\": item.get(\"width\", 450),\n \"height\": item.get(\"height\", 600),\n })\n else:\n warnings.append(f\"{item.get('preset_id', f'variant_{i}')} 合成失败: {item.get('error', 'unknown')}\")\n return {\n \"status\": \"succeeded\" if len(variants) > 0 else \"failed\",\n \"variants\": json.dumps(variants, ensure_ascii=False),\n \"warnings\": json.dumps(warnings, ensure_ascii=False),\n }\n"
code_language: python3
dependencies: []
desc: '收集循环结果,输出统一 JSON'
diff --git a/docs/dify/laser_prompt_enhancer_v2.yml b/docs/dify/laser_prompt_enhancer_v2.yml
new file mode 100644
index 0000000..bd8f286
--- /dev/null
+++ b/docs/dify/laser_prompt_enhancer_v2.yml
@@ -0,0 +1,190 @@
+app:
+ description: 'GPT relay prompt enhancer — 通过中转站优化生图 prompt,单张调试版'
+ icon: ✨
+ icon_background: '#EBF5FF'
+ mode: workflow
+ name: laser_prompt_enhancer_v2
+ use_icon_as_answer_icon: false
+kind: app
+version: 0.3.0
+dependencies:
+ - current_identifier: null
+ type: marketplace
+ value:
+ marketplace_plugin_unique_identifier: langgenius/openai_api_compatible
+workflow:
+ conversation_variables: []
+ environment_variables: []
+ features:
+ file_upload:
+ image:
+ enabled: false
+ number_limits: 3
+ transfer_methods:
+ - local_file
+ - remote_url
+ opening_statement: ''
+ retriever_resource:
+ enabled: false
+ sensitive_word_avoidance:
+ enabled: false
+ speech_to_text:
+ enabled: false
+ suggested_questions: []
+ suggested_questions_after_answer:
+ enabled: false
+ text_to_speech:
+ enabled: false
+ language: ''
+ voice: ''
+ graph:
+ edges:
+ - data:
+ isInIteration: false
+ isInLoop: false
+ sourceType: start
+ targetType: llm
+ id: start-source-llm-enhance-target
+ source: start
+ sourceHandle: source
+ target: llm-enhance
+ targetHandle: target
+ type: custom
+ zIndex: 0
+ - data:
+ isInIteration: false
+ isInLoop: false
+ sourceType: llm
+ targetType: end
+ id: llm-enhance-source-end-target
+ source: llm-enhance
+ sourceHandle: source
+ target: end
+ targetHandle: target
+ type: custom
+ zIndex: 0
+ nodes:
+ # ================================================================
+ # 开始节点
+ # ================================================================
+ - data:
+ desc: '接收原始 bg_prompt 和用户风格描述'
+ selected: false
+ title: "开始"
+ type: start
+ variables:
+ - label: bg_prompt
+ max_length: 4096
+ options: []
+ required: true
+ type: text-input
+ variable: bg_prompt
+ - label: user_prompt
+ max_length: 1024
+ options: []
+ required: false
+ type: text-input
+ variable: user_prompt
+ height: 130
+ id: start
+ position:
+ x: 30
+ y: 300
+ positionAbsolute:
+ x: 30
+ y: 300
+ selected: false
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ width: 244
+ # ================================================================
+ # LLM 节点 — 通过中转站增强 prompt
+ # ================================================================
+ - data:
+ desc: '通过中转站的 LLM 优化 bg_prompt,输出增强后的单条 prompt'
+ selected: false
+ title: "中转站 LLM 增强"
+ type: llm
+ model:
+ provider: openai_api_compatible
+ name: gpt-4o
+ mode: chat
+ completion_params:
+ temperature: 0.7
+ max_tokens: 1024
+ prompt_template:
+ - role: system
+ text: |
+ You are a professional prompt engineer for the GPT image edit API (/v1/images/edits).
+ Rewrite a SINGLE image prompt into a detailed, vivid image editing instruction.
+
+ Rules:
+ - The input image is the ORIGINAL full photo containing a person.
+ - Describe what background/style changes to apply around the person.
+ - KEEP THE ORIGINAL PERSON IDENTICAL (same face, same identity, same pose, same clothing). Only modify the background and decorative elements around them.
+ - Output only the enhanced prompt text, no markdown, no JSON wrapping.
+ - Maximum 2000 characters.
+ - role: user
+ text: |
+ Raw prompt: {{ start.bg_prompt }}
+ {% if start.user_prompt %}
+ User style direction: {{ start.user_prompt }}
+ {% endif %}
+ prompt_config:
+ jinja2_variables: []
+ memory:
+ role_prefix:
+ user: ''
+ assistant: ''
+ window:
+ enabled: false
+ query_prompt_template: ''
+ context:
+ enabled: false
+ vision:
+ enabled: false
+ configs:
+ variable_selector:
+ - sys
+ - files
+ detail: high
+ height: 180
+ id: llm-enhance
+ position:
+ x: 320
+ y: 300
+ positionAbsolute:
+ x: 320
+ y: 300
+ selected: false
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ width: 244
+ # ================================================================
+ # 结束节点
+ # ================================================================
+ - data:
+ desc: '返回增强后的 prompt'
+ outputs:
+ - value_selector:
+ - llm-enhance
+ - text
+ variable: enhanced_prompt
+ selected: false
+ title: "结束"
+ type: end
+ height: 90
+ id: end
+ position:
+ x: 610
+ y: 300
+ positionAbsolute:
+ x: 610
+ y: 300
+ selected: false
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ width: 244
diff --git a/frontend/components/laser/LaserPreviewCanvas.vue b/frontend/components/laser/LaserPreviewCanvas.vue
index e69cccc..83c5cf0 100644
--- a/frontend/components/laser/LaserPreviewCanvas.vue
+++ b/frontend/components/laser/LaserPreviewCanvas.vue
@@ -18,6 +18,9 @@
mode="aspectFill"
/>
无预览
+
+
+
@@ -289,6 +292,7 @@ onBeforeUnmount(() => {
}
.laser-preview-fallback {
+ position: relative;
width: 360px;
height: 480px;
border-radius: 24rpx;
@@ -308,5 +312,49 @@ onBeforeUnmount(() => {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
}
+
+/* 兜底场景下的镭射扫光(WebGL 不可用时仍能看到动态光感) */
+.laser-preview-fallback-shimmer {
+ position: absolute;
+ inset: 0;
+ border-radius: 24rpx;
+ background: linear-gradient(
+ 115deg,
+ transparent 32%,
+ rgba(255, 255, 255, 0.18) 46%,
+ rgba(255, 255, 255, 0.32) 50%,
+ rgba(255, 255, 255, 0.18) 54%,
+ transparent 68%
+ );
+ background-size: 220% 220%;
+ background-position: -120% -120%;
+ animation: laser-preview-shimmer-sweep 8s ease-in-out infinite;
+ mix-blend-mode: screen;
+ pointer-events: none;
+}
+
+/* 兜底场景下的彩虹描边 */
+.laser-preview-fallback-edge {
+ position: absolute;
+ inset: 0;
+ border-radius: 24rpx;
+ pointer-events: none;
+ box-shadow:
+ inset 0 0 8rpx rgba(200, 220, 240, 0.22),
+ inset 0 0 18rpx rgba(220, 230, 240, 0.12);
+ mix-blend-mode: screen;
+}
+
+@keyframes laser-preview-shimmer-sweep {
+ 0% {
+ background-position: -120% -120%;
+ }
+ 55% {
+ background-position: -120% -120%;
+ }
+ 100% {
+ background-position: 220% 220%;
+ }
+}
diff --git a/frontend/composables/useLaserBatchGenerate.js b/frontend/composables/useLaserBatchGenerate.js
index 237f709..575f6f8 100644
--- a/frontend/composables/useLaserBatchGenerate.js
+++ b/frontend/composables/useLaserBatchGenerate.js
@@ -107,21 +107,29 @@ export function useLaserBatchGenerate(options = {}) {
const imagePath = resolveImagePath(formData)
// Dify 模式:服务端 AI 生成
- const genMode ='dify'
- if (genMode === 'dify') {
+ // genMode: 'dify' | 'openai' | 'client'
+ // - dify: 调 Dify laser_card_variants_v1 工作流(后端 LASER_GEN_PROVIDER=dify)
+ // - openai: 调 OpenAI /v1/images/edits(后端 LASER_GEN_PROVIDER=openai,5 路并发 + 直接落 OSS)
+ // - client: 纯客户端 Canvas 合成
+ // 前端不感知 provider,只调 /api/v1/laser/generate,由后端按 env 路由
+ const genMode = 'openai'
+ if (genMode === 'dify' || genMode === 'openai') {
const dify = useLaserDifyGenerate()
- const userPrompt = (runOptions.userPrompt || formData?.userPrompt || '').trim()
- // 先做人像抠图,拿到 PNG 的 OSS 签名地址传给 compositor 叠加
- let cutoutUrl = ''
+ // 表单实际存的字段是 aiDescription;为兼容旧调用方(runOptions.userPrompt / formData.userPrompt)同时 fallback
+ const userPrompt = (runOptions.userPrompt || formData?.aiDescription || formData?.userPrompt || '').trim()
+
+ // 上传到 OSS 获取可访问 URL,供中转站下载原图
+ let imageUrlForAI = imagePath
try {
- const cutout = await segmentPortrait(imagePath, {
+ const uploadResult = await segmentPortrait(imagePath, {
imageBase64: formData.imageBase64 || formData.uploadedImageBase64 || '',
})
- cutoutUrl = cutout?.cutoutUrl || ''
+ imageUrlForAI = uploadResult?.originalUrl || uploadResult?.cutoutUrl || imagePath
} catch (segErr) {
- console.warn('[useLaserBatchGenerate] 抠图失败,将不叠加人像:', segErr)
+ console.warn('[useLaserBatchGenerate] 上传 OSS 失败:', segErr)
+ throw segErr
}
- await dify.submit(cutoutUrl, null, userPrompt)
+ await dify.submit(imageUrlForAI, null, userPrompt)
const infos = dify.getVariantInfos()
const instanceNoVal = dify.getInstanceNo()
await waitMinDuration(minMs)
diff --git a/frontend/composables/useLaserDifyGenerate.js b/frontend/composables/useLaserDifyGenerate.js
index 0d73a9c..9ce2b69 100644
--- a/frontend/composables/useLaserDifyGenerate.js
+++ b/frontend/composables/useLaserDifyGenerate.js
@@ -1,14 +1,18 @@
/**
- * Dify 镭射五图生成 composable
- * 通过 Gateway → Dify 工作流服务端生成镭射卡,替换客户端 Canvas 合成
+ * 镭射五图生成 composable
+ * 兼容两种后端提供方:
+ * - Dify (LASER_GEN_PROVIDER=dify): 单次 blocking POST, 直接返回 variants
+ * - MiniMax (LASER_GEN_PROVIDER=minimax): 异步, POST 后轮询
*
- * 模式切换:通过 VITE_LASER_GEN_MODE 环境变量控制
- * 'client' — 现有的客户端 laserBatchExport 路径
- * 'dify' — 本模块,Gateway 触发 Dify 工作流
+ * 选择方式: 根据 POST 响应结构自动判断
+ * - 含 job_id → 走轮询 (MiniMax 路径)
+ * - 含 variants → 走阻塞等待 (Dify 路径)
*/
import { ref } from 'vue'
import { getLaserApiBaseUrl } from '@/utils/api.js'
-import { buildRenderConfigs } from '@/utils/laser-card/laserPresets.js'
+
+const DEFAULT_TIMEOUT_MS = 60000
+const DIFY_BLOCKING_TIMEOUT_MS = 600000 // 中转站生图最慢可达 5min+, 客户端 600s 留余量
/**
* 镭射专用请求(直接请求本地 Gateway,不走远程 DEV_BASE)
@@ -30,7 +34,7 @@ async function laserRequest(opts) {
method: opts.method || 'GET',
data: opts.data || {},
header: headers,
- timeout: 60000,
+ timeout: opts.timeout || DEFAULT_TIMEOUT_MS,
success: (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(res.data?.message || `HTTP ${res.statusCode}`))
@@ -60,8 +64,62 @@ export function useLaserDifyGenerate() {
let _resolveSubmit = null
let _rejectSubmit = null
+ /**
+ * 检测字符串是否包含中文
+ */
+ function containsChinese(text) {
+ return /[一-鿿㐀-䶿]/.test(text)
+ }
+
+ /**
+ * 翻译中文到英文(使用 Google 免费翻译 API)
+ */
+ async function translateToEnglish(text) {
+ try {
+ const res = await fetch(
+ 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=zh-CN&tl=en&dt=t&q=' +
+ encodeURIComponent(text)
+ )
+ const data = await res.json()
+ if (data && data[0]) {
+ return data[0].map(function(seg) { return seg[0] }).join(' ').trim()
+ }
+ } catch (e) {
+ console.warn('[translate] failed, use original:', e)
+ }
+ return text
+ }
+
+ /**
+ * 生成 render_configs:完全由用户输入决定 prompt
+ * - 无抽卡、无风格池、无 grating_config
+ * - 用户输入中文自动翻译成英文
+ * - 每次返回 5 个 variant(同一 prompt,不同 preset_id)
+ *
+ * @param {string[]|null} _presetIds 忽略,保留参数兼容
+ * @param {string} userPrompt
+ * @returns {Promise>}
+ */
+ async function resolveRenderConfigs(_presetIds, userPrompt) {
+ let prompt = (userPrompt || '').trim().slice(0, 1000)
+ if (prompt && containsChinese(prompt)) {
+ console.log('[translate] Chinese detected, translating...')
+ prompt = await translateToEnglish(prompt)
+ console.log('[translate] result:', prompt)
+ }
+ // 4 路并发 AI 生图 + 1 张原图 = 总共 5 张
+ return [1, 2, 3, 4].map(function(i) {
+ return {
+ preset_id: 'v' + i,
+ bg_prompt: prompt || 'Transform this into a premium holographic artwork',
+ }
+ })
+ }
+
/**
* 提交生成任务到 Gateway
+ * 自动适配 Dify 阻塞 / MiniMax 异步两种模式
+ *
* @param {string} cutoutUrl 抠图后的 OSS URL(空串表示不叠加人像)
* @param {string[]} [presetIds] 需要生成的 preset ID 列表
* @param {string} [userPrompt] 用户自定义 prompt,注入到 AI 生图
@@ -71,35 +129,56 @@ export function useLaserDifyGenerate() {
error.value = null
progress.value = 0
- const renderConfigs = buildRenderConfigs(presetIds || ['dream', 'classic', 'holoFull', 'ice', 'sunset'])
+ const renderConfigs = await resolveRenderConfigs(presetIds, userPrompt)
const presetCodes = renderConfigs.map((rc) => rc.preset_id)
try {
+ // Dify 阻塞模式单次请求可能耗时 30-120s, 先用较长 timeout
const res = await laserRequest({
url: '/api/v1/laser/generate',
method: 'POST',
+ timeout: DIFY_BLOCKING_TIMEOUT_MS,
data: {
cutout_url: cutoutUrl,
preset_codes: presetCodes,
render_configs: renderConfigs,
- user_prompt: userPrompt || '',
+ // userPrompt 已经包装进 render_configs.bg_prompt,这里传空避免后端二次拼接
+ user_prompt: '',
},
})
- if (!res.data || !res.data.job_id) {
- throw new Error('生成任务创建失败:未返回 job_id')
+ if (!res || !res.data) {
+ throw new Error('生成任务失败: 响应为空')
}
- jobId.value = res.data.job_id
- status.value = 'processing'
- pollingSince = Date.now()
+ const payload = res.data
- // 等待轮询完成再 resolve
- await new Promise((resolve, reject) => {
- _resolveSubmit = resolve
- _rejectSubmit = reject
- startPolling()
- })
+ // 模式 1: Dify 阻塞 — 响应直接含 variants
+ if (payload.status === 'succeeded' && Array.isArray(payload.variants)) {
+ applySucceeded(payload, cutoutUrl)
+ return
+ }
+
+ // 模式 1.5: Dify 失败
+ if (payload.status === 'failed') {
+ throw new Error(payload.error || 'Dify 工作流执行失败')
+ }
+
+ // 模式 2: MiniMax 异步 — 响应含 job_id, 进入轮询
+ if (payload.job_id) {
+ jobId.value = payload.job_id
+ status.value = 'processing'
+ progress.value = 0.05
+ pollingSince = Date.now()
+ await new Promise((resolve, reject) => {
+ _resolveSubmit = resolve
+ _rejectSubmit = reject
+ startPolling()
+ })
+ return
+ }
+
+ throw new Error('生成任务失败: 响应格式无法识别 (既无 job_id 也无 variants)')
} catch (e) {
status.value = 'error'
error.value = e
@@ -107,6 +186,26 @@ export function useLaserDifyGenerate() {
}
}
+ /**
+ * 应用 Dify 阻塞模式的成功响应
+ */
+ function applySucceeded(payload, fallbackCutoutUrl) {
+ var aiVariants = Array.isArray(payload.variants) ? payload.variants : []
+ cutoutUrl.value = payload.cutout_url || fallbackCutoutUrl || ''
+ // 把原图也作为一个 variant 展示(第 5 张)
+ if (cutoutUrl.value) {
+ aiVariants.push({
+ preset_id: 'original',
+ signed_url: cutoutUrl.value,
+ oss_key: '',
+ })
+ }
+ variants.value = aiVariants
+ instanceNo.value = payload.instance_no || ''
+ progress.value = 1.0
+ status.value = 'done'
+ }
+
function startPolling() {
const pollInterval = 2000 // 每 2 秒轮询
const maxDuration = 120000 // 最长等待 120 秒
@@ -116,7 +215,7 @@ export function useLaserDifyGenerate() {
// 超时降级
if (Date.now() - pollingSince > maxDuration) {
- const err = new Error('Dify 生成超时,已降级到本地合成')
+ const err = new Error('生成超时, 已降级到本地合成')
status.value = 'timeout'
error.value = err
stopPolling()
@@ -129,7 +228,7 @@ export function useLaserDifyGenerate() {
method: 'GET',
})
.then((res) => {
- if (!res.data) return
+ if (!res || !res.data) return
// 更新进度
if (res.data.progress != null) {
@@ -137,22 +236,11 @@ export function useLaserDifyGenerate() {
}
if (res.data.status === 'succeeded') {
- // 提取 variants
- if (res.data.variants && Array.isArray(res.data.variants)) {
- variants.value = res.data.variants
- }
- if (res.data.cutout_url) {
- cutoutUrl.value = res.data.cutout_url
- }
- if (res.data.instance_no) {
- instanceNo.value = res.data.instance_no
- }
- status.value = 'done'
- progress.value = 1.0
+ applySucceeded(res.data, '')
stopPolling()
if (_resolveSubmit) { _resolveSubmit(); _resolveSubmit = null }
} else if (res.data.status === 'failed') {
- const err = new Error(res.data.error || 'Dify 工作流执行失败')
+ const err = new Error(res.data.error || '生成任务失败')
status.value = 'error'
error.value = err
stopPolling()
@@ -197,7 +285,7 @@ export function useLaserDifyGenerate() {
}
/**
- * 获取 variant 完整信息(含 oss_key),铸造时直接用 oss_key 避免 HEAD 请求
+ * 获取 variant 完整信息(含 oss_key),铸造时直接用 oss_key 避免 HEAD 请求
* @returns {Array<{ url: string, oss_key: string }>}
*/
function getVariantInfos() {
diff --git a/frontend/composables/useLaserSegment.js b/frontend/composables/useLaserSegment.js
index b03a28d..de703d4 100644
--- a/frontend/composables/useLaserSegment.js
+++ b/frontend/composables/useLaserSegment.js
@@ -84,16 +84,24 @@ export async function segmentPortrait(imagePath, options = {}) {
imageBase64: options.imageBase64 || '',
})
const data = res?.data
- if (!data?.success || !data?.cutout_url_signed) {
- const msg = data?.message || data?.error_code || 'LC_SEGMENT_FAILED'
- throw new Error(msg)
+
+ // 只要原图上传到 OSS 就算成功,抠图失败不影响(OpenAI 模式不需要抠图)
+ // 只有连原图都没上传成功才抛错
+ if (!data?.success && !data?.original_url_signed) {
+ throw new Error(data?.message || data?.error_code || 'LC_SEGMENT_FAILED')
+ }
+
+ // 抠图成功才下载存本地
+ let localPath = ''
+ if (data.cutout_url_signed) {
+ localPath = await downloadToLocal(data.cutout_url_signed)
+ persistCutoutToForm(localPath, data.cutout_oss_key || '')
}
- const localPath = await downloadToLocal(data.cutout_url_signed)
- persistCutoutToForm(localPath, data.cutout_oss_key || '')
return {
localPath,
ossKey: data.cutout_oss_key || '',
- cutoutUrl: data.cutout_url_signed || '', // OSS 签名的抠图 PNG 地址,供 Dify 使用
+ cutoutUrl: data.cutout_url_signed || '',
+ originalUrl: data.original_url_signed || '',
}
}
diff --git a/frontend/main.js b/frontend/main.js
index 579a0cb..1d32e31 100644
--- a/frontend/main.js
+++ b/frontend/main.js
@@ -4,6 +4,14 @@ import App from './App'
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
+Vue.config.errorHandler = function (err, vm, info) {
+ console.error('[GlobalErrorHandler]', err.message, info)
+ var tag = vm?.$options?.name || vm?.$options?._componentTag || vm?.$vnode?.tag || 'unknown'
+ console.error('[GlobalErrorHandler] component:', tag)
+ console.error('[GlobalErrorHandler] full stack:', err.stack)
+ // 开发模式下弹出通知
+ try { uni.showToast({ title: '组件渲染异常: ' + tag, icon: 'none', duration: 4000 }) } catch(e) {}
+}
App.mpType = 'app'
const app = new Vue({
...App
@@ -16,6 +24,14 @@ import { createSSRApp } from 'vue'
import store from './store'
export function createApp() {
const app = createSSRApp(App)
+ app.config.errorHandler = function (err, vm, info) {
+ console.error('[GlobalErrorHandler]', err.message, info)
+ var tag = vm?.$options?.name || vm?.$options?._componentTag || vm?.$vnode?.tag || vm?.$el?.id || 'unknown'
+ console.error('[GlobalErrorHandler] component:', tag)
+ console.error('[GlobalErrorHandler] full stack:', err.stack)
+ // 开发模式下弹出通知
+ try { uni.showToast({ title: '组件渲染异常: ' + tag, icon: 'none', duration: 4000 }) } catch(e) {}
+ }
app.use(store)
return {
app
diff --git a/frontend/manifest.json b/frontend/manifest.json
index ec8d237..6dda88e 100644
--- a/frontend/manifest.json
+++ b/frontend/manifest.json
@@ -1,6 +1,6 @@
{
"name" : "TopFans",
- "appid" : "__UNI__F199FF4",
+ "appid" : "__UNI__8CBE431",
"description" : "",
"versionName" : "1.0.5",
"versionCode" : 114,
diff --git a/frontend/package.json b/frontend/package.json
index 2c578ff..246bb56 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,5 +1,13 @@
{
+ "type": "module",
"dependencies": {
"vuex": "^4.1.0"
+ },
+ "devDependencies": {
+ "@dcloudio/uni-cli-shared": "^3.0.0-alpha-5000120260205002",
+ "@dcloudio/vite-plugin-uni": "^3.0.0-alpha-5000120260205002",
+ "@vue/compiler-sfc": "^3.5.38",
+ "sass": "^1.101.0",
+ "vite": "^5.2.8"
}
}
diff --git a/frontend/utils/laser-card/laserPresets.js b/frontend/utils/laser-card/laserPresets.js
index 08db32f..7b088ab 100644
--- a/frontend/utils/laser-card/laserPresets.js
+++ b/frontend/utils/laser-card/laserPresets.js
@@ -27,8 +27,8 @@ export const PRESET_VARIANTS = [
frostIntensity: 8,
flowSpeed: 0.35,
colorHint: '淡紫色 粉色 银色',
- bgPrompt: '梦幻柔光渐变,淡紫色调和粉色,柔和泡泡和星光光斑,虚化景深,纯净梦幻氛围',
- overlayPrompt: '精致银色星点,细边框装饰线,散落星形闪光和柔和光斑',
+ bgPrompt: 'soft dreamy holographic background, lavender and pink layered gradient, gentle bubble bokeh and soft star sparkles, single diagonal sheen sweep at 135°, dreamy celestial mood, no text, no people, no watermark',
+ overlayPrompt: 'thin pearl border, soft star scatter, subtle corner brackets',
},
{
id: 'classic',
@@ -44,8 +44,8 @@ export const PRESET_VARIANTS = [
frostIntensity: 6,
flowSpeed: 0.45,
colorHint: '香槟金 暖金色 浅米色',
- bgPrompt: '经典复古胶片风格,暖色调,浅香槟色渐变,艺术纹理和柔和光影,优雅复古氛围',
- overlayPrompt: '经典复古边框花纹,暖金色调和银色细线,精致几何图案',
+ bgPrompt: 'classic vintage refractor holographic surface, warm champagne and pale gold gradient, single diagonal sheen sweep at 120°, soft film grain texture, refined retro cardstock mood, no text, no people, no watermark',
+ overlayPrompt: 'warm sepia border, vintage corner motif',
},
{
id: 'holoFull',
@@ -61,8 +61,8 @@ export const PRESET_VARIANTS = [
frostIntensity: 10,
flowSpeed: 0.30,
colorHint: '银色 霓虹色 金属银',
- bgPrompt: '全息科技感渐变,银色金属质感,未来主义光影和几何线条,珍珠光泽,炫酷科技氛围',
- overlayPrompt: '全息科技感装饰,霓虹色细线光晕,电路板几何图案和星形闪光',
+ bgPrompt: 'full holographic tech background, silver metallic base with pearl glow, geometric neon circuit lines and star sparks, vivid diagonal sheen sweep at 150°, high foil coverage 95%, futuristic cyber-card mood, no text, no people, no watermark',
+ overlayPrompt: 'neon bevel border, sharp diagonal sheen sweep, geometric corner accents',
},
{
id: 'ice',
@@ -78,8 +78,8 @@ export const PRESET_VARIANTS = [
frostIntensity: 5,
flowSpeed: 0.50,
colorHint: '浅蓝灰 冰蓝 银色',
- bgPrompt: '冰雪冷色调渐变,浅蓝灰背景,冰晶纹理和雪花光斑,清爽透明感,纯净冰雪氛围',
- overlayPrompt: '冰晶装饰,浅蓝色和银色星点,雪花图案和细线装饰',
+ bgPrompt: 'cool ice holographic background, pale blue-gray base with crystalline frost texture, snowflake scatter and ice-crack corner motif, fast diagonal sheen sweep at 110° (speed 0.50), crisp clean winter mood, no text, no people, no watermark',
+ overlayPrompt: 'thin frosted edge line, sparse ice flake scatter, light frost corner motif',
},
{
id: 'sunset',
@@ -95,8 +95,8 @@ export const PRESET_VARIANTS = [
frostIntensity: 7,
flowSpeed: 0.40,
colorHint: '玫瑰金 暖橙色 粉色',
- bgPrompt: '日落暖色调渐变,玫瑰金背景,暖橙色和粉色光影,温柔浪漫氛围',
- overlayPrompt: '日落暖色调装饰,玫瑰金和琥珀色星点,柔和花卉和光线元素',
+ bgPrompt: 'warm sunset holographic background, rose-gold base with amber and pink layered glow, soft floral corner accents, gentle diagonal sheen sweep at 140° (speed 0.40), romantic dreamy mood, no text, no people, no watermark',
+ overlayPrompt: 'thin rose gold border, small heart corner accents',
},
]
@@ -141,3 +141,55 @@ export function buildRenderConfigs(presetIds) {
}
export { resolveGratingConfig, DEFAULT_BACKDROP_TONE } from './laserGrating.js'
+
+// =========================================================================
+// 新增:风格池 + 抽卡(45 个风格,5 档分层)
+// - 旧调用方(激光导出会、success.vue)继续用上方 export,完全兼容
+// - 新调用方(抽卡、稀有度徽章)使用下方 export
+// =========================================================================
+
+// 风格池与索引(转自 stylePool.js)
+export {
+ LASER_STYLE_POOL,
+ LASER_STYLE_IDS,
+ LASER_STYLE_INDEX,
+ RARITY_DISTRIBUTION,
+ RARITY_COLORS,
+ RARITY_STARS,
+} from './stylePool.js'
+
+// 抽卡算法(转自 gacha.js)
+export {
+ drawGachaStyles,
+ drawGachaStyleIds,
+ pickRarity,
+ pickStyleInRarity,
+ findStyleById,
+ RARITY_WEIGHTS,
+} from './gacha.js'
+
+/**
+ * 兼容旧调用方:按数字索引取 style 对象
+ * 0-4 仍映射到原 dream/classic/holoFull/ice/sunset(与旧 PRESET_VARIANTS 顺序一致)
+ * 5~44 映射到新池子
+ * @param {number} index
+ */
+export function getStyleByIndex(index) {
+ const i = Number(index)
+ if (!Number.isFinite(i) || i < 0) return LASER_STYLE_POOL[0]
+ if (i < 5) {
+ const oldIds = ['dream', 'classic', 'holoFull', 'ice', 'sunset']
+ return LASER_STYLE_POOL.find((s) => s.id === oldIds[i]) || LASER_STYLE_POOL[0]
+ }
+ return LASER_STYLE_POOL[i] || LASER_STYLE_POOL[0]
+}
+
+/**
+ * 兼容旧调用方:按 style id 反查数字索引(用于 success.vue)
+ * @param {string} id
+ * @returns {number}
+ */
+export function getStyleIndexById(id) {
+ const idx = LASER_STYLE_INDEX[String(id || '')]
+ return Number.isFinite(idx) ? idx : 0
+}
diff --git a/frontend/utils/socket/AiChatSocket.js b/frontend/utils/socket/AiChatSocket.js
index fa081fd..ea3fac6 100644
--- a/frontend/utils/socket/AiChatSocket.js
+++ b/frontend/utils/socket/AiChatSocket.js
@@ -70,6 +70,23 @@ class AiChatSocket extends SocketManager {
* 连接到 AI Chat 服务
*/
async connect(token) {
+ // 注册 auth_fail 处理器:token 无效时清除本地凭证
+ this.off('auth_fail', this._authFailHandler)
+ this._authFailHandler = () => {
+ console.warn('[AiChatSocket] Auth failed, clearing token')
+ uni.removeStorageSync('access_token')
+ uni.removeStorageSync('user')
+ uni.showModal({
+ title: '登录已过期',
+ content: '请重新登录',
+ showCancel: false,
+ success: () => {
+ uni.reLaunch({ url: '/pages/login/login' })
+ }
+ })
+ }
+ this.on('auth_fail', this._authFailHandler)
+
await super.connect(token, AI_CHAT_WS_PATH)
}
diff --git a/frontend/utils/socket/SocketManager.js b/frontend/utils/socket/SocketManager.js
index 614485d..3b910ee 100644
--- a/frontend/utils/socket/SocketManager.js
+++ b/frontend/utils/socket/SocketManager.js
@@ -75,6 +75,13 @@ class SocketManager {
url,
fail: (err) => {
console.error(`[${this.serviceName}] connectSocket fail:`, err)
+ var errMsg = (err && (err.errMsg || '')).toLowerCase()
+ if (errMsg.indexOf('auth') !== -1 || errMsg.indexOf('reject') !== -1 || errMsg.indexOf('401') !== -1) {
+ console.warn(`[${this.serviceName}] Connection rejected (auth failure)`)
+ this._emit('auth_fail', { code: 'CONNECT_FAILED', message: err.errMsg || '连接失败' })
+ this.close()
+ return
+ }
this._emit('error', { code: 'CONNECT_FAILED', message: err.errMsg || '连接失败' })
}
})
@@ -164,16 +171,22 @@ class SocketManager {
}
// 连接错误
+ var handleSocketError = function(err) {
+ console.error(`[${self.serviceName}] WebSocket error:`, err)
+ // 检查是否是鉴权相关的错误(401/403)
+ var errMsg = (err && (err.errMsg || err.message || '')).toLowerCase()
+ if (errMsg.indexOf('auth') !== -1 || errMsg.indexOf('reject') !== -1 || errMsg.indexOf('401') !== -1) {
+ console.warn(`[${self.serviceName}] Connection rejected (auth failure), clearing token`)
+ self._emit('auth_fail', err)
+ self.close()
+ return
+ }
+ self._emit('error', err)
+ }
if (typeof socket.onError === 'function') {
- socket.onError(function(err) {
- console.error(`[${self.serviceName}] WebSocket error:`, err)
- self._emit('error', err)
- })
+ socket.onError(handleSocketError)
} else if (typeof socket.onerror === 'function') {
- socket.onerror(function(err) {
- console.error(`[${self.serviceName}] WebSocket error:`, err)
- self._emit('error', err)
- })
+ socket.onerror(handleSocketError)
}
}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 89d2202..aa287b5 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,6 +1,13 @@
import { defineConfig } from 'vite'
import https from 'node:https'
-import uni from '@dcloudio/vite-plugin-uni'
+import { createRequire } from 'node:module'
+// @dcloudio/vite-plugin-uni v3 alpha 的 dist 是 CJS,
+// 且 __esModule 标志为非枚举,Node 的 CJS-to-ESM 互操作
+// 不会把它当 TS 编译产物,于是 `import uni from ...` 拿到的
+// 是整个 module.exports 对象而不是函数,调用就报
+// "uni is not a function"。用 createRequire 直走 CJS 拿 default。
+const require = createRequire(import.meta.url)
+const uni = require('@dcloudio/vite-plugin-uni').default
/** 与 upload-signature 返回的 OSS 虚拟域名一致;换桶时请同步 */
const OSS_DEV_HOST = 'top-fans-test.oss-cn-shanghai.aliyuncs.com'