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)无需修改
+- 镭射卡所有文件不受影响
+- 后端无改动