feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)

- dto.ts SYNC_ENTITY_TYPES 追加 'ad'(对齐契约 SyncEntityType)。
- decideAndApply ③依赖检查豁免 ad:albumId 恒空串,不豁免则事件被永久
  暂存且无解锁路径(slot-state 同款空 albumId 危害)。
- project() 新增 ad 分支:全量快照直投 ads_ov;墓碑或 enabled=false 均
  deleteMany(disabled=公开不可见=无投影);albumIds 整体替换;createdAt
  取 cn 原值、updatedAt 为 ov 应用时刻(载荷无该字段)。
- sender.toWireEvent 新增 ad 分支:entityId 取载荷 adId(outbox 表无列),
  泛化 categoryId/assetId 兜底之前,缺料 fail-fast。
- e2e ads.e2e-spec:手工构造 outbox 行(绕过 CRUD,Task 7 才有)走真实
  sender→HTTP→inbox 链路,5 用例覆盖快照落位/disable 删行/复活 upsert/
  迟到旧版本不回滚/墓碑删行;reset-ov 增清 ads_ov。
- db-ov schema 附带注释修:ads_ov updatedAt 语义纠正(迁移头过度声明,
  迁移文件不动防 checksum 漂移)+ sync_inbox.entityType 枚举补 ad。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 23:18:37 +08:00
parent d86f452bed
commit 4db90b0e9e
6 changed files with 333 additions and 9 deletions
+1
View File
@@ -27,6 +27,7 @@ export const SYNC_ENTITY_TYPES = [
'photo',
'asset',
'slot-state',
'ad',
] as const;
/** 契约 SyncPriority。 */
@@ -11,7 +11,8 @@
// (同事件重投递走 ① 的 eventId 幂等锚,根本到不了版本比较);
// ③ 依赖缺失暂存:非 album 事件在 albums_ov 无所属相册行时保持 appliedAt
// null(暂存不 applied,不投影);album 事件应用后按 albumId 回扫暂存
// 队列补应用(相册到达即解锁该相册全部暂存事件);
// 队列补应用(相册到达即解锁该相册全部暂存事件);slot-state/ad 豁免
// (albumPublicToken 弱关联 / 无相册依赖,见 decideAndApply ③ 注);
// ④ 应用投影(版本不低于已应用):album → albums_ov upsert(不触碰
// bannerAssetKey/emergencyDisabled——分别归 asset.updated / 紧急停用通道
// Task 5;categories 仅当载荷携带全量清单(bootstrap/reconcile 快照事件)
@@ -23,6 +24,8 @@
// 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo
// 仅推进版本,ov schema 无位);slot-state(P4b Task 2)→ embed_slots_ov
// 全量快照直投(albumPublicToken 弱关联相册、无依赖暂存,见 ③ 注);
// ad(广告位 2026-09-28)→ ads_ov 全量快照直投,墓碑或 enabled=false 均
// 删行(disabled=公开不可见=无投影);
// ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行预留)。
// ⑥ 实时转发(Task 7):photo 事件**应用事务提交后**发布 OV_PHOTO_APPLIED
// 进程内事件 → realtime 网关向相册房间广播契约 photo.* 消息(stale/
@@ -35,6 +38,7 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { components } from '@photo/contracts';
import type {
AdUpdatedPayload,
AlbumUpdatedPayload,
AssetUpdatedPayload,
CategoryDeletedPayload,
@@ -173,8 +177,14 @@ export class SyncInboxService {
// ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。slot-state
// 豁免:展示位副本以 albumPublicToken 弱关联相册(公开读时按 albums_ov
// 现查,缺席=404 与相册下线同形),且解绑事件 albumId 为空串——相册行的
// 到达顺序对副本正确性无影响,暂存反而会让解绑事件无限积压。
if (event.entityType !== 'album' && event.entityType !== 'slot-state') {
// 到达顺序对副本正确性无影响,暂存反而会让解绑事件无限积压。ad 豁免:
// ad 无相册依赖(albumId 恒空串,绑定关系在载荷 albumIds),直接应用
// ——不豁免则空串在 albums_ov 永远查不到,事件被永久暂存且无解锁路径。
if (
event.entityType !== 'album' &&
event.entityType !== 'slot-state' &&
event.entityType !== 'ad'
) {
const album = await tx.albums_ov.findUnique({
where: { albumId: event.albumId },
select: { albumId: true },
@@ -436,6 +446,40 @@ export class SyncInboxService {
});
return undefined;
}
// ad(广告位 2026-09-28):全量快照直投 ads_ov;墓碑或 enabled=false 快照
// 均表达为删行(disabled=公开不可见=无投影,设计 §2.2/§4)。albumIds
// 整体替换;createdAt 取 cn 原值(排序 tie-break)。
if (event.entityType === 'ad') {
const p = event.payload as unknown as AdUpdatedPayload;
if (event.tombstone === true || p.enabled === false) {
await tx.ads_ov.deleteMany({ where: { adId: event.entityId } });
} else {
await tx.ads_ov.upsert({
where: { adId: event.entityId },
create: {
adId: event.entityId,
imageObjectKey: p.imageObjectKey,
linkUrl: p.linkUrl,
sortOrder: p.sortOrder,
albumIds: p.albumIds as unknown as Prisma.InputJsonValue,
entityVersion: event.entityVersion,
createdAt: new Date(p.createdAt),
// ov 应用时刻(载荷无 updatedAt——投影行该列非 cn 原值)。
updatedAt: new Date(),
},
update: {
imageObjectKey: p.imageObjectKey,
linkUrl: p.linkUrl,
sortOrder: p.sortOrder,
albumIds: p.albumIds as unknown as Prisma.InputJsonValue,
entityVersion: event.entityVersion,
createdAt: new Date(p.createdAt),
updatedAt: new Date(),
},
});
}
return undefined;
}
return undefined;
}