diff --git a/services/sync/src/reconciler.ts b/services/sync/src/reconciler.ts index ed64bdd..056db31 100644 --- a/services/sync/src/reconciler.ts +++ b/services/sync/src/reconciler.ts @@ -1,10 +1,13 @@ // services/sync/src/reconciler.ts —— ov 补漏对账调度器(P2 Task 4)。 // runOnce() 两阶段,每 SYNC_RECONCILE_INTERVAL_MS 一轮(main 循环驱动): // -// 阶段一「当前态清扫」:对 album/category/photo/asset 四类逐类分页拉取 cn -// reconcile 清单(**当前状态事件**,非版本清单——Task 4 设计决策:重放走 +// 阶段一「当前态清扫」:对 album/category/photo/asset/ad 五类逐类分页拉取 +// cn reconcile 清单(**当前状态事件**,非版本清单——Task 4 设计决策:重放走 // 既有 content-batches 管道即幂等,无需第二条拉取通道),直接签名推送到 ov -// content-batches。sinceVersion 取 0(全量当前态)而非「per-type 已应用最大 +// content-batches。ad(ads-carousel Task 8)经此环获得 digest 之外的自愈 +// 通道——digest 是相册维度计数,覆盖不了无相册归属的广告;错过的 ad.* +// 推送(含 enabled=false 下线与墓碑后行已删的终态)由清扫的全量当前态 +// 快照重放收敛。sinceVersion 取 0(全量当前态)而非「per-type 已应用最大 // 版本」——cn reconcile 以**类型级全局版本阈值**过滤,类型内单实体滞后 // (如 A 实体已 v9、B 实体 v3→v4)会被 max 掩蔽;0 则由 ov 的 eventId 幂等 // + 版本单调把「已追平的实体」全部判重/判旧(确定性 recon::: @@ -43,8 +46,8 @@ const DIGEST_PATH = '/v1/internal/sync/digest'; const BOOTSTRAP_PATH = '/v1/internal/sync/bootstrap'; const CONTENT_BATCHES_PATH = '/v1/internal/sync/content-batches'; -/** 清扫覆盖的实体类型(slot-state 预留,无当前态可拉)。 */ -const SWEEP_ENTITY_TYPES = ['album', 'category', 'photo', 'asset'] as const; +/** 清扫覆盖的实体类型(slot-state 预留,无当前态可拉;ad 无相册维度,清单恒全量)。 */ +const SWEEP_ENTITY_TYPES = ['album', 'category', 'photo', 'asset', 'ad'] as const; const DEFAULT_RECONCILE_LIMIT = 500; // 任务口径单页上限(契约上限 1000) const DEFAULT_PUSH_BATCH_SIZE = 100; // content-batches 契约单批上限 @@ -72,7 +75,7 @@ export interface SyncReconcilerOptions { /** 单轮 runOnce 的观测口径(main 循环打点;测试用于断言追赶/补漏/稳态)。 */ export interface ReconcileStats { - /** 阶段一从 cn 拉到的当前态事件数(四类型合计)。 */ + /** 阶段一从 cn 拉到的当前态事件数(五类型合计)。 */ sweptEvents: number; /** 阶段一重放中 ov 实际应用(新版本/同版本重断言)的条数。 */ replayApplied: number; diff --git a/services/sync/test/reconcile.e2e-spec.ts b/services/sync/test/reconcile.e2e-spec.ts index b04af5c..e81dc2b 100644 --- a/services/sync/test/reconcile.e2e-spec.ts +++ b/services/sync/test/reconcile.e2e-spec.ts @@ -16,7 +16,9 @@ // ⑤ (ads-carousel Task 8)cn 对账 'ad' 清扫:直种两行 ads(全局+定向)→ // 签名 POST /internal/sync/reconcile {entityType:'ad'} → 2 条当前态 // wire 事件(albumId 恒空串、载荷=全量快照、确定性 recon:ad:{id}:{v} -// 合成 eventId,sortOrder 升序确定性排序)。 +// 合成 eventId,sortOrder 升序确定性排序);随后 runOnce 走生产清扫环 +// (SWEEP_ENTITY_TYPES 含 'ad')→ ads_ov 投影就位——错过的 ad.* 推送 +// 由此获得 digest 之外的自愈通道(digest 相册维度覆盖不了 ads)。 import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; import request from 'supertest'; @@ -467,5 +469,34 @@ describe('sync reconcile + digest healing (e2e)', () => { entityVersion: 1, }, }); + + // 生产清扫环贯通:runOnce 阶段一逐类拉清单(含 'ad')→ 签名推送 ov → + // ads_ov 投影就位(模拟「ad.* outbox 事件全部投后即失」——清扫当前态 + // 快照重放即补齐,ov 侧 eventId 幂等 + 版本单调承担判重)。 + const stats = await reconciler.runOnce(); + // album v5 + photo×2 v2 + ad×2(category ③ 已删、asset 无行)。 + expect(stats.sweptEvents).toBe(5); + // 仅 ad 两条为新合成 eventId(album/photo 的 recon 事件 ①-④ 已入账判重)。 + expect(stats.replayApplied).toBe(2); + // digest 相册维度不受 ads 影响:稳态无 bootstrap。 + expect(stats.mismatchedAlbums).toEqual([]); + expect( + await prismaOv.ads_ov.findUnique({ where: { adId: 'recon-ad-global' } }), + ).toMatchObject({ + adId: 'recon-ad-global', + imageObjectKey: 'photo-live/dev/ads/recon-ad-global/v1.jpg', + linkUrl: 'https://sponsor.example/global', + sortOrder: 10, + albumIds: null, + entityVersion: 1, + }); + expect( + await prismaOv.ads_ov.findUnique({ where: { adId: 'recon-ad-targeted' } }), + ).toMatchObject({ + adId: 'recon-ad-targeted', + sortOrder: 20, + albumIds: [albumId], + entityVersion: 1, + }); }); });