docs(plan): p4b implementation plan (pending review)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 21:38:37 +08:00
parent 17d09bbb01
commit ec7837ebb0
@@ -0,0 +1,90 @@
# P4b 传播组件 + 全端语言验收 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven with subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付网站悬浮轮播组件:版本化 JS 加载器(data-slot 配置、防重复初始化、postMessage 白名单、不污染宿主)+ 展示位管理(后台绑定相册、configVersion 切换)+ 项目域内轮播 iframe 页(近期照片、旧响应丢弃、不可见暂停、空态)+ 展示位 cn→ov 内容同步;收口 T15 全端语言验收(A12)。
**Architecture:** `packages/embed-loader`(独立发布物,无依赖手写 JS,版本化路径 /embed/v1/loader.js 由 viewer 静态服务)+ 轮播页挂在 apps/viewer 路由 `/embed/:slotToken`(复用 ov 公开 API 与 i18n)+ 展示位为 cn 权威实体(embed_slots/embed_slot_members),经 slot-state 事件同步 ov(contracts 已预留 entityType);公开嵌入 API 由 ov 承接(观众域名、只读副本)。
**Tech Stack:** 既有 + 零新依赖(loader 手写 ES5+ JS;二维码已在 P4a 引入 admin)。
**对应验收:** A11(悬浮/自动更新/正确跳转/不破坏宿主)、A22(后台换绑不改官网、旧响应不串图、展示位权限)、A12/T15(四端语言全验)、A23 组件部分(不可见暂停请求)。
**依赖基线:** tag `p4a`(17d09bb)。
---
## Task 1: 契约扩展(嵌入域)
**Files:** Modify `packages/contracts/openapi.yaml`(+gen)、`src/constants.ts`
- [ ] 新增:
- **管理端**(tag embed-slots):`GET /admin/embed-slots`(cursor;items: EmbedSlot{id, publicToken, name, enabled, albumId?, albumTitle?, configVersion, settings Json{position left|right, size sm|md, intervalMs 3000-30000, lang auto|zh-CN|en, collapsedDefault bool}, updatedAt});`POST /admin/embed-slots` {name, settings?} → 201;`PATCH /admin/embed-slots/{id}` {name?, enabled?, settings?, albumId?|null 绑定/解绑} → 200(albumId 变更 → configVersion+1;绑定校验相册管理权:操作者须为该相册 owner/admin 或 slot 授权成员——**简化决策:P4b 权限=仅公司 admin 可管理展示位**(需求"管理员统一管理展示位,创建者只操作被授权展示位"——被授权语义复杂度高,登记偏差:embed_slot_members 表建好但 P4b 不出授权 UI/API,P5/后续按需补));`DELETE` 不做(软性 enabled=false 即下线)
- **公开端**(ov):`GET /public/embed-slots/{token}?lang=` → 200 {slotToken, configVersion, album: {publicToken, title(resolved), defaultLocale}|null, photos: [{photoId, thumb{url,expiresAt}, width, height}](近期已发布 ≤20,按 publishedAt DESC)};404 NOT_FOUND(不存在/停用/未绑定相册→同样 404 防探测,description 说明);**ETag/If-None-Match 支持**(configVersion+照片 max entityVersion 为 ETag;304 支持——省流量,A23)
- [ ] constants: EMBED_* 轮询参数(EMBED_POLL_INTERVAL_MS 默认 30000 env 可调)
- [ ] gen + lint + 全回归绿;⏸ 快速确认点
- [ ] Commit `feat(contracts): embed slots admin and public endpoints`
## Task 2: 展示位后端(cn 管理 + ov 公开 + 同步)
- [ ] cn 迁移(校对手决):embed_slots(id, publicToken unique VarChar(32), name, enabled Bool default true, albumId?, configVersion Int default 1, settings Json, createdAt/updatedAt, entityVersion Int);embed_slot_members(slotId+userId unique, permission VarChar(32)——表建好 P4b 不用)
- [ ] cn 管理端点(admin-only):CRUD per 契约;绑定/换绑相册事务:校验相册存在且操作者可管理(admin 恒真)→ albumId 更新 + configVersion+1 + entityVersion+1 + outbox `slot-state` 事件(payload: publicToken, enabled, albumId, albumPublicToken, configVersion, settings, entityVersion——ov 重建副本所需全量);settings/enabled/name 变更同样 bump + 事件
- [ ] ov 副本:sync-inbox 应用 slot-state(albums_ov 语义:embed_slots_ov 表——ov 迁移:slotToken PK, enabled, albumPublicToken?, configVersion, settings Json, entityVersion, updatedAt;版本比较同照片规则;解绑=albumPublicToken null)
- [ ] ov 公开端点:GET public embed-slots(读副本 + photos_ov 近期 20 + album 标题副本 resolve(lang 参数回退相册 defaultLocale——albums_ov.translations JSON)+ ETag/304);限速复用 visits 模式(slot token 维度)
- [ ] e2e(services/api/test/embed.e2e-spec.ts + sync 侧):admin 建槽→绑相册→(flushSync)ov 公开返回照片 20 上限/lang 回退/ETag 304;换绑另一相册→configVersion+1→ov 反映新相册照片(A22 换绑不改官网=loader 只认 token);解绑→404;停用→404;非 admin 403;乱序/重复 slot-state 幂等(复用 harness)
- [ ] Commit `feat(api): embed slots with ov replica and public feed`
## Task 3: embed-loader(packages/embed-loader)
- [ ] `packages/embed-loader/src/loader.js`(手写 ~200 行,无构建或极简 esbuild 打包——决策:esbuild 已在依赖树(vite),加 devDep esbuild 打包+minify,产物 `dist/v1/loader.js` 提交入库):
- 扫描 `script[src*="/embed/v1/loader.js"]` 自身 data-*(data-slot/data-lang/data-position/data-interval 覆盖)→ 防重复(window.__photoliveEmbed loaded 标记 + 同 slot 去重)→ 每槽创建固定定位 iframe(right/left, 尺寸 per settings.size 映射 px,z-index 2147483000,box-shadow 圆角,sandbox 属性 allow-same-origin? NO——iframe 加载项目域需 scripts+popups:sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox",src= `{EMBED_ORIGIN}/embed/{slot}?lang=...&pos=...&interval=...`——EMBED_ORIGIN 从 loader.js 自身 src 推导(document.currentScript.origin),支持 CDN 部署)
- postMessage 白名单:监听 message(origin 必须等于 iframe origin)仅接受 {photoliveEmbed: true, type: resize|collapse|expand, ...}→ 调整 iframe 高度/收起态(收起=高度 0+展示小把手按钮 div——把手也在 loader 侧,点击 expand);其余消息忽略。iframe 内不发其他命令
- 收起记忆 sessionStorage per slot(会话内不反复弹出,需求 9)
- 不污染宿主:零全局(仅一个标记属性)、无样式注入到宿主元素(把手 div inline style)、动态卸载 API 可选(P4b 略)
- [ ] 单测(node 环境模拟 DOM 太薄——用 Playwright 宿主页测试替代,包内 vitest 仅测 src 解析/参数合并纯函数)
- [ ] viewer 静态服务:apps/viewer/public/embed/v1/loader.js(构建时从 packages/embed-loader/dist 拷贝——vite publicDir 静态放 public/ 即可,脚本 build 步骤 copy+校验 hash;dev 直接可访问)
- [ ] Commit `feat(embed): versioned loader with postMessage whitelist`
## Task 4: 轮播 iframe 页(apps/viewer /embed/:slotToken)
- [ ] 路由 /embed/:slotToken(无顶栏的独立精简布局):加载 GET public embed-slots(带当前 configVersion 内存值)→ 渲染轮播:近期照片队列(thumb 小尺寸)按 intervalMs 切换(淡入淡出),点击 → window.open(viewer /a/{albumToken}/p/{photoId}?lang=) 新标签(opener 隔离 noopener)
- [ ] **旧响应丢弃**:每次请求带本地 configVersion,响应版本低或请求发起时版本已变 → 丢弃(竞态防护,A22 核心);轮询 EMBED_POLL_INTERVAL_MS(ETag 304 处理)拉新照片入队尾、旧淘汰;configVersion 变化 → 全量重置渲染
- [ ] **不可见暂停**(A23 组件部分):document.visibilityState hidden → 停轮询停切换;恢复→立即校准一次;IntersectionObserver 自身不可见(被滚出视口)同样暂停
- [ ] postMessage 上行:初始+照片变化 → resize 高度;collapse/expand 按钮 → 父窗口收起/展开;lang 参数(auto→相册 defaultLocale)
- [ ] 空态:相册无照片→「照片即将上线」小卡;槽停用/网络错→静默隐藏自身(postMessage collapse 且不弹错误)
- [ ] i18n 复用 viewer 词条(嵌入模式独立轻量 messages 子集)
- [ ] vitest(~10:configVersion 丢弃逻辑/队列淘汰/暂停恢复/高度上报纯函数)+ Playwright(viewer e2e/embed.spec.ts):宿主页 fixture(test/host/embed-host.html 一次性静态页引入 loader script 指向 dev viewer)→ iframe 出现右侧→照片轮换(两次 src/opacity 断言)→点击开新页到 viewer 照片→宿主收起按钮→高度 0→sessionStorage 记忆→刷新仍收起;CSP 说明文档(宿主需允许 script-src/frame-src——README-embed.md)
- [ ] Commit `feat(viewer): embed carousel iframe with visibility pause`
## Task 5: admin 展示位管理页
- [ ] /system/embed-slots:列表(名称/绑定相册/enabled/configVersion/更新时间)+ 新建对话框(名称+默认设置)+ 编辑(设置表单:位置/尺寸/间隔/语言/默认收起)+ 绑定相册选择器(搜索自己管理的相册——admin 全量)+ 启停开关 + **嵌入代码卡**(`<script src="{EMBED_ORIGIN}/embed/v1/loader.js" data-slot="{token}" defer>` 复制;EMBED_ORIGIN env 配置)
- [ ] vitest(~6:表单→payload、绑定变更提示 configVersion 将递增、代码卡拼接)+ Playwright(admin e2e:建槽→绑相册→嵌入代码出现→(经 ov API 断言公开数据)→换绑→configVersion+1)
- [ ] Commit `feat(admin): embed slot management with code snippet`
## Task 6: T15 全端语言验收 + 漏译修复
- [ ] 验收清单执行(脚本化+人工核对结合):
- viewer:词条完整性已有 arb 测试;**运行时抽查**(Playwright 遍历主要页面截图 zh/en 对比关键文案存在性——已有各 spec 覆盖,汇总核对)
- admin:模板 i18n zh/en 键数一致断言(新增 vitest 扫描 locale 文件键集合相等)+ P4a/P4b 新页面 zh/en 逐屏抽查(Playwright 切语言跑 admin 主旅程 en 版一遍)
- mobile:arb 完整性测试已有(280 内);语言切换 IT 腿已过(引用 P3 记录)
- embed:iframe lang 参数两语言渲染断言(Task 4 Playwright 内已含,汇总)
- **后端错误码文案映射表核对**(三端错误码→本地化键覆盖率断言脚本——api/admin/viewer 各自的映射文件遍历 ErrorCode 枚举全集,缺失即 fail(A12 主要提示无漏译的机器验证)
- [ ] 发现漏译即修(预期少量);登记任何无法机器验证的残留(截图类)
- [ ] Commit `feat(i18n): T15 full-stack language audit with coverage assertions`
## Task 7: 回归门 + P4b 验收记录
- [ ] 全仓回归绿 + mobile 不降级;记录数字
- [ ] `docs/plans/progress/P4b-验收记录.md`(A11/A22/A23 组件/A12+T15 证据、偏差表[embed_slot_members 建而未用/权限简化 admin-only/ETag 简化实现/esbuild devDep]、P5 移交[真实域名/CDN 预热/展示位授权 UI 按需])
- [ ] 总纲 P4b 进度注记(P4b 完成 → **P1-P4b 全部完成,仅余 P5 真实环境接入**);`git tag p4b`;Commit `docs: p4b acceptance record`
---
## 执行注意
- 沿用全部既有约定(校对手决/gen 先行/reset 隔离/串行/偏差登记/trailer)。
- loader.js 是对外发布物:无 console.log、错误静默、≤5KB 目标;版本路径 /embed/v1/ 长期稳定(v2 才破坏性变更)。
- 轮播页加载的是**小尺寸**资源:ov 公开端点 photos 用 thumb(小图)——与 viewer 大图 browse 区分(省流量,需求 4.8)。
- 宿主测试页(embed-host.html)仅测试资产,不入 viewer 生产路由。
- postMessage 双向白名单是安全边界(技术方案 §9):origin 校验缺失=Critical 级审查项。