feat: 接入微达API中转站,重构镭射卡生图流程

- 替换中转站从 xbcl.link 到 weda.cc
- prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖)
- 4 路并发调用 + 原图展示 = 5 张 variant
- 前端提示词中译英支持
- 全局 Vue errorHandler
- WebSocket 鉴权失败跳登录
- 删除已弃用的 laserCompositor 微服务

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Lenticular Studio Agent 2026-06-23 22:43:49 +08:00
parent d9473fda7a
commit af7908e72e
28 changed files with 1923 additions and 450 deletions

View File

@ -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 Keylaser_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

View File

@ -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"),
},

View File

@ -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 工作流增强 promptLLM 通过中转站)
// 3. 调中转站 /v1/images/edits复用 openaiClient原图 + 增强 prompt
// 4. 落 OSS + 预签名 URL
// 5. 返回单个 variant后续可扩展为 5 路并发 callDifyEnhance → bulk edits
func (ctrl *LaserGenerateController) handleRelayDifySingle(c *gin.Context, userID, starID int64, req generateReq) {
ctx := c.Request.Context()
if len(req.RenderConfigs) == 0 {
response.BadRequest(c, "render_configs 不能为空")
return
}
if req.CutoutURL == "" {
response.BadRequest(c, "cutout_url 不能为空(需原图 URL")
return
}
// 1. 取第一个 variant 的 bg_prompt
rc := req.RenderConfigs[0]
presetID, _ := rc["preset_id"].(string)
if presetID == "" {
presetID = "variant_0"
}
bgPrompt, _ := rc["bg_prompt"].(string)
if bgPrompt == "" {
response.BadRequest(c, "render_configs[0].bg_prompt 不能为空")
return
}
logger.Logger.Info("RelayDify single start",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("preset_id", presetID),
zap.String("bg_prompt_prefix", safePrefix(bgPrompt, 60)),
)
// 2. 调 Dify 工作流增强 prompt
enhancedPrompt := bgPrompt // 默认 fallback
difyInputs := map[string]interface{}{
"bg_prompt": bgPrompt,
"user_prompt": req.UserPrompt,
}
difyOutput, err := ctrl.difyClient.RunWorkflow(ctx, difyInputs, fmt.Sprintf("%d", userID))
if err != nil {
logger.Logger.Warn("Dify prompt enhancer failed, use raw prompt",
zap.Error(err),
)
} else if enhanced, ok := difyOutput.Outputs["enhanced_prompt"].(string); ok && enhanced != "" {
enhancedPrompt = enhanced
logger.Logger.Info("Dify prompt enhanced",
zap.Int("raw_len", len(bgPrompt)),
zap.Int("enhanced_len", len(enhancedPrompt)),
)
} else {
// Dify 返回了但字段格式不对,用原始 prompt 兜底
logger.Logger.Warn("Dify returned no enhanced_prompt field, use raw",
zap.Any("outputs", difyOutput.Outputs),
)
}
// 3. 调中转站 /v1/images/edits
pngBytes, err := ctrl.openaiClient.EditImage(ctx, req.CutoutURL, enhancedPrompt)
if err != nil {
logger.Logger.Error("Relay edits failed",
zap.String("preset", presetID),
zap.Error(err),
)
response.InternalError(c, fmt.Sprintf("AI 生图失败: %v", err))
return
}
if len(pngBytes) == 0 {
response.InternalError(c, "AI 生图返回为空")
return
}
// 4. 落 OSS(只取第一张)
ossKey := fmt.Sprintf("laser-card/relay-dify/%d/%d/%s_%s.png",
starID, userID, presetID, uuid.New().String()[:8])
if err := ctrl.ossHelper.PutObject(ossKey, bytes.NewReader(pngBytes), "image/png"); err != nil {
logger.Logger.Error("OSS upload failed",
zap.String("oss_key", ossKey),
zap.Error(err),
)
response.InternalError(c, fmt.Sprintf("OSS 上传失败: %v", err))
return
}
signedURL, err := ctrl.ossHelper.SignGetURL(ossKey, 3600)
if err != nil {
logger.Logger.Warn("Sign URL failed", zap.Error(err))
}
// 5. 持久化 + 返回
templateCode := "default"
if len(req.PresetCodes) > 0 {
templateCode = req.PresetCodes[0]
}
instanceID, instanceNo := ctrl.persistGeneratedInstance(userID, starID, templateCode, "")
variants := []map[string]interface{}{{
"preset_id": presetID,
"oss_key": ossKey,
"signed_url": signedURL,
}}
ctrl.attachMaterialsSnapshot(instanceID, instanceNo, userID, req.CutoutURL, "source_image", variants, "")
logger.Logger.Info("RelayDify single succeeded",
zap.String("preset", presetID),
zap.Int("png_bytes", len(pngBytes)),
zap.String("oss_key", ossKey),
)
response.Success(c, gin.H{
"status": "succeeded",
"variants": variants,
"warnings": []string{},
"cutout_url": req.CutoutURL,
"instance_no": instanceNo,
})
}
// runParallelGeneration 并行生成 5 个 variantMiniMax(背景+装饰) → compositor 合成
func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64, jobID, cutoutURL, userPrompt string, variants []variantConfig) {
ctx := context.Background()
@ -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
}

View File

@ -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)
}
// 资产相关路由(需要认证)

View File

@ -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 余量
},
}
}

View File

@ -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"
}

View File

@ -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

View File

@ -7,7 +7,6 @@ use (
./services/aiChatService
./services/assetService
./services/galleryService
./services/laserCompositor
./services/socialService
./services/statisticService
./services/taskService

View File

@ -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=

52
docker/.env.local.dev Normal file
View File

@ -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
# 本机要走自起的 Difyproject=difynginx 暴露在 host:80 → /v1 路由到 api:5001
#
# ⚠️ 不能用 host.docker.internalDocker Desktop 的 com.docker.backend.exe
# 抢占了 host 的 0.0.0.0:80host.docker.internal (192.168.65.254) 走不通。
# 用 host 真实 IP 172.23.0.1 直连 host:80Dify 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

View File

@ -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 通过容器名 + 端口访问)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -18,6 +18,9 @@
mode="aspectFill"
/>
<text v-else class="laser-preview-fallback-text">无预览</text>
<!-- 兜底时也保留镭射扫光感(CSS 动画, LaserVariantPyramid 一致) -->
<view v-if="variantPath || fallbackPath" class="laser-preview-fallback-shimmer" />
<view v-if="variantPath || fallbackPath" class="laser-preview-fallback-edge" />
</view>
</view>
</template>
@ -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%;
}
}
</style>

View File

@ -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)

View File

@ -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<Array<{preset_id: string, bg_prompt: string}>>}
*/
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() {

View File

@ -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 || '',
}
}

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"name" : "TopFans",
"appid" : "__UNI__F199FF4",
"appid" : "__UNI__8CBE431",
"description" : "",
"versionName" : "1.0.5",
"versionCode" : 114,

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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'