主要改动: 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>
1881 lines
83 KiB
Markdown
1881 lines
83 KiB
Markdown
# 镭射卡生图系统改造——灵感池(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 份完全相同
|
||
}
|
||
})
|
||
}
|
||
```
|
||
|
||
**问题 1:4 份 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 行简单 SELECT,QPS 低,运营改完立即生效更重要 |
|
||
| 读取后处理 | **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 Repository(DB 访问层)
|
||
|
||
```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` service,B 链路**默认生成 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 错误
|
||
|
||
### 约定 5:weight 语义
|
||
|
||
- weight 表示**单次抽卡时被选中的概率权重**(不放回加权采样)
|
||
- 默认 3 模板 weight=[2,1,1] → 期望产出 [2,1,1],实际可能 [3,1,0] / [2,0,2] 等
|
||
- weight=0 → 永不选中
|
||
- weight=1 → 最低有效权重
|
||
|
||
### 约定 6:sort_order
|
||
|
||
- 越小越靠前;允许重复(重复时按 id 兜底)
|
||
- Admin UI 建议提供"拖拽排序"或"上移/下移"按钮
|
||
|
||
### 约定 7:enabled 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 Path(4+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 约定执行,**性能上** 不缓存不增加延迟反而让运营调试更顺畅。
|