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
@@ -664,6 +664,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/internal/sync/digest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 每相册实体计数摘要
* @description cn 侧权威计数(P2 Task 4):每相册 {categoryCount, publishedPhotoCount,
* photoCount, assetCount}。ov 对账调度定期拉取并与副本计数比对——不一致
* 的相册触发该相册 bootstrap(当前状态快照重发,含分类全量清单整体替换),
* 收敛错过的墓碑与漏掉的事件。albumId 缺省表示全部相册。鉴权见 [internal]
* tag 描述。
*/
post: operations["syncDigest"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1376,9 +1400,14 @@ export interface components {
*/
limit: number;
};
/** @description 对账结果:按 entityVersion 升序的事件(无缺失为空数组)。 */
/**
* @description 对账结果:按 entityVersion 升序的**当前状态事件**(可直接重放的完整
* SyncEvent,非仅版本清单——P2 Task 4 设计决策:重放走既有
* content-batches 管道,无需第二条拉取通道;sinceVersion 之后无实体时为
* 空数组)。
*/
ReconcileResponse: {
/** @description 缺失事件(升序);请求方按序重放。 */
/** @description 当前状态事件(升序);请求方按序经 content-batches 重放。 */
events: components["schemas"]["SyncEvent"][];
};
/** @description 全量快照引导请求。 */
@@ -1396,6 +1425,35 @@ export interface components {
*/
watermark: number;
};
/** @description 对账摘要请求(P2 Task 4)。 */
DigestRequest: {
/** @description 指定相册;缺省为全部相册。 */
albumId?: string;
};
/**
* @description 单相册权威计数摘要。ov 侧比对口径:albums_ov.categories 元素数应等于
* categoryCount;photos_ov 行数落在 [publishedPhotoCount, photoCount]
* 区间内即视为一致(hidden/deleted 照片若从未以 published 态同步过,ov
* 合法地无行——缺席即撤下语义);相册行缺失即不一致。assetCount 供观测
* (ov 无 asset 副本行,不参与比对)。
*/
AlbumDigest: {
/** @description 相册 ID。 */
albumId: string;
/** @description 当前分类数(cn 权威行数)。 */
categoryCount: number;
/** @description 可见(published)照片数——ov 必须持有的副本行数下界。 */
publishedPhotoCount: number;
/** @description published+hidden+deleted 照片总数——ov 副本行数上界。 */
photoCount: number;
/** @description 生效(effective)资产数(观测用;ov 无 asset 副本行)。 */
assetCount: number;
};
/** @description 每相册计数摘要(albumId 升序)。 */
DigestResponse: {
/** @description 相册摘要清单;空库为空数组。 */
albums: components["schemas"]["AlbumDigest"][];
};
};
responses: {
/** @description 未认证:令牌缺失、无效或已过期(AUTH_TOKEN_EXPIRED)。 */
@@ -2916,4 +2974,38 @@ export interface operations {
500: components["responses"]["InternalError"];
};
};
syncDigest: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DigestRequest"];
};
};
responses: {
/** @description 每相册计数摘要(albumId 升序)。 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DigestResponse"];
};
};
/** @description 请求体校验失败(VALIDATION_FAILED)。 */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
500: components["responses"]["InternalError"];
};
};
}