docs(plan): p2 implementation plan (approved, both review gaps fixed)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 13:19:52 +08:00
parent b5145d6860
commit 06111e1466
@@ -0,0 +1,124 @@
# P2 双地域仿真与互动 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付海外观众服务(本地双实例仿真):cn→ov 版本化内容同步(outbox/inbox、墓碑、补漏对账、高优先级下线)、海外紧急停用入口、点赞批次幂等 + WebSocket 实时气泡、浏览统计窗口去重、统计快照回传;viewer 切换 ov-api 并获得点赞/气泡/热门排序/浏览上报能力。
**Architecture:** ov 侧 = `services/api` 新增 ov-app 部署入口(:8081)+ ov Prisma schema(独立 MySQL `photo_ov`)+ WebSocket 网关;`services/sync` 承担 cn outbox 发送 / ov inbox 接收 / 双向对账与统计回传。全链路本地 compose 仿真(MySQL×2/Redis×2 以 profiles 启用 ov 侧)。对齐总纲 P2 与技术实施方案 §2A/§7-§8。
**Tech Stack:** 沿用 P1 全套;新增 `ws`(NestJS ws adapter)、内部同步接口服务间鉴权(HMAC 签名 + 时间戳,env 密钥)。
**对应验收(仿真级):** A09 A10 A14 A21 A26 A27;A25 部分(本地直读语义)。
**依赖基线:** tag `p1`(b5145d6)。
**决策初值登记(D07/D11,按建议值先行,验收前可调):**
- D07:浏览去重窗口 30 分钟(env `VISIT_WINDOW_MINUTES=30`);主办字段不纳入 P2。
- D11:outbox 推送间隔 2s/批 100 条、高优先级即时;补漏对账每 5 分钟;统计回传每 60s 增量快照;中断恢复=水位重放。全部 env 可调。
---
## Task 1: ov 契约与决策初值
**Files:** Modify `packages/contracts/openapi.yaml`(+gen)、`src/constants.ts`、`.env.example`
- [ ] 契约新增:
- **公开互动端点(ov 域,路径前缀不变,部署域名区分)**:`POST /public/albums/{token}/photos/{photoId}/likes`(匿名+限速;body {sessionId, batchId, delta 1..20} → 200 {total, countVersion, batchAccepted};重复 batchId → 幂等返回已确认结果);`POST /public/albums/{token}/visits`(body {visitorId, requestId} → 200 {viewCount, counted});likes 上限/限速语义写入 description
- **WS 事件契约**(components 不适用——单独 `packages/contracts/src/realtime.ts`):server→client 消息 `photo.published` / `photo.hidden` / `photo.deleted` / `likes.burst`{photoId, albumId, delta, total, countVersion, burstId, originBatchIds[]} / `stats.updated`{albumId, viewCount};client→server `subscribe{albumToken, sinceVersion?}` / `unsubscribe`;心跳 ping/pong 20s
- **内部同步接口**(tag internal,security: internalSyncKey 描述):`POST /internal/sync/content-batches`(数组:{eventId, schemaVersion, entityType: album|category|photo|asset|slot-state(预留), entityId, albumId, entityVersion, tombstone?, priority, payload});`POST /internal/sync/stat-snapshots`(cn←ov 方向:数组 {photoId?, albumId, kind: likes|views, total, statVersion, source, confirmedAt});对账 `POST /internal/sync/reconcile`{entityType, sinceVersion, limit} → 缺失清单;全量快照 `POST /internal/sync/bootstrap`{albumId?}→{snapshotEventIds[]}(经对账通道分批拉取)
- [ ] constants:SYNC 批量/间隔、VISIT_WINDOW_MINUTES、REALTIME 心跳等 env 键名与默认值入 `.env.example`
- [ ] gen + lint + 全回归绿;Commit `feat(contracts): p2 realtime, likes, visits and internal sync contracts`
## Task 2: ov 数据层
**Files:** Create `packages/db-ov/`(mirror db-cn 模式:package/schema/src);`services/api` ov 入口骨架 `src/main-ov.ts` + `app-ov.module.ts`;compose ov 侧 profile(mysql-ov 3307、redis-ov 6381,127.0.0.1 绑定);`test/reset-ov.ts`(api 包共用,按连接串区分)
- [ ] ov schema:`albums_ov`(cn 副本:publicToken/状态/开关/翻译 JSON/分类 JSON/banner 对象键/版本)、`photos_ov`(photoId/albumId/分类/宽高/objectKeys/likeTotal/countVersion/visibility/版本/publishedAt)、`sync_inbox`(eventId 唯一、entityType/entityId/entityVersion、tombstone、payload、receivedAt/appliedAt)、`sync_state`(entityType+entityId → appliedVersion,水位表 per source)、`like_batches`(photoId+sessionId+batchId 唯一、delta、createdAt + 索引/清理窗口)、`album_stats`(albumId 主键 viewCount/updatedAt/statVersion)、`visit_sessions`(albumId+visitorId 唯一、lastCountedAt)、`visit_requests`(requestId 唯一、counted、createdAt)、`outbox_stat_events`(ov→cn 回传队列)
- [ ] 迁移走固化校对手决流程;14→N 表全 bin 校对验证;健康检查 + /health
- [ ] Commit `feat(ov): ov schema, entry skeleton and compose profile`
## Task 3: cn 事件补全 + sync 发送器(cn→ov)
**Files:** Create `services/sync/`(@photo/sync:sender + scheduler);Modify `services/api/src/albums|photos|public`(事件补全)、`packages/db-cn/prisma/schema.prisma`(albums/categories/album_assets 增 `entityVersion` 列——迁移走校对手决流程)
- [ ] **cn 事件补全(评审缺口 1)**:albums 服务在 创建/更新(含开关、下线 status、translations 替换)时写 outbox `album.updated`(下线与 publicEnabled=false → 高优先级);categories 增改删 → `category.*`(删除=tombstone);assets complete 生效 → `asset.updated`;各表 entityVersion 条件自增(同 photos 模式:更新事务内 +1);payload 携带 ov 所需展示字段(translations JSON、开关、banner 对象键、分类翻译)
- [ ] **首次全量快照**:`POST /internal/sync/bootstrap`{albumId?}——对指定相册(或全部)生成当前快照事件序列(album→categories→assets→photos 含版本),ov 冷启动或新建副本时经对账通道拉取(§2A"首次全量快照携带水位")
- [ ] outbox 发送器:轮询 outbox_events(未 delivered)按优先级(高优先级=相册下线/照片隐藏删除 tombstone 类,即发)批量 HMAC 签名推送 content-batches → ov 返回 {acceptedEventIds, appliedWatermarks} → 回写 deliveredAt;失败退避重试,绝不丢失(deliveredAt 为空即重发);eventId 幂等由 ov 保证
- [ ] 集成测试:cn 发布照片→sender→ov inbox applied→photos_ov 出现;**相册更新(开关/翻译)→ albums_ov 副本同步**;重复推送→幂等;乱序版本→旧版本忽略;墓碑→副本删除且不被旧事件复活(A26 核心);bootstrap 冷启动建全套副本
- [ ] Commit `feat(sync): cn event coverage, bootstrap and outbox sender`
## Task 4: inbox 接收与对账(ov 侧)
**Files:** `services/api/src/sync-inbox/`(ov module);sync 服务补漏调度
- [ ] inbox 端点(internal 鉴权):事务内 eventId 唯一插入→entityVersion 比较(sync_state)→应用或忽略→依赖缺失(相册未到而照片先到)暂存不 applied、待依赖事件到达后补应用(或对账补取);墓碑优先;applied 后应答
- [ ] 对账:定时(5min)按 sync_state 水位向 cn 请求 reconcile 清单→拉缺失→重放;同步状态表供管理查询(延迟、积压——P4a 展示,本阶段出 API/DB)
- [ ] 集成测试:断续中断(停 sender)→恢复后补齐;隐藏优先于发布乱序到达(先 photo.hidden 后 photo.published 旧版本→保持隐藏)
- [ ] Commit `feat(sync): ov inbox with versioning, tombstones and reconciliation`
## Task 5: 海外紧急停用入口
- [ ] ov 侧 `POST /internal/emergency/disable`{albumToken|albumId, reason}(internal 鉴权):ov DB 直接置 `albums_ov.emergency_disabled=true`(独立于同步版本——不被任何后续同步事件解除,解除仅经显式 enable 端点);公开 API/WS 检查该标志;cn 侧不感知(文档化);审计写 ov 侧日志文件(无 cn audit——登记偏差)
- [ ] 测试:停用→ov 公开 404→cn 继续同步新照片(ov 保持屏蔽)→显式启用恢复
- [ ] Commit `feat(ov): emergency disable channel independent of sync`
## Task 6: ov 公开读 API
- [ ] ov-app 实现 P1 同形公开三端点(album/photos 列表/单图)读 photos_ov 副本 + 最新/热门排序(sort=latest|hot,hot=likeTotal 排序短期快照——30s 缓冲快照表或内存缓存)+ banner 签名 URL(ov 侧持有 MinIO/OSS 只读凭证——本地同 MinIO)
- [ ] viewer 切换:API base env `VITE_API_BASE`(dev 默认 ov :8081);全 viewer e2e 迁移跑 ov 端(P1 套件保持绿=契约同形证明)
- [ ] Commit `feat(ov): public read api on replicas with hot sorting`
## Task 7: 点赞与实时(ov)
- [ ] likes 端点:事务(like_batches 唯一插入→photos_ov.likeTotal 原子+delta、countVersion++→outbox_stat_events + 实时 burst 事件入队);重复 batchId 幂等返回;基础限速(sessionId+IP 滑窗,Redis ov);delta 越界 400;照片/相册非公开 404
- [ ] WS 网关(NestJS ws adapter on ov):subscribe 校验相册公开→加入相册房间;广播 likes.burst(短窗 500ms 合并、保留 originBatchIds 供自身过滤)、photo.*、stats.updated;心跳 20s 踢死链;断线客户端降级轮询(viewer 侧 Task 10)
- [ ] (契约含 POST /internal/sync/bootstrap——Task 1 已列)集成测试:A09(连点累计、重复请求不加计)、A10(两相册隔离:房间不串)、A21 部分(合并广播 batch 过滤在后端侧验证 originBatchIds 正确性)
- [ ] Commit `feat(ov): idempotent batched likes and realtime broadcast`
## Task 8: 浏览统计(ov)+ 去重数据清理
- [ ] visits 端点:事务(visit_requests requestId 唯一→visit_sessions 锁行/upsert→窗口判断(VISIT_WINDOW_MINUTES)→counted 则 album_stats.viewCount++、statVersion++→应答 {viewCount, counted});并发首访唯一(唯一约束+重试);过期 requestId 拒绝;GET album 返回 viewCount(真实值,替换 P1 占位 null)
- [ ] 上报排除规则落地:接口仅显式调用(列表/图片/WS 皆不触发——测试断言);基础限速(sessionId 滑窗——去重本身为 P2 主缓解,登记)
- [ ] **清理调度(评审缺口 2)**:ov 定时任务清理 `like_batches`/`visit_requests`/`visit_sessions` 超窗口数据(LIKE_BATCH_WINDOW 默认 24h、VISIT_REQUEST_TTL 同窗口;累计总数长期保留);**过期批次拒绝重放**:likes 端点遇窗口外 batchId → 拒绝并返回当前权威 total(counted=false 语义),不得重新加计(测试:清理后重放旧 batchId 不加计)
- [ ] 集成测试:A14 仿真级(窗口内刷新不重复、跨 requestId 重试幂等、新窗口再计、并发首访只加一次、过期重放拒绝)
- [ ] Commit `feat(ov): windowed dedup visit counting with retention cleanup`
## Task 9: 统计回传(ov→cn)
- [ ] **cn 副本列迁移**(走校对手决流程):`albums.view_count_replica` + `albums.stats_synced_at`(photos.like_count 已存在作副本位)
- [ ] ov 定时(60s)聚合 outbox_stat_events→stat-snapshots(HMAC)→cn 接收端(internal)事务替换 cn 侧 photos.like_count 副本/album view 副本(仅更高 statVersion 替换,绝不相加)+ 回执确认→ov 清理队列;中断重发幂等(statVersion 比较)
- [ ] cn 管理列表(listAlbumPhotos/albums)展示副本计数 + `statsSyncedAt`(A27:不重复累计、最终一致、展示同步时间)
- [ ] 集成测试:A27 仿真级(重复回传不加计、乱序快照旧版本忽略、最终一致断言)
- [ ] Commit `feat(sync): stat snapshot return path with version replacement`
## Task 10: viewer 互动化
- [ ] 点赞 UI:大图/列表点击→本地动画+计数即时、批次收集(500ms 或 5 张一批)提交 {sessionId(会话随机), batchId, delta}→确认校准(total 权威);失败退避重试同 batchId;自身气泡过滤(originBatchIds 含己 batchId→抑制己部动画,他人照常)
- [ ] 气泡:WS 连接(断线降级 8s 轮询列表页首校准 + likes 总数校准;不补播历史气泡);气泡含被赞照片小缩略图+点击定位;页面不可见暂停动画(visibilitychange);"减少动画"设置(localStorage,隐藏气泡仅保计数跳动)
- [ ] 浏览上报:visitorId 跨标签共享(localStorage+BroadcastChannel 协调写、读回一致;不可用降级页面级随机)、页面可见且 album 加载成功后一次性 {visitorId, requestId(uuid 持久到 counted 确认为止)}、确认后更新显示
- [ ] 热门/最新排序切换 UI(排序=快照语义:切换重新拉列表,当前浏览序列不变——对齐技术方案 §7)
- [ ] Playwright:点赞累计+刷新校准、自身气泡不重复(同页点赞看他人模拟广播)、断线重连计数校准(断 WS→续)、排序切换、隐藏照片气泡/列表即时消失
- [ ] Commit `feat(viewer): likes, bubbles, visit reporting and hot sorting`
## Task 11: 全链路集成测试(同步+互动)
- [ ] 场景套件(集成级,cn+ov+sync+worker 全起):①上传→发布→同步→ov 可见→点赞→回传→cn 副本一致(A25 本地直读语义+A27)②隐藏→高优先级同步→ov 消失+WS 推送隐藏③sender 停机 30s(积压)→恢复→追平+对账无缺④墓碑后旧事件重放不复活(A26)⑤紧急停用→ov 屏蔽→cn 无感⑥并发点赞/浏览压力小样(50 并发)幂等
- [ ] Commit `test(sync): cross-region simulation integration suite`
## Task 12: 回归门 + P2 验收记录
- [ ] 全量回归绿(P1 套件不降级 + 新增);记录数字
- [ ] `docs/plans/progress/P2-验收记录.md`(A09/A10/A14/A21/A26/A27 仿真级证据、D07/D11 实测建议值、偏差登记、P4a/P5 移交:真实地域部署/CDN/管理页同步状态展示/清理任务生产化)
- [ ] 总纲 P2 行进度注记;`git tag p2`;Commit `docs: p2 acceptance record`
---
## 执行注意
- 沿用 P1 全部工程约定(校对手决、reset 隔离、turbo concurrency=1、maxWorkers=1、契约先行+gen、Co-Authored-By trailer、偏差登记)。
- ov 入口与 cn 入口同一 services/api 代码库:模块按部署角色组织(sync-inbox/likes/visits/ws 仅 ov 装配;auth/albums/uploads 仅 cn 装配)——app-ov.module.ts 显式挑选。
- 双数据库连接:PrismaService(cn) 已全局;新增 OvPrismaService 独立 provider(非 global,按模块注入);测试 reset 区分两库。**env 键名明确**:`DATABASE_URL_OV`(mysql://...3307/photo_ov)、`REDIS_URL_OV`(redis://...6381)。
- **偏差预登记(评审建议)**:ov 侧"确认资源可访问再设可见"的 head 探测在本地仿真不做(同 MinIO 必然可达),真实 OSS/CDN 探测随 G1/P5;cn 侧相册浏览数副本落新列 `albums.view_count_replica`(Task 9 迁移)。
- 内部接口 HMAC:`X-Sync-Signature: hmacsha256(body, SYNC_INTERNAL_KEY)` + `X-Sync-Timestamp`(±300s 防重放);key 仅 env。
- WS 与 HTTP 同端口(ov 8081);dev:sync / dev:ov 根脚本补齐。
- 性能口径(P5 压测才硬性):本阶段并发小样仅验语义正确性,不做容量结论。