feat(sync): cn event coverage, bootstrap and outbox sender
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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('*');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// services/sync/jest.config.cjs —— 单测配置(镜像 services/worker/jest.config.cjs
|
||||
// 约定;P2 Task 3 当前仅有 e2e,单测配置保留供后续 sender 单测落地,
|
||||
// passWithNoTests 让空单测包不挂 turbo test 门)。
|
||||
// · maxWorkers 1:单进程串行(e2e 配置继承后共享同一 MySQL/MinIO,spec 间以
|
||||
// api test/reset*.ts TRUNCATE + 前缀清理隔离,并行会互相清掉对方数据);
|
||||
// · setupFiles 经 test/load-env.ts 载入仓库根 .env(全仓库唯一 env 文件);
|
||||
// · @photo/contracts(ESM)/@photo/db-cn/@photo/db-ov/@photo/storage(CJS)
|
||||
// 经 moduleNameMapper 直指 TS 源码,ts-jest 以 CJS 编译,无需先 build 依赖
|
||||
// 包 dist;contracts 源码 NodeNext 风格 .js 后缀导入 → 映射回 .ts。
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
maxWorkers: 1,
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
setupFiles: ['./test/load-env.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
||||
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
|
||||
// e2e 直连 ov 库断言副本(PrismaClient from '@photo/db-ov')。
|
||||
'^@photo/db-ov$': '<rootDir>/../../packages/db-ov/src/index.ts',
|
||||
// 驱动 worker PublishService(上传即公开事务)需要 storage 适配器。
|
||||
'^@photo/storage$': '<rootDir>/../../packages/storage/src/index.ts',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
testMatch: ['<rootDir>/src/**/*.spec.ts', '<rootDir>/test/**/*.spec.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
passWithNoTests: true,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// services/sync/jest.e2e.config.cjs —— e2e(test/*.e2e-spec.ts,P2 Task 3
|
||||
// cn→ov 同步链路),复用单测配置仅切换 testMatch;隔离工具复用 api 包
|
||||
// test/reset.ts / test/reset-ov.ts(跨包按文件位置解析)。
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
// CJS 配置文件复用基础配置只能经 require(node10 互操作),局部豁免 lint 规则。
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
...require('./jest.config.cjs'),
|
||||
testMatch: ['<rootDir>/test/**/*.e2e-spec.ts'],
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@photo/sync",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CN→OV outbox sender (轮询 outbox_events 推送 content-batches 到 ov 入口;P2 Task 3)",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "tsc -p tsconfig.build.json && node dist/main.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config jest.e2e.config.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@photo/contracts": "workspace:*",
|
||||
"@photo/db-cn": "workspace:*",
|
||||
"pino": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^10.4.6",
|
||||
"@nestjs/core": "^10.4.6",
|
||||
"@nestjs/platform-express": "^10.4.6",
|
||||
"@photo/db-ov": "workspace:*",
|
||||
"@photo/storage": "workspace:^",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"jest": "^29.7.0",
|
||||
"sharp": "^0.35.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// services/sync/src/main.ts —— sync 发送器进程入口(P2 Task 3)。构建产物
|
||||
// dist/main.js 由根 dev:sync / 生产启动命令运行;env 一律来自进程环境(根
|
||||
// dotenv-cli 注入),此处不读文件。循环:每 SYNC_PUSH_INTERVAL_MS 轮询一次
|
||||
// outbox(SYNC_BATCH_SIZE 一批,high 优先——见 sender.ts 排序说明;满批立即
|
||||
// 追下一轮,积压排空不被轮询间隔拖慢)。失败退避:连续失败按间隔 ×2^n 指数
|
||||
// 递增、60s 封顶,成功即归零。优雅退出:SIGINT/SIGTERM 停轮询(等待在飞
|
||||
// 一轮,fetch 超时已封顶 30s)后断开 Prisma。
|
||||
import { pino } from 'pino';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import {
|
||||
SYNC_BATCH_SIZE,
|
||||
SYNC_INTERNAL_KEY_ENV,
|
||||
SYNC_PUSH_INTERVAL_MS,
|
||||
} from '@photo/contracts';
|
||||
import { OutboxSender } from './sender';
|
||||
|
||||
/** 失败退避封顶(对齐 SYNC_RECONCILE_INTERVAL_MS 量级:分钟级封顶足够)。 */
|
||||
const MAX_BACKOFF_MS = 60_000;
|
||||
/** 单次 HTTP 请求超时。 */
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** 读正整数 env(可覆盖契约默认值);非法值回退默认并告警。 */
|
||||
function readIntEnv(
|
||||
logger: { warn: (obj: object, msg: string) => void },
|
||||
name: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === '') {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
logger.warn({ name, raw }, 'env 非正整数,回退契约默认值');
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
|
||||
const intervalMs = readIntEnv(logger, 'SYNC_PUSH_INTERVAL_MS', SYNC_PUSH_INTERVAL_MS);
|
||||
const batchSize = readIntEnv(logger, 'SYNC_BATCH_SIZE', SYNC_BATCH_SIZE);
|
||||
const ovApiUrl = process.env.OV_API_URL ?? 'http://127.0.0.1:8081';
|
||||
const internalKey = process.env[SYNC_INTERNAL_KEY_ENV] ?? '';
|
||||
if (internalKey === '') {
|
||||
// 与接收端 guard 同一取舍:密钥缺失宁可不可用,绝不裸推。
|
||||
logger.error({ env: SYNC_INTERNAL_KEY_ENV }, '缺少内部同步密钥,发送器退出');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const sender = new OutboxSender({
|
||||
prisma,
|
||||
ovApiUrl,
|
||||
internalKey,
|
||||
batchSize,
|
||||
timeoutMs: REQUEST_TIMEOUT_MS,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
{ ovApiUrl, intervalMs, batchSize },
|
||||
'sync sender started',
|
||||
);
|
||||
|
||||
let stopping = false;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
const loop = async (): Promise<void> => {
|
||||
let failureStreak = 0;
|
||||
while (!stopping) {
|
||||
try {
|
||||
const stats = await sender.runOnce();
|
||||
failureStreak = 0;
|
||||
if (stats.picked > 0) {
|
||||
logger.info(stats, 'outbox batch pushed');
|
||||
}
|
||||
if (stats.picked >= batchSize) {
|
||||
continue; // 满批:积压未清,立即追下一轮
|
||||
}
|
||||
} catch (err) {
|
||||
failureStreak += 1;
|
||||
const backoffMs = Math.min(
|
||||
intervalMs * 2 ** (failureStreak - 1),
|
||||
MAX_BACKOFF_MS,
|
||||
);
|
||||
logger.error(
|
||||
{
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
failureStreak,
|
||||
backoffMs,
|
||||
},
|
||||
'outbox push failed(整批未标记,下轮重推)',
|
||||
);
|
||||
await sleep(backoffMs);
|
||||
continue;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
};
|
||||
inFlight = loop();
|
||||
|
||||
const shutdown = (signal: string): void => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
logger.info({ signal }, 'sync sender shutting down');
|
||||
void (async () => {
|
||||
try {
|
||||
// 等在飞一轮收尾(runOnce 各步均有界:HTTP 30s 超时 / DB 查询即回)。
|
||||
await inFlight;
|
||||
await prisma.$disconnect();
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'sync sender 关闭异常',
|
||||
);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
})();
|
||||
};
|
||||
process.once('SIGINT', () => shutdown('SIGINT'));
|
||||
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,251 @@
|
||||
// services/sync/src/sender.ts —— cn→ov outbox 发送器(P2 Task 3)。
|
||||
// 单次推送 runOnce():从 cn 库 outbox_events 取 deliveredAt 为空的事件一批,
|
||||
// 转内部同步线格式(openapi SyncEvent)后 HMAC 签名推送 ov 入口
|
||||
// POST /v1/internal/sync/content-batches;响应 acceptedEventIds 即「受理」锚点
|
||||
// (含幂等重复),据此回写 deliveredAt。
|
||||
//
|
||||
// 高优先级排序:**显式两段取数**(先 high 后其余,段内 occurredAt 升序)而非
|
||||
// ORDER BY priority DESC——'high' < 'normal'(字典序 h<n),DESC 反而把 normal
|
||||
// 排前;两段取数不依赖任何字符串序假设,新增优先级值也不失效。间隔轮询模型下
|
||||
// high 事件最坏等待一个 SYNC_PUSH_INTERVAL_MS(默认 2s,D11「high 不受间隔
|
||||
// 约束」的落地取舍:2s 上限可接受,专路即时唤醒属 YAGNI)。
|
||||
//
|
||||
// 失败语义:任一环节抛错则本 tick 整批不标记、下轮重推(eventId 幂等保证重试
|
||||
// 安全);退避由 main 循环负责(指数递增封顶),本模块无内部重试。
|
||||
import type { PrismaClient } from '@photo/db-cn';
|
||||
import type { components } from '@photo/contracts';
|
||||
import {
|
||||
type OutboxEvent,
|
||||
SYNC_SIGNATURE_HEADER,
|
||||
SYNC_TIMESTAMP_HEADER,
|
||||
isTombstoneOutboxType,
|
||||
signSyncBody,
|
||||
syncEntityTypeOfOutboxType,
|
||||
} from '@photo/contracts';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
/** openapi SyncEvent(线格式;payload 为实体类型相关载荷对象)。 */
|
||||
export type SyncEventWire = components['schemas']['SyncEvent'];
|
||||
|
||||
/** ov content-batches 响应(openapi ContentBatchResponse)。 */
|
||||
interface ContentBatchResponse {
|
||||
acceptedEventIds: string[];
|
||||
applied: number;
|
||||
}
|
||||
|
||||
/** outbox 行(发送器只读关心的列)。 */
|
||||
type OutboxRow = {
|
||||
id: string;
|
||||
type: string;
|
||||
albumId: string;
|
||||
photoId: string | null;
|
||||
entityVersion: number;
|
||||
priority: string;
|
||||
payload: unknown;
|
||||
occurredAt: Date;
|
||||
};
|
||||
|
||||
export interface OutboxSenderOptions {
|
||||
prisma: PrismaClient;
|
||||
/** ov 入口基址(如 http://127.0.0.1:8081,不含路径);端点路径本模块拼接。 */
|
||||
ovApiUrl: string;
|
||||
/** HMAC 密钥(与接收端 InternalSyncGuard 同一 SYNC_INTERNAL_KEY 值)。 */
|
||||
internalKey: string;
|
||||
/** 单批上限(默认契约 SYNC_BATCH_SIZE)。 */
|
||||
batchSize?: number;
|
||||
/** 单次 HTTP 超时毫秒(默认 30s)。 */
|
||||
timeoutMs?: number;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/** 单次 runOnce 的观测口径(main 循环打点;测试用于排空/排序判断)。 */
|
||||
export interface PushStats {
|
||||
/** 本批取到的事件数(0 则跳过 HTTP)。 */
|
||||
picked: number;
|
||||
/** ov 受理的事件 ID 清单(含幂等重复;批内顺序即受理顺序)。 */
|
||||
acceptedEventIds: string[];
|
||||
/** ov 实际应用(新版本生效)的条数。 */
|
||||
applied: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** ov 侧 content-batches 端点路径(全局前缀 v1 + InternalSyncGuard 域)。 */
|
||||
const CONTENT_BATCHES_PATH = '/v1/internal/sync/content-batches';
|
||||
|
||||
export class OutboxSender {
|
||||
private readonly batchSize: number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly endpoint: string;
|
||||
|
||||
constructor(private readonly options: OutboxSenderOptions) {
|
||||
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
// 基址容错:吞并尾斜杠再拼路径,端点只此一处。
|
||||
this.endpoint = `${options.ovApiUrl.replace(/\/+$/, '')}${CONTENT_BATCHES_PATH}`;
|
||||
}
|
||||
|
||||
/** 单轮推送:取批 → 线格式 → 签名推送 → 按受理回写 deliveredAt。 */
|
||||
async runOnce(): Promise<PushStats> {
|
||||
const rows = await this.pickBatch();
|
||||
if (rows.length === 0) {
|
||||
return { picked: 0, acceptedEventIds: [], applied: 0 };
|
||||
}
|
||||
const events = rows.map((row) => this.toWireEvent(row));
|
||||
const response = await this.push(events);
|
||||
// 只标记 ov 明确受理的 eventId(且仍为未投递),幂等可重入。
|
||||
if (response.acceptedEventIds.length > 0) {
|
||||
await this.options.prisma.outbox_events.updateMany({
|
||||
where: {
|
||||
id: { in: response.acceptedEventIds },
|
||||
deliveredAt: null,
|
||||
},
|
||||
data: { deliveredAt: new Date() },
|
||||
});
|
||||
}
|
||||
this.options.logger?.debug(
|
||||
{
|
||||
picked: rows.length,
|
||||
accepted: response.acceptedEventIds.length,
|
||||
applied: response.applied,
|
||||
},
|
||||
'outbox batch pushed',
|
||||
);
|
||||
return {
|
||||
picked: rows.length,
|
||||
acceptedEventIds: response.acceptedEventIds,
|
||||
applied: response.applied,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 两段取数(见文件头排序说明):high 段占满批则不发第二查;两查之间存在
|
||||
* 微小时序窗口(新事件落入两查之间)——对轮询器无害,下一轮补上。
|
||||
*/
|
||||
private async pickBatch(): Promise<OutboxRow[]> {
|
||||
const prisma = this.options.prisma;
|
||||
const high = await prisma.outbox_events.findMany({
|
||||
where: { deliveredAt: null, priority: 'high' },
|
||||
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
|
||||
take: this.batchSize,
|
||||
// 只取发送器关心的列(payload JsonValue 原样透传)。
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
albumId: true,
|
||||
photoId: true,
|
||||
entityVersion: true,
|
||||
priority: true,
|
||||
payload: true,
|
||||
occurredAt: true,
|
||||
},
|
||||
});
|
||||
if (high.length >= this.batchSize) {
|
||||
return high;
|
||||
}
|
||||
const rest = await prisma.outbox_events.findMany({
|
||||
where: { deliveredAt: null, priority: { not: 'high' } },
|
||||
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
|
||||
take: this.batchSize - high.length,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
albumId: true,
|
||||
photoId: true,
|
||||
entityVersion: true,
|
||||
priority: true,
|
||||
payload: true,
|
||||
occurredAt: true,
|
||||
},
|
||||
});
|
||||
return [...high, ...rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* outbox 行 → SyncEvent 线格式。entityId 按实体类型取自不同位置:
|
||||
* album → albumId 列;photo → photoId 列;category/asset → 载荷内的
|
||||
* categoryId/assetId(outbox 表无此二列)。映射缺料抛错(fail-fast:
|
||||
* 本轮不推、不标记,错误日志暴露;cn 侧发射器按构造保证不缺料)。
|
||||
*/
|
||||
private toWireEvent(row: OutboxRow): SyncEventWire {
|
||||
const type = row.type as OutboxEvent['type'];
|
||||
const entityType = syncEntityTypeOfOutboxType(type);
|
||||
const payload = (row.payload ?? {}) as Record<string, unknown>;
|
||||
let entityId: string;
|
||||
if (entityType === 'album') {
|
||||
entityId = row.albumId;
|
||||
} else if (entityType === 'photo') {
|
||||
if (row.photoId === null) {
|
||||
throw new Error(`outbox 事件 ${row.id}(${type})缺 photoId,无法推导 entityId`);
|
||||
}
|
||||
entityId = row.photoId;
|
||||
} else if (typeof payload.categoryId === 'string') {
|
||||
entityId = payload.categoryId;
|
||||
} else if (typeof payload.assetId === 'string') {
|
||||
entityId = payload.assetId;
|
||||
} else {
|
||||
throw new Error(`outbox 事件 ${row.id}(${type})载荷缺实体 ID,无法推导 entityId`);
|
||||
}
|
||||
const tombstone = isTombstoneOutboxType(type);
|
||||
return {
|
||||
eventId: row.id,
|
||||
schemaVersion: 1,
|
||||
entityType,
|
||||
entityId,
|
||||
albumId: row.albumId,
|
||||
entityVersion: row.entityVersion,
|
||||
// 线格式 tombstone 显式携带(生成类型必填:openapi-typescript 把带
|
||||
// default 的属性渲染为非可选)。
|
||||
tombstone,
|
||||
priority: row.priority === 'high' ? 'high' : 'normal',
|
||||
payload: payload as SyncEventWire['payload'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名推送:同一 JSON.stringify 产物既作签名输入也作请求体(原始字节一致,
|
||||
* 接收端 rawBody 验签才能通过——见 contracts/internal-sync.ts 头注)。
|
||||
* 非 2xx / 响应形状异常均抛错(调用方整批重试)。
|
||||
*/
|
||||
private async push(events: SyncEventWire[]): Promise<ContentBatchResponse> {
|
||||
const rawBody = JSON.stringify({ batch: events });
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
[SYNC_TIMESTAMP_HEADER]: timestamp,
|
||||
[SYNC_SIGNATURE_HEADER]: signSyncBody(this.options.internalKey, rawBody),
|
||||
},
|
||||
body: rawBody,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`content-batches 请求失败(${this.endpoint}):${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
`content-batches 非 2xx(${this.endpoint}):status=${response.status} body=${body.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
data === null ||
|
||||
!Array.isArray((data as ContentBatchResponse).acceptedEventIds) ||
|
||||
!((data as ContentBatchResponse).acceptedEventIds as unknown[]).every(
|
||||
(id) => typeof id === 'string',
|
||||
) ||
|
||||
typeof (data as ContentBatchResponse).applied !== 'number'
|
||||
) {
|
||||
throw new Error('content-batches 响应形状异常(acceptedEventIds/applied 缺失)');
|
||||
}
|
||||
return data as ContentBatchResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// services/sync/test/load-env.ts
|
||||
// 全仓库唯一 env 加载机制(Jest 侧):仓库根 .env(与 services/api 同约定)。
|
||||
// 开发/生产经根 dotenv-cli 注入;运行时代码只读 process.env。
|
||||
import { join } from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config({ path: join(__dirname, '../../../.env') });
|
||||
@@ -0,0 +1,787 @@
|
||||
// services/sync/test/sync.e2e-spec.ts —— P2 Task 3 cn→ov 同步链路 E2E。
|
||||
// ONE spec 串完整同步流:进程内起**两个真实 HTTP 入口**(cn=AppCnModule、
|
||||
// ov=AppOvModule,均 NestFactory rawBody:true + configureApp + listen(0)),
|
||||
// 发送器为生产 OutboxSender 实例(指向 ov 入口真实 URL),业务动作全走 cn
|
||||
// 公开 API(登录/建相册/分类/上传/complete),发布事务直驱 worker
|
||||
// PublishService(同 api full-chain e2e 模式,免 BullMQ 异步等待)。
|
||||
//
|
||||
// 覆盖(任务验收 8 场景):
|
||||
// ① 全链路复制:相册+分类+发布照片 → 排空发送器 → ov 副本齐备;
|
||||
// ② 相册下线(publicEnabled=false)→ outbox high 优先 + 批内先于更早发生的
|
||||
// normal 事件被受理;
|
||||
// ③ 重复推送幂等:清 deliveredAt 模拟「推送成功未确认」重推 → 受理 applied=0;
|
||||
// ④ 隐藏照片 → photos_ov.visibility=hidden;旧 published 以新 eventId 重放不
|
||||
// 复活(A26 版本单调);同 eventId 重放受理不计应用;
|
||||
// ⑤ bootstrap 空 ov → 快照事件入 outbox(eventCount/watermark)→ 排空 →
|
||||
// 副本反映**当前**状态(下线/隐藏仍在);
|
||||
// ⑥ 依赖暂存:ov 无相册行时 photo 事件 appliedAt=null 暂存,album 事件到达
|
||||
// 即回扫补应用;
|
||||
// ⑦ HMAC 验签:缺头/错签/签名覆盖体不符/时间戳超窗 → 401 SYNC_AUTH_FAILED
|
||||
// (ov content-batches + cn bootstrap 同一守卫);
|
||||
// ⑧ reconcile:按 entityType 返回当前状态清单(sinceVersion 过滤/升序);
|
||||
// ⑨ 乱序:v7 hidden 先应用、迟到的 v6 published 被版本比较拒绝(A26)。
|
||||
// 真实 MySQL×2 + MinIO(staging/originals/variants)+ HMAC 全链路字节一致。
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||
import request from 'supertest';
|
||||
import sharp from 'sharp';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||||
import { ErrorCodes, signSyncBody } from '@photo/contracts';
|
||||
import {
|
||||
createStorageFromEnv,
|
||||
type MinioStorageAdapter,
|
||||
} from '@photo/storage';
|
||||
import { AppCnModule } from '../../api/src/app-cn.module';
|
||||
import { AppOvModule } from '../../api/src/app-ov.module';
|
||||
import { seedAdmin } from '../../api/src/scripts/seed-admin';
|
||||
import { configureApp } from '../../api/src/shared/configure-app';
|
||||
import { PublishService } from '../../worker/src/publish.service';
|
||||
import { resetDb } from '../../api/test/reset';
|
||||
import { resetOvDb } from '../../api/test/reset-ov';
|
||||
import { OutboxSender } from '../src/sender';
|
||||
|
||||
jest.setTimeout(180_000);
|
||||
|
||||
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
||||
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
||||
|
||||
const CONTENT_BATCHES_PATH = '/v1/internal/sync/content-batches';
|
||||
const BOOTSTRAP_PATH = '/v1/internal/sync/bootstrap';
|
||||
const RECONCILE_PATH = '/v1/internal/sync/reconcile';
|
||||
|
||||
/** 测试内生成真实 JPEG(同 api full-chain e2e;无二进制入库)。 */
|
||||
async function makeJpeg(
|
||||
width: number,
|
||||
height: number,
|
||||
rgb: { r: number; g: number; b: number },
|
||||
): Promise<Buffer> {
|
||||
return sharp({ create: { width, height, channels: 3, background: rgb } })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** 签名 POST(可注入坏签名/时间戳偏移/缺头,场景⑦专用)。 */
|
||||
interface SignOverrides {
|
||||
signature?: string;
|
||||
timestampOffsetSeconds?: number;
|
||||
omitSignature?: boolean;
|
||||
omitTimestamp?: boolean;
|
||||
}
|
||||
|
||||
async function signedPost(
|
||||
base: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
overrides: SignOverrides = {},
|
||||
): Promise<Response> {
|
||||
const raw = JSON.stringify(body);
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||
if (!overrides.omitTimestamp) {
|
||||
headers['x-sync-timestamp'] = String(
|
||||
Math.floor(Date.now() / 1000) + (overrides.timestampOffsetSeconds ?? 0),
|
||||
);
|
||||
}
|
||||
if (!overrides.omitSignature) {
|
||||
headers['x-sync-signature'] =
|
||||
overrides.signature ?? signSyncBody(SYNC_KEY, raw);
|
||||
}
|
||||
return fetch(`${base}${path}`, { method: 'POST', headers, body: raw });
|
||||
}
|
||||
|
||||
/** 线格式事件(openapi SyncEvent;测试内手工构造乱序/重放/暂存场景)。 */
|
||||
interface WireEvent {
|
||||
eventId: string;
|
||||
schemaVersion: 1;
|
||||
entityType: 'album' | 'category' | 'photo' | 'asset';
|
||||
entityId: string;
|
||||
albumId: string;
|
||||
entityVersion: number;
|
||||
tombstone: boolean;
|
||||
priority: 'normal' | 'high';
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** reconcile 清单元素(断言用形状)。 */
|
||||
interface ReconEvent {
|
||||
eventId: string;
|
||||
schemaVersion: number;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityVersion: number;
|
||||
payload: {
|
||||
albumId?: string;
|
||||
publicEnabled?: boolean;
|
||||
visibility?: string;
|
||||
entityVersion?: number;
|
||||
};
|
||||
}
|
||||
|
||||
describe('sync cn→ov (e2e)', () => {
|
||||
let cnApp: NestExpressApplication;
|
||||
let ovApp: NestExpressApplication;
|
||||
let cnBase: string;
|
||||
let ovBase: string;
|
||||
let prismaCn: PrismaClient;
|
||||
let prismaOv: OvPrismaClient;
|
||||
let storage: MinioStorageAdapter;
|
||||
let sender: OutboxSender;
|
||||
let adminToken: string;
|
||||
|
||||
let albumId: string;
|
||||
let publicToken: string;
|
||||
let categoryId: string;
|
||||
let photo1: { photoId: string; objectKey: string };
|
||||
let photo2: { photoId: string; objectKey: string };
|
||||
/** 场景②的 high 事件行 id(场景③复用)。 */
|
||||
let offlineEventId: string;
|
||||
/** photo2 的 published 载荷(场景⑨乱序重放用)。 */
|
||||
let photo2PublishedPayload: Record<string, unknown>;
|
||||
|
||||
/** 排空发送器:循环 runOnce 直至无未投递事件(每轮整批推送)。 */
|
||||
async function drainSender(): Promise<void> {
|
||||
for (let round = 0; round < 20; round++) {
|
||||
const stats = await sender.runOnce();
|
||||
if (stats.picked === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
|
||||
}
|
||||
|
||||
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
|
||||
async function uploadAndPublish(
|
||||
clientTaskId: string,
|
||||
jpeg: Buffer,
|
||||
): Promise<{ photoId: string; objectKey: string }> {
|
||||
const init = await request(cnBase)
|
||||
.post('/v1/uploads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
albumId,
|
||||
clientTaskId,
|
||||
filename: 'sync.jpg',
|
||||
bytes: jpeg.length,
|
||||
contentType: 'image/jpeg',
|
||||
categoryId,
|
||||
});
|
||||
expect(init.status).toBe(201);
|
||||
const { uploadId, photoId, credential } = init.body as {
|
||||
uploadId: string;
|
||||
photoId: string;
|
||||
credential: { url: string; method: string; headers: Record<string, string> };
|
||||
};
|
||||
const put = await fetch(credential.url, {
|
||||
method: credential.method,
|
||||
headers: credential.headers,
|
||||
body: new Uint8Array(jpeg),
|
||||
});
|
||||
expect(put.ok).toBe(true);
|
||||
const done = await request(cnBase)
|
||||
.post(`/v1/uploads/${uploadId}/complete`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(done.status).toBe(200);
|
||||
expect(await new PublishService(prismaCn, storage).processPhoto(photoId)).toBe(
|
||||
'published',
|
||||
);
|
||||
// init 响应的 objectKey 是 staging 键;complete 受控复制后正式键在 DB 行上
|
||||
//(同 api full-chain e2e 的取值口径)。
|
||||
const finalKey = (
|
||||
await prismaCn.photos.findUniqueOrThrow({
|
||||
where: { id: photoId },
|
||||
select: { objectKey: true },
|
||||
})
|
||||
).objectKey;
|
||||
return { photoId, objectKey: finalKey };
|
||||
}
|
||||
|
||||
/** 直推 ov content-batches(绕过发送器,构造乱序/重放/暂存场景)。 */
|
||||
async function pushEvents(
|
||||
events: WireEvent[],
|
||||
): Promise<{ acceptedEventIds: string[]; applied: number }> {
|
||||
const res = await signedPost(ovBase, CONTENT_BATCHES_PATH, { batch: events });
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as { acceptedEventIds: string[]; applied: number };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(SYNC_KEY).not.toBe('');
|
||||
await resetDb();
|
||||
await resetOvDb();
|
||||
|
||||
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
configureApp(cnApp);
|
||||
await cnApp.listen(0);
|
||||
cnBase = await cnApp.getUrl();
|
||||
|
||||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
configureApp(ovApp);
|
||||
await ovApp.listen(0);
|
||||
ovBase = await ovApp.getUrl();
|
||||
|
||||
prismaCn = new PrismaClient();
|
||||
prismaOv = new OvPrismaClient();
|
||||
storage = createStorageFromEnv();
|
||||
sender = new OutboxSender({
|
||||
prisma: prismaCn,
|
||||
ovApiUrl: ovBase,
|
||||
internalKey: SYNC_KEY,
|
||||
});
|
||||
|
||||
await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||||
const login = await request(cnBase).post('/v1/auth/password').send({
|
||||
account: ADMIN_ACCOUNT,
|
||||
password: ADMIN_PASSWORD,
|
||||
});
|
||||
expect(login.status).toBe(200);
|
||||
adminToken = login.body.sessionToken as string;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cnApp.close();
|
||||
await ovApp.close();
|
||||
await prismaCn.$disconnect();
|
||||
await prismaOv.$disconnect();
|
||||
});
|
||||
|
||||
it('① 全链路复制:建相册+分类+发布照片 → 发送器排空 → ov 副本齐备', async () => {
|
||||
const album = await request(cnBase)
|
||||
.post('/v1/albums')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title: '同步晚会', description: '端到端验收' },
|
||||
{ locale: 'en', title: 'Sync Gala' },
|
||||
],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: '2026-10-01T10:00:00.000Z',
|
||||
});
|
||||
expect(album.status).toBe(201);
|
||||
albumId = album.body.id as string;
|
||||
publicToken = album.body.publicToken as string;
|
||||
|
||||
const category = await request(cnBase)
|
||||
.post(`/v1/albums/${albumId}/categories`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
sortOrder: 0,
|
||||
translations: [
|
||||
{ locale: 'zh-CN', name: '主舞台' },
|
||||
{ locale: 'en', name: 'Main Stage' },
|
||||
],
|
||||
});
|
||||
expect(category.status).toBe(201);
|
||||
categoryId = category.body.id as string;
|
||||
|
||||
photo1 = await uploadAndPublish(
|
||||
'sync-a',
|
||||
await makeJpeg(900, 600, { r: 180, g: 120, b: 60 }),
|
||||
);
|
||||
await drainSender();
|
||||
|
||||
// outbox:三类事件均已确认投递。
|
||||
const albumEvent = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { type: 'album.updated', albumId },
|
||||
});
|
||||
expect(albumEvent).toMatchObject({ entityVersion: 1, priority: 'normal' });
|
||||
expect(albumEvent.deliveredAt).not.toBeNull();
|
||||
expect(
|
||||
await prismaCn.outbox_events.count({
|
||||
where: { deliveredAt: null },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// ov:相册副本(标量 + 翻译 locale 升序 + 分类 JSON 元素)。
|
||||
const albumReplica = await prismaOv.albums_ov.findUniqueOrThrow({
|
||||
where: { albumId },
|
||||
});
|
||||
expect(albumReplica).toMatchObject({
|
||||
publicToken,
|
||||
publicEnabled: true,
|
||||
defaultLocale: 'zh-CN',
|
||||
entityVersion: 1,
|
||||
});
|
||||
expect(albumReplica.translations).toEqual([
|
||||
{ locale: 'en', title: 'Sync Gala', description: null, venue: null, address: null },
|
||||
{
|
||||
locale: 'zh-CN',
|
||||
title: '同步晚会',
|
||||
description: '端到端验收',
|
||||
venue: null,
|
||||
address: null,
|
||||
},
|
||||
]);
|
||||
expect(albumReplica.categories).toEqual([
|
||||
{
|
||||
id: categoryId,
|
||||
sortOrder: 0,
|
||||
translations: [
|
||||
{ locale: 'en', name: 'Main Stage' },
|
||||
{ locale: 'zh-CN', name: '主舞台' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// ov:照片副本(发布载荷全量:三键 + 尺寸 + publishedAt)。
|
||||
const photoReplica = await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photo1.photoId },
|
||||
});
|
||||
expect(photoReplica).toMatchObject({
|
||||
albumId,
|
||||
originalObjectKey: photo1.objectKey,
|
||||
visibility: 'published',
|
||||
width: 900,
|
||||
height: 600,
|
||||
entityVersion: 2,
|
||||
});
|
||||
expect(photoReplica.thumbObjectKey).toContain(`/variants/${photo1.photoId}/`);
|
||||
expect(photoReplica.browseObjectKey).toContain(`/variants/${photo1.photoId}/`);
|
||||
expect(photoReplica.publishedAt).not.toBeNull();
|
||||
|
||||
// ov:版本账本。
|
||||
for (const [entityType, entityId, appliedVersion] of [
|
||||
['album', albumId, 1],
|
||||
['category', categoryId, 1],
|
||||
['photo', photo1.photoId, 2],
|
||||
] as const) {
|
||||
expect(
|
||||
await prismaOv.sync_state.findUniqueOrThrow({
|
||||
where: { entityType_entityId: { entityType, entityId } },
|
||||
}),
|
||||
).toMatchObject({ appliedVersion, watermark: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('② 相册下线 → high 优先事件先于更早发生的 normal 事件被受理', async () => {
|
||||
// 先造一条更早发生的 normal 事件(photo2 发布),再下线相册(high)。
|
||||
photo2 = await uploadAndPublish(
|
||||
'sync-b',
|
||||
await makeJpeg(800, 600, { r: 60, g: 130, b: 180 }),
|
||||
);
|
||||
const offline = await request(cnBase)
|
||||
.patch(`/v1/albums/${albumId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ publicEnabled: false });
|
||||
expect(offline.status).toBe(200);
|
||||
expect(offline.body).toMatchObject({ id: albumId, publicEnabled: false });
|
||||
|
||||
const highRow = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { deliveredAt: null, priority: 'high' },
|
||||
});
|
||||
expect(highRow).toMatchObject({ type: 'album.updated', entityVersion: 2 });
|
||||
offlineEventId = highRow.id;
|
||||
const photo2Row = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { deliveredAt: null, type: 'photo.published', photoId: photo2.photoId },
|
||||
});
|
||||
|
||||
const stats = await sender.runOnce();
|
||||
expect(stats.picked).toBe(2);
|
||||
expect(stats.applied).toBe(2);
|
||||
// 顺序断言:high(后发生)先于 normal(先发生)。
|
||||
expect(stats.acceptedEventIds).toEqual([offlineEventId, photo2Row.id]);
|
||||
|
||||
expect(
|
||||
await prismaOv.albums_ov.findUniqueOrThrow({ where: { albumId } }),
|
||||
).toMatchObject({ publicEnabled: false, entityVersion: 2 });
|
||||
expect(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({ where: { photoId: photo2.photoId } }),
|
||||
).toMatchObject({ visibility: 'published', entityVersion: 2 });
|
||||
});
|
||||
|
||||
it('③ 重复推送幂等:重推已应用 eventId → 受理但 applied=0', async () => {
|
||||
// 模拟「ov 已应用、cn 未收到确认」:清 deliveredAt 让发送器重推同一事件。
|
||||
await prismaCn.outbox_events.update({
|
||||
where: { id: offlineEventId },
|
||||
data: { deliveredAt: null },
|
||||
});
|
||||
const stats = await sender.runOnce();
|
||||
expect(stats.picked).toBe(1);
|
||||
expect(stats.acceptedEventIds).toEqual([offlineEventId]);
|
||||
expect(stats.applied).toBe(0);
|
||||
|
||||
const replica = await prismaOv.albums_ov.findUniqueOrThrow({
|
||||
where: { albumId },
|
||||
});
|
||||
expect(replica).toMatchObject({ publicEnabled: false, entityVersion: 2 });
|
||||
expect(
|
||||
(await prismaCn.outbox_events.findUniqueOrThrow({
|
||||
where: { id: offlineEventId },
|
||||
})).deliveredAt,
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('④ 隐藏照片 → photos_ov.hidden;旧 published 重放不复活(A26)', async () => {
|
||||
const hide = await request(cnBase)
|
||||
.patch(`/v1/photos/${photo1.photoId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ visibility: 'hidden' });
|
||||
expect(hide.status).toBe(200);
|
||||
|
||||
const hiddenRow = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { type: 'photo.hidden', photoId: photo1.photoId },
|
||||
});
|
||||
expect(hiddenRow).toMatchObject({ entityVersion: 3, priority: 'high' });
|
||||
await drainSender();
|
||||
|
||||
expect(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photo1.photoId },
|
||||
}),
|
||||
).toMatchObject({ visibility: 'hidden', entityVersion: 3 });
|
||||
|
||||
// A26:旧 published(v2)换新 eventId 重放 → 版本比较拒绝,不复活。
|
||||
const publishedRow = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { type: 'photo.published', photoId: photo1.photoId },
|
||||
});
|
||||
const replayId = `e2e-${randomUUID()}`;
|
||||
const replay = await pushEvents([
|
||||
{
|
||||
eventId: replayId,
|
||||
schemaVersion: 1,
|
||||
entityType: 'photo',
|
||||
entityId: photo1.photoId,
|
||||
albumId,
|
||||
entityVersion: 2,
|
||||
tombstone: false,
|
||||
priority: 'normal',
|
||||
payload: publishedRow.payload as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
expect(replay).toEqual({ acceptedEventIds: [replayId], applied: 0 });
|
||||
expect(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photo1.photoId },
|
||||
}),
|
||||
).toMatchObject({ visibility: 'hidden', entityVersion: 3 });
|
||||
|
||||
// 同 eventId 重放:受理不计应用(eventId 幂等锚)。
|
||||
const again = await pushEvents([
|
||||
{
|
||||
eventId: hiddenRow.id,
|
||||
schemaVersion: 1,
|
||||
entityType: 'photo',
|
||||
entityId: photo1.photoId,
|
||||
albumId,
|
||||
entityVersion: 3,
|
||||
tombstone: false,
|
||||
priority: 'high',
|
||||
payload: hiddenRow.payload as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
expect(again).toEqual({ acceptedEventIds: [hiddenRow.id], applied: 0 });
|
||||
});
|
||||
|
||||
it('⑤ bootstrap 空 ov → 快照入 outbox(eventCount/watermark)→ 副本反映当前状态', async () => {
|
||||
await resetOvDb();
|
||||
const boot = await signedPost(cnBase, BOOTSTRAP_PATH, { albumId });
|
||||
expect(boot.status).toBe(200);
|
||||
// 当前状态:album v2(下线)+ category v1 + photo1 hidden v3 + photo2 published v2。
|
||||
expect(await boot.json()).toEqual({ eventCount: 4, watermark: 3 });
|
||||
expect(
|
||||
await prismaCn.outbox_events.count({ where: { deliveredAt: null } }),
|
||||
).toBe(4);
|
||||
|
||||
await drainSender();
|
||||
|
||||
expect(
|
||||
await prismaOv.albums_ov.findUniqueOrThrow({ where: { albumId } }),
|
||||
).toMatchObject({ publicEnabled: false, entityVersion: 2 });
|
||||
// photo1 当前为 hidden 且空 ov 上从无 published 副本 → 无行(缺席即撤下
|
||||
// 语义),版本账本照常推进(旧 published 重放会被版本比较拒绝)。
|
||||
expect(
|
||||
await prismaOv.photos_ov.count({ where: { photoId: photo1.photoId } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prismaOv.sync_state.findUniqueOrThrow({
|
||||
where: {
|
||||
entityType_entityId: { entityType: 'photo', entityId: photo1.photoId },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ appliedVersion: 3, watermark: false });
|
||||
expect(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photo2.photoId },
|
||||
}),
|
||||
).toMatchObject({ visibility: 'published', entityVersion: 2 });
|
||||
expect(await prismaOv.sync_state.count({ where: { watermark: false } })).toBe(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it('⑥ 依赖暂存:相册缺失时 photo 暂存,album 到达后回扫补应用', async () => {
|
||||
await resetOvDb();
|
||||
const source = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { type: 'photo.published', photoId: photo2.photoId },
|
||||
});
|
||||
photo2PublishedPayload = source.payload as Record<string, unknown>;
|
||||
|
||||
const stagedId = `e2e-${randomUUID()}`;
|
||||
const stage = await pushEvents([
|
||||
{
|
||||
eventId: stagedId,
|
||||
schemaVersion: 1,
|
||||
entityType: 'photo',
|
||||
entityId: photo2.photoId,
|
||||
albumId,
|
||||
entityVersion: 2,
|
||||
tombstone: false,
|
||||
priority: 'normal',
|
||||
payload: photo2PublishedPayload,
|
||||
},
|
||||
]);
|
||||
expect(stage).toEqual({ acceptedEventIds: [stagedId], applied: 0 });
|
||||
// 暂存:inbox 行在、appliedAt 为空、无副本投影、无版本账本。
|
||||
expect(
|
||||
await prismaOv.sync_inbox.findUniqueOrThrow({ where: { eventId: stagedId } }),
|
||||
).toMatchObject({ appliedAt: null, entityType: 'photo' });
|
||||
expect(await prismaOv.photos_ov.count({ where: { albumId } })).toBe(0);
|
||||
expect(
|
||||
await prismaOv.sync_state.count({
|
||||
where: { entityType: 'photo', entityId: photo2.photoId },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// album 事件到达:应用 + 回扫暂存(photo 补应用)。
|
||||
const albumSource = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { type: 'album.updated', albumId },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
});
|
||||
const albumEventId = `e2e-${randomUUID()}`;
|
||||
const unlock = await pushEvents([
|
||||
{
|
||||
eventId: albumEventId,
|
||||
schemaVersion: 1,
|
||||
entityType: 'album',
|
||||
entityId: albumId,
|
||||
albumId,
|
||||
entityVersion: albumSource.entityVersion,
|
||||
tombstone: false,
|
||||
priority: 'normal',
|
||||
payload: albumSource.payload as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
expect(unlock).toEqual({
|
||||
acceptedEventIds: [albumEventId],
|
||||
applied: 2, // album 自身 + 回扫的 photo
|
||||
});
|
||||
expect(await prismaOv.albums_ov.count({ where: { albumId } })).toBe(1);
|
||||
expect(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photo2.photoId },
|
||||
}),
|
||||
).toMatchObject({ visibility: 'published', entityVersion: 2 });
|
||||
expect(
|
||||
(
|
||||
await prismaOv.sync_inbox.findUniqueOrThrow({
|
||||
where: { eventId: stagedId },
|
||||
})
|
||||
).appliedAt,
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('⑦ HMAC 验签:缺头/错签/签名体不符/时间戳超窗 → 401 SYNC_AUTH_FAILED', async () => {
|
||||
const body = {
|
||||
batch: [
|
||||
{
|
||||
eventId: `e2e-${randomUUID()}`,
|
||||
schemaVersion: 1,
|
||||
entityType: 'album',
|
||||
entityId: 'hmac-probe',
|
||||
albumId: 'hmac-probe',
|
||||
entityVersion: 1,
|
||||
tombstone: false,
|
||||
priority: 'normal',
|
||||
payload: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const expectUnauthorized = async (res: Promise<Response>): Promise<void> => {
|
||||
const settled = await res;
|
||||
expect(settled.status).toBe(401);
|
||||
expect(((await settled.json()) as { code: string }).code).toBe(
|
||||
ErrorCodes.SYNC_AUTH_FAILED,
|
||||
);
|
||||
};
|
||||
|
||||
// 缺签名/时间戳头。
|
||||
await expectUnauthorized(
|
||||
signedPost(ovBase, CONTENT_BATCHES_PATH, body, {
|
||||
omitSignature: true,
|
||||
omitTimestamp: true,
|
||||
}),
|
||||
);
|
||||
// 形状合法的错签。
|
||||
await expectUnauthorized(
|
||||
signedPost(ovBase, CONTENT_BATCHES_PATH, body, { signature: '0'.repeat(64) }),
|
||||
);
|
||||
// 签名覆盖体与实际请求体不符(验的是原始字节,非重序列化 body)。
|
||||
await expectUnauthorized(
|
||||
signedPost(ovBase, CONTENT_BATCHES_PATH, body, {
|
||||
signature: signSyncBody(SYNC_KEY, '{"batch":[]}'),
|
||||
}),
|
||||
);
|
||||
// 时间戳超窗(签名本身有效)。
|
||||
await expectUnauthorized(
|
||||
signedPost(ovBase, CONTENT_BATCHES_PATH, body, {
|
||||
timestampOffsetSeconds: -400,
|
||||
}),
|
||||
);
|
||||
// cn 侧端点同一守卫。
|
||||
await expectUnauthorized(
|
||||
signedPost(cnBase, BOOTSTRAP_PATH, {}, { omitSignature: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('⑧ reconcile:按 entityType 返回当前状态清单(sinceVersion 过滤、升序)', async () => {
|
||||
const albumList = await signedPost(cnBase, RECONCILE_PATH, {
|
||||
entityType: 'album',
|
||||
sinceVersion: 0,
|
||||
limit: 10,
|
||||
});
|
||||
expect(albumList.status).toBe(200);
|
||||
const albumEvents = ((await albumList.json()) as { events: ReconEvent[] })
|
||||
.events;
|
||||
expect(albumEvents).toHaveLength(1);
|
||||
expect(albumEvents[0]).toMatchObject({
|
||||
eventId: `recon:album:${albumId}:2`,
|
||||
schemaVersion: 1,
|
||||
entityType: 'album',
|
||||
entityId: albumId,
|
||||
entityVersion: 2,
|
||||
priority: 'normal',
|
||||
});
|
||||
expect(albumEvents[0].payload).toMatchObject({
|
||||
albumId,
|
||||
publicEnabled: false,
|
||||
entityVersion: 2,
|
||||
});
|
||||
|
||||
const photoList = await signedPost(cnBase, RECONCILE_PATH, {
|
||||
entityType: 'photo',
|
||||
albumId,
|
||||
sinceVersion: 0,
|
||||
limit: 10,
|
||||
});
|
||||
expect(photoList.status).toBe(200);
|
||||
const photoEvents = ((await photoList.json()) as { events: ReconEvent[] })
|
||||
.events;
|
||||
// 升序:photo2 v2(published)→ photo1 v3(hidden)。
|
||||
expect(
|
||||
photoEvents.map((e) => [e.entityId, e.entityVersion]),
|
||||
).toEqual([
|
||||
[photo2.photoId, 2],
|
||||
[photo1.photoId, 3],
|
||||
]);
|
||||
expect(photoEvents[1].payload.visibility).toBe('hidden');
|
||||
|
||||
// sinceVersion=当前版本 → 空(增量语义)。
|
||||
const empty = await signedPost(cnBase, RECONCILE_PATH, {
|
||||
entityType: 'album',
|
||||
sinceVersion: 2,
|
||||
limit: 10,
|
||||
});
|
||||
expect(((await empty.json()) as { events: unknown[] }).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('⑨ 乱序:v7 hidden 先应用、迟到的 v6 published 不复活(A26)', async () => {
|
||||
const v7Id = `e2e-${randomUUID()}`;
|
||||
const first = await pushEvents([
|
||||
{
|
||||
eventId: v7Id,
|
||||
schemaVersion: 1,
|
||||
entityType: 'photo',
|
||||
entityId: photo2.photoId,
|
||||
albumId,
|
||||
entityVersion: 7,
|
||||
tombstone: false,
|
||||
priority: 'high',
|
||||
payload: {
|
||||
photoId: photo2.photoId,
|
||||
albumId,
|
||||
visibility: 'hidden',
|
||||
entityVersion: 7,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(first).toEqual({ acceptedEventIds: [v7Id], applied: 1 });
|
||||
expect(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photo2.photoId },
|
||||
}),
|
||||
).toMatchObject({ visibility: 'hidden', entityVersion: 7 });
|
||||
|
||||
const v6Id = `e2e-${randomUUID()}`;
|
||||
const second = await pushEvents([
|
||||
{
|
||||
eventId: v6Id,
|
||||
schemaVersion: 1,
|
||||
entityType: 'photo',
|
||||
entityId: photo2.photoId,
|
||||
albumId,
|
||||
entityVersion: 6,
|
||||
tombstone: false,
|
||||
priority: 'normal',
|
||||
payload: { ...photo2PublishedPayload, entityVersion: 6 },
|
||||
},
|
||||
]);
|
||||
expect(second).toEqual({ acceptedEventIds: [v6Id], applied: 0 });
|
||||
expect(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photo2.photoId },
|
||||
}),
|
||||
).toMatchObject({ visibility: 'hidden', entityVersion: 7 });
|
||||
});
|
||||
|
||||
it('⑩ 分类更新/删除:category.updated 投影 upsert、墓碑移除元素', async () => {
|
||||
const rename = await request(cnBase)
|
||||
.patch(`/v1/categories/${categoryId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
sortOrder: 5,
|
||||
translations: [
|
||||
{ locale: 'zh-CN', name: '主舞台' },
|
||||
{ locale: 'en', name: 'Main Stage Renamed' },
|
||||
],
|
||||
});
|
||||
expect(rename.status).toBe(200);
|
||||
const updatedRow = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { type: 'category.updated', albumId },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
});
|
||||
expect(updatedRow).toMatchObject({ entityVersion: 2, priority: 'normal' });
|
||||
await drainSender();
|
||||
let replica = await prismaOv.albums_ov.findUniqueOrThrow({
|
||||
where: { albumId },
|
||||
});
|
||||
expect(replica.categories).toEqual([
|
||||
{
|
||||
id: categoryId,
|
||||
sortOrder: 5,
|
||||
translations: [
|
||||
{ locale: 'en', name: 'Main Stage Renamed' },
|
||||
{ locale: 'zh-CN', name: '主舞台' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const remove = await request(cnBase)
|
||||
.delete(`/v1/categories/${categoryId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(remove.status).toBe(204);
|
||||
const tombstoneRow = await prismaCn.outbox_events.findFirstOrThrow({
|
||||
where: { type: 'category.deleted', albumId },
|
||||
});
|
||||
expect(tombstoneRow.entityVersion).toBe(3);
|
||||
await drainSender();
|
||||
replica = await prismaOv.albums_ov.findUniqueOrThrow({
|
||||
where: { albumId },
|
||||
});
|
||||
expect(replica.categories).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user