65726702f3
Co-Authored-By: Claude Code <noreply@anthropic.com>
157 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|