docs: p4b acceptance record

- 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>
This commit is contained in:
浅念
2026-09-20 23:59:21 +08:00
parent 7385785504
commit 22188ce3ee
3 changed files with 194 additions and 32 deletions
+2
View File
@@ -83,6 +83,8 @@ Schema 所有权:国内库表(users、albums、photos、upload_sessions、ou
> **进度:P4a 完成,2026-09-20,tag `p4a`**。验收记录见 docs/plans/progress/P4a-验收记录.md(A16/A17/A18/A20/A23/A29 mock 级证据落档,web 侧 589 测试全绿 +136、mobile 280/280 零漂移,契约增补批次 36→51 operations + DOWNLOAD_DISABLED 已登记)。**D06 已登记工作默认**(downloadEnabled=true / originalDownloadEnabled=false / 水印关闭,水印开启为相册级显式操作——用户可随时改)。**G4/D08 待真实接口**:UserSourcePort 替换点与 D08 字段确认清单见验收记录(引擎/契约/管理页不变,仅换 provider)。
| P4b 传播组件 | embed-loader + 展示位管理 + 轮播 iframe:configVersion 校验、旧响应丢弃、不可见暂停、postMessage 白名单、data-lang 语言配置 | T14 T23 T15 | A11 A12 A22;A23 组件部分 | 回归全绿 + 验收记录 + 全端语言验收(T15/A12) | 无 |
> **进度:P4b 完成,2026-09-20/21,tag `p4b`**。验收记录见 docs/plans/progress/P4b-验收记录.md(A11 悬浮轮播全链 / A22 简化级(展示位权限 admin-only,embed_slot_members 建而未用)/ A23 组件部分(不可见暂停 + 轮询退避 + ETag 304)/ A12+T15 全端语言验收(三端错误码映射枚举全覆盖守护 + locale 键集相等 + 运行时 en 抽查,漏译 4 处修复)证据落档;web 侧 655 测试全绿 +66、mobile 280/280 零漂移)。**至此 P1-P4b 全部本地阶段完成,仅余 P5 真实环境接入**(G5/G6 关口;待确认决策 D03/D05/D08/D09/D10/D13;P4b 移交清单——真实域名/CDN 预热/embed 页 frame-ancestors CSP/展示位授权 UI 按需/轮询参数冻结——见验收记录)。
| P5 真实环境接入 | 真 OSS/STS 适配器验证、海外部署与域名、全球 CDN 接入与失效策略、用户接口真实对接、跨地域故障演练、容量压测、试运行与分发交接 | T16 T17 T25(真实)T28 T29 T18 | A24 A25 A28 + 全量 A 回归 + 容量报告 | 发布门槛(对齐项目计划 7.3) | G5、G6 |
阶段顺序 P1 → P2 → P3 → P4a → P4b → P5。P3 与 P4a 在 P2 契约稳定后可并行;P4b 依赖 P2 的公开列表契约与同步通道(展示位事件扩展),其 T15 全端语言验收还需 P3 完成;P5 依赖全部前序。
@@ -1,6 +1,8 @@
# 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.
> **✅ 阶段已完成(2026-09-20/21,tag `p4b`,基线 17d09bb..HEAD)。** 完成状态的权威记录是 [docs/plans/progress/P4b-验收记录.md](./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)。
@@ -18,67 +20,67 @@
**Files:** Modify `packages/contracts/openapi.yaml`(+gen)、`src/constants.ts`
- [ ] 新增:
- [x] 新增:
- **管理端**(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
- [ ] constants: EMBED_* 轮询参数(EMBED_POLL_INTERVAL_MS 默认 30000 env 可调)
- [ ] gen + lint + 全回归绿;⏸ 快速确认点
- [ ] Commit `feat(contracts): embed slots admin and public endpoints`
- [x] constants: EMBED_* 轮询参数(EMBED_POLL_INTERVAL_MS 默认 30000 env 可调)
- [x] gen + lint + 全回归绿;⏸ 快速确认点
- [x] 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`
- [x] 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 不用)
- [x] 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 + 事件
- [x] ov 副本:sync-inbox 应用 slot-state(albums_ov 语义:embed_slots_ov 表——ov 迁移:slotToken PK, enabled, albumPublicToken?, configVersion, settings Json, entityVersion, updatedAt;版本比较同照片规则;解绑=albumPublicToken null)
- [x] ov 公开端点:GET public embed-slots(读副本 + photos_ov 近期 20 + album 标题副本 resolve(lang 参数回退相册 defaultLocale——albums_ov.translations JSON)+ ETag/304);限速复用 visits 模式(slot token 维度)
- [x] 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)
- [x] 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` 提交入库):
- [x] `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`
- [x] 单测(node 环境模拟 DOM 太薄——用 Playwright 宿主页测试替代,包内 vitest 仅测 src 解析/参数合并纯函数)
- [x] viewer 静态服务:apps/viewer/public/embed/v1/loader.js(构建时从 packages/embed-loader/dist 拷贝——vite publicDir 静态放 public/ 即可,脚本 build 步骤 copy+校验 hash;dev 直接可访问)
- [x] 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`
- [x] 路由 /embed/:slotToken(无顶栏的独立精简布局):加载 GET public embed-slots(带当前 configVersion 内存值)→ 渲染轮播:近期照片队列(thumb 小尺寸)按 intervalMs 切换(淡入淡出),点击 → window.open(viewer /a/{albumToken}/p/{photoId}?lang=) 新标签(opener 隔离 noopener)
- [x] **旧响应丢弃**:每次请求带本地 configVersion,响应版本低或请求发起时版本已变 → 丢弃(竞态防护,A22 核心);轮询 EMBED_POLL_INTERVAL_MS(ETag 304 处理)拉新照片入队尾、旧淘汰;configVersion 变化 → 全量重置渲染
- [x] **不可见暂停**(A23 组件部分):document.visibilityState hidden → 停轮询停切换;恢复→立即校准一次;IntersectionObserver 自身不可见(被滚出视口)同样暂停
- [x] postMessage 上行:初始+照片变化 → resize 高度;collapse/expand 按钮 → 父窗口收起/展开;lang 参数(auto→相册 defaultLocale)
- [x] 空态:相册无照片→「照片即将上线」小卡;槽停用/网络错→静默隐藏自身(postMessage collapse 且不弹错误)
- [x] i18n 复用 viewer 词条(嵌入模式独立轻量 messages 子集)
- [x] 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)
- [x] 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`
- [x] /system/embed-slots:列表(名称/绑定相册/enabled/configVersion/更新时间)+ 新建对话框(名称+默认设置)+ 编辑(设置表单:位置/尺寸/间隔/语言/默认收起)+ 绑定相册选择器(搜索自己管理的相册——admin 全量)+ 启停开关 + **嵌入代码卡**(`<script src="{EMBED_ORIGIN}/embed/v1/loader.js" data-slot="{token}" defer>` 复制;EMBED_ORIGIN env 配置)
- [x] vitest(~6:表单→payload、绑定变更提示 configVersion 将递增、代码卡拼接)+ Playwright(admin e2e:建槽→绑相册→嵌入代码出现→(经 ov API 断言公开数据)→换绑→configVersion+1)
- [x] Commit `feat(admin): embed slot management with code snippet`
## Task 6: T15 全端语言验收 + 漏译修复
- [ ] 验收清单执行(脚本化+人工核对结合):
- [x] 验收清单执行(脚本化+人工核对结合):
- 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`
- [x] 发现漏译即修(预期少量);登记任何无法机器验证的残留(截图类)
- [x] 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`;Commit `docs: p4b acceptance record`
- [x] 全仓回归绿 + mobile 不降级;记录数字
- [x] `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 配置**/初始轮询失效上限参数化])
- [x] 总纲 P4b 进度注记(P4b 完成 → **P1-P4b 全部完成,仅余 P5 真实环境接入**);`git tag p4b`;Commit `docs: p4b acceptance record`
---
+158
View File
@@ -0,0 +1,158 @@
# P4b 验收记录
- **阶段**:P4b 传播组件 + 全端语言验收(计划 `docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md`,Task 1–7)
- **日期**:2026-09-20/21(契约→后端→loader→轮播页→管理页同日链式推进,语言验收与回归门跨夜收口)
- **基线 commits**:`17d09bb..HEAD`(tag `p4a` → 本记录提交;共 9 个提交 = 2 计划(含评审修正)+ 6 实施 + 1 本记录)
- **执行模式**:子代理驱动(每任务先写失败测试→实现→通过→提交;契约批次先行 gen + redocly 门);Task 6 语言验收与 Task 7 回归门/本记录由主代理执行
- **回归门**:web 侧 `pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm e2e` 全绿(lint 10/10、typecheck 13/13、test 12/12、build 10/10 包任务;数字见文末统计表);mobile 侧 `flutter analyze` **No issues found!** + `flutter test` **280/280**(P4b 仅补错误码镜像常量 + 注释,语义零漂移)
- **基础设施**:`infra/docker-compose.yml --profile ov`(mysql/redis/minio 双地域)healthy;e2e 均为进程内真实 Nest 应用 + 真实 MinIO/Redis/MySQL + 真实 outbox→ov 传播 + 真实 BullMQ worker;Playwright 以 viewer publicDir 宿主夹具页(`/__test__/embed-host.html`)承载真实 loader.js→iframe→ov 公开端点全链
---
## 任务清单与提交
| Task | 内容 | Commit |
|------|------|--------|
| 1 | 契约扩展 P4b 批次(embed-slots 管理/公开端点 + ETag/304 + constants EMBED_* 轮询参数) | `eb51069` |
| 2 | 展示位后端(cn 管理 admin-only + slot-state 同步 + ov 副本 + 公开 feed + 限速 + e2e) | `aee4b7a` |
| 3 | embed-loader(版本化 /embed/v1/loader.js,postMessage 白名单、窄屏规则、收起记忆) | `ddb4c81` |
| 4 | 轮播 iframe 页 `/embed/:slotToken`(configVersion 旧响应丢弃、不可见暂停、ETag 轮询) | `8f2c21d` |
| 5 | admin 展示位管理页(列表/设置表单/绑定换绑/嵌入代码卡) | `0238f50` |
| 6 | T15 全端语言验收 + 漏译修复(四端机器守护 + 运行时抽查) | `7385785` |
| 7 | 回归门 + 本记录 | 本提交 |
---
## 验收证据(按 A 编号)
### A11 悬浮组件(加载器 + 轮播 + 自动更新 + 不破坏宿主)
**embed-loader 单元**(`packages/embed-loader/src/params.test.js`,11 例):data-* 参数解析(缺省全集/slot 清洗/lang/position 白名单回退/interval 夹取 [3000,30000])、**窄屏 <480px 强制 sm + 默认收起**(评审缺口:移动端可用)、查询参数拼接、**slot token 编码(防路径注入)**、resize 高度夹取 [60,640] 与非法值忽略。
**宿主页全链 Playwright**(`apps/viewer/e2e/embed.spec.ts`,8 例,真实后端 + 真实 loader.js):
- 宿主夹具页引入 loader → iframe 出现 → `lang=auto` → 相册 defaultLocale 标题;3 张照片按 publishedAt 倒序就位;intervalMs 切换(active 照片 src 轮换断言);**点击照片 → 新标签打开 viewer 单图深链(noopener,lang 跟随 resolvedLocale)**
- `lang=en` → iframe 内英文标题 + 深链 `lang=en`(T12/A12 embed 双语,Task 6 补)
- 收起按钮 → postMessage collapse → wrapper 高度 0 + 把手出现 + sessionStorage 记忆 → 刷新仍收起 → 把手点击展开恢复轮询
- 窄屏视口(375×667)默认收起 + sm 尺寸(280px)+ 把手展开可达
- **新照片发布 → 真实管线(cn→outbox→ov)→ 轮询窗口内队尾追加**(A11 数据路径)
- 空相册 → 「照片即将上线」小卡;幽灵 token → 静默 collapse(iframe 内无错误 UI,防探测)
### A22 展示位管理(简化级:admin-only 权限)
**api e2e** `services/api/test/embed.e2e-spec.ts`(真实 cn+ov 双应用 + 真实 outbox/sync-inbox,9 例):
- ① admin 创建/列表 + **非 admin 403** + 校验 400 + 创建即 slot-state 事件(未绑定 → 公开 404)
- ② 绑定相册 → ov 公开数据:照片 publishedAt 倒序 / lang 标题回退(相册 defaultLocale)/ **ETag 304**(If-None-Match 命中零负载)
- ③ 新照片发布 → ETag 必变 → 200 增量(A11 数据路径)
- ④ **换绑相册:configVersion+1 → ov 服务新相册照片**(20 张上限)——loader 只认 token,宿主无需改动
- ⑤ 相册下线(publicEnabled=false)→ 公开端 404;恢复 → 200
- ⑥ 解绑 → 404;复绑 → 200;停用(软下线 enabled=false)→ 404
- ⑦ slot-state 乱序/重复投递幂等(版本比较,复用 sync harness)
- ⑧ 公开端点限速:slotToken+IP 固定窗超额 429(**304 同样消耗预算**)
**viewer 纯测试**(`apps/viewer/src/__tests__/embed-feed.spec.ts`,18 例)之 A22 核心:**旧响应丢弃双守卫**(请求派发序号 seq 过期丢弃 + configVersion 低于本地已接受值丢弃)、同版本合并(新照片队尾追加/消失淘汰/URL 刷新)、**版本升 → 全量重置**(换绑)、404 静默 + collapse 上报。
**admin Playwright** `apps/admin/e2e/embed-slots.spec.ts`(4 例):新建(设置表单 → 行回显);**绑定相册(A22 确认文案 → configVersion 递增 → 经 ov API 断言公开 feed 返回相册照片)**;换绑(configVersion+1 → ov feed 切换新相册——换绑不改官网);停用(软下线 → ov 404)。vitest `src/utils/system/embedSlots.spec.ts` 9 例(缺省/夹取/整体替换载荷/EMBED_ORIGIN 代码卡拼接/绑定三类确认文案携 A22 短语/zh-en 键集一致)。
**权限简化说明(偏差 #1/#2)**:「创建者只操作被授权展示位」语义本阶段未出——embed_slot_members 表已建(迁移)但无授权 UI/API,管理权限=仅公司 admin(A22 简化级,镜像 P2 A25 部分交付模式;授权面移交 P5/后续按需)。
### A23 组件部分(不可见暂停 + 轮询经济性)
`apps/viewer/src/__tests__/embed-feed.spec.ts`(18 例)之 A23:**document.visibilityState hidden → 停轮询停切换,恢复 → 立即校准一次**;**轮询退避(连续失败 5 次间隔 ×5,成功即恢复)**;collapse 停止计时器/expand 恢复并补报 resize;**ETag/If-None-Match 轮询(304 = 零处理零负载)**。Playwright 增量用例(embed.spec「newly published photo appears within the poll window」)以 2s 轮询窗口验证真实数据到达。参数默认集中在 `packages/contracts/src/constants.ts`(EMBED_POLL_INTERVAL_MS=30000 / MAX_FAILURES=5 / BACKOFF_MULTIPLIER=5,env 可调——P5 冻结,见移交)。
### A12 + T15 全端语言验收(Task 6)
**机器覆盖断言(新增 3 个守护测试文件)**:
| 端 | 守护 | 内容 |
|----|------|------|
| viewer | `src/__tests__/error-coverage.spec.ts`(3 例) | 契约 ErrorCodes 枚举全集在 `src/api/errors.ts` 映射表无缺漏;表内每个文案键在 zh-CN/en 词条均存在且非空;**zh-CN/en 词条键集递归相等** |
| admin | `src/utils/http/error.spec.ts`(3 例) | 同上三断言(映射表 `API_CODE_MSG_KEYS`,locale zh.json/en.json 键集相等) |
| mobile | `test/l10n/arb_completeness_test.dart`(P3 落地,引用) | zh/en arb 消息键集合相等 + 占位符 token 集合相等 + `@key` 元数据一致(本次 280/280 复跑通过) |
**api 侧口径**:服务端不做文案本地化——错误信封 `{code, message, requestId}` 的 message 为英文诊断兜底,客户端按稳定 code 映射多语言(openapi.yaml info.description 契约定)。枚举单源性由 `packages/contracts/src/__tests__/events.test.ts` 的 **openapi Error.code enum ↔ errors.ts ErrorCodes 编译期双向恒等守护**保证(P2 落地,引用);api 全部错误码均经 `ErrorCodes.*` 常量抛出(TS 收敛,无字面量漂移)。故「无漏译」断言面 = 三客户端映射表全覆盖(上表)。
**漏译发现与修复(4 处,全部由机器审计捕获后修复)**:
1. **viewer `viewer.photoRemoved` en 词条缺失**(zh 有 en 无——键集相等断言直接命中;补 'This photo has been removed')
2. **admin 映射表与 locale 词条脱节**:locale 侧 apiMsg 10 码词条早已存在,映射表仅登记 6 码——ALBUM_NOT_FOUND/PHOTO_NOT_*/UPLOAD_*/MEMBER_CONFLICT 实际回退英文服务端文案;按枚举全集登记(+RATE_LIMITED/ALBUM_NOT_PUBLIC/DOWNLOAD_DISABLED/SYNC_AUTH_FAILED 四码新词条 ×2 语言)
3. **viewer DOWNLOAD_DISABLED 观众可达但无专属文案**(P4a 下载两级开关中途关闭时只有通用「下载失败」):新增 `errors.downloadDisabled` 双语词条 + `useDownload` 按码接线(专属提示,vitest 新用例固化)
4. **mobile 错误码镜像漂移**:P4a 增补的 DOWNLOAD_DISABLED 未镜像进 `lib/core/error_codes.dart`(「1:1 对照」注释计数 17 → 实际契约 18)——补常量 + 计数修正(App 无下载面,文案走 errorGeneric 兜底)
**运行时抽查(Playwright,真实 DOM)**:
- **admin en 主旅程**:`apps/admin/e2e/locale-en.spec.ts`——登录页顶栏语言菜单(用户级设置)切 English → 表单/占位符/按钮英文(Welcome back / Enter account / Login)→ 正确登录 → 相册管理列表页英文(Album Management / New Album)→ 刷新后偏好持久仍英文。zh 版主旅程由既有 login/albums/embed-slots/users 系列覆盖(引用)
- **viewer en 旅程**:既有各 spec 覆盖(i18n.spec 切换语言重渲染 + `lang` 属性 + localStorage 持久;feed/likes/download/journey 等跨页面用例断言英文文案,引用 P1b/P4a 记录)
- **embed iframe 双语**:embed.spec `lang=auto → zh-CN`(defaultLocale 回退)与 `lang=en`(Task 6 新增)两语言渲染断言 + 深链 lang 跟随
- **mobile 语言切换**:P3 IT 腿已过(`test/app/locale_test.dart` + arb 守护;本次复跑 280/280,引用 P3 记录)
**无法机器验证的残留**:无(四端均为键集/枚举机器断言 + 代表性真实 DOM 抽查;截图级逐屏对比不单独留档,以既有各页面 Playwright 断言为准)。
---
## 偏差与发现登记
### 计划内(计划/代码注释明示)
| # | 事项 | 说明 |
|---|------|------|
| 1 | embed_slot_members 建而未用 | 表结构与迁移已落(P4b Task 2),授权 UI/API 不出——P5/后续按需 |
| 2 | 展示位权限简化 admin-only | 需求「管理员统一管理 + 创建者操作被授权展示位」的后半语义简化为仅公司 admin 可管理(A22 简化级,镜像 P2 A25 部分模式);绑定校验退化为 admin 恒真 |
| 3 | ETag 四元组 | `"{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"`(计划评审修正:纯实体版本会因新照片小版本号 304 陈旧;含 photoCount/publishedAt 后新照片发布必变) |
| 4 | esbuild devDep | loader 打包+minify 复用依赖树内 esbuild 显式声明;产物 `packages/embed-loader/dist/v1/loader.js` 提交入库(≤5KB、无 console) |
| 5 | contracts 子路径导出 | `./constants`(P4b Task 3 既有:浏览器安全入口,根 index 含 internal-sync 的 node:crypto);Task 6 同理增补 `./errors`(errors.ts 纯 TS,viewer 生产代码运行时枚举引用走此入口) |
| 6 | CORS exposedHeaders ETag | ov 公开端点需跨源暴露 ETag 头供 loader 页读取(embed-ov 模块 CORS 配置) |
| 7 | eslint ignore loader 产物 | `dist/v1/loader.js` 为打包产物,格式检查豁免(eslint ignore 配置) |
| 8 | settings 整体替换语义 | PATCH settings 变更送完整对象(契约 Json 字段整体语义,非逐字段 merge);admin 表单侧保证字段齐全(embedSlots.spec 固化) |
| 9 | 宿主夹具页入 publicDir | `apps/viewer/public/__test__/embed-host.html` 仅测试资产(计划约定:不入 viewer 生产路由);模拟第三方引 loader script,查询参数透传 data-* + 同源注入 `__embedPollMs` 加速轮询断言 |
| 10 | 轮询缺省跟随 interval 查询参数 | `clampEmbedIntervalMs(intervalParam) ?? EMBED_POLL_INTERVAL_MS`——展示间隔与轮询间隔共用一个参数(3s 轮播即 3s 轮询);独立调节仅测试注入,P5 若需解耦再拆参数(D10) |
| 11 | 跨源路径自动化以同源覆盖 | 夹具页与 viewer 同源,真实第三方跨源场景以「消息 origin === iframe contentWindow 源」白名单校验(loader 侧实现 + 评审保证);sandbox 必含 allow-same-origin(opaque origin 会拒掉自身 resize/collapse 消息,计划评审修正已落) |
### 计划外(本记录补登)
| # | 事项 | 影响/处理 |
|---|------|-----------|
| 12 | admin apiMsg 词条与映射表脱节(Task 6 发现) | 模板 locale 早有 10 码词条但映射表仅 6 码——上传/相册域错误实际回退英文服务端文案(真漏译);按枚举全集登记修复 + 守护测试防回退 |
| 13 | viewer.photoRemoved en 缺失(Task 6 发现) | 轮播中被移除照片的轻提示 en 侧无词条;键集相等断言捕获,补齐 |
| 14 | mobile 错误码镜像漏 DOWNLOAD_DISABLED(Task 6 发现) | P4a 契约增补未同步镜像(注释仍写 17 项);补常量 + 计数修正(文案走 errorGeneric 兜底,App 无下载面) |
| 15 | admin 增 @photo/contracts devDep(仅测试消费) | 错误码枚举单源引用进守护测试;admin tsconfig 为 classic moduleResolution(不走 package exports),测试内从根入口导入(node 环境 node:crypto 无碍),生产代码不受影响 |
| 16 | viewer 下载失败提示按码分化 | `useDownload` 的 failed 由布尔改为文案键(''/download.failed/errors.downloadDisabled),PhotoLightbox 直接渲染——DOWNLOAD_DISABLED 观众专属文案(T15 修复项 ③) |
## 移交 P5
- **真实域名与部署**:EMBED_ORIGIN env(loader src 自推导已支持 CDN 部署;admin 代码卡拼接已按 env)
- **CDN 预热**:/embed/v1/loader.js 与轮播页静态资产上 CDN;版本路径 /embed/v1/ 长期稳定(v2 才破坏性变更)
- **embed 页 frame-ancestors CSP**:宿主接入 CSP 要求(script-src/frame-src {EMBED_ORIGIN})已文档化(packages/embed-loader/README-embed.md);**服务端响应头 frame-ancestors 配置 P5**(本地仿真未配)
- **展示位授权 UI 按需**:embed_slot_members 表已建;「创建者操作被授权展示位」API/UI + 绑定校验升级(A22 完整级)
- **轮询参数冻结(D10)**:EMBED_POLL_INTERVAL_MS=30000 / MAX_FAILURES=5 / BACKOFF_MULTIPLIER=5、公开端点限速窗——env 可调,P5 实测后与 D05/D10 一并冻结
- **待用户决策**:D03 分发方式、D05 并发目标、D08 用户接口契约(G4)、D09、D10、D13(总纲决策挂起清单);G2 真机验证(P3 清单)、G5(字体打包/真实 OSS)、G6(跨地域演练)仍待真实环境
---
## 测试统计汇总(2026-09-20/21 回归门实测)
**Web 侧**(P4a 出口 589 → 655,+66)
| 包 | 单测 suites(files)/tests | e2e suites(tests) |
|----|--------------------------|-------------------|
| @photo/contracts | 4 / 29 | – |
| @photo/storage | 1 / 6 | – |
| @photo/embed-loader(P4b 新) | 1 / 11 | –(宿主页用例计入 viewer playwright) |
| @photo/api-cn | 7 / 51 | **23 / 247**(P3 239 → +8:embed slots ①-⑧ 全链) |
| @photo/worker | 2 / 14 | 2 / 13 |
| @photo/sync | –(passWithNoTests) | 4 / 27 |
| @photo/viewer | vitest 19 / 136(P4a 17/114 → +2 文件 +22:embed-feed 18 + error-coverage 3 + download 新用例) | playwright 8 spec / 32(+1 spec/+8:embed.spec 宿主全链含 lang=en) |
| @photo/admin | vitest 14 / 69(P4a 12/57 → +2 文件 +12:embedSlots 9 + error 3) | playwright 6 spec / 20(+2 spec/+5:embed-slots 4 + locale-en 1) |
| **web 合计** | **48 / 316**(jest 14 suites / 100 + vitest 34 files / 216) | **43 / 339**(jest 29 suites / 287 + playwright 14 spec / 52) |
- 附加门:lint 10/10、typecheck 13/13、test 12/12、build 10/10 包任务全绿(turbo)。
**移动端**(P4b 仅补镜像常量,语义零漂移)
| 套件 | 结果 |
|------|------|
| `flutter analyze` | **No issues found!**(0 issue,9.7s) |
| `flutter test` | **280 / 280 全过**(`00:14 +280: All tests passed!`) |
**总量**:web 316 + 339 = 655(P4a 出口 589 → **+66**:单测 +45、e2e +21);mobile flutter 280(零漂移)。本次回归门合计 **935**。