feat(sync): cn event coverage, bootstrap and outbox sender
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// services/api/src/sync/dto.ts —— 内部同步端点请求 DTO(P2 Task 3)。
|
||||
// 契约权威:openapi.yaml [internal](syncBootstrap/syncReconcile)。
|
||||
// 经全局 ValidationPipe(whitelist)剥离未知字段;校验失败 400 VALIDATION_FAILED。
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/** 契约 SyncEntityType(slot-state 预留——与 openapi enum 同步)。 */
|
||||
export const SYNC_ENTITY_TYPES = [
|
||||
'album',
|
||||
'category',
|
||||
'photo',
|
||||
'asset',
|
||||
'slot-state',
|
||||
] as const;
|
||||
|
||||
/** 契约 BootstrapRequest:albumId 缺省 = 全部相册。 */
|
||||
export class BootstrapRequestDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
albumId?: string;
|
||||
}
|
||||
|
||||
/** 契约 ReconcileRequest:必填 entityType/sinceVersion/limit。 */
|
||||
export class ReconcileRequestDto {
|
||||
@IsIn(SYNC_ENTITY_TYPES)
|
||||
entityType!: (typeof SYNC_ENTITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
albumId?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sinceVersion!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
limit!: number;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// services/api/src/sync/payloads.ts —— cn outbox 事件载荷构建器(P2 Task 3)。
|
||||
// 纯函数:输入 cn DB 行,输出契约载荷(@photo/contracts events.ts)。三类消费方
|
||||
// 共用同一构建器,保证「业务事务内即时事件」与「bootstrap/reconcile 快照事件」
|
||||
// 载荷逐字节同形(ov 侧按 (entityId, entityVersion) 幂等,形状漂移会造成两类
|
||||
// 事件交替时误判字段缺失):
|
||||
// · albums/categories/assets/photos 服务的业务事务内 outbox 写入;
|
||||
// · SyncService.bootstrap(当前状态快照事件入 outbox,发送器投递);
|
||||
// · SyncService.reconcile(按 entityType 的当前状态事件清单)。
|
||||
// photo.published 载荷自 photos 行 + 变体行构建(photos.service 恢复发布与
|
||||
// bootstrap/reconcile 同一出口);worker publish.service 的首次发布载荷来自
|
||||
// 流水线内存值(同构不同源),保持不动。
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import type {
|
||||
AlbumUpdatedPayload,
|
||||
AssetUpdatedPayload,
|
||||
CategoryDeletedPayload,
|
||||
CategoryUpdatedPayload,
|
||||
PhotoPublishedPayload,
|
||||
} from '@photo/contracts';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import { ApiException } from '../api-exception';
|
||||
|
||||
// 载荷构建器按结构需要的列子集取参(业务侧 select 不必拉全列)。
|
||||
type AlbumRow = Pick<
|
||||
Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>,
|
||||
| 'id'
|
||||
| 'publicToken'
|
||||
| 'status'
|
||||
| 'uploadEnabled'
|
||||
| 'publicEnabled'
|
||||
| 'downloadEnabled'
|
||||
| 'originalDownloadEnabled'
|
||||
| 'defaultLocale'
|
||||
| 'timezone'
|
||||
| 'startAt'
|
||||
| 'endAt'
|
||||
| 'entityVersion'
|
||||
>;
|
||||
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
|
||||
Prisma.album_translationsDefaultArgs
|
||||
>;
|
||||
type CategoryRow = Prisma.categoriesGetPayload<Prisma.categoriesDefaultArgs>;
|
||||
type CategoryTranslationRow = Prisma.category_translationsGetPayload<
|
||||
Prisma.category_translationsDefaultArgs
|
||||
>;
|
||||
type AssetRow = Prisma.album_assetsGetPayload<Prisma.album_assetsDefaultArgs>;
|
||||
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
||||
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
||||
Prisma.photo_variantsDefaultArgs
|
||||
>;
|
||||
|
||||
/** album.updated 载荷:albums_ov 展示所需标量 + 翻译全集(locale 升序)。 */
|
||||
export function buildAlbumUpdatedPayload(
|
||||
album: AlbumRow,
|
||||
translations: AlbumTranslationRow[],
|
||||
): AlbumUpdatedPayload {
|
||||
return {
|
||||
albumId: album.id,
|
||||
publicToken: album.publicToken,
|
||||
status: album.status,
|
||||
uploadEnabled: album.uploadEnabled,
|
||||
publicEnabled: album.publicEnabled,
|
||||
downloadEnabled: album.downloadEnabled,
|
||||
originalDownloadEnabled: album.originalDownloadEnabled,
|
||||
defaultLocale: album.defaultLocale,
|
||||
timezone: album.timezone,
|
||||
startAt: album.startAt.toISOString(),
|
||||
endAt: album.endAt === null ? null : album.endAt.toISOString(),
|
||||
translations: [...translations]
|
||||
.sort((a, b) => (a.locale < b.locale ? -1 : 1))
|
||||
.map((t) => ({
|
||||
locale: t.locale,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
venue: t.venue,
|
||||
address: t.address,
|
||||
})),
|
||||
entityVersion: album.entityVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/** category.updated 载荷:分类标量 + 翻译(locale 升序)。 */
|
||||
export function buildCategoryUpdatedPayload(
|
||||
category: CategoryRow,
|
||||
translations: CategoryTranslationRow[],
|
||||
): CategoryUpdatedPayload {
|
||||
return {
|
||||
categoryId: category.id,
|
||||
albumId: category.albumId,
|
||||
sortOrder: category.sortOrder,
|
||||
translations: [...translations]
|
||||
.sort((a, b) => (a.locale < b.locale ? -1 : 1))
|
||||
.map((t) => ({ locale: t.locale, name: t.name })),
|
||||
entityVersion: category.entityVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/** category.deleted 载荷:墓碑最小事实。 */
|
||||
export function buildCategoryDeletedPayload(
|
||||
categoryId: string,
|
||||
albumId: string,
|
||||
entityVersion: number,
|
||||
): CategoryDeletedPayload {
|
||||
return { categoryId, albumId, entityVersion };
|
||||
}
|
||||
|
||||
/** asset.updated 载荷:资产生效(kind/locale/objectKey)。 */
|
||||
export function buildAssetUpdatedPayload(asset: AssetRow): AssetUpdatedPayload {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
albumId: asset.albumId,
|
||||
kind: asset.kind,
|
||||
locale: asset.locale,
|
||||
objectKey: asset.objectKey,
|
||||
entityVersion: asset.entityVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* photo.published 载荷(契约全集)。能进入 published/hidden 态的照片必经发布
|
||||
* 事务(变体行+尺寸+publishedAt 齐备);缺任一项属 post-publish 不变量破坏
|
||||
* ——5xx(不向 ov 发出键为空的事件,带病数据交由人工介入)。
|
||||
*/
|
||||
export function buildPhotoPublishedPayload(
|
||||
photo: PhotoRow,
|
||||
variants: PhotoVariantRow[],
|
||||
entityVersion: number,
|
||||
): PhotoPublishedPayload {
|
||||
const thumb = variants.find((v) => v.variant === 'thumb');
|
||||
const browse = variants.find((v) => v.variant === 'browse');
|
||||
if (
|
||||
thumb === undefined ||
|
||||
browse === undefined ||
|
||||
photo.width === null ||
|
||||
photo.height === null ||
|
||||
photo.publishedAt === null
|
||||
) {
|
||||
throw new ApiException(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal server error',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
return {
|
||||
photoId: photo.id,
|
||||
albumId: photo.albumId,
|
||||
objectKey: photo.objectKey,
|
||||
thumbKey: thumb.objectKey,
|
||||
browseKey: browse.objectKey,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
publishedAt: photo.publishedAt.toISOString(),
|
||||
entityVersion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// services/api/src/sync/sync.controller.ts —— cn 侧内部同步端点(P2 Task 3)。
|
||||
// 契约权威:openapi.yaml [internal](POST /internal/sync/bootstrap、
|
||||
// POST /internal/sync/reconcile)。鉴权:InternalSyncGuard(HMAC 原始请求体
|
||||
// 签名 + 时间戳 ±300s,密钥 SYNC_INTERNAL_KEY 仅 env)。仅 cn 入口装配
|
||||
// (app-cn.module);ov 入口的 content-batches 在 sync-inbox 模块(同守卫)。
|
||||
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
import { InternalSyncGuard } from '../shared/internal-sync.guard';
|
||||
import { BootstrapRequestDto, ReconcileRequestDto } from './dto';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@Controller('internal/sync')
|
||||
@UseGuards(InternalSyncGuard)
|
||||
export class SyncController {
|
||||
constructor(private readonly sync: SyncService) {}
|
||||
|
||||
/** 全量快照引导:当前状态快照事件写入 outbox(见 sync.service 设计说明)。 */
|
||||
@Post('bootstrap')
|
||||
@HttpCode(200) // 契约 responses '200'(Nest @Post 缺省 201,RPC 语义非建资源)
|
||||
bootstrap(@Body() dto: BootstrapRequestDto) {
|
||||
return this.sync.bootstrap(dto.albumId);
|
||||
}
|
||||
|
||||
/** 对账清单:entityType 当前状态事件(entityVersion > sinceVersion,升序)。 */
|
||||
@Post('reconcile')
|
||||
@HttpCode(200)
|
||||
reconcile(@Body() dto: ReconcileRequestDto) {
|
||||
return this.sync.reconcile(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// services/api/src/sync/sync.module.ts —— cn 侧内部同步模块(P2 Task 3)。
|
||||
// bootstrap/reconcile 端点 + 载荷构建器(payloads.ts 供 albums/categories/
|
||||
// assets/photos 业务服务与快照生成共用)。PrismaService 经 @Global 注入。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user