diff --git a/CLAUDE.md b/CLAUDE.md index 12f7e8e..f268467 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,63 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. --- +## 前端开发规范(uniapp + vue3 · app 端) + +### 技术栈基线 + +- **框架**:UniApp 3.x + **Vue 3 组合式 API**(`vueVersion: "3"`,`@vue/compiler-sfc ^3.5`),不要再写 Vue 2 Options API 或混用 `this` +- **状态管理**:Vuex 4(`store/index.js` + `store/modules/*`),跨页面状态走 Vuex,组件临时状态用 `ref` / `reactive` +- **复用逻辑**:放进 `composables/useXxx.js`(已有 `useHolographicPreview` / `useDashboardData` / `useLenticularStudioTilt` 等),**禁止**把可复用的逻辑写在单文件组件里 +- **目标平台**:以 **app-plus(Android + iOS)为主**,H5 / 微信小程序等其他端仅在显式 `#ifdef` 支持时才能用 +- **原生能力**:`plus.*`(陀螺仪、角标、Intent 跳转等)、UniPush、设备指纹、Socket 全都走 `utils/` 下专用封装,**不要**在组件里直接 `plus.*` + +### 关键约定 + +1. **条件编译是硬约束**——涉及原生 API / 原生插件 / 平台差异代码必须包在 `// #ifdef APP-PLUS … // #endif`(或 `MP-WEIXIN` / `H5` 等): + ```js + // 正确 + // #ifdef APP-PLUS + plus.runtime.setBadgeNumber(0) + // #endif + + // 错误(plus 在非 app 端是 undefined,会直接报错) + plus.runtime.setBadgeNumber(0) + ``` + **禁止**用 `if (typeof plus !== 'undefined')` 之类兜底代替条件编译。 + +2. **API 调用统一封装**——所有后端接口走 `utils/api.js`(设备注册、WebSocket token 上报等),组件层只调封装函数,**禁止**在 `.vue` 里直接 `uni.request`。 + +3. **路由与页面注册**——新页面**先在 [frontend/pages.json](frontend/pages.json) 注册再写 `.vue` 文件**;Tab 页面用 `uni.switchTab`,普通跳转用 `navigateTo`,**禁止**用 `redirectTo` 替代 `navigateBack` 制造假"返回"。 + +4. **权限申请必须给出口**——通知 / 相机 / 相册 / 定位等敏感权限,授权失败时必须给"去设置"按钮并调用系统设置页(参照 `App.vue#setPermissions` 的 Android Intent / iOS `app-settings:` 范式),**禁止**静默失败。 + +5. **性能基线**——长列表使用现有 [components/VirtualList.vue](frontend/components/VirtualList.vue),图片用 [components/LazyImage.vue](frontend/components/LazyImage.vue);自定义字体已知坑:部分 Android WebView 对部分 `.ttf` 会报 OTS / cmap 解析失败(如 `JDLTYuanTiJian.ttf`),新增字体前先在小内存 Android 机上验证。 + +6. **资源与产物隔离**——`unpackage/dist/*` 是编译产物,**禁止**手动修改;图标准备物放在 `unpackage/res/icons/`,源码改动放 `static/`。 + +### 禁止的反模式 + +- ❌ 在 Vue 3 项目里混用 `export default { data() { return {} } }` Options API +- ❌ 在非 `APP-PLUS` 分支直接调 `plus.*` / 原生插件 API +- ❌ 在组件里直接 `uni.request` / `uni.connectSocket`,绕过 `utils/api.js` +- ❌ 跨页面状态用 props 层层下传 / `getApp().globalData` 散落——必须走 Vuex +- ❌ 新增页面不写 `pages.json` 就提交 +- ❌ 权限被拒后只 `console.warn` 不引导用户去开启 +- ❌ 手动编辑 `unpackage/dist/` 下的任何文件 + +### 完成自检(提交前过一遍) + +- [ ] 新组件用 ` + + +``` + +### 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 约定执行,**性能上** 不缓存不增加延迟反而让运营调试更顺畅。 diff --git a/docs/superpowers/specs/2026-06-26-lenticular-webgl-engine-design.md b/docs/superpowers/specs/2026-06-26-lenticular-webgl-engine-design.md new file mode 100644 index 0000000..264f1af --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-lenticular-webgl-engine-design.md @@ -0,0 +1,472 @@ +# 光栅卡 WebGL 引擎整合设计 + +> **创建日期:** 2026-06-26 +> **项目:** TopFans 星卡 · 光栅卡渲染升级 +> **状态:** 设计审核中 +> **版本:** v1.0 + +--- + +## 一、背景与目标 + +### 1.1 现状 + +光栅卡目前采用**CSS DOM 渲染路径**:`LenticularEngine`(纯 JS)计算每层权重/偏移,`LenticularCard.vue` 通过 `v-for` 渲染 N 个 `` + `` 标签,用 `translate3d` + `opacity` 模拟柱镜光栅的视角切换效果。 + +镭射卡已有独立的 `HolographicCard.vue` + `HolographicEngine`(WebGL),但**该组件是做单图全息光效的,不解决多图条纹交织问题**。 + +### 1.2 问题 + +| 问题 | 说明 | +|---|---| +| 无真正的条纹级像素交织 | 当前是叠化(opacity crossfade),不是物理柱镜的"逐像素切换" | +| N>2 时性能线性衰减 | 每多一层就多一个 `` 合成层 | +| 两套引擎职责混淆 | HolographicEngine 被误认为"光栅的 WebGL 方案",实际它只处理单图全息 | + +### 1.3 目标 + +- 用 WebGL 实现真正的条纹级像素交织 +- 保留现有 `LenticularEngine` 的成熟权重算法和陀螺仪稳定逻辑 +- 不破坏现有 CSS DOM 降级路径 +- 代码组织与镭射卡 `utils/laser-card/` 对仗 + +--- + +## 二、视觉目标(Before / After) + +### 现在:CSS opacity 叠化 + +倾斜手机时,用户看到的是两张图以不同透明度叠加: + +``` + 倾斜 0° 倾斜 -15° 倾斜 +15° + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ A 图 100% │ │ A 图 60% │ │ A 图 0% │ + │ B 图 0% │ → │ B 图 40% │ → │ B 图 100% │ + │ │ │ (半透叠化) │ │ │ + └──────────────┘ └──────────────┘ └──────────────┘ + 效果:A 渐隐、B 渐显,全程两张图同时存在,类似"交叉淡入淡出" + 问题:没有"一张图上、一张图下"的切换感 +``` + +### 改后:WebGL 条纹级像素交织 + +每个像素只来自一张图,相邻像素交替采样 A/B: + +``` + 倾斜 0° 倾斜 -15° 倾斜 +15° + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │ + │ A│B│A│B│A│B │ → │ A│A│B│B│A│A │ → │ B│A│B│A│B│A │ + │ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │ + └──────────────┘ └──────────────┘ └──────────────┘ + 每个格子 = 1 个像素 + 效果:倾斜时 A 图区域缩小、B 图区域扩大,条纹边界非此即彼 + 视觉:类似真实物理柱镜片的"啪一下亮出来" +``` + +### 一句话总结 + +> **从"两张图互相半透叠化"变为"逐像素条纹交替",视觉上从"数字感"变为"物理柱镜卡片的质感"。** + +--- + +## 三、关键决策 + +| 决策 | 选项 | 结论 | +|---|---|---| +| 图层数 | A) 2 层 / B) 3 层 / C) 动态 | **A) 2 层**(背景 + 主体) | +| WebGL 降级 | A) CSS fallback / B) 静态图 / C) 不做 | **A) CSS DOM fallback** | +| 技术路线 | A) 新引擎 / B) 扩展现有 / C) PixiJS | **A) 新 LenticularWebGLEngine** | +| Composable 职责 | 仅数据逻辑,不碰 WebGL 生命周期 | 与组件现有模式一致 | +| 目录层级 | 与镭射卡 `utils/laser-card/` 对仗 | `utils/lenticular-card/` | + +--- + +## 四、整体架构 + +``` + ┌───────────────────────┐ + │ useLenticularStudio- │ ← 不改 + │ Tilt.js (陀螺仪采集) │ + └──────────┬────────────┘ + │ gamma/beta + ┌──────────▼────────────┐ + │ LenticularEngine │ ← 追加 engineUniforms 汇出 + │ (视角→权重/偏移动画) │ 不破坏原输出 + └──────────┬────────────┘ + │ parallax, phase, density + ┌──────────▼────────────┐ + │ useLenticularPreview │ ← 追加 engineUniforms 返回值 + │ (composable) │ + └──────────┬────────────┘ + │ + ┌────────────────────┴────────────────────┐ + │ WebGL 可用 │ CSS 不可用 + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────┐ +│ LenticularWebGLEngine│ │ (现有 CSS DOM 路径) │ +│ (新增,组件内部管理) │ │ layerTransforms → │ +│ 8 个 uniforms → │ │ translate3d+opacity │ +│ 1 draw call │ │ │ +└──────────┬───────────┘ └──────────────────────┘ + │ + ▼ + Fragment Shader 管线: + ① UV parallax 偏移 + ② stripe 条纹交织 (mix textureA/B) + ③ 输出修饰 (圆角/暗角/edgeAA) +``` + +### 4.1 模块职责 + +| 模块 | 职责 | 改否 | +|---|---|---| +| `useLenticularStudioTilt.js` | 陀螺仪采集 + 跳变拒绝 + EMA 平滑 | 不改 | +| `LenticularEngine` | 视角→权重映射,stripShares,EMA 平滑 | 追加 `engineUniforms` 汇出,不破坏原输出 | + +`engineUniforms` 格式: +```js +{ + parallax: float, // -1~1,视差偏移量 + phase: float, // 0~1,条纹相位(由 displayGamma 映射) + density: float, // ≈0.08~0.3,条纹密度(由 lenticularPitchPx 算出) +} +``` + +| `useLenticularPreview` | 管理 rAF 循环,暴露 layerTransforms + engineUniforms | 追加 engineUniforms 返回值 | +| `LenticularWebGLEngine` | **新增**。WebGL init/compile/resize/rAF/destroy | — | +| `LenticularCard.vue` | 渲染入口,自选 WebGL/CSS 路径 | 重写 template,保留 fallback | + +--- + +## 五、完整调用链路(从陀螺仪到屏幕像素) + +以下是一个倾斜动作从上到下经过的全部环节: + +``` +用户手指在卡面上拖动 / 手机倾斜 + │ + ▼ +[硬件层] + deviceorientation 事件 (陀螺仪) 或 touchstart/touchmove (拖动) + │ 原始角度/坐标 + ▼ +[useLenticularStudioTilt.js] ← 不改 + 跳变拒绝:abs(raw - prev) > 15° → clamp(prev ± 15°) + EMA 平滑 (快通道 α=0.25, 慢通道 α=0.08) + delta 死区:abs < 0.5° → 0 + │ 输出: { gamma: -1~1, beta: 0 } + ▼ +[LenticularEngine.updateDisplayStable()] + sensorDeadzoneStrength → softAttenuateNearZero + │ 输出: displayGamma: -1~1 + ▼ +[LenticularEngine.computeRenderState()] + 入参: displayGamma, physics (sensitivity, smoothness, depth, pitch 等) + │ + ├─ 计算 stripShares(N=2 时各 0.5;N=3 时分 background/foreground/mid) + ├─ 计算 rawWeights(视角位置 u 落在各层覆盖区的距离) + ├─ smoothstep 平滑 → smoothedW + ├─ 找到 dominant 层 + ├─ prevLayerGhost + nonDominantResidual 保底 + ├─ 按 parallaxFactor 计算各层 offsetX(CSS 降级用) + │ + └─ 汇出 engineUniforms(新增部分,不破坏以上原有输出): + { + parallax: displayGamma × sensitivity × depth × 0.42, // -1~1 + phase: (displayGamma × 0.38 + 0.5) % 1, // 0~1 + density: 50 / lenticularPitchPx, // ≈3~6 + } + ▼ +[useLenticularPreview.tick()] ← rAF 循环(唯一驱动源) + │ 每帧 16ms 执行一次 + │ + ├─ [CSS 降级路径]: applyLayerTransformsFromRenderState() + │ → 更新 layerTransforms.value → Vue 响应式 → DOM re-render + │ + └─ [WebGL 路径]: 调用 registerOnTick 回调 + │ + ▼ + [LenticularWebGLEngine.setUniforms(uniforms)] + │ 设置 8 个 uniform: + │ u_parallax, u_phase, u_density ← 来自 engineUniforms + │ u_textureA, u_textureB ← 纹理绑定 + │ u_cornerRadius, u_resolution, u_dpr ← 来自组件 props + canvas + ▼ + [LenticularWebGLEngine.draw()] + │ + ├─ gl.useProgram(program) ← 编译好的 shader + ├─ gl.activeTexture(GL.TEXTURE0) ← 绑定纹理 A + ├─ gl.activeTexture(GL.TEXTURE1) ← 绑定纹理 B + ├─ gl.uniform*(loc, value) × 8 ← 写入 uniform(含 vec2) + ├─ gl.bindBuffer + gl.vertexAttribPointer + └─ gl.drawArrays(GL_TRIANGLES, 0, 6) ← 1 个 draw call + │ + ▼ + [GPU Fragment Shader 并行执行] + 管线 3 阶段(见 §6.2): + ① UV parallax 偏移 + ② stripe 条纹交织 → mix(textureA, textureB, mask) + ③ 输出修饰 (圆角/暗角/edgeAA) + │ + ▼ + [屏幕像素] + Canvas 上的最终像素显示给用户 + 每帧 ~200 万像素(以 375×500 CSS 尺寸 × DPR=2 计) +``` + +### 关键时序 + +| 环节 | 耗时估计 | 说明 | +|---|---|---| +| 陀螺仪采集 + 平滑 | < 0.5ms | JS 纯算术 | +| computeRenderState() + engineUniforms 汇出 | < 0.3ms | 10 层循环 + 浮点运算 | +| setUniforms + draw | < 1ms | WebGL 调用开销 | +| Fragment Shader 执行 | < 2ms | GPU 并行,200 万像素 | +| 总计单帧 | < 4ms | 远低于 16ms 帧预算 | + +--- + +## 六、Fragment Shader 管线 + +### 6.1 Uniform 清单(精简) + +| uniform | 类型 | 来源 | 说明 | +|---|---|---|---| +| `u_textureA` | sampler2D | layer[0] 底图 | 背景纹理 | +| `u_textureB` | sampler2D | layer[1] 前景 | 人物纹理 | +| `u_parallax` | float | LenticularEngine | 视差偏移 -1~1 | +| `u_phase` | float | LenticularEngine | 条纹相位 0~1 | +| `u_density` | float | LenticularEngine | 条纹密度 ≈0.08~0.3 | +| `u_cornerRadius` | float | props | 圆角半径 | +| `u_resolution` | vec2 | canvas size | 分辨率 | +| `u_dpr` | float | devicePixelRatio | 像素比 | + +共 **8 个 uniform**,无全息/时间相关参数。 + +### 6.2 管线(3 阶段) + +#### ① UV 变换 + +```glsl +vec2 uv = v_texCoord; +uv.x += u_parallax * 0.03; +float scale = 1.0 + abs(u_parallax) * 0.015; +uv = (uv - 0.5) * scale + 0.5; +``` + +#### ② 条纹交织(核心,仅此一个像素级效果) + +```glsl +float stripe = fract(uv.x * u_density + u_phase); +float mask = smoothstep(0.45, 0.55, stripe); +vec4 baseColor = mix(texture2D(u_textureA, uv), + texture2D(u_textureB, uv), + mask); +``` + +**每个像素只来自一张图**,相邻像素交替采样 A/B。`u_density` 控制条纹密度(等效每毫米条纹数),`u_phase` 控制视角偏移时条纹组的左右滑动。这是整个引擎**唯一**的像素级视觉效果。 + +#### ③ 输出修饰 + +```glsl +// 圆角裁剪 +float sdf = roundedRectSDF(pn, halfRes - cornerRadius, cornerRadius); +if (sdf > 1.5) discard; + +// 暗角 +baseColor *= 1.0 - pow(clamp(length(pnNorm) * 1.1, 0, 1), 2.8) * 0.35; + +// 边缘抗锯齿 +float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf); +gl_FragColor = vec4(baseColor, cornerMask * edgeAA); +``` + +无全息效果、无噪声、无色散、无高光、无划痕、无珠光。 + +--- + +## 七、组件 API + +### 7.1 LenticularCard.vue + +```vue + +``` + +使用方不感知渲染路径差异。 + +**WebGL 可用性判断**(在 `onMounted` 中按序执行): +1. `webglPreferred` 为 `false` → 直接使用 CSS DOM 路径,不尝试 WebGL +2. `webglPreferred` 为 `true` → 尝试创建 WebGL1 上下文 +3. 创建成功 → 使用 WebGL 路径,发射 `@ready` 事件 +4. 创建失败 → 自动降级到 CSS DOM 路径,发射 `@error` 事件(组件内不抛白屏) + +**WebGL 初始化失败后重试**:不重试。一次失败代表该设备不支持 WebGL,重试无意义。 + +### 组件内部结构 + +```vue + +``` + +--- + +## 八、文件结构 + +### 新增 + +| # | 文件 | 职责 | +|---|---|---| +| 1 | `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC(约 80 行,无全息效果) | +| 2 | `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine` 类(8 uniforms,3 阶段管线) | + +### 修改 + +| # | 文件 | 改动 | +|---|---|---| +| 3 | `frontend/utils/lenticular-engine.js` → 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` | +| 4 | `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 到返回值 | +| 5 | `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 | + +### 不改 + +- `useLenticularStudioTilt.js` +- `HolographicCard.vue` / `HolographicEngine` / `holographic-shaders.js` +- 所有页面层(`lenticular-create.vue` / `lenticular-result.vue` / `lenticular-thinking.vue`) +- 所有镭射卡文件 + +### 目录对仗 + +``` +utils/laser-card/ utils/lenticular-card/ +├── laserGrating.js ├── lenticular-engine.js ← 迁入 +├── laserPreviewWebgl.js ├── lenticular-webgl-engine.js ← 新增 +├── laserBatchExport.js ├── lenticular-webgl-shaders.js ← 新增 +├── laserPresets.js └── ...(后续光栅工具) +├── gacha.js +├── stylePool.js +└── ... +``` + +--- + +## 九、实施步骤 + +### Phase 1 — 核心引擎 + +1. 建 `frontend/utils/lenticular-card/` 目录,将 `lenticular-engine.js` 迁入 +2. 编写 `lenticular-webgl-shaders.js`(VERT_SRC + FRAG_SRC,仅条纹交织 + 圆角暗角) +3. 编写 `lenticular-webgl-engine.js`(`LenticularWebGLEngine` 类,8 uniform 管理) +4. `lenticular-engine.js` 追加 `engineUniforms` 汇出 +5. `useLenticularPreview.js` 追加返回值 + +### Phase 2 — 组件集成 + +6. 重构 `LenticularCard.vue`:双路径 template + WebGL 引擎生命周期 +7. 联调:验证陀螺仪 → engineUniforms → uniform → stripe shader 响应 + +### Phase 3 — 回归 + +8. 验证 CSS DOM 降级(`webglPreferred=false`) +9. 验证 2 图层各场景(创建页拖动、结果页陀螺仪) +10. 低端安卓真机测试 + +--- + +## 十、DPR 与纹理管理策略 + +### 10.1 DPR 管理 + +- Canvas 物理分辨率 = CSS 尺寸 × `min(devicePixelRatio, 2)` +- Fragment shader 中 `u_dpr` 用于圆角 SDF 计算 +- resize 时重建 viewport + +### 10.2 纹理更新 + +当 `layers[].src` 变化时: +1. 加载新图片 → `engine.uploadTexture(index, image)` 更新对应纹理单元 +2. 纹理使用 `gl.LINEAR_MIPMAP_LINEAR` 采样,上传后生成 mipmap +3. 初始化或任一纹理未就绪时,已就绪的纹理正常显示,未就绪的通道采样纯黑色 +4. **shader 无需重新编译**——uniform 和纹理单元绑定不变,只换纹理数据 + +**纹理内存峰值**:2 张纹理 × `(CSS宽×DPR) × (CSS高×DPR) × 4 bytes`。若卡片 CSS 尺寸为 375×500、DPR=2,则单纹理约 1.5MB,合计约 3MB。在移动端可控。 + +### 10.3 生命周期 + +``` +组件 onMounted + 1. new LenticularWebGLEngine(canvas) + 2. engine.init(textureA, textureB) // layer[0] + layer[1] + 3. 注册 onTick 回调:engine.setUniforms() + engine.draw() + 4. useLenticularPreview.startRenderLoop() // 启动唯一 rAF + +组件 watch layers[].src + → 加载新图片 → engine.uploadTexture(index, newImage) + +组件 onUnmounted + → useLenticularPreview.stopRenderLoop() + → engine.destroy() +``` + +### 10.4 rAF 循环 — 单一驱动 + +**不能有两套独立的 rAF**(一个来自 composable,一个来自 engine),否则帧同步紊乱。 + +改为 composable 的 rAF 作为唯一驱动源: + +``` +composable.tick() → feedSimulatedTilt → computeRenderState() + → applyLayerTransformsFromRenderState() // CSS 降级用 + → callback(engineUniforms) // WebGL 用 + └── component 注册的 onTick + └── engine.setUniforms(uniforms) + └── engine.draw() + → requestAnimationFrame(tick) +``` + +实现方式:`useLenticularPreview` 新增一个 `onTick` 回调注册机制,组件在 `onMounted` 时传入: + +```js +// LenticularCard.vue +const { ..., registerOnTick } = useLenticularPreview(layersRef) +registerOnTick((uniforms) => { + if (webglEngine) { + webglEngine.setUniforms(uniforms) + webglEngine.draw() + } +}) +``` + +这样整个渲染管线**单帧内同步**完成,没有多 rAF 竞态。 + +--- + +## 十一、不影响的范围 + +- HolographicCard / HolographicEngine 保持独立,不参与本设计 +- 页面层(lenticular-create/lenticular-result/lenticular-thinking)无需修改 +- 镭射卡所有文件不受影响 +- 后端无改动