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
+153 -73
View File
@@ -27,6 +27,7 @@ import {
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { buildAlbumUpdatedPayload } from '../sync/payloads';
import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto';
import { PermissionsService } from './permissions.service';
import {
@@ -153,9 +154,24 @@ export class AlbumsService {
defaultLocale: dto.defaultLocale,
downloadEnabled: dto.downloadEnabled ?? true,
originalDownloadEnabled: dto.originalDownloadEnabled ?? false,
// status/uploadEnabled/publicEnabled/watermarkVersion 走 schema 默认。
// status/uploadEnabled/publicEnabled/watermarkVersion/entityVersion
// 走 schema 默认。
},
// P2 Task 3:outbox album.updated v1 载荷需要主行标量全集。
select: {
id: true,
publicToken: true,
status: true,
uploadEnabled: true,
publicEnabled: true,
downloadEnabled: true,
originalDownloadEnabled: true,
defaultLocale: true,
timezone: true,
startAt: true,
endAt: true,
entityVersion: true,
},
select: { id: true },
});
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
@@ -175,6 +191,24 @@ export class AlbumsService {
status: 'active',
},
});
// P2 Task 3(cn 事件补全):同一事务写 outbox album.updated(v1),
// 载荷 = ov 展示全集(payloads.ts 与 bootstrap/reconcile 共用构建器)。
// 分类是独立事件(category.*),不进相册载荷。
const translations = await tx.album_translations.findMany({
where: { albumId: album.id },
orderBy: { locale: 'asc' },
});
await tx.outbox_events.create({
data: {
type: 'album.updated',
albumId: album.id,
entityVersion: album.entityVersion,
payload: buildAlbumUpdatedPayload(
album,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: userId,
@@ -268,83 +302,129 @@ export class AlbumsService {
throw albumForbidden('仅创建者或管理员可修改相册');
}
await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
throw albumNotFound();
}
// 合并校验①:endAt(给值时)晚于合并后的 startAt(单边提交对比存量)。
const startAt = dto.startAt !== undefined ? new Date(dto.startAt) : album.startAt;
const endAt =
dto.endAt === undefined
? album.endAt
: dto.endAt === null
? null
: new Date(dto.endAt);
if (endAt !== null && endAt.getTime() <= startAt.getTime()) {
throw validationFailed('endAt 必须晚于 startAt');
}
// 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。
const defaultLocale = dto.defaultLocale ?? album.defaultLocale;
if (dto.translations !== undefined) {
if (!dto.translations.some((t) => t.locale === defaultLocale)) {
throw validationFailed(
'translations 必须包含(更新后的)defaultLocale 对应语言的条目',
);
// P2 Task 3:条件更新(WHERE entityVersion=读取版本)承担事件版本自增的
// 原子性(同 photos 模式)。并发更新败方(count=0)整事务重跑——update 无
// 状态机语义,重跑合并校验后重试即最终一致;3 次仍败属异常并发,5xx 兜底。
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
throw albumNotFound();
}
} else if (dto.defaultLocale !== undefined) {
// 只切 defaultLocale:存量翻译行必须已含新语言。
const rows = await tx.album_translations.findMany({
// 合并校验①:endAt(给值时)晚于合并后的 startAt(单边提交对比存量)。
const startAt = dto.startAt !== undefined ? new Date(dto.startAt) : album.startAt;
const endAt =
dto.endAt === undefined
? album.endAt
: dto.endAt === null
? null
: new Date(dto.endAt);
if (endAt !== null && endAt.getTime() <= startAt.getTime()) {
throw validationFailed('endAt 必须晚于 startAt');
}
// 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。
const defaultLocale = dto.defaultLocale ?? album.defaultLocale;
if (dto.translations !== undefined) {
if (!dto.translations.some((t) => t.locale === defaultLocale)) {
throw validationFailed(
'translations 必须包含(更新后的)defaultLocale 对应语言的条目',
);
}
} else if (dto.defaultLocale !== undefined) {
// 只切 defaultLocale:存量翻译行必须已含新语言。
const rows = await tx.album_translations.findMany({
where: { albumId },
select: { locale: true },
});
if (!rows.some((r) => r.locale === defaultLocale)) {
throw validationFailed('defaultLocale 必须有对应翻译条目');
}
}
const claimed = await tx.albums.updateMany({
where: { id: albumId, entityVersion: album.entityVersion },
data: {
startAt: dto.startAt === undefined ? undefined : new Date(dto.startAt),
endAt:
dto.endAt === undefined
? undefined
: dto.endAt === null
? null
: new Date(dto.endAt),
timezone: dto.timezone,
defaultLocale: dto.defaultLocale,
uploadEnabled: dto.uploadEnabled,
publicEnabled: dto.publicEnabled,
downloadEnabled: dto.downloadEnabled,
originalDownloadEnabled: dto.originalDownloadEnabled,
entityVersion: { increment: 1 },
},
});
if (claimed.count === 0) {
return 'retry' as const;
}
const nextVersion = album.entityVersion + 1;
if (dto.translations !== undefined) {
await tx.album_translations.deleteMany({ where: { albumId } });
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
albumId,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
});
}
// P2 Task 3(cn 事件补全):同一事务写 outbox album.updated,载荷 =
// 更新后状态全集(分类仍走 category.* 独立事件,相册事件不触碰
// albums_ov.categories)。公开关闭迁移(publicEnabled true→false)为高
// 优先级:ov 观众侧须尽快停止可见(下线 status=offline 同理——当前 API
// 无该迁移入口,入口落地时同样置 high)。已在关闭态的常规更新保持 normal。
const updated = await tx.albums.findUniqueOrThrow({
where: { id: albumId },
});
const translations = await tx.album_translations.findMany({
where: { albumId },
select: { locale: true },
orderBy: { locale: 'asc' },
});
if (!rows.some((r) => r.locale === defaultLocale)) {
throw validationFailed('defaultLocale 必须有对应翻译条目');
}
}
await tx.albums.update({
where: { id: albumId },
data: {
startAt: dto.startAt === undefined ? undefined : new Date(dto.startAt),
endAt:
dto.endAt === undefined
? undefined
: dto.endAt === null
? null
: new Date(dto.endAt),
timezone: dto.timezone,
defaultLocale: dto.defaultLocale,
uploadEnabled: dto.uploadEnabled,
publicEnabled: dto.publicEnabled,
downloadEnabled: dto.downloadEnabled,
originalDownloadEnabled: dto.originalDownloadEnabled,
},
});
if (dto.translations !== undefined) {
await tx.album_translations.deleteMany({ where: { albumId } });
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
await tx.outbox_events.create({
data: {
type: 'album.updated',
albumId,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
entityVersion: nextVersion,
priority:
dto.publicEnabled === false && album.publicEnabled === true
? 'high'
: 'normal',
payload: buildAlbumUpdatedPayload(
updated,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.update',
resourceId: albumId,
metadata: { fields },
},
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.update',
resourceId: albumId,
metadata: { fields },
},
});
return 'done' as const;
});
});
}
if (outcome !== 'done') {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const album = await this.loadAlbum(albumId);
if (album === null) {
+19 -2
View File
@@ -29,8 +29,10 @@
// presign 为 SigV4 本地计算,无网络副作用。
import { Inject, Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import type { AuthUser } from '../auth/session.guard';
import { buildAssetUpdatedPayload } from '../sync/payloads';
import { STORAGE_PORT } from '../uploads/storage.provider';
import {
assetsObjectKey,
@@ -164,13 +166,28 @@ export class AssetsService {
);
}
// ② 事务:条件更新抢占(并发 complete 恰一人胜出)+ 胜者独占 audit。
// ② 事务:条件更新抢占(并发 complete 恰一人胜出,state pending→effective
// 迁移同时自增 entityVersion 并写 outbox asset.updated——P2 Task 3 起仅
// 生效迁移产生事件,幂等重复 complete 不再发)+ 胜者独占 audit。
await this.prisma.$transaction(async (tx) => {
const race = await tx.album_assets.updateMany({
where: { id: asset.id, state: 'pending' },
data: { state: 'effective' },
data: { state: 'effective', entityVersion: { increment: 1 } },
});
if (race.count === 1) {
const effective = await tx.album_assets.findUniqueOrThrow({
where: { id: asset.id },
});
await tx.outbox_events.create({
data: {
type: 'asset.updated',
albumId,
entityVersion: effective.entityVersion,
payload: buildAssetUpdatedPayload(
effective,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
+111 -23
View File
@@ -18,6 +18,10 @@ import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
import {
buildCategoryDeletedPayload,
buildCategoryUpdatedPayload,
} from '../sync/payloads';
import type {
CategoryCreateRequestDto,
CategoryUpdateRequestDto,
@@ -89,7 +93,7 @@ export class CategoriesService {
const categoryId = await this.prisma.$transaction(async (tx) => {
const category = await tx.categories.create({
data: { albumId, sortOrder: dto.sortOrder ?? 0 },
select: { id: true },
select: { id: true, entityVersion: true },
});
await tx.category_translations.createMany({
data: dto.translations.map((t) => ({
@@ -98,6 +102,25 @@ export class CategoriesService {
name: t.name,
})),
});
// P2 Task 3(cn 事件补全):同一事务写 outbox category.updated(v1)。
const created = await tx.categories.findUniqueOrThrow({
where: { id: category.id },
});
const translations = await tx.category_translations.findMany({
where: { categoryId: category.id },
orderBy: { locale: 'asc' },
});
await tx.outbox_events.create({
data: {
type: 'category.updated',
albumId,
entityVersion: created.entityVersion,
payload: buildCategoryUpdatedPayload(
created,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
@@ -139,30 +162,72 @@ export class CategoriesService {
}
const existing = await this.assertCategoryManaged(user, categoryId);
await this.prisma.$transaction(async (tx) => {
await tx.categories.update({
where: { id: categoryId },
data: { sortOrder: dto.sortOrder },
});
if (dto.translations !== undefined) {
await tx.category_translations.deleteMany({ where: { categoryId } });
await tx.category_translations.createMany({
data: dto.translations.map((t) => ({
categoryId,
locale: t.locale,
name: t.name,
})),
// P2 Task 3:条件更新自增 entityVersion(同 albums.update 模式),并发败方
// 整事务重跑;3 次仍败 5xx 兜底。
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const category = await tx.categories.findUnique({
where: { id: categoryId },
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.category.update',
resourceId: existing.albumId,
metadata: { categoryId, fields },
},
if (category === null) {
throw albumNotFound();
}
const claimed = await tx.categories.updateMany({
where: { id: categoryId, entityVersion: category.entityVersion },
data: { sortOrder: dto.sortOrder, entityVersion: { increment: 1 } },
});
if (claimed.count === 0) {
return 'retry' as const;
}
const nextVersion = category.entityVersion + 1;
if (dto.translations !== undefined) {
await tx.category_translations.deleteMany({ where: { categoryId } });
await tx.category_translations.createMany({
data: dto.translations.map((t) => ({
categoryId,
locale: t.locale,
name: t.name,
})),
});
}
// P2 Task 3:同一事务写 outbox category.updated(载荷 = 更新后状态)。
const updated = await tx.categories.findUniqueOrThrow({
where: { id: categoryId },
});
const translations = await tx.category_translations.findMany({
where: { categoryId },
orderBy: { locale: 'asc' },
});
await tx.outbox_events.create({
data: {
type: 'category.updated',
albumId: existing.albumId,
entityVersion: nextVersion,
payload: buildCategoryUpdatedPayload(
updated,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.category.update',
resourceId: existing.albumId,
metadata: { categoryId, fields },
},
});
return 'done' as const;
});
});
}
if (outcome !== 'done') {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const category = await this.loadCategory(categoryId);
if (category === null) {
throw albumNotFound();
@@ -174,8 +239,31 @@ export class CategoriesService {
async remove(user: AuthUser, categoryId: string): Promise<void> {
const existing = await this.assertCategoryManaged(user, categoryId);
await this.prisma.$transaction(async (tx) => {
// 先读版本:硬删除后行不存在,墓碑事件版本 = 删除时刻终值(v+1)。
// 已知边界(Task 3 登记):墓碑后 cn 表不再有该实体,reconcile 清单不
// 含已删除分类——删除语义依赖事件推送,ov 侧墓碑不被旧版本复活(A26)。
const category = await tx.categories.findUnique({
where: { id: categoryId },
});
if (category === null) {
throw albumNotFound();
}
await tx.category_translations.deleteMany({ where: { categoryId } });
await tx.categories.delete({ where: { id: categoryId } });
// P2 Task 3:同一事务写 outbox 墓碑 category.deleted(v+1,normal——
// 分类删除无「即刻从公开面撤下」的紧迫性,照片/相册级墓碑才是 high)。
await tx.outbox_events.create({
data: {
type: 'category.deleted',
albumId: existing.albumId,
entityVersion: category.entityVersion + 1,
payload: buildCategoryDeletedPayload(
categoryId,
existing.albumId,
category.entityVersion + 1,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
+4
View File
@@ -23,6 +23,7 @@ import { PhotosModule } from './photos/photos.module';
import { PrismaModule } from './prisma/prisma.module';
import { PublicModule } from './public/public.module';
import { RequestIdMiddleware } from './request-id.middleware';
import { SyncModule } from './sync/sync.module';
import { UploadsModule } from './uploads/uploads.module';
// P2 Task 2:logger 装配选项(genReqId/redact/level)收敛至 shared,与 ov 入口
// 共用单一来源;此处仅消费,行为不变(P1 e2e 套件守护)。
@@ -44,6 +45,9 @@ import { createLoggerOptions } from './shared/logger-options';
PhotosModule,
// 管理辅助域(P1b Task 4:/admin/users 账号前缀查询)。
AdminModule,
// 内部同步域(P2 Task 3:/internal/sync/bootstrap|reconcile,HMAC 鉴权,
// 仅 cn 入口装配——cn 是内容同步的源端)。
SyncModule,
LoggerModule.forRoot(createLoggerOptions()),
],
controllers: [HealthController],
+13 -7
View File
@@ -1,9 +1,9 @@
// services/api/src/app-ov.module.ts —— OV(海外观众)部署根模块(P2 Task 2 骨架)。
// 与 app-cn.module.ts 同一管线骨架(日志 + request-id + 全局异常过滤器 + 全局
// 校验管道 + /v1/health),但**显式挑选** ov 角色模块:cn 专属域(auth/albums/
// uploads/photos/admin——写路径与 MinIO 上传)不装配;OvPrismaService、公开读、
// sync-inbox、likes/visits、WS 网关自 Task 3+ 起逐个加入(本任务仅骨架,
// feature modules 在后续任务装配)。cn 模块保持原样,两入口互不影响。
// services/api/src/app-ov.module.ts —— OV(海外观众)部署根模块(P2 Task 2
// 骨架 + Task 3 起装配收件箱)。与 app-cn.module.ts 同一管线骨架(日志 +
// request-id + 全局异常过滤器 + 全局校验管道 + /v1/health),但**显式挑选**
// ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO
// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);公开读、
// likes/visits、WS 网关自 Task 6+ 起逐个加入。cn 模块保持原样,两入口互不影响。
import {
type MiddlewareConsumer,
Module,
@@ -16,9 +16,14 @@ import { AllExceptionsFilter } from './all-exceptions.filter';
import { HealthController } from './health.controller';
import { RequestIdMiddleware } from './request-id.middleware';
import { createLoggerOptions } from './shared/logger-options';
import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
@Module({
imports: [LoggerModule.forRoot(createLoggerOptions())],
imports: [
LoggerModule.forRoot(createLoggerOptions()),
// ov 内容事件收件箱(P2 Task 3):POST /v1/internal/sync/content-batches。
SyncInboxModule,
],
controllers: [HealthController],
providers: [
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
@@ -33,3 +38,4 @@ export class AppOvModule implements NestModule {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
+5
View File
@@ -11,6 +11,11 @@ import { configureApp } from './shared/configure-app';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppCnModule, {
bufferLogs: true,
// 内部 sync 端点(bootstrap/reconcile)HMAC 验签需要「原始请求体字节」:
// rawBody 让 body-parser 在 JSON 解析前把 Buffer 挂到 req.rawBody
// (InternalSyncGuard 读取)。与 JSON.parse 后的 body 再序列化相比,
// 原始字节不受键序/空白影响,签名两端一致(Task 3 执行注意)。
rawBody: true,
});
app.useLogger(app.get(Logger));
configureApp(app);
+4
View File
@@ -14,6 +14,10 @@ import { configureApp } from './shared/configure-app';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppOvModule, {
bufferLogs: true,
// 内部 sync 端点(content-batches)HMAC 验签需要「原始请求体字节」:
// rawBody 让 body-parser 在 JSON 解析前把 Buffer 挂到 req.rawBody
// (InternalSyncGuard 读取)。发送器对同一字符串只签一次、原样发送。
rawBody: true,
});
app.useLogger(app.get(Logger));
configureApp(app);
+11 -47
View File
@@ -19,12 +19,9 @@
// + entityVersion 自增 + 事务内 outbox 事件(P2 副本按 entityVersion 单调消费)
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
// 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。
import { HttpStatus, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import { ErrorCodes } from '@photo/contracts';
import type { PhotoPublishedPayload } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import { ApiException } from '../api-exception';
import type { AuthUser } from '../auth/session.guard';
import { PermissionsService } from '../albums/permissions.service';
import {
@@ -38,6 +35,7 @@ import {
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { buildPhotoPublishedPayload } from '../sync/payloads';
import type { PhotoVisibility, PhotoVisibilityTarget } from './dto';
import {
photoForbidden,
@@ -49,9 +47,6 @@ export type PhotoRecord = components['schemas']['PhotoRecord'];
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
Prisma.photo_variantsDefaultArgs
>;
function toPhotoRecord(row: PhotoRow): PhotoRecord {
return {
@@ -303,13 +298,17 @@ export class PhotosService {
const variants = await tx.photo_variants.findMany({
where: { photoId: photo.id },
});
const payload = this.publishedPayload(photo, variants, nextVersion);
// 载荷构建与 bootstrap/reconcile 快照共用同一出口(sync/payloads.ts),
// 保证两类事件逐字节同形;post-publish 不变量破坏时 5xx 回滚整个迁移。
const payload = buildPhotoPublishedPayload(photo, variants, nextVersion);
await tx.outbox_events.create({
data: {
type: 'photo.published',
albumId: photo.albumId,
photoId: photo.id,
entityVersion: nextVersion,
// P2 Task 3:恢复发布为常规优先级(显式声明,不依赖列默认)。
priority: 'normal',
// 接口类型无隐式索引签名,Prisma InputJsonValue 需显式收窄
//(同 worker publish.service 先例)。
payload: payload as unknown as Prisma.InputJsonValue,
@@ -322,6 +321,10 @@ export class PhotosService {
albumId: photo.albumId,
photoId: photo.id,
entityVersion: nextVersion,
// P2 Task 3:隐藏/删除是「即刻从公开面撤下」的墓碑类事件——高优先级,
// 发送器批次内优先投递(worker 首次发布的 photo.published 走列默认
// normal,无需改动)。
priority: 'high',
payload: {
photoId: photo.id,
albumId: photo.albumId,
@@ -367,43 +370,4 @@ export class PhotosService {
}
return toPhotoRecord(updatedRow);
}
/**
* 恢复发布的 outbox 载荷(契约 PhotoPublishedPayload 全集)。能走到「隐藏」
* 前态的照片必经发布事务(变体行+尺寸+publishedAt 齐备);缺任一项属
* post-publish 不变量破坏——5xx 回滚整个迁移(照片保持 hidden,不向 P2
* 副本发出键为空的事件),交由人工介入而非带病发布。
*/
private publishedPayload(
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,28 @@
// services/api/src/prisma-ov/ —— OV 库(photo_ov)的可注入封装(P2 Task 3)。
// 与 cn 侧 PrismaModule 的两点刻意差异(执行注意 §双数据库连接):
// 1. 非 @Global——按模块显式 import(SyncInboxModule 等_ov 角色模块),
// 防止 cn 角色模块误注入 ov 客户端;
// 2. 独立类名 OvPrismaService——两库同库不同 schema(@photo/db-ov 生成的
// 独立客户端),DI 令牌不同才可并存于同一代码库。
import { Injectable, Module, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@photo/db-ov';
@Injectable()
export class OvPrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
@Module({
providers: [OvPrismaService],
exports: [OvPrismaService],
})
export class OvPrismaModule {}
@@ -0,0 +1,58 @@
// services/api/src/shared/internal-sync.guard.ts —— /internal/** 服务间同步
// 端点的 HMAC 守卫(P2 Task 3)。规则单一实现见 @photo/contracts internal-sync.ts
// (与 services/sync 发送器共用,防两侧漂移):
// X-Sync-Signature = hex(HMAC-SHA256(原始请求体, SYNC_INTERNAL_KEY))
// X-Sync-Timestamp = Unix 秒(±300s 防重放)
// 失败路径(头缺失/签名不符/时间戳超窗/密钥未配置)一律同码 401 SYNC_AUTH_FAILED,
// 不区分具体原因(不向调用方泄露校验进度)。原始请求体依赖 NestFactory 的
// rawBody: true(body-parser verify 钩子在 JSON 解析前捕获 Buffer 到 req.rawBody,
// 两个部署入口 main-cn/main-ov 均已开启);未经解析器捕获的请求无 rawBody → 401。
// 使用方式:@UseGuards(InternalSyncGuard)(按控制器/路由启用,不注册 APP_GUARD)。
// 守卫无依赖(纯 env + 纯函数校验),任意模块可直接引用。
import {
type CanActivate,
type ExecutionContext,
HttpStatus,
Injectable,
} from '@nestjs/common';
import {
SYNC_INTERNAL_KEY_ENV,
SYNC_SIGNATURE_HEADER,
SYNC_TIMESTAMP_HEADER,
verifySyncSignature,
} from '@photo/contracts';
import { ApiException } from '../api-exception';
import { ErrorCodes } from '@photo/contracts';
/** 经 body-parser verify 捕获的原始请求体(NestFactory rawBody: true)。 */
export interface RequestWithRawBody {
rawBody?: Buffer;
headers: Record<string, string | string[] | undefined>;
}
function syncUnauthorized(): ApiException {
return new ApiException(
ErrorCodes.SYNC_AUTH_FAILED,
'Internal sync signature is missing, invalid, or expired',
HttpStatus.UNAUTHORIZED,
);
}
@Injectable()
export class InternalSyncGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<RequestWithRawBody>();
// rawBody 缺失(未走 JSON 解析器的请求体/入口未开 rawBody)以空串参与校验,
// 结果恒 false——与签名不符同一出口,不单独暴露「原始体缺失」这一内部细节。
const ok = verifySyncSignature({
key: process.env[SYNC_INTERNAL_KEY_ENV] ?? '',
rawBody: req.rawBody ?? '',
timestampHeader: req.headers[SYNC_TIMESTAMP_HEADER],
signatureHeader: req.headers[SYNC_SIGNATURE_HEADER],
});
if (!ok) {
throw syncUnauthorized();
}
return true;
}
}
+75
View File
@@ -0,0 +1,75 @@
// services/api/src/sync-inbox/dto.ts —— content-batches 请求 DTO(P2 Task 3)。
// 契约权威:openapi.yaml [internal] syncContentBatches(ContentBatchRequest/
// SyncEvent)。经全局 ValidationPipe(whitelist 剥离未知字段 + ValidateNested
// 逐事件校验);payload 为开放对象(@IsObject 原样透传,结构由 schemaVersion
// 约定,不在传输层强校验)。
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsObject,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import type { components } from '@photo/contracts';
type SyncEventWire = components['schemas']['SyncEvent'];
/** 契约 SyncEntityType(与 openapi enum 同步;reconcile 端点同表)。 */
export const SYNC_ENTITY_TYPES = [
'album',
'category',
'photo',
'asset',
'slot-state',
] as const;
/** 契约 SyncPriority。 */
export const SYNC_PRIORITIES = ['normal', 'high'] as const;
export class SyncEventDto implements SyncEventWire {
@IsString()
eventId!: string;
@IsInt()
@Min(1)
schemaVersion!: number;
@IsIn(SYNC_ENTITY_TYPES)
entityType!: SyncEventWire['entityType'];
@IsString()
entityId!: string;
@IsString()
albumId!: string;
@IsInt()
@Min(1)
entityVersion!: number;
// 生成类型把 default:false 属性渲染为必填(openapi-typescript 约定),发送侧
// (sender/bootstrap/reconcile)一律显式携带,DTO 同步必填。
@IsBoolean()
tombstone!: boolean;
@IsIn(SYNC_PRIORITIES)
priority!: SyncEventWire['priority'];
@IsObject()
payload!: Record<string, unknown>;
}
export class ContentBatchRequestDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => SyncEventDto)
batch!: SyncEventDto[];
}
@@ -0,0 +1,20 @@
// services/api/src/sync-inbox/sync-inbox.controller.ts —— ov 侧内容事件批量
// 接收端点(P2 Task 3)。契约权威:openapi.yaml [internal] syncContentBatches
// (POST /internal/sync/content-batches)。鉴权:InternalSyncGuard(与 cn 侧
// bootstrap/reconcile 同一 HMAC 规则)。仅 ov 入口装配(app-ov.module)。
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { InternalSyncGuard } from '../shared/internal-sync.guard';
import { ContentBatchRequestDto } from './dto';
import { SyncInboxService } from './sync-inbox.service';
@Controller('internal/sync')
@UseGuards(InternalSyncGuard)
export class SyncInboxController {
constructor(private readonly syncInbox: SyncInboxService) {}
@Post('content-batches')
@HttpCode(200) // 契约 responses '200'(受理结果,非资源创建)
receive(@Body() dto: ContentBatchRequestDto) {
return this.syncInbox.receiveBatch(dto.batch);
}
}
@@ -0,0 +1,14 @@
// services/api/src/sync-inbox/sync-inbox.module.ts —— ov 侧内容事件收件箱模块
// (P2 Task 3)。OvPrismaService 显式 import(非 @Global,按模块注入);
// 对账调度(水位/补漏)属 Task 4,本模块只承担接收 + 持久化 + 简单应用。
import { Module } from '@nestjs/common';
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
import { SyncInboxController } from './sync-inbox.controller';
import { SyncInboxService } from './sync-inbox.service';
@Module({
imports: [OvPrismaModule],
controllers: [SyncInboxController],
providers: [SyncInboxService],
})
export class SyncInboxModule {}
@@ -0,0 +1,360 @@
// services/api/src/sync-inbox/sync-inbox.service.ts —— ov 内容事件收件箱
// (P2 Task 3:接收 + 持久化 + 简单应用;对账调度属 Task 4)。逐事件单事务:
//
// ① eventId 幂等:sync_inbox 主键冲突(含并发窗口)→ duplicate,受理不计应用;
// ② entityVersion 单调:sync_state 已应用版本 ≥ 事件版本 → stale,标记
// appliedAt(已裁决,非积压)但不投影——旧事件不能复活新状态(A26);
// ③ 依赖缺失暂存:非 album 事件在 albums_ov 无所属相册行时保持 appliedAt
// null(暂存不 applied,不投影);album 事件应用后按 albumId 回扫暂存
// 队列补应用(相册到达即解锁该相册全部暂存事件);
// ④ 应用投影(版本已更高):album → albums_ov upsert(不触碰 categories/
// bannerAssetKey/emergencyDisabled——分别归 category.* / asset.updated /
// 紧急停用通道 Task 5);category → albums_ov.categories JSON 元素
// upsert/移除;photo.published → photos_ov upsert(保留 likeTotal/
// countVersion 权威计数);photo.hidden/deleted → photos_ov.visibility
// 置位(行不存在 = 从未复制即不可见,缺席即撤下语义);asset → banner
// 投影 albums_ov.bannerAssetKey(cover/logo 仅推进版本,ov schema 无位);
// ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行归
// Task 4 对账)。
//
// 墓碑优先由 ② 保证:tombstone 事件版本必高于其前的任意旧版本,旧
// photo.published/category.updated 重放被版本比较直接拒绝。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import type {
AlbumUpdatedPayload,
AssetUpdatedPayload,
CategoryDeletedPayload,
CategoryUpdatedPayload,
PhotoPublishedPayload,
PhotoVisibilityChangedPayload,
} from '@photo/contracts';
import type { Prisma } from '@photo/db-ov';
// 值导入(非 import type):构造器注入的 design:paramtypes 依赖类引用,
// type-only 导入会让 Nest 侧元数据退化为 Function 而 DI 失败(repo 约定)。
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
export type ContentBatchResponse =
components['schemas']['ContentBatchResponse'];
type SyncEventWire = components['schemas']['SyncEvent'];
/** 单事件裁决结果(响应只聚合 applied,其余内部口径)。 */
type Outcome = 'duplicate' | 'stale' | 'staged' | 'applied';
/** albums_ov.categories JSON 元素形状(ov schema 注释约定的投影结构)。 */
interface CategoryReplicaEntry {
id: string;
sortOrder: number;
translations: Array<{ locale: string; name: string }>;
}
@Injectable()
export class SyncInboxService {
constructor(private readonly prisma: OvPrismaService) {}
/**
* 批量接收:批内按序逐事件事务处理;任一事件失败抛错(整批 500)——发送器
* 整批重试,eventId 幂等保证重试安全。acceptedEventIds 含幂等重复(契约:
* 受理 ≠ 应用);applied 仅统计新版本生效。
*/
async receiveBatch(batch: SyncEventWire[]): Promise<ContentBatchResponse> {
const acceptedEventIds: string[] = [];
let applied = 0;
for (const event of batch) {
const outcome = await this.applyOne(event);
acceptedEventIds.push(event.eventId);
if (outcome === 'applied') {
applied += 1;
// 依赖解锁:相册副本落位后,回扫该相册的暂存事件(photo/category/
// asset 都只依赖相册行;album 事件自身永不暂存,回扫无递归)。
if (event.entityType === 'album') {
applied += await this.sweepStaged(event.albumId);
}
}
}
return { acceptedEventIds, applied };
}
/** 单事件事务(见文件头 ①-⑤)。P2002 = 并发同 eventId → duplicate。 */
async applyOne(event: SyncEventWire): Promise<Outcome> {
try {
return await this.prisma.$transaction(async (tx) => {
// ① eventId 幂等锚:先查后插 + 唯一约束兜底(并发窗口)。
const seen = await tx.sync_inbox.findUnique({
where: { eventId: event.eventId },
select: { eventId: true },
});
if (seen !== null) {
return 'duplicate' as const;
}
await tx.sync_inbox.create({
data: {
eventId: event.eventId,
entityType: event.entityType,
entityId: event.entityId,
albumId: event.albumId,
entityVersion: event.entityVersion,
tombstone: event.tombstone === true,
priority: event.priority,
payload: event.payload as unknown as Prisma.InputJsonValue,
},
});
return this.decideAndApply(tx, event);
});
} catch (err) {
// P2002 鸭子类型判别(同 albums/support.ts isUniqueViolationOn 的约定:
// Prisma 命名空间经 @photo/db-ov 仅 type 出口,不做值导入)——本端点
// 唯一约束只有 sync_inbox 主键,命中即并发同 eventId。
if (
typeof err === 'object' &&
err !== null &&
(err as { code?: unknown }).code === 'P2002'
) {
return 'duplicate';
}
throw err;
}
}
/**
* ②-⑤ 裁决与应用(inbox 行已存在的共用核心):新事件(applyOne 落库后)
* 与暂存回扫(sweepStaged 对既有行重跑)同一出口。回扫**不能**走 applyOne
* 整程——其 eventId 幂等检查会把暂存行判成 duplicate 而永不应用。
*/
private async decideAndApply(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<Outcome> {
// ② 版本单调:旧版本裁决即终(appliedAt 置位,不入积压)。
const state = await tx.sync_state.findUnique({
where: {
entityType_entityId: {
entityType: event.entityType,
entityId: event.entityId,
},
},
});
if (state !== null && state.appliedVersion >= event.entityVersion) {
await tx.sync_inbox.update({
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'stale' as const;
}
// ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。
if (event.entityType !== 'album') {
const album = await tx.albums_ov.findUnique({
where: { albumId: event.albumId },
select: { albumId: true },
});
if (album === null) {
return 'staged' as const;
}
}
// ④ 投影。
await this.project(tx, event);
// ⑤ 版本账本。
await tx.sync_state.upsert({
where: {
entityType_entityId: {
entityType: event.entityType,
entityId: event.entityId,
},
},
update: {
appliedVersion: event.entityVersion,
appliedAt: new Date(),
},
create: {
entityType: event.entityType,
entityId: event.entityId,
appliedVersion: event.entityVersion,
watermark: false,
},
});
await tx.sync_inbox.update({
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'applied' as const;
}
/** ④ 按实体类型投影到副本表(版本已由 ② 保证更高)。 */
private async project(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<void> {
if (event.entityType === 'album') {
const p = event.payload as unknown as AlbumUpdatedPayload;
// update 分支不触碰 categories/bannerAssetKey/emergencyDisabled(见文件头④)。
await tx.albums_ov.upsert({
where: { albumId: event.entityId },
create: {
albumId: event.entityId,
publicToken: p.publicToken,
status: p.status,
publicEnabled: p.publicEnabled,
downloadEnabled: p.downloadEnabled,
originalDownloadEnabled: p.originalDownloadEnabled,
translations: p.translations as unknown as Prisma.InputJsonValue,
categories: [],
defaultLocale: p.defaultLocale,
timezone: p.timezone,
startAt: new Date(p.startAt),
endAt: p.endAt === null ? null : new Date(p.endAt),
entityVersion: event.entityVersion,
},
update: {
publicToken: p.publicToken,
status: p.status,
publicEnabled: p.publicEnabled,
downloadEnabled: p.downloadEnabled,
originalDownloadEnabled: p.originalDownloadEnabled,
translations: p.translations as unknown as Prisma.InputJsonValue,
defaultLocale: p.defaultLocale,
timezone: p.timezone,
startAt: new Date(p.startAt),
endAt: p.endAt === null ? null : new Date(p.endAt),
entityVersion: event.entityVersion,
},
});
return;
}
if (event.entityType === 'category') {
const album = await tx.albums_ov.findUniqueOrThrow({
where: { albumId: event.albumId },
});
const entries = (
Array.isArray(album.categories) ? album.categories : []
) as unknown as CategoryReplicaEntry[];
if (event.tombstone === true) {
const p = event.payload as unknown as CategoryDeletedPayload;
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: {
// 接口类型无 Json 输入所需索引签名(同 outbox 写入侧约定),结构化
// 拷贝落 Json 列。
categories: entries.filter(
(c) => c.id !== p.categoryId,
) as unknown as Prisma.InputJsonValue,
},
});
return;
}
const p = event.payload as unknown as CategoryUpdatedPayload;
const entry: CategoryReplicaEntry = {
id: p.categoryId,
sortOrder: p.sortOrder,
translations: p.translations,
};
const index = entries.findIndex((c) => c.id === p.categoryId);
if (index >= 0) {
entries[index] = entry;
} else {
entries.push(entry);
}
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { categories: entries as unknown as Prisma.InputJsonValue },
});
return;
}
if (event.entityType === 'photo') {
const payload = event.payload as unknown as Partial<
PhotoPublishedPayload & PhotoVisibilityChangedPayload
>;
// visibility 推导:hidden/deleted 载荷显式携带;published 载荷缺省即发布。
const visibility =
payload.visibility ?? (event.tombstone === true ? 'deleted' : 'published');
const existing = await tx.photos_ov.findUnique({
where: { photoId: event.entityId },
select: { photoId: true },
});
if (existing === null) {
if (visibility !== 'published') {
// 墓碑/隐藏先于任何 published 到达且副本行不存在:缺席即撤下语义,
// 无行可改——版本账本照常推进(旧 published 之后到会被版本比较拒绝)。
return;
}
const p = payload as PhotoPublishedPayload;
await tx.photos_ov.create({
data: {
photoId: event.entityId,
albumId: event.albumId,
originalObjectKey: p.objectKey,
thumbObjectKey: p.thumbKey,
browseObjectKey: p.browseKey,
width: p.width,
height: p.height,
visibility,
publishedAt: new Date(p.publishedAt),
entityVersion: event.entityVersion,
},
});
return;
}
// update 分支保留 likeTotal/countVersion(ov 权威计数,Task 7/9)。
const data: Prisma.photos_ovUpdateInput = {
visibility,
entityVersion: event.entityVersion,
};
if (visibility === 'published') {
const p = payload as PhotoPublishedPayload;
data.originalObjectKey = p.objectKey;
data.thumbObjectKey = p.thumbKey;
data.browseObjectKey = p.browseKey;
data.width = p.width;
data.height = p.height;
data.publishedAt = new Date(p.publishedAt);
}
await tx.photos_ov.update({
where: { photoId: event.entityId },
data,
});
return;
}
if (event.entityType === 'asset') {
const p = event.payload as unknown as AssetUpdatedPayload;
if (p.kind === 'banner') {
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { bannerAssetKey: p.objectKey },
});
}
// cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。
return;
}
// slot-state 预留:受理即 applied(无投影),版本账本照常推进。
}
/** album 应用后回扫该相册暂存事件(receivedAt 升序),返回补应用数。 */
private async sweepStaged(albumId: string): Promise<number> {
const staged = await this.prisma.sync_inbox.findMany({
where: { appliedAt: null, albumId },
orderBy: [{ receivedAt: 'asc' }, { eventId: 'asc' }],
});
let applied = 0;
for (const row of staged) {
const event: SyncEventWire = {
eventId: row.eventId,
schemaVersion: 1,
entityType: row.entityType as SyncEventWire['entityType'],
entityId: row.entityId,
albumId: row.albumId ?? albumId,
entityVersion: row.entityVersion,
tombstone: row.tombstone,
priority: row.priority as SyncEventWire['priority'],
payload: row.payload as unknown as SyncEventWire['payload'],
};
// 行已存在(eventId 即本行主键),走 ②-⑤ 核心而非 applyOne 整程
// (幂等检查会把回扫误判 duplicate,见 decideAndApply 注释)。
const outcome = await this.prisma.$transaction((tx) =>
this.decideAndApply(tx, event),
);
if (outcome === 'applied') {
applied += 1;
}
}
return applied;
}
}
+46
View File
@@ -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;
}
+156
View File
@@ -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,
};
}
+29
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+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'],
};
}
}