diff --git a/docs/plans/2026-09-17-实施总纲.md b/docs/plans/2026-09-17-实施总纲.md index 25e4a2b..7165d0a 100644 --- a/docs/plans/2026-09-17-实施总纲.md +++ b/docs/plans/2026-09-17-实施总纲.md @@ -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 依赖全部前序。 diff --git a/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md b/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md index 4b906b5..e2fb714 100644 --- a/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md +++ b/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md @@ -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 全量)+ 启停开关 + **嵌入代码卡**(`