feat(sync): reconciliation scheduler with digest and bootstrap healing

P2 Task 4(inbox 对账):
- services/sync 新增 SyncReconciler(runOnce 可测;main 双循环,间隔
  SYNC_RECONCILE_INTERVAL_MS env 可调,CN_API_URL 指向 cn 入口):每轮
  先按 album/category/photo/asset 逐类分页拉取 cn reconcile「当前状态
  事件」并直接签名推送到既有 content-batches 管道。sinceVersion 取 0
  (全量当前态)而非 per-type 已应用最大版本——cn reconcile 以类型级
  全局版本阈值过滤,类型内单实体滞后会被 max 掩蔽;ov 的 eventId 幂等
  + 版本单调把已追平实体全部判重(recon 确定性 eventId 重扫零成本),
  偏差登记:计划原文对账返回「缺失清单」,契约已定为可重放 SyncEvent
  数组(免建第二条拉取通道),openapi 描述同步更新
- cn 新增 POST /internal/sync/digest:每相册权威计数 {categoryCount,
  publishedPhotoCount, photoCount, assetCount};ov 比对副本计数(照片
  取 [publishedPhotoCount, photoCount] 区间口径——hidden/deleted 照片
  若从未以 published 态同步过 ov 合法无行,缺席即撤下),不一致相册
  自动触发 cn bootstrap 重建;ov-only 相册(cn 行已删,bootstrap 无法
  表达删除)告警登记不自动处置
- 收敛语义两处落位:① sync-inbox 版本比较改「严格更旧才拒绝」——
  同版本新 eventId(bootstrap/reconcile 快照重发)为当前态幂等重断言
  应用,修复 ov 缺失副本行;A26 不受影响(更旧版本仍拒绝,cn 侧
  entityVersion 与状态一一对应)② album.updated 快照载荷(bootstrap/
  reconcile)携带分类全量清单,ov 存在即整体替换 albums_ov.categories
  ——修复错过的 category 墓碑(Task 3 登记边界:cn 行已删,无现存实体
  事件可重放,唯全量替换可收敛)
- e2e(reconcile.e2e-spec):漏事件追赶(ov v1/cn v5 事件投后即失)→
  清扫追平;digest 修复缺失 photos_ov 行(bootstrap 重发 → 同版本
  重断言重建);digest 修复丢失的分类墓碑(全量清单替换收敛);稳态
  空转(applied=0、无 bootstrap);Task 3 套件保持绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 16:18:31 +08:00
parent 65726702f3
commit 36082c5a74
13 changed files with 1179 additions and 63 deletions
+16 -1
View File
@@ -34,8 +34,10 @@ export interface PhotoPublishedPayload {
}
// album.updated 载荷:ov 副本(albums_ov)展示一张相册所需的标量 + 翻译全集。
// categories 不在此列——分类是独立事件(category.*,各自携带 entityVersion);
// categories 默认不在此列——分类是独立事件(category.*,各自携带 entityVersion);
// banner 对象键经 asset.updated 事件同步(albums_ov.bannerAssetKey)。
// 例外:bootstrap/reconcile 的快照事件携带可选 categories 全量清单(Task 4
// digest 补漏的收敛原语,见 AlbumCategorySnapshot 注释)。
export interface AlbumUpdatedPayload {
albumId: string;
publicToken: string;
@@ -49,9 +51,22 @@ export interface AlbumUpdatedPayload {
startAt: string; // ISO UTC
endAt: string | null; // ISO UTC;null 表示未定
translations: AlbumTranslationPayload[]; // locale 升序(确定性输出)
/** 分类全量清单(可选):存在时 ov **整体替换** albums_ov.categories。 */
categories?: AlbumCategorySnapshot[];
entityVersion: number;
}
// albums_ov.categories JSON 元素的载荷形状。日常增量由 category.updated 逐个
// upsert / category.deleted 墓碑逐个移除;但**错过的墓碑**(cn 行已删,对账
// 无法为不存在的行重放事件)只有「全量清单整体替换」才能收敛——bootstrap/
// reconcile 的 album.updated 快照事件据此携带当前全集(Task 4 digest→bootstrap
// 补漏通道)。缺省(增量事件不携带)时 ov 不触碰 categories,语义不变。
export interface AlbumCategorySnapshot {
id: string;
sortOrder: number;
translations: CategoryTranslationPayload[]; // locale 升序
}
export interface AlbumTranslationPayload {
locale: string;
title: string;