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
+94 -2
View File
@@ -1369,6 +1369,40 @@ paths:
'500':
$ref: '#/components/responses/InternalError'
/internal/sync/digest:
post:
operationId: syncDigest
tags: [internal]
summary: 每相册实体计数摘要
description: |
cn 侧权威计数(P2 Task 4):每相册 {categoryCount, publishedPhotoCount,
photoCount, assetCount}。ov 对账调度定期拉取并与副本计数比对——不一致
的相册触发该相册 bootstrap(当前状态快照重发,含分类全量清单整体替换),
收敛错过的墓碑与漏掉的事件。albumId 缺省表示全部相册。鉴权见 [internal]
tag 描述。
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DigestRequest'
responses:
'200':
description: 每相册计数摘要(albumId 升序)。
content:
application/json:
schema:
$ref: '#/components/schemas/DigestResponse'
'400':
description: 请求体校验失败(VALIDATION_FAILED)。
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
$ref: '#/components/responses/InternalError'
components:
securitySchemes:
bearerAuth:
@@ -2719,12 +2753,16 @@ components:
ReconcileResponse:
type: object
description: 对账结果:按 entityVersion 升序的事件(无缺失为空数组)。
description: |
对账结果:按 entityVersion 升序的**当前状态事件**(可直接重放的完整
SyncEvent,非仅版本清单——P2 Task 4 设计决策:重放走既有
content-batches 管道,无需第二条拉取通道;sinceVersion 之后无实体时为
空数组)。
required: [events]
properties:
events:
type: array
description: 缺失事件(升序);请求方按序重放。
description: 当前状态事件(升序);请求方按序经 content-batches 重放。
items:
$ref: '#/components/schemas/SyncEvent'
@@ -2750,3 +2788,57 @@ components:
format: int64
minimum: 0
description: 快照水位:后续增量对账的 sinceVersion 起点。
DigestRequest:
type: object
description: 对账摘要请求(P2 Task 4)。
properties:
albumId:
type: string
description: 指定相册;缺省为全部相册。
AlbumDigest:
type: object
description: |
单相册权威计数摘要。ov 侧比对口径:albums_ov.categories 元素数应等于
categoryCount;photos_ov 行数落在 [publishedPhotoCount, photoCount]
区间内即视为一致(hidden/deleted 照片若从未以 published 态同步过,ov
合法地无行——缺席即撤下语义);相册行缺失即不一致。assetCount 供观测
(ov 无 asset 副本行,不参与比对)。
required:
- albumId
- categoryCount
- publishedPhotoCount
- photoCount
- assetCount
properties:
albumId:
type: string
description: 相册 ID。
categoryCount:
type: integer
minimum: 0
description: 当前分类数(cn 权威行数)。
publishedPhotoCount:
type: integer
minimum: 0
description: 可见(published)照片数——ov 必须持有的副本行数下界。
photoCount:
type: integer
minimum: 0
description: published+hidden+deleted 照片总数——ov 副本行数上界。
assetCount:
type: integer
minimum: 0
description: 生效(effective)资产数(观测用;ov 无 asset 副本行)。
DigestResponse:
type: object
description: 每相册计数摘要(albumId 升序)。
required: [albums]
properties:
albums:
type: array
description: 相册摘要清单;空库为空数组。
items:
$ref: '#/components/schemas/AlbumDigest'