topfans/docs/superpowers/specs/2026-06-25-laser-card-inspiration-pool-design.md
Lenticular Studio Agent 65ce6bba12 feat: Dify 部署脚本修复 + AI 搭子 MVP 接入
主要改动:

fix(docker/dify-deploy): 修复脚本核心功能
- heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开
- 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置
- 加 dc_cmd() 兼容 docker-compose v1/v2 plugin
- openssl rand 生成强随机密码与 SECRET_KEY(42 字符)
- install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY)
- read -p < /dev/tty 兼容非 tty 环境(CI/CD)
- show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT

docs(mvp-design): 修正 §3.2 workflow inputs 描述
- 实际只有 query,删除错误的 user_id input 声明
- 节点序列图同步更新

feat(aiChatService): 新增 Dify 客户端与适配器
- service/dify_client.go: Dify Workflow 调用 + SSE 解析
- service/dify_adapter.go: 与现有 chat_service 桥接
- provider/ai_chat_provider.go: Dubbo 入口简化
- main.go: 装配 ConversationRepository + DifyClient

feat(migrations): 新增 AI 搭子会话表 ai_chat.sql
- ai_conversations / ai_messages 表 + 索引

docs: 新增 Dify 集成设计文档
- 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级)
- 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图)
- docs/dify/角角.yml (Workflow DSL 导出)

config: 更新 env 模板与 docker 配置
- backend/.env.example: DIFY_* 环境变量声明
- docker/.env.prod: DIFY_API_BASE 对齐 8083
- docker/build.sh: 微调
- CLAUDE.md: 项目规范补充

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 12:32:34 +08:00

1881 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 镭射卡生图系统改造——灵感池Inspiration Pool设计方案
> 范围:改造 `useLaserDifyGenerate` 的 4+1 生图流程,从"4 份相同 prompt"升级为"4 张来自不同世界观的差异化 prompt",让中转站 (`gpt-image-2` via `/v1/images/edits`) 生成的 4 张图视觉差异明显。
>
> 关联文档:
> - [frontend/composables/useLaserDifyGenerate.js](frontend/composables/useLaserDifyGenerate.js)(现有生图 composable本方案改造其 `resolveRenderConfigs`
> - [backend/gateway/controller/laser_generate_controller.go](backend/gateway/controller/laser_generate_controller.go)(现有生图 Controller本方案复用其 `handleOpenAIDirect` 流程)
> - [backend/gateway/service/openai_client.go](backend/gateway/service/openai_client.go)(中转站 `/v1/images/edits` 客户端)
> - [docs/superpowers/specs/2026-06-04-castlove-config-admin-design.md](docs/superpowers/specs/2026-06-04-castlove-config-admin-design.md)(铸爱工艺配置案例,本方案复用其"外部 Admin 直连同库"模式)
---
## 0. 现状与问题
### 0.1 现状
**当前 4+1 生图流程**[useLaserDifyGenerate.js](frontend/composables/useLaserDifyGenerate.js)
1. 用户上传照片 + 可选描述
2. 前端 `submit(cutoutUrl, presetIds, userPrompt)` 调用
3. `resolveRenderConfigs` 返回 **4 份相同的 prompt**
4. 后端 `handleOpenAIDirect` 收到 4 个 render_config**对每个调一次** `openaiClient.EditImage(cutoutURL, bgPrompt)`
5. 中转站 `/v1/images/edits` 接收 4 次调用,每次都用 (用户原图 + prompt) 调用 `gpt-image-2`**直接返回成品图**
6. 4 张成品图 + 1 张原图 (cutout_url) = 5 张图返回给前端
**关键调用链**
```
前端 后端 中转站
┌──────────────┐
submit() ────────────────────────▶│ gateway │
POST /api/v1/laser/generate │ controller │
{ cutout_url, render_configs } └──────┬───────┘
│ 4 次并发
┌──────────────┐
│ openaiClient │ ──────▶ POST /v1/images/edits
│ .EditImage() │ (image + prompt) × 4
└──────────────┘
│ 中转站内部调 gpt-image-2
│ 直接返回 4 张成品图
OSS 存储 + 返回 variants
```
### 0.2 核心问题
`useLaserDifyGenerate.js#resolveRenderConfigs` 当前实现(`resolveRenderConfigs` 函数,[L103-117](frontend/composables/useLaserDifyGenerate.js#L103-L117)
```js
async function resolveRenderConfigs(_presetIds, userPrompt) {
let prompt = (userPrompt || '').trim().slice(0, 1000)
if (prompt && containsChinese(prompt)) {
prompt = await translateToEnglish(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', // ← 4 份完全相同
}
})
}
```
**问题 14 份 prompt 完全相同** → 中转站收到 4 次相同的 (image + prompt) 调用,**4 张图几乎一模一样**。用户感知不到"5 张图来自不同世界"的体验。
**问题 2中文翻译在前端**`translateToEnglish` 在前端完成, 依赖客户端网络和 Google API 可达性; 如果新的前端/第三方直接调后端, 容易漏翻译。本方案将翻译移至后端 `BuildPrompts` service 统一处理。
### 0.3 现有 `stylePool.js` 为何没解决问题
[frontend/utils/laser-card/stylePool.js](frontend/utils/laser-card/stylePool.js) 定义了 45 个 style含 R/SR/SSR/UR 稀有度),但:
- **主流程没接入**`resolveRenderConfigs` 没有读 stylePool
- 即使接入了stylePool 里的 prompt 是"材料池"`pure silver holographic / crystal prism`AI 创造力被锁死
### 0.4 设计目标
1. **4 份 prompt 差异化**:每张图用一个不同的"艺术世界观"Luxury Editorial / Dreamlike Aurora / Experimental Light Art
2. **灵感池而非材料池**:用"光学 DNA / 奢侈品 DNA / 镭射 DNA / 情绪 DNA"发散方向AI 自主决定实现方式
3. **后端组装 prompt**:模板存后端数据库,前端只关心"传什么 user_prompt + 收什么图"
4. **复用现有生成链路**:仍走中转站 `/v1/images/edits`,仍 4 次并发,仍返回 4+1 张
5. **保持 4+1 契约**:前端无感升级,第 5 张原图仍由 cutout_url 承担
6. **运营可配置**:模板存到 PostgreSQL外部 Admin (`TopFans-activity-admin`) 团队可增删改
7. **不改 0 行现有生成代码**`handleOpenAIDirect` / `EditImage` 完全不动
---
## 1. 架构总览
### 1.1 数据流V4 改造后)
```
┌────────────────────────────────────────────────────────────────────┐
│ 前端 (uniapp + vue3) │
│ │
│ useLaserDifyGenerate.submit(cutoutUrl, userPrompt) │
│ │ │
│ │ ① POST /api/v1/laser/build-prompts ← 新增 │
│ │ body: { user_prompt, variant_count: 4 } │
│ │ │
│ │ ② response: { prompts: [ { preset_id, world, │
│ │ bg_prompt }, x4 ] } │
│ │ │
│ │ ③ POST /api/v1/laser/generate ← 现有, 不动 │
│ │ body: { cutout_url, render_configs: [...4 个] } │
│ │ │
│ │ ④ 4 次中转站 /v1/images/edits 调用 (并发) │
│ │ 每次: (user_photo + 不同 prompt) → gpt-image-2 │
│ │ │
│ │ ⑤ 返回 4 张图 + 1 张原图 = 5 张 │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ 后端 (Go gateway) │
│ │
│ ┌──────────────────────────────┐ │
│ │ laser_build_prompts_ │ ← 新增 Controller │
│ │ controller.go │ POST /api/v1/laser/build-prompts│
│ └──────────────┬───────────────┘ │
│ ↓ │
│ ┌──────────────────────────────┐ ┌──────────────────────────┐ │
│ │ inspiration_pool.go │ ←→ │ laser_world_templates │ │
│ │ - ListEnabled (DB) │ │ (PG 表, 3 模板种子) │ │
│ │ - weightedSample() │ └──────────────────────────┘ │
│ │ - sanitize() 兜底 │ ↑ 直连写 │
│ │ - BuildInspirationPrompt() │ │ │
│ │ *无缓存, 每次直查 DB* │ │ │
│ └──────────────┬───────────────┘ │ │
│ ↓ │ │
│ ┌──────────────────────────────┐ │ │
│ │ laser_generate_controller │ ← 现有, 零改动 │
│ │ - handleOpenAIDirect() │ 4 次并发 EditImage │
│ │ - openaiClient │ │ │
│ └──────────────┬───────────────┘ │ │
│ ↓ │ │
│ ┌──────────────────────────────┐ │ │
│ │ openai_client.go (现有) │ │ │
│ │ - EditImage() → 中转站 │ │ │
│ └──────────────┬───────────────┘ │ │
└─────────────────│────────────────────────────│─────────────────────┘
│ │
▼ │
┌────────────────────┐ │
│ 中转站 (gpt-image-2) │ │
│ POST /v1/images/edits│ │
│ 返回 1 张成品图 ×4 │ │
└────────────────────┘ │
│ 共用同一 PostgreSQL
│ 直连(不经过 Go)
┌──────────────────────────┐
│ TopFans-activity-admin │
│ (外部项目, Python) │
│ - 直连 PG 写配置 │
│ - 自己的 Vue CRUD UI │
│ - 不在本文档范围 │
└──────────────────────────┘
```
### 1.2 调用时序
```
用户点击"生成 5 张"
├──→ buildPrompts() // ① 调 /build-prompts
│ │ 后端: 直查 DB 读 3 个 enabled 模板
│ │ 权重采样 4 个不放回 (期望 2 Luxury + 1 Dreamlike + 1 Experimental)
│ │ 抽灵感词 + 组装 4 份 prompt
│ │ sanitize 兜底
│ ↓
│ [4 份差异化 prompt]
│ ↓
└──→ generateLaserCard() // ② 调 /generate (现有流程, 零改动)
│ 4 次并发调 openaiClient.EditImage
│ 每次: (cutout_url + 不同 prompt) → 中转站 → 1 张成品图
4 张差异化 AI 图 + 1 张原图
```
### 1.3 后台写库流(独立链路, 不经 Go)
```
Admin 用户在 TopFans-activity-admin 前端改模板
Admin Python 后端直连 PostgreSQL
↓ UPDATE / INSERT / 软删除
laser_world_templates 表
↓ 下次 /build-prompts 请求时自然读到新值
C 端用户生图时使用新模板
```
---
## 2. 后端灵感池服务DB 可配置 + 权重采样 + 不缓存)
### 2.1 关键决策
| 决策 | 选择 | 原因 |
|------|------|------|
| 模板存储 | **PostgreSQL `laser_world_templates` 表** | 复用外部 Admin直连同库 |
| 是否缓存 | **不缓存** | 3-10 行简单 SELECTQPS 低,运营改完立即生效更重要 |
| 读取后处理 | **sanitize 兜底校验** | 防 Admin 同学绕开 UI 直接 SQL 注入脏数据 |
| 软删除 | **`deleted_at` 字段** | 与 castlove 一致,禁止物理 DELETE |
| 序列同步 | **`setval` 必须** | 项目 CLAUDE.md 强制规则 |
| 是否走合成 | **不走** | 中转站 `/v1/images/edits` 直接返回成品图 |
### 2.2 新建文件
- `backend/services/assetService/service/inspiration_pool.go`(核心服务:读 DB + 权重采样 + sanitize + prompt 组装)
- `backend/gateway/controller/laser_build_prompts_controller.go`C 端 `/build-prompts` HTTP 入口)
- `backend/gateway/dto/inspiration_dto.go`C 端请求/响应 DTO
- `backend/services/assetService/repository/world_template_repository.go`DB 访问层)
- `backend/migrations/2026_06_25_001_laser_world_templates.sql`(建表 + 种子数据)
### 2.3 数据模型
```sql
-- backend/migrations/2026_06_25_001_laser_world_templates.sql
-- 说明:世界观模板表
-- - 存 3 个初始模板 (Luxury / Dreamlike / Experimental)
-- - 由外部 Admin (TopFans-activity-admin) 直连此表做 CRUD
-- - Go gateway 只读不写
-- - 软删除 (deleted_at), 禁止物理 DELETE
-- - 见 §5C. 后台直连写库约定
-- 1. 建表(序列从 10000 起步,给测试数据预留空间 —— 项目 CLAUDE.md 约定)
CREATE SEQUENCE IF NOT EXISTS laser_world_templates_id_seq START WITH 10000;
CREATE TABLE IF NOT EXISTS public.laser_world_templates (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) NOT NULL, -- 英文唯一标识
display_name VARCHAR(100) NOT NULL, -- 英文显示名
display_zh VARCHAR(100) NOT NULL, -- 中文显示名(前端展示用)
weight INT NOT NULL DEFAULT 1, -- 权重,见 §2.6 分配算法
inspiration_pool JSONB NOT NULL, -- 灵感词数组
hard_control TEXT NOT NULL, -- 20% 强控制层 prompt
negative TEXT NOT NULL, -- 负向约束
enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 软下线开关
sort_order INT NOT NULL DEFAULT 0, -- 后台列表排序
created_at BIGINT NOT NULL, -- unix timestamp
updated_at BIGINT NOT NULL, -- unix timestamp
deleted_at BIGINT, -- 软删除时间戳, NULL = 未删除
CONSTRAINT uq_laser_world_templates_code UNIQUE (code)
);
-- 只查未删除 + 启用的
CREATE INDEX idx_laser_world_templates_active
ON public.laser_world_templates(enabled, sort_order)
WHERE deleted_at IS NULL;
-- 2. 种子数据(原"硬编码 3 个模板"迁入 DB)
INSERT INTO public.laser_world_templates
(code, display_name, display_zh, weight, inspiration_pool, hard_control, negative, enabled, sort_order, created_at, updated_at)
VALUES
-- 1) Luxury Editorial: weight=2, 4 张图中期望占 2 张
(
'luxury_editorial',
'Luxury Editorial',
'奢侈大片',
2,
'["luxury fashion campaigns","high-end beauty photography","iridescent materials","optical interference","contemporary light installations","chromatic light behavior","elegant color transitions","subtle diffraction","museum-quality photographic prints"]'::jsonb,
'Transform the uploaded image into a premium holographic editorial artwork.
Preserve the subject''s identity, facial features, expression, and overall likeness.
Ultra-detailed. Luxury editorial quality. Sophisticated and visually surprising.',
'No borders. No typography. No card layout. No watermark.
Avoid predictable holographic clichés.
Discover new combinations of light, material perception, atmosphere, and visual depth.',
TRUE, 1, EXTRACT(EPOCH FROM NOW())::BIGINT, EXTRACT(EPOCH FROM NOW())::BIGINT
),
-- 2) Dreamlike Aurora: weight=1
(
'dreamlike_aurora',
'Dreamlike Aurora',
'梦幻极光',
1,
'["aurora phenomena","iridescent light behavior","atmospheric optics","fine art photography","cinematic glow","immersive light exhibitions","spectral gradients","floating luminosity","optical depth","elegant abstraction"]'::jsonb,
'Reimagine the uploaded image as a dreamlike holographic collectible artwork.
Preserve the subject completely while transforming the visual atmosphere into something ethereal, luminous, and emotionally captivating.
Museum-quality photographic artwork. Premium collectible aesthetic.',
'No text. No poster design. No frames. No trading card elements.
Each generation should discover a different visual language rather than repeating recognizable holographic styles.
Visually magical and unique.',
TRUE, 2, EXTRACT(EPOCH FROM NOW())::BIGINT, EXTRACT(EPOCH FROM NOW())::BIGINT
),
-- 3) Experimental Light Art: weight=1
(
'experimental_light',
'Experimental Light',
'灯光实验',
1,
'["cutting-edge light installations","optical experiments","futuristic visual design","luxury exhibition spaces","reflective surfaces","advanced photographic techniques","dynamic light behavior","spectral distortions","chromatic reflections","abstract luminosity"]'::jsonb,
'Transform the uploaded image into an experimental holographic light-art masterpiece.
Preserve the subject''s identity and photographic realism while allowing the surrounding visual environment to evolve creatively.
High-end commercial imaging quality. Luxury contemporary art aesthetic.',
'No borders. No labels. No typography. No card design.
Avoid repetitive holographic motifs.
Unexpected, immersive, and visually striking.',
TRUE, 3, EXTRACT(EPOCH FROM NOW())::BIGINT, EXTRACT(EPOCH FROM NOW())::BIGINT
)
ON CONFLICT (code) DO NOTHING;
-- 3. 序列同步(项目 CLAUDE.md 强制要求: 手动 INSERT 后必须 setval)
SELECT setval(
'laser_world_templates_id_seq',
(SELECT COALESCE(MAX(id), 0) FROM public.laser_world_templates)
);
```
### 2.4 业务模型Go
```go
// backend/services/assetService/service/inspiration_pool.go
package service
// WorldTemplate DB 中的世界观模板(对应 laser_world_templates 表)
type WorldTemplate struct {
ID int64
Code string
DisplayName string
DisplayZh string
Weight int
InspirationPool []string // 从 JSONB 反序列化
HardControl string
Negative string
Enabled bool
SortOrder int
}
// InspirationEntry 单次返回的 4 个 prompt 之一
type InspirationEntry struct {
PresetID string `json:"preset_id"`
World string `json:"world"` // template.code
WorldDisplay string `json:"world_display"` // template.display_zh
InspirationWords []string `json:"inspiration_words"`
BgPrompt string `json:"bg_prompt"`
}
// InspirationPoolResult buildInspirationPool 的返回
type InspirationPoolResult struct {
Entries []InspirationEntry `json:"entries"`
WorldDistribution map[string]int `json:"world_distribution"` // key: template.code
}
```
### 2.5 模板存储说明
模板内容已迁入 `laser_world_templates` 表。**3 个初始模板的英文 prompt 全文见 §2.3 SQL 种子数据**。修改模板的入口在外部 Admin (`TopFans-activity-admin`),不在本仓库代码。
**Admin 同学可以做的操作**:
- 编辑任一模板的灵感词池、强控制层、负向词、显示名
- 调整权重(影响 4 张图的分配概率)
- 临时禁用某模板(`enabled=false`
- 新增模板(例如增加"商务风""复古风"等 4-7 个额外风格)
- 软删除模板UPDATE `deleted_at`,禁止物理 DELETE
**本仓库 Go 侧能做的操作**:
- 只读 `enabled=true AND deleted_at IS NULL` 的模板
- 任何写操作都应该在外部 Admin 完成,**本仓库不写 Admin CRUD 端点**
### 2.6 核心算法DB 读取 + 权重采样 + 不缓存)
```go
// buildInspirationPool 4 张图的世界观分配 + 灵感词抽取
//
// 流程:
// 1. 从 DB 读所有 enabled=true AND deleted_at IS NULL 的模板(每次直查,不缓存)
// 2. sanitize 兜底(防 Admin 注入脏数据)
// 3. 权重采样 4 次不放回(weighted sampling without replacement)
// - 每个模板按 weight 概率被选中
// - 选中后从池中移除,避免重复
// - 默认 3 模板 weight [2,1,1] 期望产出 [2,1,1]
// 4. 每次从选中模板的 inspiration_pool 抽 2-3 个灵感词(全局去重)
//
// 性能: 3-10 行简单 SELECT, DB < 1ms, 每次生图调用 1 次, 整体开销可忽略
// 不需要缓存(缓存收益为负: 5s 延迟 vs 简单查询的 < 1ms 收益)
func buildInspirationPool(
ctx context.Context,
repo *repository.WorldTemplateRepository,
userPrompt string,
variantCount int,
seed int64,
) (*InspirationPoolResult, error) {
if seed == 0 {
seed = time.Now().UnixNano()
}
rng := rand.New(rand.NewSource(seed))
// 1. 从 DB 直查(不缓存)
templates, err := repo.ListEnabled(ctx)
if err != nil {
return nil, fmt.Errorf("read world templates: %w", err)
}
// 2. 兜底: 至少 1 个模板才能工作
if len(templates) == 0 {
return nil, fmt.Errorf("no enabled world templates in DB (need at least 1)")
}
// 3. sanitize 兜底(防 Admin 注入脏数据, 详见 §2.10)
for i, t := range templates {
if err := sanitizeTemplate(&t); err != nil {
return nil, fmt.Errorf("template %q invalid: %w", t.Code, err)
}
templates[i] = t
}
// 4. 权重采样 4 次不放回
picked := weightedSampleWithoutReplacement(templates, variantCount, rng)
if len(picked) < variantCount {
// 启用模板不足 4 个, 补足(用第一个模板)
for i := len(picked); i < variantCount; i++ {
picked = append(picked, templates[0])
}
}
// 5. 灵感词抽取(全局去重)
usedInspirations := make(map[string]bool)
entries := make([]InspirationEntry, 0, len(picked))
dist := make(map[string]int)
for i, tpl := range picked {
// 抽 2-3 个灵感词
n := 2 + rng.Intn(2)
inspiration := pickInspiration(tpl.InspirationPool, n, usedInspirations, rng)
for _, p := range inspiration {
usedInspirations[p] = true
}
// 组装 prompt
bgPrompt := BuildInspirationPrompt(tpl, inspiration, userPrompt)
entries = append(entries, InspirationEntry{
PresetID: fmt.Sprintf("v%d", i+1),
World: tpl.Code,
WorldDisplay: tpl.DisplayZh,
InspirationWords: inspiration,
BgPrompt: bgPrompt,
})
dist[tpl.Code]++
}
return &InspirationPoolResult{
Entries: entries,
WorldDistribution: dist,
}, nil
}
// weightedSampleWithoutReplacement 权重采样不放回
func weightedSampleWithoutReplacement(templates []WorldTemplate, count int, rng *rand.Rand) []WorldTemplate {
if count >= len(templates) {
shuffled := make([]WorldTemplate, len(templates))
copy(shuffled, templates)
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
return shuffled
}
pool := make([]WorldTemplate, len(templates))
copy(pool, templates)
result := make([]WorldTemplate, 0, count)
for len(result) < count && len(pool) > 0 {
totalWeight := 0
for _, t := range pool {
w := t.Weight
if w <= 0 {
w = 1
}
totalWeight += w
}
r := rng.Intn(totalWeight)
hitIdx := -1
for i, t := range pool {
w := t.Weight
if w <= 0 {
w = 1
}
r -= w
if r < 0 {
hitIdx = i
break
}
}
if hitIdx < 0 {
hitIdx = len(pool) - 1
}
result = append(result, pool[hitIdx])
pool = append(pool[:hitIdx], pool[hitIdx+1:]...)
}
return result
}
// pickInspiration 从 candidates 随机抽 n 个, 排除已用
func pickInspiration(candidates []string, n int, used map[string]bool, rng *rand.Rand) []string {
pool := make([]string, 0, len(candidates))
for _, c := range candidates {
if !used[c] {
pool = append(pool, c)
}
}
if len(pool) == 0 {
// 池子耗尽, 兜底: 不再追求去重, 从全量抽
pool = candidates
}
rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
if n > len(pool) {
n = len(pool)
}
return pool[:n]
}
```
### 2.7 Prompt 拼接
```go
// BuildInspirationPrompt 组装单张图的最终 prompt
// 1. 强控制层(20%): 保留人物 + 高端品质
// 2. 灵感词层(80%): 随机 2-3 个发散方向
// 3. 用户输入(可选): 引导性注入
// 4. 负向约束
func BuildInspirationPrompt(tpl WorldTemplate, inspiration []string, userPrompt string) string {
var b strings.Builder
// 1. 强控制层
b.WriteString(tpl.HardControl)
b.WriteString("\n\n")
// 2. 灵感词层
b.WriteString("Draw inspiration from:\n")
for i, ins := range inspiration {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(ins)
}
b.WriteString(".\n\n")
// 3. 用户输入(可选)
if u := strings.TrimSpace(userPrompt); u != "" {
b.WriteString("User-provided theme to weave naturally into the artwork: ")
b.WriteString(u)
b.WriteString("\n\n")
}
// 4. 负向约束
b.WriteString(tpl.Negative)
return b.String()
}
```
### 2.8 RepositoryDB 访问层)
```go
// backend/services/assetService/repository/world_template_repository.go
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
type WorldTemplateRepository struct {
db *sql.DB
}
func NewWorldTemplateRepository(db *sql.DB) *WorldTemplateRepository {
return &WorldTemplateRepository{db: db}
}
// ListEnabled 列出所有 enabled=true AND deleted_at IS NULL 的模板
// 按 sort_order ASC, id ASC 排序
// 每次直查 DB, 不缓存(spec §2.1 决策)
func (r *WorldTemplateRepository) ListEnabled(ctx context.Context) ([]WorldTemplate, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, code, display_name, display_zh, weight, inspiration_pool,
hard_control, negative, enabled, sort_order
FROM laser_world_templates
WHERE enabled = TRUE
AND deleted_at IS NULL
ORDER BY sort_order ASC, id ASC
`)
if err != nil { return nil, err }
defer rows.Close()
var result []WorldTemplate
for rows.Next() {
t, err := scanTemplate(rows)
if err != nil { return nil, err }
result = append(result, t)
}
return result, rows.Err()
}
// scanTemplate 扫描一行,JSONB 反序列化为 []string
func scanTemplate(scanner interface{ Scan(...any) error }) (WorldTemplate, error) {
var t WorldTemplate
var poolJSON []byte
err := scanner.Scan(&t.ID, &t.Code, &t.DisplayName, &t.DisplayZh, &t.Weight,
&poolJSON, &t.HardControl, &t.Negative, &t.Enabled, &t.SortOrder)
if err != nil { return t, err }
if err := json.Unmarshal(poolJSON, &t.InspirationPool); err != nil {
return t, fmt.Errorf("unmarshal inspiration_pool: %w", err)
}
return t, nil
}
```
### 2.9 Service 层(薄包装 + DB 注入 + sanitize 兜底)
```go
// backend/services/assetService/service/inspiration_pool.go 末尾追加
type InspirationPoolService struct {
repo *repository.WorldTemplateRepository
}
func NewInspirationPoolService() *InspirationPoolService {
db := database.GetDB()
return &InspirationPoolService{
repo: repository.NewWorldTemplateRepository(db),
}
}
// BuildPrompts C 端入口:中文翻译 → 读 DB → sanitize → 权重采样 → 组装 prompt
// 每次直查 DB, 不缓存(详见 spec §2.1 决策)
func (s *InspirationPoolService) BuildPrompts(
ctx context.Context, userPrompt string, variantCount int, seed int64,
) (*InspirationPoolResult, error) {
if variantCount <= 0 { variantCount = 4 }
if variantCount > 4 { variantCount = 4 }
// 中文翻译(后端统一处理): 检测中文并调用 Google Translate API 翻译为英文
// 2026-06-25 规范变更: 翻译从前端移至后端, 所有客户端统一走此路径
prompt := strings.TrimSpace(userPrompt)
if prompt != "" && containsChinese(prompt) {
translated, err := translateToEnglish(ctx, prompt)
if err != nil {
log.Warn("build-prompts translate failed, fallback to original text",
zap.String("prompt_prefix", safePrefix(prompt, 60)),
zap.Error(err))
} else {
prompt = translated
}
}
return buildInspirationPool(ctx, s.repo, prompt, variantCount, seed)
}
// containsChinese 检查字符串是否包含中文字符
func containsChinese(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Han, r) {
return true
}
}
return false
}
// translateToEnglish 调用 Google Translate API 将中文翻译为英文
// 复用 pkg/translate 或直接通过 HTTP 调用; 失败时调用方应兜底使用原文
func translateToEnglish(ctx context.Context, text string) (string, error) {
// 简化实现: POST https://translation.googleapis.com/language/translate/v2
// 参数: q=text&target=en&source=zh
// 完整实现见 backend/pkg/translate/client.go(如已有) 或内联 HTTP 调用
return "", fmt.Errorf("translateToEnglish 需根据项目已有的 translate 服务实现")
}
// sanitizeTemplate 兜底校验
// Admin 直连同库写入, Go 这边没有 service 校验, 读出后必须做最低限度的 sanity check
// 防止 Admin 同学绕开 UI 直接 SQL 注入脏数据(spec §5C 约定)
func sanitizeTemplate(t *WorldTemplate) error {
if strings.TrimSpace(t.Code) == "" {
return fmt.Errorf("code 不能为空")
}
if strings.TrimSpace(t.DisplayZh) == "" {
return fmt.Errorf("display_zh 不能为空")
}
if len(t.InspirationPool) < 2 {
return fmt.Errorf("inspiration_pool 至少 2 个词(实际 %d)", len(t.InspirationPool))
}
for i, w := range t.InspirationPool {
if strings.TrimSpace(w) == "" {
return fmt.Errorf("inspiration_pool[%d] 为空", i)
}
}
if strings.TrimSpace(t.HardControl) == "" {
return fmt.Errorf("hard_control 不能为空")
}
if strings.TrimSpace(t.Negative) == "" {
return fmt.Errorf("negative 不能为空")
}
if t.Weight < 0 {
return fmt.Errorf("weight 不能为负(实际 %d)", t.Weight)
}
if t.Weight > 100 {
return fmt.Errorf("weight 不能超过 100(实际 %d)", t.Weight)
}
return nil
}
```
---
## 3. 后端API 与 DTO新增
### 3.1 DTO 定义
```go
// backend/gateway/dto/inspiration_dto.go
package dto
// BuildPromptsRequest POST /api/v1/laser/build-prompts 请求体
type BuildPromptsRequest struct {
UserPrompt string `json:"user_prompt" binding:"max=1000"` // 可空, 由前端传空字符串
VariantCount int `json:"variant_count" binding:"min=1,max=4"` // 默认 4
Seed int64 `json:"seed"` // 可选, 用于 QA 复现
}
// InspirationPromptItem 单个 prompt 变体
type InspirationPromptItem struct {
PresetID string `json:"preset_id"`
World string `json:"world"` // luxury_editorial / dreamlike_aurora / experimental_light
WorldDisplay string `json:"world_display"` // 奢侈大片 / 梦幻极光 / 灯光实验
BgPrompt string `json:"bg_prompt"`
InspirationWords []string `json:"inspiration_words"`
}
// BuildPromptsResponse 响应体
type BuildPromptsResponse struct {
Prompts []InspirationPromptItem `json:"prompts"`
WorldDistribution map[string]int `json:"world_distribution"`
}
```
### 3.2 Controller
```go
// backend/gateway/controller/laser_build_prompts_controller.go
package controller
import (
"github.com/gin-gonic/gin"
"github.com/topfans/backend/gateway/dto"
"github.com/topfans/backend/gateway/pkg/response"
"github.com/topfans/backend/pkg/logger"
"go.uber.org/zap"
"github.com/topfans/backend/services/assetService/service"
)
// LaserBuildPromptsController 灵感池 prompt 组装控制器
type LaserBuildPromptsController struct {
inspirationPool *service.InspirationPoolService
}
func NewLaserBuildPromptsController() *LaserBuildPromptsController {
return &LaserBuildPromptsController{
inspirationPool: service.NewInspirationPoolService(),
}
}
// BuildPrompts POST /api/v1/laser/build-prompts
// 接收 user_prompt, 返回 4 个差异化 prompt
func (ctrl *LaserBuildPromptsController) BuildPrompts(c *gin.Context) {
var req dto.BuildPromptsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if req.VariantCount == 0 {
req.VariantCount = 4
}
if req.VariantCount < 1 || req.VariantCount > 4 {
response.BadRequest(c, "variant_count 必须在 1-4 之间")
return
}
result, err := ctrl.inspirationPool.BuildPrompts(
c.Request.Context(),
req.UserPrompt,
req.VariantCount,
req.Seed,
)
if err != nil {
logger.Logger.Error("build-prompts failed",
zap.String("user_prompt_prefix", req.UserPrompt[:min(60, len(req.UserPrompt))]), // Go 1.21+ 内置 min
zap.Error(err),
)
response.InternalError(c, "灵感池生成失败: "+err.Error())
return
}
response.Success(c, gin.H{
"prompts": result.Entries,
"world_distribution": result.WorldDistribution,
})
}
```
### 3.3 路由注册
修改 `backend/gateway/router/router.go``laserGenCtrl` 初始化之后,[L118](backend/gateway/router/router.go#L118) 附近):
```go
// 在 laserGenCtrl := controller.NewLaserGenerateController(config.Load()) 之后追加
buildPromptsCtrl := controller.NewLaserBuildPromptsController()
// 在 laser := v1.Group("/laser") 组内, /generate 之前追加
laser.POST("/build-prompts", buildPromptsCtrl.BuildPrompts)
laser.POST("/generate", laserGenCtrl.CreateGenerateJob)
laser.GET("/generate/:id", laserGenCtrl.GetGenerateJob)
laser.POST("/compose", composeCtrl.ComposeSingle)
```
### 3.4 Swagger 注释
在新 Controller 方法上添加 Swagger 注释,保持与项目 `gen-swagger.sh` 一致。
---
## 4. 前端Composable 改造
### 4.1 改动文件
- [frontend/composables/useLaserDifyGenerate.js](frontend/composables/useLaserDifyGenerate.js)
### 4.2 改造点
#### 4.2.1 `resolveRenderConfigs` 改造
```js
// 旧实现: 4 份相同 prompt
async function resolveRenderConfigs(_presetIds, userPrompt) {
let prompt = (userPrompt || '').trim().slice(0, 1000)
if (prompt && containsChinese(prompt)) {
prompt = await translateToEnglish(prompt)
}
return [1, 2, 3, 4].map(function(i) {
return { preset_id: 'v' + i, bg_prompt: prompt || 'Transform this into a premium holographic artwork' }
})
}
// 新实现: 调 /build-prompts 拿 4 个差异化 prompt
// 中文翻译已移至后端 BuildPrompts service, 前端直接传原始 userPrompt
async function resolveRenderConfigs(userPrompt) {
let prompt = (userPrompt || '').trim().slice(0, 1000)
const buildRes = await laserRequest({
url: '/api/v1/laser/build-prompts',
method: 'POST',
timeout: 10000, // 后端组装极快(无缓存直查 DB), 给 10s 足够
data: {
user_prompt: prompt,
variant_count: 4,
},
})
if (!buildRes || !buildRes.data) {
throw new Error('build-prompts 响应为空')
}
const prompts = Array.isArray(buildRes.data.prompts) ? buildRes.data.prompts : []
if (prompts.length !== 4) {
throw new Error('build-prompts 返回数量异常: ' + prompts.length)
}
return prompts.map(p => ({
preset_id: p.preset_id,
world: p.world,
world_display: p.world_display,
bg_prompt: p.bg_prompt,
}))
}
```
#### 4.2.2 `submit` 适配
```js
async function submit(cutoutUrl, presetIds = null, userPrompt = '') {
status.value = 'submitting'
error.value = null
progress.value = 0
const renderConfigs = await resolveRenderConfigs(userPrompt) // 不再需要 presetIds
const presetCodes = renderConfigs.map((rc) => rc.preset_id)
try {
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 已在 build-prompts 时注入到 bg_prompt, 后端不再二次拼接
user_prompt: '',
},
})
// ... 后续处理保持不变
}
}
```
#### 4.2.3 暴露 world 信息(供 UI 展示)
在 composable 顶部新增一个 `presetWorldMap` 内部状态:
```js
export function useLaserDifyGenerate() {
// ... 现有 ref
const presetWorldMap = ref({}) // { v1: 'luxury_editorial', ... }
// ...
}
```
修改 `resolveRenderConfigs`,把 world 信息写进 map
```js
async function resolveRenderConfigs(userPrompt) {
// ... 调 /build-prompts
const prompts = buildRes.data.prompts
// 维护 preset_id → world 映射, 供 applySucceeded 使用
presetWorldMap.value = {}
prompts.forEach(p => { presetWorldMap.value[p.preset_id] = p.world })
return prompts.map(p => ({ /* ... 现有字段 ... */ }))
}
```
修改 `applySucceeded`,把 world 字段补到每个 variant
```js
function applySucceeded(payload, fallbackCutoutUrl) {
var aiVariants = Array.isArray(payload.variants) ? payload.variants : []
// 给每个 variant 补 world 字段(按 preset_id 匹配)
aiVariants = aiVariants.map(v => {
const world = presetWorldMap.value[v.preset_id] || ''
return { ...v, world, world_display: worldDisplayMap[world] || '' }
})
cutoutUrl.value = payload.cutout_url || fallbackCutoutUrl || ''
if (cutoutUrl.value) {
aiVariants.push({ preset_id: 'original', signed_url: cutoutUrl.value, oss_key: '' })
}
variants.value = aiVariants
// ...
}
```
`reset()` 函数中同步清空 `presetWorldMap.value = {}`
### 4.3 调用方兼容性
旧调用方式:
```js
const { submit } = useLaserDifyGenerate()
await submit(cutoutUrl, ['v1','v2','v3','v4'], '演唱会')
```
新调用方式presetIds 仍可传但被忽略):
```js
const { submit } = useLaserDifyGenerate()
await submit(cutoutUrl, null, '演唱会')
// 或
await submit(cutoutUrl, undefined, '演唱会')
// 或
await submit(cutoutUrl, [], '演唱会')
```
---
## 5. 前端UI 增强(可选, 不阻塞主流程)
### 5.1 4 张图卡片上显示世界标签
```vue
<template>
<view class="variant-card">
<image :src="variant.signed_url" mode="aspectFill" />
<view class="world-tag" :class="worldClass">
{{ variant.world_display }}
</view>
</view>
</template>
<script setup>
const props = defineProps({ variant: Object })
const worldClass = computed(() => 'world-' + props.variant.world)
</script>
<style scoped>
.world-tag {
position: absolute;
top: 12rpx;
left: 12rpx;
padding: 4rpx 12rpx;
border-radius: 20rpx;
font-size: 20rpx;
color: #fff;
backdrop-filter: blur(8rpx);
}
.world-luxury_editorial { background: rgba(212, 175, 55, 0.6); }
.world-dreamlike_aurora { background: rgba(147, 112, 219, 0.6); }
.world-experimental_light { background: rgba(70, 130, 180, 0.6); }
</style>
```
### 5.2 不做的YAGNI
- ❌ 不做"用户选偏好"加权 UI
- ❌ 不做"5 张图连抽动画"
- ❌ 不做"重抽单张"按钮(保持简单)
---
## 5B. B 链路(资产图生图)同步升级
> **范围**:项目内还有第二条独立的"图生图"链路(`POST /api/v1/assets/mints/image/generation`,由 `backend/gateway/controller/asset_controller.go#ImageGeneration` 处理),使用 **MiniMax** 而非 OpenAI gpt-image-2目前由 `frontend/pages/discover/generation-loading.vue` 在 `onLoad` 阶段调用。
>
> **本节目的**:把 B 链路同步升级为"灵感池风格"——与 A 链路共用 `laser_world_templates` 表和 `inspiration_pool.go` serviceB 链路**默认生成 4 张图**(与 A 链路一致,对齐结构)。
> **生命周期**B 链路(资产图生图)预计在 2026Q3 随旧版"发现"页面重构而取消。本节改造以**最小改动**为原则,复用 A 链路 Service 层,不引入 B 链路独有的持久化/缓存逻辑。
>
> **关联代码**:
> - [backend/gateway/controller/asset_controller.go](backend/gateway/controller/asset_controller.go) `ImageGeneration` (现有 L1728,本次重构) + `BuildImagePrompts` (本次新增)
> - [backend/gateway/dto/image_dto.go](backend/gateway/dto/image_dto.go) `ImageGenerationRequest` (本次改 DTO 字段名)
> - [backend/gateway/service/minimax_client.go](backend/gateway/service/minimax_client.go) `GenerateImageWithSubject` (**零改动**,继续当 B 链路后端)
> - [backend/gateway/router/router.go](backend/gateway/router/router.go) L309 附近 (本次加 1 行路由)
> - [frontend/utils/api.js](frontend/utils/api.js) `imageGenerationApi` (本次加 `imageBuildPromptsApi`)
> - [frontend/pages/discover/generation-loading.vue](frontend/pages/discover/generation-loading.vue) `callImageGeneration` (本次改调用顺序)
> - [frontend/utils/castloveGenerationFlow.js](frontend/utils/castloveGenerationFlow.js) `startAiImageGenerationFlow` (零改动,继续构造 storage 数据)
### 5B.1 B 链路现状
```
用户进入"生成加载页" (generation-loading.vue)
↓ onMounted
读 GENERATION_REQUEST_KEY 拿到 generationData
│ - prompt (用户/castlove form)
│ - model = 'image-01'
│ - aspect_ratio = '16:9'
│ - subject_reference = [{ type: 'character', image_file: '...' }]
│ - n = 4 ← 注意: 前端 n=4 但后端 minimax_client.go 写死 N=1,已存在契约不一致,本方案不动
调 imageGenerationApi(generationData) → POST /api/v1/assets/mints/image/generation
后端 ImageGeneration 接收 → 直接转给 minimaxService.GenerateImage
MiniMax 返回 1 张图(写死 N=1
前端把图存到 GENERATED_IMAGES_KEY → 跳"选择结果"页
```
**核心问题**:
- `prompt` 是**前端原始 prompt**(可能空、可能含中文、可能与世界观无关)
- 后端没有 prompt 工程化,直接转发给 MiniMax
- 没有"差异化"概念(B 链路只生成 1 张图)
### 5B.2 重构目标
1. **后端组装 prompt** —— 把 B 链路 `/generation` 接收的字段从 `prompt` 改为 `bg_prompt`(已组装的)
2. **两步调用** —— 前端先调 `/build-prompts` 拿 1 个 prompt,再调 `/generation` 出图
3. **共用 A 链路的 `laser_world_templates` 表** —— A 和 B 读同一张表,共享权重采样 / 灵感词去重逻辑
4. **B 链路 `variant_count = 4`** —— 与 A 链路对齐, 一次拿 4 个 prompt, 4 次并发调 MiniMax 出 4 张图
5. **MiniMax 继续当后端** —— 不改 `minimax_client.go`,继续 `GenerateImageWithSubject(ctx, bg_prompt, subject_image_url)`
6. **零 LLM 增量成本** —— 复用 `inspiration_pool.go` service,无新依赖
### 5B.3 数据流B 改造后)
```
前端 后端 MiniMax
generation-loading.vue
│ ① 读 GENERATION_REQUEST_KEY
│ 拿到 user_prompt + subject_reference + aspect_ratio + model
imageBuildPromptsApi({ POST /api/v1/assets/mints/image/build-prompts
user_prompt, → 复用 A 链路的 InspirationPoolService.BuildPrompts
variant_count: 4 → variant_count 默认 4 (与 A 一致)
}) → 读 laser_world_templates, 权重采样 4 个模板
↓ → 抽 2-3 个灵感词 + 组装 4 份 bg_prompt
拿到 { → sanitize 兜底
prompts: [ → 返回 4 个 prompt
{ bg_prompt: "...", ← B 链路取全部 4 个 prompts
world: "luxury_editorial",
world_display: "奢侈大片" },
{ bg_prompt: "...",
world: "dreamlike_aurora", ... },
{ bg_prompt: "...",
world: "experimental_light", ... },
{ bg_prompt: "...",
world: "luxury_editorial", ... }
]
}
imageGenerationApi({ POST /api/v1/assets/mints/image/generation
bg_prompts: [4 个 prompt], → 不再组装 prompt, 4 份直接转发
subject_reference, → 4 次并发调 minimaxService.GenerateImage
model, → 每次: minimaxClient.GenerateImageWithSubject
aspect_ratio, → (1 个 bg_prompt + subject_reference) → MiniMax API
}) → 收集 4 张图 URL
↓ → 返回 { images: [url1, url2, url3, url4] }
GENERATED_IMAGES_KEY = [url1, url2, url3, url4]
```
### 5B.4 后端改动
#### 5B.4.1 新增 `BuildImagePrompts` 方法(在 `asset_controller.go`
```go
// BuildImagePrompts POST /api/v1/assets/mints/image/build-prompts
// B 链路 (资产图生图) 的 prompt 组装入口
// 复用 A 链路的 InspirationPoolService,固定 variant_count=1
func (ctrl *AssetController) BuildImagePrompts(c *gin.Context) {
var req dto.BuildPromptsRequest // 复用 A 链路 DTO
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
result, err := ctrl.inspirationPool.BuildPrompts(
c.Request.Context(),
req.UserPrompt,
req.VariantCount, // 默认 4, 允许 1-4 (复用 A 链路 DTO 的 binding: min=1,max=4)
req.Seed,
)
if err != nil {
logger.Logger.Error("image build-prompts failed",
zap.String("user_prompt_prefix", req.UserPrompt[:min(60, len(req.UserPrompt))]), // Go 1.21+ 内置 min
zap.Error(err),
)
response.InternalError(c, "灵感池生成失败: "+err.Error())
return
}
response.Success(c, gin.H{
"prompts": result.Entries,
"world_distribution": result.WorldDistribution,
})
}
```
#### 5B.4.2 重构 `ImageGeneration`(在 `asset_controller.go:1728`
> **设计前提**:
> - A 链路 (`laser_generate_controller.go#handleOpenAIDirect`) **不可变**, 是 B 链路的参考标准
> - B 链路**会取消**, 不做 A 链路那种 `persistGeneratedInstance` / `attachMaterialsSnapshot` 持久化
> - B 链路**并发模式与 A 链路完全一致** (sync.WaitGroup + buffered channel), 便于将来整体删除时一眼可读
```go
// ImageGeneration 图生图同步调用4 张并发)— 灵感池版
// @Summary 图生图
// @Description B 链路图生图, 前端先调 /build-prompts 拿 4 个 bg_prompts, 再调本接口 4 次并发生成
// @Tags assets
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.ImageGenerationRequest true "图生图请求 (bg_prompts 已由 /build-prompts 组装, 4 份)"
// @Success 200 {object} response.Response
// @Router /api/v1/assets/mints/image/generation [post]
func (ctrl *AssetController) ImageGeneration(c *gin.Context) {
var req dto.ImageGenerationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "Invalid request: "+err.Error())
return
}
// BgPrompts 必填, 长度 1-4 (默认 4, 与 A 链路对齐)
if len(req.BgPrompts) == 0 {
response.Error(c, 400, "bg_prompts 必填且至少 1 个, 请先调 /build-prompts")
return
}
if len(req.BgPrompts) > 4 {
response.Error(c, 400, "bg_prompts 最多 4 个 (实际 "+strconv.Itoa(len(req.BgPrompts))+")")
return
}
// debug 模式读 mock 数据(原行为, 保留)
if config.Load().Server.Mode == "debug" {
mockData, err := os.ReadFile(filepath.Join(config.Load().Root, "..", "mock", "minimax.json"))
if err != nil {
response.Error(c, 500, "Failed to read mock data: "+err.Error())
return
}
var mockResult map[string]interface{}
if err := json.Unmarshal(mockData, &mockResult); err != nil {
response.Error(c, 500, "Failed to parse mock data: "+err.Error())
return
}
response.Success(c, mockResult)
return
}
// 4 次并发调 MiniMax, 并发模式与 A 链路 handleOpenAIDirect 完全一致:
// - sync.WaitGroup + buffered channel (不引入 errgroup)
// - 失败不阻断, 写 warnings
// - 全部失败才 500
ctx := c.Request.Context()
type variantResult struct {
BgPrompt string // 记录用, 出错时知道是哪一份 prompt
Url string // 成功时填
Err string // 失败时填, 与 A 链路一致
}
resultCh := make(chan variantResult, len(req.BgPrompts))
var wg sync.WaitGroup
for i, bgPrompt := range req.BgPrompts {
wg.Add(1)
go func(idx int, prompt string) {
defer wg.Done()
subReq := dto.ImageGenerationRequest{
Model: req.Model,
BgPrompt: prompt, // 单次调用传单个 bg_prompt
AspectRatio: req.AspectRatio,
SubjectReference: req.SubjectReference,
}
r, err := ctrl.minimaxService.GenerateImage(ctx, &subReq)
if err != nil {
logger.Logger.Error("MiniMax variant failed",
zap.String("bg_prompt_prefix", safePrefix(prompt, 60)),
zap.Error(err),
)
resultCh <- variantResult{BgPrompt: prompt, Err: err.Error()}
return
}
if len(r.Images) == 0 {
resultCh <- variantResult{BgPrompt: prompt, Err: "minimax 返回空"}
return
}
resultCh <- variantResult{BgPrompt: prompt, Url: r.Images[0]}
}(i, bgPrompt)
}
wg.Wait()
close(resultCh)
// 收集结果 (与 A 链路 for-range 模式一致)
images := []string{}
var warnings []string
for r := range resultCh {
if r.Err != "" {
warnings = append(warnings, r.Err)
} else if r.Url != "" {
images = append(images, r.Url)
}
}
if len(images) == 0 {
response.InternalError(c, "MiniMax 生成全部失败: "+strings.Join(warnings, "; "))
return
}
// 把用户原图追加到 images 末尾B 链路原行为, 保留兼容)
if len(req.SubjectReference) > 0 && req.SubjectReference[0].ImageFile != "" {
images = append(images, req.SubjectReference[0].ImageFile)
}
response.Success(c, gin.H{
"images": images,
"warnings": warnings,
})
}
```
**与 A 链路的差异**(刻意最小化, 便于将来整体删除):
-**不做** `persistGeneratedInstance` (A 链路做, B 链路不做 — 反正要取消)
-**不做** `attachMaterialsSnapshot` (同上)
-**不做** OSS 落盘 + 预签名 URL (A 链路做, B 链路直接返回 MiniMax 给的 URL)
-**不做** 失败重试 / 降级 (A 链路明确不重试不降级, 见 `laser_generate_controller.go:269` "无需轮询" 和 L396 "失败时不重试,不降级"; B 链路对齐此原则)
-**不做** 轮询状态机 (与 A 一致, 单次请求同步返回, 前端拿多少算多少)
-**保留** 失败写 warnings, 全部失败才 500
-**保留** subject_reference 原图追加
-**保留** debug 模式 mock
**关于"用户感知失败"的设计取舍**:
- A 链路选择了"用户看到缺图 + warnings", 而不是"用户看到 4 张但其中 1 张是占位 / cutout 原图"
- 这是 A 链路的有意设计: 4 路独立并发全失败的概率极低, 不值得为它增加重试/降级的复杂度
- B 链路继承此原则: 真要给"用户无感"体验, 正确做法是改 A 链路, 不是改 B 链路
- 由于 A 链路不可变, **此问题在当前约束下无解**, 留给未来 A 链路重构时再决定
```
#### 5B.4.3 DTO 改动(`image_dto.go`
```go
// ImageGenerationRequest B 链路图生图请求 — 灵感池版4 张并发)
// 注: 旧版 Prompt 字段已重命名为 BgPrompts (数组), 语义变为"由 /build-prompts 组装好的 4 份 prompt"
type ImageGenerationRequest struct {
Model string `json:"model"`
BgPrompts []string `json:"bg_prompts" binding:"required,min=1,max=4,dive,required"` // 改: 由 /build-prompts 组装, 1-4 份
AspectRatio string `json:"aspect_ratio"`
SubjectReference []SubjectReference `json:"subject_reference"`
// 移除 N: 由 bg_prompts 长度决定
}
```
#### 5B.4.4 路由注册(`router.go` L309 附近)
```go
// 在 /mints/image/generation 之前插入:
assets.POST("/mints/image/build-prompts", assetCtrl.BuildImagePrompts) // B 链路: 灵感池 prompt 组装
assets.POST("/mints/image/generation", assetCtrl.ImageGeneration) // B 链路: 图生图 (现状, 改 bg_prompt)
```
### 5B.5 前端改动
#### 5B.5.1 `utils/api.js` 新增 `imageBuildPromptsApi`
```js
// B 链路 (资产图生图) 灵感池 prompt 组装
export function imageBuildPromptsApi(params) {
return request({
url: '/api/v1/assets/mints/image/build-prompts',
method: 'POST',
data: params
})
}
```
#### 5B.5.2 `generation-loading.vue` `callImageGeneration` 改造
```js
const callImageGeneration = async () => {
try {
// 第 1 步: 调 /build-prompts 拿 4 个 bg_prompts (与 A 链路一致)
const buildRes = await imageBuildPromptsApi({
user_prompt: generationData.user_prompt || '',
variant_count: 4,
})
if (!buildRes.data?.prompts || buildRes.data.prompts.length !== 4) {
throw new Error('build-prompts 返回数量异常: ' + (buildRes.data?.prompts?.length || 0))
}
const bgPrompts = buildRes.data.prompts.map(p => p.bg_prompt)
// 第 2 步: 调 /generation 出 4 张图 (4 张由后端并发生成, 与 A 链路 render_configs 模式一致)
const res = await imageGenerationApi({
bg_prompts: bgPrompts,
model: generationData.model,
aspect_ratio: generationData.aspect_ratio,
subject_reference: generationData.subject_reference,
})
if (res.data?.images?.length > 0) {
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(res.data.images))
// 把 4 张图的 world 信息也存下来, 供"选择结果"页展示世界标签
// GENERATED_WORLD_KEY 定义在 generation-loading.vue 顶部常量区 (与 GENERATED_IMAGES_KEY 并列)
const worldInfo = buildRes.data.prompts.map(p => ({
world: p.world,
world_display: p.world_display,
}))
uni.setStorageSync(GENERATED_WORLD_KEY, JSON.stringify(worldInfo))
completeProgress()
} else {
uni.showToast({ title: '未生成图片', icon: 'none' })
revertProgress()
}
} catch (err) {
console.error('[GenerationLoading] API', err)
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
revertProgress()
}
}
```
> **注意**: `castloveGenerationFlow.js#startAiImageGenerationFlow` 当前构造的 storage 数据是 `{ prompt, model, aspect_ratio, subject_reference, n }`, 需要确认是否包含 `user_prompt` 字段(不是 `prompt`)。本方案要求 storage 里改用 `user_prompt` 作为键(与 A 链路 `useLaserDifyGenerate` 一致), 或在前端转换时映射 `prompt → user_prompt`。
### 5B.6 与 A 链路的关键差异
| 维度 | A 链路(镭射卡 4+1 | B 链路(资产图生图) |
|------|--------------------|-------------------|
| 路由前缀 | `/api/v1/laser/...` | `/api/v1/assets/mints/image/...` |
| 模板表 | `laser_world_templates` | **同一张表**(共享) |
| `variant_count` | 4拿 4 个 prompt | **4**(与 A 一致, 拿 4 个 prompt |
| 第三方后端 | OpenAI `gpt-image-2`(中转站) | **MiniMax**(保留) |
| 并发 | 4 次并发4 张图) | 4 次并发4 张图, 后端 `sync.WaitGroup` |
| 实际图数 | 4 AI + 1 cutout = 5 张 | 4 AI + 0 cutout = 4 张B 链路不需要 cutout, 后端会把 subject_reference 原图追加为第 5 张保留兼容) |
| 前端入口 | `useLaserDifyGenerate.js` | `generation-loading.vue` |
| Service 复用 | — | 复用 A 的 `inspiration_pool.go` service |
| `bg_prompt` 注入位置 | 后端 `/laser/build-prompts` 注入 | 后端 `/assets/mints/image/build-prompts` 注入 |
| DTO 字段 | `render_configs[].bg_prompt` | `ImageGenerationRequest.bg_prompts []string`(由 `Prompt string` 改为数组, 1-4 份) |
### 5B.7 B 链路实施步骤(独立小步,按依赖顺序)
| 步骤 | 范围 | 验收 |
|------|------|------|
| B-1 | 后端 DTO: 改 `image_dto.go` `Prompt``BgPrompts []string` | `go build` 通过 |
| B-2 | 后端 Controller: 重构 `ImageGeneration` + 新增 `BuildImagePrompts` | 单元编译过 |
| B-3 | 后端 Router: `router.go` 加 1 行 | `swag init` 通过 |
| B-4 | 前端 `api.js`: 新增 `imageBuildPromptsApi` | Lint 通过 |
| B-5 | 前端 `generation-loading.vue`: 改 `callImageGeneration` 调用顺序 | Lint 通过 |
| B-6 | 前端 `castloveGenerationFlow.js`: 确认 storage 字段对齐(可能要加 `user_prompt` 字段) | Lint 通过 |
| B-7 | 集成: 端到端跑一次 B 链路生成 | 1 张图能正常生成, prompt 工程化生效 |
**注意**: B 链路依赖 A 链路的 `inspiration_pool.go` service 与 `laser_world_templates` 表。所以 B 链路**必须**在 A 链路 service 落地之后才能上线; 反之, A 链路可以独立上线B 不依赖 A 的前端代码)。
### 5B.8 B 链路风险与缓解
| 风险 | 影响 | 缓解 |
|------|------|------|
| 旧前端仍传 `prompt` 字段(无 `bg_prompts` 数组) | B 链路 400 报错 | DTO 兼容: 若 `bg_prompts` 为空/不存在, 日志 WARN 提示"前端未升级"; 给 1 周过渡期再删兼容 |
| B 链路 `/build-prompts` 返回为空DB 无模板) | B 链路 500 | 复用 A 链路 "no enabled world templates" 错误信息 |
| storage 字段对齐出错(`prompt` vs `user_prompt` | B 链路传错字段 | 实施前先确认 `castloveGenerationFlow.js` 的 storage 结构 |
| 4 次并发 MiniMax 调用部分失败 | 返回 < 4 张图, 用户看到缺图 | `ImageGeneration` 收集成功 URL + warnings, 不阻断; 前端按"实际返回张数"展示, 缺失位置显示占位 |
| B 链路用户期望看 "world 标签" | UI 未做 | 复用 A 链路 §5.1 方案, "选择结果"页加标签 |
---
## 5C. 后台直连写库约定(给外部 Admin 团队看)
> 本节是对接 `TopFans-activity-admin` 团队的**接口契约**。Admin 团队直接操作 `laser_world_templates` 表,**不走 Go 业务层**,所有数据校验由 DB 约束 + 约定的"软删除/序列同步"规范保证。
### 约定 1软删除
- 删除一律走 `UPDATE laser_world_templates SET deleted_at = EXTRACT(EPOCH FROM NOW())::BIGINT WHERE id = $1`
- **禁止** `DELETE FROM laser_world_templates`
- Go 侧读取 API `WHERE deleted_at IS NULL`软删后 C 端立即不可见
### 约定 2序列同步项目 CLAUDE.md 强制规则)
- 任何手动 `INSERT ... VALUES (id, ...)` 必须末尾跟一句 `SELECT setval(...)`
```sql
SELECT setval('laser_world_templates_id_seq', (SELECT MAX(id) FROM laser_world_templates));
```
- 通过 Admin 正常表单新增不指定 id PG 自增不用手动 setval
### 约定 3唯一性
- `code` 唯一DB UNIQUE 约束约束名 `uq_laser_world_templates_code`
- `code` 不允许重复 UPDATE INSERT
### 约定 4必填字段
- `code` / `display_name` / `display_zh` / `inspiration_pool` / `hard_control` / `negative` 都不能为空
- `inspiration_pool` 至少 2 个元素
- `weight` 范围 [0, 100]0 表示禁用
- 详见 `sanitizeTemplate` 函数[inspiration_pool.go](backend/services/assetService/service/inspiration_pool.go)如果数据不合规Go 侧会返回 500 错误
### 约定 5weight 语义
- weight 表示**单次抽卡时被选中的概率权重**不放回加权采样
- 默认 3 模板 weight=[2,1,1] 期望产出 [2,1,1]实际可能 [3,1,0] / [2,0,2]
- weight=0 永不选中
- weight=1 最低有效权重
### 约定 6sort_order
- 越小越靠前允许重复重复时按 id 兜底
- Admin UI 建议提供"拖拽排序""上移/下移"按钮
### 约定 7enabled vs deleted_at
- `enabled=false` 临时下线运营可快速切换回来
- `deleted_at IS NOT NULL` 软删除不再显示未来如需彻底清理可手动物理 DELETE但当前业务没必要
- 大多数情况用 `enabled=false` 即可
---
## 6. 废弃清单
| 资产 | 处置 |
|------|------|
| [frontend/utils/laser-card/stylePool.js](frontend/utils/laser-card/stylePool.js)45 style 节点 | 文件头部加 `@deprecated` 注释**保留代码不删**给可能的回滚留口子 |
| [frontend/utils/laser-card/gacha.js](frontend/utils/laser-card/gacha.js) | 同上 |
| [frontend/utils/laser-card/laserPresets.js](frontend/utils/laser-card/laserPresets.js) | 检查是否还有引用如无则头部加 deprecated 注释 |
| `useLaserDifyGenerate.resolveRenderConfigs` "4 份相同 prompt"逻辑 | **删除**已被新实现替代 |
| `useLaserDifyGenerate` `translateToEnglish` 调用 | **移除前端调用**翻译已移至后端 `BuildPrompts` service, §2.9 |
| `laser_prompt.go` `BuildBgPrompt` / `BuildOverlayPrompt` / `bgPrefix` / `overlayPrefix` | **保留不删**服务于"材料池"老模式 |
| `laser_prompt.go` `BuildInspirationPrompt` | **新增** |
| **`backend/gateway/dto/image_dto.go`** `ImageGenerationRequest.Prompt` 字段 | **重命名为 BgPrompts []string**语义: " /build-prompts 组装好的 1-4 prompt"JSON tag `prompt` 改为 `bg_prompts`。**兼容期 1 **若收到 `prompt` 字段先 WARN 日志, §5B.8 风险表所述"前端未升级" |
| **`backend/gateway/dto/image_dto.go`** `ImageJobResponse` / `ImageJobCreateResponse` | **保留不删**预留异步任务结构, 本方案 B 链路仍走同步, 但未来扩展可能用 |
---
## 7. 数据流详图
### 7.1 Happy Path4+1 生成)
```
1. 用户点击"生成 5 张"
→ useLaserDifyGenerate.submit(cutoutUrl, null, "演唱会")
2. 前端 resolveRenderConfigs("演唱会")
→ POST /api/v1/laser/build-prompts (直接传原文 "演唱会", 不翻译)
→ 后端 BuildPrompts 检测到中文 → translateToEnglish("演唱会") → "concert"
→ buildInspirationPool("concert", seed=NOW)
├─ repo.ListEnabled(ctx) 直查 DB, 不缓存
├─ sanitizeTemplate 兜底校验
├─ weightedSample 抽 4 个不放回 (期望 2 Luxury + 1 Dreamlike + 1 Experimental)
├─ pickInspiration 每张图抽 2-3 个灵感词
└─ 返回 4 个差异化 prompt
3. 前端 submit 拿到 4 个 render_configs
→ POST /api/v1/laser/generate
→ body: { cutout_url, render_configs: [...4 个], user_prompt: "" }
→ 后端 handleOpenAIDirect 4 次并发调 openaiClient.EditImage
→ 每次: (cutout_url + 不同 prompt) → 中转站 /v1/images/edits → 1 张成品图
→ 4 张图存 OSS, 返回 variants
4. 前端 applySucceeded(payload)
→ variants = [...4 个 AI 图, 1 个原图(cutout_url)]
→ UI 渲染 5 张卡片 + 世界标签
```
### 7.2 后台改模板立即生效流
```
1. Admin 在 TopFans-activity-admin 前端改 Luxury 模板的 hard_control
→ Admin Python 后端直接执行:
UPDATE laser_world_templates
SET hard_control = '...新值...', updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
WHERE code = 'luxury_editorial'
2. (无任何 invalidate 步骤, Go 这边也不感知)
3. C 端用户点"生成 5 张"
→ 前端调 /api/v1/laser/build-prompts
→ 后端 repo.ListEnabled() 直查 DB, 读到的是新 hard_control
→ 立即使用新值生成 prompt
```
### 7.3 错误处理
| 阶段 | 失败 | 行为 |
|------|------|------|
| 翻译中文(后端) | Google API 失败 | 兜底用原文, 后端 `log.Warn` 记录, 继续流程; 前端无感知 |
| `/build-prompts` | 后端 5xx | 抛出, 整组失败, 前端弹 Toast 提示重试 |
| `/build-prompts` | 4xx 参数错 | 抛出, Toast 显示具体错误 |
| `/build-prompts` | 响应 prompts 数量 4 | 抛出, Toast "灵感池异常" |
| `/build-prompts` | DB 没有 enabled 模板 | 返回 500 "no enabled world templates in DB" |
| `/build-prompts` | sanitize 失败Admin 注入脏数据 | 返回 500 "template X invalid: Y" |
| `/generate` | 单张图失败 | 现有逻辑: warnings, 其他图继续, 仍有 1 AI + 1 原图 |
| `/generate` | 4 张全失败 | 现有逻辑: 整组 failed, Toast |
---
## 8. 风险与缓解
| 风险 | 影响 | 缓解 |
|------|------|------|
| 灵感词抽完仍不够 2-3 | 抽不到 n 个时 | `pickInspiration` 兜底从全量池抽不再去重 |
| 固定 2/1/1 分配导致连续两次抽到相似组合 | 用户感觉单调 | 每次都打乱顺序, 灵感词每次随机抽, 体验足够差异化 |
| Admin 注入脏数据 | Go 500 报错 | sanitizeTemplate 兜底校验, 错误信息明确指出哪个模板哪个字段 |
| DB 暂时无模板Admin 还没建好 | C 500 | 返回明确错误 "no enabled world templates in DB (need at least 1)" |
| Admin 改完模板后, C 端没生效 | 用户困惑 | 不缓存 Admin 改完下次请求立即生效, 无需任何操作 |
| 前端要发 2 次请求 | 1 RTT | build-prompts 后端组装 < 10ms(直查 DB), 总开销可忽略 |
| stylePool/gacha 调用方未发现 | 其他模块可能依赖 | 保留代码 + deprecated 注释, 走代码审查发现 |
| **中转站 4 次调用 4 张几乎相同的图** | **同质化(本方案要解决的问题)** | **4 份 prompt 差异化, 期望产出 4 张视觉差异明显的图** |
---
## 9. 测试策略
### 9.1 单元测试
```go
// backend/services/assetService/service/inspiration_pool_test.go
// 准备 mock repo(用 go-sqlmock)
func newTestRepo(t *testing.T) (*repository.WorldTemplateRepository, sqlmock.Sqlmock) {
db, mock, _ := sqlmock.New()
mock.ExpectQuery("SELECT id, code").WillReturnRows(
sqlmock.NewRows([]string{"id", "code", "display_name", "display_zh", "weight",
"inspiration_pool", "hard_control", "negative", "enabled", "sort_order"}).
AddRow(1, "luxury_editorial", "Luxury Editorial", "奢侈大片", 2,
[]byte(`["a","b","c"]`), "hard1", "neg1", true, 1).
AddRow(2, "dreamlike_aurora", "Dreamlike Aurora", "梦幻极光", 1,
[]byte(`["d","e","f"]`), "hard2", "neg2", true, 2).
AddRow(3, "experimental_light", "Experimental Light", "灯光实验", 1,
[]byte(`["g","h","i"]`), "hard3", "neg3", true, 3),
)
return repository.NewWorldTemplateRepository(db), mock
}
func TestBuildInspirationPool_Distribution(t *testing.T) {
repo, _ := newTestRepo(t)
// 跑 1000 次, weight=[2,1,1] 期望平均分布 [2,1,1]
counts := map[string]int{}
for i := 0; i < 1000; i++ {
result, err := buildInspirationPool(context.Background(), repo, "", 4, int64(i))
assert.NoError(t, err)
assert.Equal(t, 4, len(result.Entries))
for code, c := range result.WorldDistribution {
counts[code] += c
}
}
assert.InDelta(t, 2500, counts["luxury_editorial"], 200)
assert.InDelta(t, 750, counts["dreamlike_aurora"], 150)
assert.InDelta(t, 750, counts["experimental_light"], 150)
}
func TestBuildInspirationPool_PromptsAreDifferent(t *testing.T) {
repo, _ := newTestRepo(t)
result, _ := buildInspirationPool(context.Background(), repo, "", 4, 12345)
// 4 份 prompt 必须互不相同
seen := map[string]bool{}
for _, e := range result.Entries {
assert.False(t, seen[e.BgPrompt], "prompt 重复: %s", e.BgPrompt)
seen[e.BgPrompt] = true
}
}
func TestBuildInspirationPool_SeedReproducibility(t *testing.T) {
repo, _ := newTestRepo(t)
r1, _ := buildInspirationPool(context.Background(), repo, "concert", 4, 12345)
r2, _ := buildInspirationPool(context.Background(), repo, "concert", 4, 12345)
assert.Equal(t, r1.Entries[0].BgPrompt, r2.Entries[0].BgPrompt)
assert.Equal(t, r1.Entries[0].World, r2.Entries[0].World)
}
func TestBuildInspirationPool_InspirationUniqueness(t *testing.T) {
repo, _ := newTestRepo(t)
result, _ := buildInspirationPool(context.Background(), repo, "", 4, 99999)
seen := make(map[string]bool)
for _, e := range result.Entries {
for _, w := range e.InspirationWords {
assert.False(t, seen[w], "灵感词 %s 在多张图中重复", w)
seen[w] = true
}
}
}
func TestSanitizeTemplate_Empty(t *testing.T) {
t1 := WorldTemplate{Code: "", DisplayZh: "x", InspirationPool: []string{"a", "b"}, HardControl: "x", Negative: "x"}
assert.Error(t, sanitizeTemplate(&t1))
}
func TestSanitizeTemplate_TooFewInspiration(t *testing.T) {
t1 := WorldTemplate{Code: "x", DisplayZh: "x", InspirationPool: []string{"only one"}, HardControl: "x", Negative: "x"}
assert.Error(t, sanitizeTemplate(&t1))
}
func TestSanitizeTemplate_NegativeWeight(t *testing.T) {
t1 := WorldTemplate{Code: "x", DisplayZh: "x", Weight: -1, InspirationPool: []string{"a", "b"}, HardControl: "x", Negative: "x"}
assert.Error(t, sanitizeTemplate(&t1))
}
func TestBuildInspirationPrompt_UserPromptInjection(t *testing.T) {
tpl := WorldTemplate{
Code: "luxury_editorial",
DisplayZh: "奢侈大片",
HardControl: "Transform the uploaded image into a premium holographic editorial artwork.",
Negative: "No borders. No typography. No watermark.",
}
prompt := BuildInspirationPrompt(tpl, []string{"luxury fashion"}, "concert")
assert.Contains(t, prompt, "concert")
assert.Contains(t, prompt, "User-provided theme to weave naturally")
assert.Contains(t, prompt, "No borders")
assert.Contains(t, prompt, "luxury fashion")
}
```
### 9.2 集成测试
```go
// 1. 启动后端 + 模拟前端请求
// 2. POST /api/v1/laser/build-prompts
// - 空 user_prompt → 验证 4 个 prompt 全部走"中性"模板
// - 中文 user_prompt → 验证后端 BuildPrompts 自动翻译为英文, prompt 内不含中文字符
// 3. 用 4 个 prompt 调 POST /api/v1/laser/generate
// 4. 验证: 4 张 AI 图均落 OSS + 1 张 cutout_url 原图
// 5. 验证: 4 张图的 prompt 文本互不相同(种子固定时可复现)
// 6. 验证: Admin 直连 PG 改模板后, 重新调 /build-prompts 立即使用新内容
// - 步骤: 先调一次拿到 prompt A → 直接 SQL UPDATE 改 hard_control →
// 再调一次拿到 prompt B → 验证 A.hard_control != B.hard_control
// 7. 验证: 软删除(设 deleted_at)后, /build-prompts 不返回该模板
// 8. 验证: enabled=false 后, /build-prompts 不返回该模板
```
### 9.3 前端测试
```js
// composables/__tests__/useLaserDifyGenerate.test.js
import { useLaserDifyGenerate } from '../useLaserDifyGenerate.js'
describe('resolveRenderConfigs', () => {
it('4 个 prompt 互不相同', async () => {
const { resolveRenderConfigs } = useLaserDifyGenerate()
const configs = await resolveRenderConfigs('concert')
const prompts = configs.map(c => c.bg_prompt)
expect(new Set(prompts).size).toBe(4)
})
it('4 张图的 world 字段不同', async () => {
const { resolveRenderConfigs } = useLaserDifyGenerate()
const configs = await resolveRenderConfigs('concert')
const worlds = configs.map(c => c.world)
expect(new Set(worlds).size).toBeGreaterThanOrEqual(2)
})
it('空字符串也返回 4 个', async () => {
const configs = await resolveRenderConfigs('')
expect(configs).toHaveLength(4)
})
})
```
### 9.4 人工验收
- [ ] 不输入描述, 生成 5 张: 4 AI 图视觉差异明显, 来自 3 个不同世界观
- [ ] 输入"演唱会"中文, 生成 5 张: 4 张图都能看到"演唱会"元素的自然融入, 但风格仍分 3 个世界
- [ ] 输入具体描述如"紫色为主的高级感", 生成 5 张: 4 张图都有紫色调, 但风格仍差异化
- [ ] 连续生成 3 次: 每次 4 张图的世界观分配可能不同(2/1/1 是期望, 但顺序和灵感词每次变化)
- [ ] 用固定 seed build-prompts: 相同 seed 产生相同结果(QA 复现)
- [ ] **Admin 在 TopFans-activity-admin 改模板后, C 端立即生效**: 改完 < 1 秒下次生图就使用新值
- [ ] **Admin 软删除模板后, C 端不再出现该世界观**
- [ ] **Admin 把模板 enabled=false 后, C 端权重采样跳过该模板**
---
## 10. 实施步骤(高阶)
按依赖顺序, 每步可独立提交:
| 步骤 | 范围 | 验收 | 风险 |
|------|------|------|------|
| 1 | **DB 迁移**: `2026_06_25_001_laser_world_templates.sql` | `\d laser_world_templates` 显示表结构 + 3 条种子数据 + setval 正确 | |
| 2 | **后端 Repo**: 新建 `world_template_repository.go` + 单测 | `go test ./repository/...` 通过 | |
| 3 | **后端 Service**: 新建 `inspiration_pool.go` ( buildInspirationPool / weightedSample / BuildInspirationPrompt / sanitize) + 单测 | 8 个单测全过 | |
| 4 | **后端 DTO + C 端 Controller**: 新建 `inspiration_dto.go` + `laser_build_prompts_controller.go` | `curl POST /api/v1/laser/build-prompts` 返回 4 个差异化 prompt | |
| 5 | **后端 Router**: `router.go` 注册 1 个新路由 + Swagger 注释 | `swag init` 通过 | 极低 |
| 6 | **前端 C 端**: 改造 `useLaserDifyGenerate.resolveRenderConfigs` + `presetWorldMap` 暴露 | 前端联调能拿到 4 个差异化 prompt + world 标签 | |
| 7 | **前端 C 端 UI**: 4 张卡片上显示世界标签 | UI 渲染符合预期 | |
| 8 | **集成**: 端到端测试 | 5 张图能正常生成 + 4 张图视觉差异明显 | |
| 9 | **集成**: Admin 改模板后 C 端立即生效(直连 SQL) | Luxury 模板的 hard_control 立即生效(不缓存) | |
| 10 | **集成**: 软删除 / enabled=false 行为正确 | 软删除后 /build-prompts 不返回该模板 | |
| 11 | **废弃标记**: stylePool.js / gacha.js @deprecated | git diff 显示注释变更 | 极低 |
| 12 | **文档**: 更新 CLAUDE.md / 内部 wiki | 文档已同步 | 极低 |
**对接团队(非本仓库)工作**:
- `TopFans-activity-admin` 团队在他们的 Python 后端 + Vue 前端加 `laser_world_templates` CRUD UI
- 不在本仓库实施步骤内, 但应同步告知(由项目 owner 决定)
- 详见 §5C. 后台直连写库约定
---
## 11. 开放问题(暂不决策, 后续可加)
1. **A/B 实验框架** 当前 `laser_world_templates` 顶层未加 version 字段, 未来想 A/B 不同 prompt 风格时再加
2. **用户偏好加权** 是否让用户选"高级感 / 未来感 / 梦幻感"做加权? (当前不做, 简单优先)
3. **重抽单张** 是否支持"重抽第 3 "? (当前不支持, YAGNI)
4. **连续动画** 5 张图是否要错峰显示? (当前阻塞一次性返回, 简单优先)
5. **跨语言 prompt** 后端模板是否要做中英双语? (当前英文, AI 生图模型对齐)
---
## 11A. A 链路中转站高并发分析与防护
> **范围声明**: 本节是 A 链路 (`laser_generate_controller.go#handleOpenAIDirect` → `openai_client.go`) 在调用中转站时的高并发风险分析与防护建议。**不属于本次灵感池改造的实施范围**, 仅作为 A 链路未来高可用重构的参考基线。
>
> **关联代码**:
> - [backend/gateway/controller/laser_generate_controller.go](backend/gateway/controller/laser_generate_controller.go) `handleOpenAIDirect` (L397-528)
> - [backend/gateway/service/openai_client.go](backend/gateway/service/openai_client.go) 完整文件
> - [backend/gateway/service/oss_helper.go](backend/gateway/service/oss_helper.go) OSS 落盘 + 签名 URL
### 11A.1 高并发风险全景
| # | 风险 | 严重度 | 触发条件 | 现象 |
|---|------|--------|---------|------|
| R1 | Goroutine 堆积 / OOM | 🔴 致命 | 中转站故障 5min+ | 4000 goroutine 各卡 360s gateway OOM |
| R2 | 中转站 429 限流 | 🟠 | 100+ 并发用户 | 4× 放大触发限流 |
| R3 | HTTP 连接数爆 | 🟠 | 50+ 并发 | 默认 MaxIdleConnsPerHost=2 |
| R4 | 中转站 5xx | 🟡 | 偶发 | warning, 不重试 |
| R5 | 中转站返回错误数据 | 🟡 | 中转站 bug | 直接 error |
| R6 | 部分成功体验不一致 | 🟡 | 中转站抖动 | 4 张里随机少 1-3 |
| R7 | OSS 上传失败 | 🟢 | OSS 抽风 | warning |
| R8 | 签名 URL 失败 | 🟢 | OSS 抽风 | warning |
### 11A.2 现有兜底分析
| 兜底机制 | 状态 | 评价 |
|---------|------|------|
| HTTP 超时 (360s) | | 太长, 反作用 |
| Context 透传 | | OK |
| 4 路独立错误 | | OK |
| 部分失败 warnings | | OK |
| 全失败 500 | | OK |
| 重试 / 熔断 / 限流 / 降级 / 错误分类 / Backoff / 连接池限制 | 均缺 | 建议 P0 优先补 |
### 11A.3 建议措施与优先级
| 优先级 | 措施 | 工作量 | 预期收益 |
|-------|------|-------|---------|
| **P0-1** | HTTP timeout 360s 90s | 1 | 减少 OOM 风险 80% |
| **P0-2** | per-host 连接池 (MaxIdleConnsPerHost=50) | 5 | 减少新建连接 95% |
| **P0-3** | 全局 semaphore (最多 200 并发) | 10 | 防止单点过载 |
| **P1-1** | 错误分类 + 1 retry + 退避 | 30 | 提升成功率 30%+ |
| **P1-2** | Circuit breaker (连续 5 次失败熔断 30s) | 50 | 防止故障放大 |
| **P1-3** | 全失败降级到 cutout 原图 | 20 | 全失败时仍有图 |
| **P2-1** | middleware 限流 ( user_id) | 50 | 限恶意用户 |
| **P2-2** | 监控告警 (QPS/失败率/P99) | 1 | 提前发现 |
| **P2-3** | 灰度发布 | 1 | 安全上线 |
| **P2-4** | Async job | 2 | 根本解决长连接 |
> **完整设计**(含代码示例、调用路径图、熔断/重试/降级实现细节)已移入独立文档 [docs/superpowers/specs/2026-06-25-laser-card-concurrency-analysis.md](docs/superpowers/specs/2026-06-25-laser-card-concurrency-analysis.md),原约 300 行详析内容已从本文档裁剪。
### 11A.4 与本文档其他章节的关系
| 章节 | 关系 |
|------|------|
| §1-§10 (灵感池改造) | **独立**, 本节不阻塞灵感池改造 |
| §5B (B 链路) | 继承本节所有风险 (但不引入新风险) |
| §11 (开放问题) | 衔接: P0/P1 措施**可以**作为"暂不决策, 后续可加" |
| §12 (变更影响面) | 本节**不属于**本次灵感池实施, 不增加新行 |
| §13 (总结) | 不变 |
**结论**: 本次灵感池改造**不实施**本节任何措施P0 措施 (timeout/连接池/semaphore) 建议在灵感池上线后 1 周内由独立任务优先实施
---
## 12. 变更影响面
### A 链路(镭射卡 4+1
| 模块 | 影响 |
|------|------|
| **新增** `laser_world_templates` | 新建migration 脚本 |
| **新增** `services/assetService/repository/world_template_repository.go` | 新建DB 访问层 |
| **新增** `services/assetService/service/inspiration_pool.go` | 新建核心服务 DB + 权重采样 + sanitize + prompt 组装 |
| **新增** `gateway/dto/inspiration_dto.go` | 新建C DTO |
| **新增** `gateway/controller/laser_build_prompts_controller.go` | 新建C /build-prompts 入口 |
| `useLaserDifyGenerate.js` | 改造, 必修resolveRenderConfigs /build-prompts |
| `stylePool.js` / `gacha.js` | @deprecated 注释, 不删 |
| `laserPresets.js` | 检查是否还有引用, 无引用则加 @deprecated |
| `laser_prompt.go` | 新增 `BuildInspirationPrompt`, 不改现有函数 |
| `laser_generate_controller.go` | **零改动** |
| `openai_client.go` | **零改动** |
| `router.go` | 新增 1 行路由注册A 链路部分 |
| **对接团队(非本仓库)**: `TopFans-activity-admin` 团队的 Python + Vue | **不在本仓库范围**; 详见 §5C. 后台直连写库约定 |
| 第三方 API 依赖 | **零增加**不调 GPT, 全靠 DB 模板 + 权重采样 |
### B 链路(资产图生图)— 详见 §5B
| 模块 | 影响 |
|------|------|
| `gateway/controller/asset_controller.go` `BuildImagePrompts` | **新增方法**复用 A 链路 `InspirationPoolService` |
| `gateway/controller/asset_controller.go` `ImageGeneration` | **重构**`Prompt` 必填校验改为 `BgPrompts` 必填, 业务逻辑保持调 `minimaxService.GenerateImage` |
| `gateway/dto/image_dto.go` `ImageGenerationRequest` | **字段重命名**`Prompt` `BgPrompts []string`JSON tag 同步改 `bg_prompt` `bg_prompts` |
| `router.go` | **新增 1 行**`assets.POST("/mints/image/build-prompts", assetCtrl.BuildImagePrompts)` |
| `frontend/utils/api.js` | **新增** `imageBuildPromptsApi` 函数 |
| `frontend/pages/discover/generation-loading.vue` `callImageGeneration` | **重构**先调 `/build-prompts` bg_prompt, 再调 `/generation` |
| `frontend/utils/castloveGenerationFlow.js` `startAiImageGenerationFlow` | **可能微调**`GENERATION_REQUEST_KEY` 存储字段对齐 `user_prompt`确认后小改 |
| `gateway/service/minimax_client.go` `GenerateImageWithSubject` | **零改动**继续当 B 链路后端 |
| `laser_world_templates` | **复用** A 链路的表, 不新建 |
| 第三方 API 依赖 | **零增加**B 链路继续用 MiniMax, 不引入新调用 |
---
## 13. 总结
本方案核心是** 4 份相同 prompt 升级为 4 份差异化 prompt**, 通过
1. **DB 可配置** 3 个世界观模板存到 `laser_world_templates` 外部 Admin (`TopFans-activity-admin`) 增删改
2. **不缓存** 每次直查 DB运营改完**立即生效**
3. **sanitize 兜底** 读出后做最低限度的字段校验 Admin 注入脏数据
4. **权重采样** `weightedSampleWithoutReplacement` weight 概率抽 4 个模板不放回
5. **灵感词去重** 已用灵感词不在后续图中重复
6. **后端组装 + 前端透传** 模板在后端C 端零感知
**真实生成链路不变**
- 中转站 `/v1/images/edits` 4 次并发调用
- `gpt-image-2` 仍直接返回 4 张成品图
- 不引入任何新合成/叠加/分层逻辑
**架构上** 1 个新 C 端接口 + 1 个新 DB + 1 个新 service**业务上** 4 张图视觉差异显著**代码上** 现有 /generate 流程零改动**资源上** LLM 增量成本**降级上** stylePool / gacha 保留可回滚**协作上** Admin 工作由外部团队按 §5C 约定执行**性能上** 不缓存不增加延迟反而让运营调试更顺畅