feat(sync): cn event coverage, bootstrap and outbox sender

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 15:21:41 +08:00
parent 3d6e9ce175
commit 65726702f3
43 changed files with 3333 additions and 181 deletions
+419
View File
@@ -0,0 +1,419 @@
// services/api/src/sync/sync.service.ts —— cn 侧内部同步服务(P2 Task 3):
// bootstrap(全量快照引导)+ reconcile(缺失事件对账清单)。两端的共同设计
// 决策(与计划文档 §2A「首次全量快照携带水位」对齐,登记为 Task 3 设计):
//
// · bootstrap = 「把当前状态快照事件**写回 outbox**」,而不是另建拉取通道——
// ov 冷启动时经本端点触发 cn 入队,随后由常规发送器(services/sync)批量
// HMAC 推送、ov 按既有 content-batches 幂等/版本逻辑应用。好处:快照与增量
// 走同一条已被验证的管道(无第二条拉取路径),ov 侧无需新增消费代码;
// 重复 bootstrap 幂等(同实体同版本的新 eventId 在 ov 被 entityVersion
// 比较忽略)。eventCount=入队事件数,watermark=入队事件的最高 entityVersion
// (契约语义「后续增量对账的 sinceVersion 起点」的全局保守值;reconcile 的
// sinceVersion 按 per-entity 版本过滤,见下)。
//
// · reconcile = 按 entityType 返回「当前状态」事件清单(entityVersion >
// sinceVersion 的实体,升序、limit 截断)——数据源是 cn 权威表而非 outbox
// 历史流水(快照即最终状态,ov 重放即补齐;outbox 中的中间版本无需重放,
// 高版本载荷已包含最终状态)。eventId 为确定性合成 ID(recon:<type>:<id>:
// <version>):与真实 outbox eventId(cuid)永不冲突,同一 (实体,版本) 的
// 合成事件无论重放多少次在 ov 侧都幂等(eventId 唯一 + 版本比较双保险)。
// 已知边界(登记):硬删除实体(category 墓碑后行已删)不再出现在清单中,
// 删除语义依赖事件推送而非对账补漏。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import { PrismaService } from '../prisma/prisma.service';
import {
buildAlbumUpdatedPayload,
buildAssetUpdatedPayload,
buildCategoryUpdatedPayload,
buildPhotoPublishedPayload,
} from './payloads';
export type BootstrapResponse = components['schemas']['BootstrapResponse'];
export type ReconcileResponse = components['schemas']['ReconcileResponse'];
export type SyncEventWire = components['schemas']['SyncEvent'];
/** 线格式 schema 版本(契约 SyncEvent.schemaVersion 当前为 1)。 */
const SCHEMA_VERSION = 1;
/** 合成 eventId 前缀(与 cuid 分布不重叠,且可读可溯源)。 */
const RECON_EVENT_ID_PREFIX = 'recon';
@Injectable()
export class SyncService {
constructor(private readonly prisma: PrismaService) {}
/**
* 全量快照引导:单事务读当前状态(album→categories→assets→photos),
* 以同一套载荷构建器生成快照事件并写入 outbox(priority=normal,由发送器
* 常规批量投递)。albumId 指定不存在的相册 → 空快照(eventCount=0),不报
* 404——契约未定义 404,且对幂等重试语义更友好。
*/
async bootstrap(albumId?: string): Promise<BootstrapResponse> {
const occurredAt = new Date();
const result = await this.prisma.$transaction(async (tx) => {
const albums = await tx.albums.findMany({
where: albumId === undefined ? {} : { id: albumId },
});
const albumIds = albums.map((a) => a.id);
const albumTranslations =
albumIds.length === 0
? []
: await tx.album_translations.findMany({
where: { albumId: { in: albumIds } },
});
const categories =
albumIds.length === 0
? []
: await tx.categories.findMany({
where: { albumId: { in: albumIds } },
});
const categoryTranslations =
categories.length === 0
? []
: await tx.category_translations.findMany({
where: { categoryId: { in: categories.map((c) => c.id) } },
});
const assets =
albumIds.length === 0
? []
: await tx.album_assets.findMany({
where: { albumId: { in: albumIds }, state: 'effective' },
});
// 未发布(unpublished)照片对 ov 不可见语义,不入快照。
const photos =
albumIds.length === 0
? []
: await tx.photos.findMany({
where: {
albumId: { in: albumIds },
visibility: { in: ['published', 'hidden', 'deleted'] },
},
});
const variants =
photos.length === 0
? []
: await tx.photo_variants.findMany({
where: { photoId: { in: photos.map((p) => p.id) } },
});
const translationsByAlbum = new Map<string, typeof albumTranslations>();
for (const row of albumTranslations) {
const bucket = translationsByAlbum.get(row.albumId);
if (bucket === undefined) {
translationsByAlbum.set(row.albumId, [row]);
} else {
bucket.push(row);
}
}
const translationsByCategory = new Map<
string,
typeof categoryTranslations
>();
for (const row of categoryTranslations) {
const bucket = translationsByCategory.get(row.categoryId);
if (bucket === undefined) {
translationsByCategory.set(row.categoryId, [row]);
} else {
bucket.push(row);
}
}
const variantsByPhoto = new Map<string, typeof variants>();
for (const row of variants) {
const bucket = variantsByPhoto.get(row.photoId);
if (bucket === undefined) {
variantsByPhoto.set(row.photoId, [row]);
} else {
bucket.push(row);
}
}
const rows: Prisma.outbox_eventsCreateManyInput[] = [];
let watermark = 0;
const push = (
type: string,
albumIdOf: string,
photoId: string | null,
entityVersion: number,
payload: unknown,
): void => {
rows.push({
type,
albumId: albumIdOf,
photoId: photoId ?? undefined,
entityVersion,
priority: 'normal',
payload: payload as Prisma.InputJsonValue,
occurredAt,
});
watermark = Math.max(watermark, entityVersion);
};
for (const album of albums) {
push(
'album.updated',
album.id,
null,
album.entityVersion,
buildAlbumUpdatedPayload(
album,
translationsByAlbum.get(album.id) ?? [],
),
);
}
for (const category of categories) {
push(
'category.updated',
category.albumId,
null,
category.entityVersion,
buildCategoryUpdatedPayload(
category,
translationsByCategory.get(category.id) ?? [],
),
);
}
for (const asset of assets) {
push(
'asset.updated',
asset.albumId,
null,
asset.entityVersion,
buildAssetUpdatedPayload(asset),
);
}
for (const photo of photos) {
if (photo.visibility === 'published') {
push(
'photo.published',
photo.albumId,
photo.id,
photo.entityVersion,
buildPhotoPublishedPayload(
photo,
variantsByPhoto.get(photo.id) ?? [],
photo.entityVersion,
),
);
} else {
// hidden/deleted:与 photos.service 事务内事件同构的最小事实载荷。
push(
photo.visibility === 'hidden' ? 'photo.hidden' : 'photo.deleted',
photo.albumId,
photo.id,
photo.entityVersion,
{
photoId: photo.id,
albumId: photo.albumId,
visibility: photo.visibility,
entityVersion: photo.entityVersion,
},
);
}
}
if (rows.length > 0) {
await tx.outbox_events.createMany({ data: rows });
}
return { eventCount: rows.length, watermark };
});
return result;
}
/**
* 对账清单:entityType 的「当前状态」事件(entityVersion > sinceVersion,
* 升序、limit 截断)。数据源为 cn 权威表(见文件头设计说明)。
*/
async reconcile(query: {
entityType: string;
albumId?: string;
sinceVersion: number;
limit: number;
}): Promise<ReconcileResponse> {
const events: SyncEventWire[] = [];
if (query.entityType === 'album') {
const albums = await this.prisma.albums.findMany({
where: {
...(query.albumId !== undefined ? { id: query.albumId } : {}),
entityVersion: { gt: query.sinceVersion },
},
orderBy: { entityVersion: 'asc' },
take: query.limit,
});
const translations =
albums.length === 0
? []
: await this.prisma.album_translations.findMany({
where: { albumId: { in: albums.map((a) => a.id) } },
});
const byAlbum = new Map<string, typeof translations>();
for (const row of translations) {
const bucket = byAlbum.get(row.albumId);
if (bucket === undefined) {
byAlbum.set(row.albumId, [row]);
} else {
bucket.push(row);
}
}
for (const album of albums) {
events.push(
this.toWireEvent(
'album',
album.id,
album.id,
album.entityVersion,
false,
buildAlbumUpdatedPayload(album, byAlbum.get(album.id) ?? []),
),
);
}
} else if (query.entityType === 'category') {
const categories = await this.prisma.categories.findMany({
where: {
...(query.albumId !== undefined ? { albumId: query.albumId } : {}),
entityVersion: { gt: query.sinceVersion },
},
orderBy: { entityVersion: 'asc' },
take: query.limit,
});
const translations =
categories.length === 0
? []
: await this.prisma.category_translations.findMany({
where: { categoryId: { in: categories.map((c) => c.id) } },
});
const byCategory = new Map<string, typeof translations>();
for (const row of translations) {
const bucket = byCategory.get(row.categoryId);
if (bucket === undefined) {
byCategory.set(row.categoryId, [row]);
} else {
bucket.push(row);
}
}
for (const category of categories) {
events.push(
this.toWireEvent(
'category',
category.id,
category.albumId,
category.entityVersion,
false,
buildCategoryUpdatedPayload(
category,
byCategory.get(category.id) ?? [],
),
),
);
}
} else if (query.entityType === 'photo') {
const photos = await this.prisma.photos.findMany({
where: {
...(query.albumId !== undefined ? { albumId: query.albumId } : {}),
entityVersion: { gt: query.sinceVersion },
visibility: { in: ['published', 'hidden', 'deleted'] },
},
orderBy: { entityVersion: 'asc' },
take: query.limit,
});
const published = photos.filter((p) => p.visibility === 'published');
const variants =
published.length === 0
? []
: await this.prisma.photo_variants.findMany({
where: { photoId: { in: published.map((p) => p.id) } },
});
const byPhoto = new Map<string, typeof variants>();
for (const row of variants) {
const bucket = byPhoto.get(row.photoId);
if (bucket === undefined) {
byPhoto.set(row.photoId, [row]);
} else {
bucket.push(row);
}
}
for (const photo of photos) {
if (photo.visibility === 'published') {
events.push(
this.toWireEvent(
'photo',
photo.id,
photo.albumId,
photo.entityVersion,
false,
buildPhotoPublishedPayload(
photo,
byPhoto.get(photo.id) ?? [],
photo.entityVersion,
),
),
);
} else {
events.push(
this.toWireEvent(
'photo',
photo.id,
photo.albumId,
photo.entityVersion,
photo.visibility === 'deleted',
{
photoId: photo.id,
albumId: photo.albumId,
visibility: photo.visibility,
entityVersion: photo.entityVersion,
},
),
);
}
}
} else if (query.entityType === 'asset') {
const assets = await this.prisma.album_assets.findMany({
where: {
...(query.albumId !== undefined ? { albumId: query.albumId } : {}),
state: 'effective',
entityVersion: { gt: query.sinceVersion },
},
orderBy: { entityVersion: 'asc' },
take: query.limit,
});
for (const asset of assets) {
events.push(
this.toWireEvent(
'asset',
asset.id,
asset.albumId,
asset.entityVersion,
false,
buildAssetUpdatedPayload(asset),
),
);
}
}
// slot-state 预留:恒空清单。
return { events };
}
/** 当前状态 → 线格式事件(确定性合成 eventId,见文件头设计说明)。 */
private toWireEvent(
entityType: SyncEventWire['entityType'],
entityId: string,
albumId: string,
entityVersion: number,
tombstone: boolean,
payload: unknown,
): SyncEventWire {
return {
eventId: `${RECON_EVENT_ID_PREFIX}:${entityType}:${entityId}:${entityVersion}`,
schemaVersion: SCHEMA_VERSION,
entityType,
entityId,
albumId,
entityVersion,
// 显式携带(生成类型必填,见 sync-inbox dto 同注)。
tombstone,
priority: 'normal',
payload: payload as SyncEventWire['payload'],
};
}
}