- P4b 验收记录(A11/A22 简化级/A23 组件/A12+T15 证据、偏差 16 项、 P5 移交清单、测试统计 web 655 +66 / mobile 280 零漂移) - 总纲 P4b 进度注记:P1-P4b 全部本地阶段完成,仅余 P5 真实环境接入 (G5/G6 + D03/D05/D08/D09/D10/D13) - P4b 计划勾选回填 + 完成头注 Co-Authored-By: Claude Code <noreply@anthropic.com>
13 KiB
P4b 传播组件 + 全端语言验收 实施计划
✅ 阶段已完成(2026-09-20/21,tag
p4b,基线 17d09bb..HEAD)。 完成状态的权威记录是 docs/plans/progress/P4b-验收记录.md;Task 1-7 全部交付、勾选已回填。至此 P1-P4b 全部本地阶段完成,仅余 P5 真实环境接入。偏差(计划内 11 + 计划外 5:embed_slot_members 建而未用、展示位权限 admin-only 简化、ETag 四元组、esbuild devDep、contracts 子路径导出、T15 审计发现 4 处漏译等)与 P5 移交(真实域名/CDN 预热/frame-ancestors CSP/展示位授权 UI 按需/轮询参数冻结)见验收记录。
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven with subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (
- [x]) 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 支持(ETag ="{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"——评审修正:含 photoCount 与 publishedAt,新照片发布必变;纯 max entityVersion 会因新照片小版本号而 304 陈旧);304 支持——省流量,A23
- 管理端(tag embed-slots):
- 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-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"——评审修正:必须含 allow-same-origin,否则 iframe 源为 opaque "null",loader 的 origin 相等白名单会拒掉自己 iframe 的 resize/collapse 消息且 iframe 内 API 跨源需 ACAO *;嵌入 iframe 对宿主天然跨源,allow-same-origin 仅使其保有项目域源,白名单按「消息 origin === iframe contentWindow 的源」严格校验,src={EMBED_ORIGIN}/embed/{slot}?lang=...&pos=...&interval=...——EMBED_ORIGIN 从 loader.js 自身 src 推导(document.currentScript.origin),支持 CDN 部署) - 窄屏规则(评审缺口:需求 4.8 移动端可用状态):viewport <480px → 强制 collapsedDefault=true + 尺寸降为 sm;Playwright 移动视口用例覆盖
- postMessage 白名单:监听 message(origin 必须等于 iframe origin)仅接受 {photoliveEmbed: true, type: resize|collapse|expand, ...}→ 调整 iframe 高度/收起态(收起=高度 0+展示小把手按钮 div——把手也在 loader 侧,点击 expand);其余消息忽略。iframe 内不发其他命令
- 收起记忆 sessionStorage per slot(会话内不反复弹出,需求 9);初始轮询失效上限(连续失败 N=5 次退避至 5×interval,参数 env 可调——评审建议)
- 不污染宿主:零全局(仅一个标记属性)、无样式注入到宿主元素(把手 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(简化级:展示位权限 admin-only,镜像 P2 A25 部分模式)/A23 组件/A12+T15 证据、偏差表[embed_slot_members 建而未用/权限简化 admin-only/ETag 四元组实现/esbuild devDep]、P5 移交[真实域名/CDN 预热/展示位授权 UI 按需/embed 页 frame-ancestors CSP 配置/初始轮询失效上限参数化])- 总纲 P4b 进度注记(P4b 完成 → P1-P4b 全部完成,仅余 P5 真实环境接入);
git tag p4b;Commitdocs: 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 级审查项。