// 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, | 'id' | 'publicToken' | 'status' | 'uploadEnabled' | 'publicEnabled' | 'downloadEnabled' | 'originalDownloadEnabled' | 'defaultLocale' | 'timezone' | 'startAt' | 'endAt' | 'entityVersion' >; type AlbumTranslationRow = Prisma.album_translationsGetPayload< Prisma.album_translationsDefaultArgs >; type CategoryRow = Prisma.categoriesGetPayload; type CategoryTranslationRow = Prisma.category_translationsGetPayload< Prisma.category_translationsDefaultArgs >; type AssetRow = Prisma.album_assetsGetPayload; type PhotoRow = Prisma.photosGetPayload; 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, }; }