Files
photo-live/services/api/src/sync/payloads.ts
T
2026-09-18 15:21:41 +08:00

157 lines
5.3 KiB
TypeScript

// 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,
};
}