Files
photo-live/docs/plans/2026-09-18-P2-双地域仿真与互动.md
T
浅念 3d6e9ce175 feat(ov): ov schema, entry skeleton and compose profile
P2 Task 2 ov 数据层:
- compose ov profile:mysql-ov(mysql:8.4,127.0.0.1:3309,库级
  utf8mb4_0900_bin)+ redis-ov(6381),cn 侧默认 up 不受影响
  (3307/3308 已被其它本机项目占用,端口决策 3309)
- @photo/db-ov:9 表 schema(albums_ov/photos_ov 副本、sync_inbox/
  sync_state 同步账本(水位行同表标志位)、like_batches/album_stats/
  visit_sessions/visit_requests 互动域、outbox_stat_events 回传队列);
  迁移 20260918060102_init_ov 走固化校对手决流程,information_schema
  复核全部业务表/列 utf8mb4_0900_bin(_prisma_migrations 同 cn 先例
  保持 unicode_ci)
- generator 独立 output(包内 generated/):pnpm 将两包 @prisma/client
  提升为同一 store 实例,默认 .prisma/client 输出会被双 schema 互相
  覆盖,ov 客户端独立产出经 src/index.ts 转出口
- ov 入口骨架:main-ov.ts(PORT_OV 默认 8081)+ app-ov.module.ts
  (仅管线骨架 + /v1/health,feature modules 后续任务装配);logger
  装配选项抽取至 shared/logger-options.ts 供 cn/ov 双入口共用(行为
  不变,P1 套件守护)
- test/reset-ov.ts:ov 库 truncate-if-exists 隔离工具(无 MinIO 清理)
- 根脚本:db:generate 双库齐跑,新增 db-ov:migrate/db-ov:generate/dev:ov

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 14:11:41 +08:00

125 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
- [x] 契约新增:
- **公开互动端点(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[]}(经对账通道分批拉取)
- [x] constants:SYNC 批量/间隔、VISIT_WINDOW_MINUTES、REALTIME 心跳等 env 键名与默认值入 `.env.example`
- [x] 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 3309、redis-ov 6381,127.0.0.1 绑定);`test/reset-ov.ts`(api 包共用,按连接串区分)
- [x] 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 回传队列)
- [x] 迁移走固化校对手决流程;14→N 表全 bin 校对验证;健康检查 + /health
- [x] 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 压测才硬性):本阶段并发小样仅验语义正确性,不做容量结论。