feat(sync): cn event coverage, bootstrap and outbox sender

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 15:21:41 +08:00
parent 3d6e9ce175
commit 65726702f3
43 changed files with 3333 additions and 181 deletions
+75
View File
@@ -0,0 +1,75 @@
// services/api/src/sync-inbox/dto.ts —— content-batches 请求 DTO(P2 Task 3)。
// 契约权威:openapi.yaml [internal] syncContentBatches(ContentBatchRequest/
// SyncEvent)。经全局 ValidationPipe(whitelist 剥离未知字段 + ValidateNested
// 逐事件校验);payload 为开放对象(@IsObject 原样透传,结构由 schemaVersion
// 约定,不在传输层强校验)。
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsObject,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import type { components } from '@photo/contracts';
type SyncEventWire = components['schemas']['SyncEvent'];
/** 契约 SyncEntityType(与 openapi enum 同步;reconcile 端点同表)。 */
export const SYNC_ENTITY_TYPES = [
'album',
'category',
'photo',
'asset',
'slot-state',
] as const;
/** 契约 SyncPriority。 */
export const SYNC_PRIORITIES = ['normal', 'high'] as const;
export class SyncEventDto implements SyncEventWire {
@IsString()
eventId!: string;
@IsInt()
@Min(1)
schemaVersion!: number;
@IsIn(SYNC_ENTITY_TYPES)
entityType!: SyncEventWire['entityType'];
@IsString()
entityId!: string;
@IsString()
albumId!: string;
@IsInt()
@Min(1)
entityVersion!: number;
// 生成类型把 default:false 属性渲染为必填(openapi-typescript 约定),发送侧
// (sender/bootstrap/reconcile)一律显式携带,DTO 同步必填。
@IsBoolean()
tombstone!: boolean;
@IsIn(SYNC_PRIORITIES)
priority!: SyncEventWire['priority'];
@IsObject()
payload!: Record<string, unknown>;
}
export class ContentBatchRequestDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => SyncEventDto)
batch!: SyncEventDto[];
}
@@ -0,0 +1,20 @@
// services/api/src/sync-inbox/sync-inbox.controller.ts —— ov 侧内容事件批量
// 接收端点(P2 Task 3)。契约权威:openapi.yaml [internal] syncContentBatches
// (POST /internal/sync/content-batches)。鉴权:InternalSyncGuard(与 cn 侧
// bootstrap/reconcile 同一 HMAC 规则)。仅 ov 入口装配(app-ov.module)。
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { InternalSyncGuard } from '../shared/internal-sync.guard';
import { ContentBatchRequestDto } from './dto';
import { SyncInboxService } from './sync-inbox.service';
@Controller('internal/sync')
@UseGuards(InternalSyncGuard)
export class SyncInboxController {
constructor(private readonly syncInbox: SyncInboxService) {}
@Post('content-batches')
@HttpCode(200) // 契约 responses '200'(受理结果,非资源创建)
receive(@Body() dto: ContentBatchRequestDto) {
return this.syncInbox.receiveBatch(dto.batch);
}
}
@@ -0,0 +1,14 @@
// services/api/src/sync-inbox/sync-inbox.module.ts —— ov 侧内容事件收件箱模块
// (P2 Task 3)。OvPrismaService 显式 import(非 @Global,按模块注入);
// 对账调度(水位/补漏)属 Task 4,本模块只承担接收 + 持久化 + 简单应用。
import { Module } from '@nestjs/common';
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
import { SyncInboxController } from './sync-inbox.controller';
import { SyncInboxService } from './sync-inbox.service';
@Module({
imports: [OvPrismaModule],
controllers: [SyncInboxController],
providers: [SyncInboxService],
})
export class SyncInboxModule {}
@@ -0,0 +1,360 @@
// services/api/src/sync-inbox/sync-inbox.service.ts —— ov 内容事件收件箱
// (P2 Task 3:接收 + 持久化 + 简单应用;对账调度属 Task 4)。逐事件单事务:
//
// ① eventId 幂等:sync_inbox 主键冲突(含并发窗口)→ duplicate,受理不计应用;
// ② entityVersion 单调:sync_state 已应用版本 ≥ 事件版本 → stale,标记
// appliedAt(已裁决,非积压)但不投影——旧事件不能复活新状态(A26);
// ③ 依赖缺失暂存:非 album 事件在 albums_ov 无所属相册行时保持 appliedAt
// null(暂存不 applied,不投影);album 事件应用后按 albumId 回扫暂存
// 队列补应用(相册到达即解锁该相册全部暂存事件);
// ④ 应用投影(版本已更高):album → albums_ov upsert(不触碰 categories/
// bannerAssetKey/emergencyDisabled——分别归 category.* / asset.updated /
// 紧急停用通道 Task 5);category → albums_ov.categories JSON 元素
// upsert/移除;photo.published → photos_ov upsert(保留 likeTotal/
// countVersion 权威计数);photo.hidden/deleted → photos_ov.visibility
// 置位(行不存在 = 从未复制即不可见,缺席即撤下语义);asset → banner
// 投影 albums_ov.bannerAssetKey(cover/logo 仅推进版本,ov schema 无位);
// ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行归
// Task 4 对账)。
//
// 墓碑优先由 ② 保证:tombstone 事件版本必高于其前的任意旧版本,旧
// photo.published/category.updated 重放被版本比较直接拒绝。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import type {
AlbumUpdatedPayload,
AssetUpdatedPayload,
CategoryDeletedPayload,
CategoryUpdatedPayload,
PhotoPublishedPayload,
PhotoVisibilityChangedPayload,
} from '@photo/contracts';
import type { Prisma } from '@photo/db-ov';
// 值导入(非 import type):构造器注入的 design:paramtypes 依赖类引用,
// type-only 导入会让 Nest 侧元数据退化为 Function 而 DI 失败(repo 约定)。
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
export type ContentBatchResponse =
components['schemas']['ContentBatchResponse'];
type SyncEventWire = components['schemas']['SyncEvent'];
/** 单事件裁决结果(响应只聚合 applied,其余内部口径)。 */
type Outcome = 'duplicate' | 'stale' | 'staged' | 'applied';
/** albums_ov.categories JSON 元素形状(ov schema 注释约定的投影结构)。 */
interface CategoryReplicaEntry {
id: string;
sortOrder: number;
translations: Array<{ locale: string; name: string }>;
}
@Injectable()
export class SyncInboxService {
constructor(private readonly prisma: OvPrismaService) {}
/**
* 批量接收:批内按序逐事件事务处理;任一事件失败抛错(整批 500)——发送器
* 整批重试,eventId 幂等保证重试安全。acceptedEventIds 含幂等重复(契约:
* 受理 ≠ 应用);applied 仅统计新版本生效。
*/
async receiveBatch(batch: SyncEventWire[]): Promise<ContentBatchResponse> {
const acceptedEventIds: string[] = [];
let applied = 0;
for (const event of batch) {
const outcome = await this.applyOne(event);
acceptedEventIds.push(event.eventId);
if (outcome === 'applied') {
applied += 1;
// 依赖解锁:相册副本落位后,回扫该相册的暂存事件(photo/category/
// asset 都只依赖相册行;album 事件自身永不暂存,回扫无递归)。
if (event.entityType === 'album') {
applied += await this.sweepStaged(event.albumId);
}
}
}
return { acceptedEventIds, applied };
}
/** 单事件事务(见文件头 ①-⑤)。P2002 = 并发同 eventId → duplicate。 */
async applyOne(event: SyncEventWire): Promise<Outcome> {
try {
return await this.prisma.$transaction(async (tx) => {
// ① eventId 幂等锚:先查后插 + 唯一约束兜底(并发窗口)。
const seen = await tx.sync_inbox.findUnique({
where: { eventId: event.eventId },
select: { eventId: true },
});
if (seen !== null) {
return 'duplicate' as const;
}
await tx.sync_inbox.create({
data: {
eventId: event.eventId,
entityType: event.entityType,
entityId: event.entityId,
albumId: event.albumId,
entityVersion: event.entityVersion,
tombstone: event.tombstone === true,
priority: event.priority,
payload: event.payload as unknown as Prisma.InputJsonValue,
},
});
return this.decideAndApply(tx, event);
});
} catch (err) {
// P2002 鸭子类型判别(同 albums/support.ts isUniqueViolationOn 的约定:
// Prisma 命名空间经 @photo/db-ov 仅 type 出口,不做值导入)——本端点
// 唯一约束只有 sync_inbox 主键,命中即并发同 eventId。
if (
typeof err === 'object' &&
err !== null &&
(err as { code?: unknown }).code === 'P2002'
) {
return 'duplicate';
}
throw err;
}
}
/**
* ②-⑤ 裁决与应用(inbox 行已存在的共用核心):新事件(applyOne 落库后)
* 与暂存回扫(sweepStaged 对既有行重跑)同一出口。回扫**不能**走 applyOne
* 整程——其 eventId 幂等检查会把暂存行判成 duplicate 而永不应用。
*/
private async decideAndApply(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<Outcome> {
// ② 版本单调:旧版本裁决即终(appliedAt 置位,不入积压)。
const state = await tx.sync_state.findUnique({
where: {
entityType_entityId: {
entityType: event.entityType,
entityId: event.entityId,
},
},
});
if (state !== null && state.appliedVersion >= event.entityVersion) {
await tx.sync_inbox.update({
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'stale' as const;
}
// ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。
if (event.entityType !== 'album') {
const album = await tx.albums_ov.findUnique({
where: { albumId: event.albumId },
select: { albumId: true },
});
if (album === null) {
return 'staged' as const;
}
}
// ④ 投影。
await this.project(tx, event);
// ⑤ 版本账本。
await tx.sync_state.upsert({
where: {
entityType_entityId: {
entityType: event.entityType,
entityId: event.entityId,
},
},
update: {
appliedVersion: event.entityVersion,
appliedAt: new Date(),
},
create: {
entityType: event.entityType,
entityId: event.entityId,
appliedVersion: event.entityVersion,
watermark: false,
},
});
await tx.sync_inbox.update({
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'applied' as const;
}
/** ④ 按实体类型投影到副本表(版本已由 ② 保证更高)。 */
private async project(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<void> {
if (event.entityType === 'album') {
const p = event.payload as unknown as AlbumUpdatedPayload;
// update 分支不触碰 categories/bannerAssetKey/emergencyDisabled(见文件头④)。
await tx.albums_ov.upsert({
where: { albumId: event.entityId },
create: {
albumId: event.entityId,
publicToken: p.publicToken,
status: p.status,
publicEnabled: p.publicEnabled,
downloadEnabled: p.downloadEnabled,
originalDownloadEnabled: p.originalDownloadEnabled,
translations: p.translations as unknown as Prisma.InputJsonValue,
categories: [],
defaultLocale: p.defaultLocale,
timezone: p.timezone,
startAt: new Date(p.startAt),
endAt: p.endAt === null ? null : new Date(p.endAt),
entityVersion: event.entityVersion,
},
update: {
publicToken: p.publicToken,
status: p.status,
publicEnabled: p.publicEnabled,
downloadEnabled: p.downloadEnabled,
originalDownloadEnabled: p.originalDownloadEnabled,
translations: p.translations as unknown as Prisma.InputJsonValue,
defaultLocale: p.defaultLocale,
timezone: p.timezone,
startAt: new Date(p.startAt),
endAt: p.endAt === null ? null : new Date(p.endAt),
entityVersion: event.entityVersion,
},
});
return;
}
if (event.entityType === 'category') {
const album = await tx.albums_ov.findUniqueOrThrow({
where: { albumId: event.albumId },
});
const entries = (
Array.isArray(album.categories) ? album.categories : []
) as unknown as CategoryReplicaEntry[];
if (event.tombstone === true) {
const p = event.payload as unknown as CategoryDeletedPayload;
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: {
// 接口类型无 Json 输入所需索引签名(同 outbox 写入侧约定),结构化
// 拷贝落 Json 列。
categories: entries.filter(
(c) => c.id !== p.categoryId,
) as unknown as Prisma.InputJsonValue,
},
});
return;
}
const p = event.payload as unknown as CategoryUpdatedPayload;
const entry: CategoryReplicaEntry = {
id: p.categoryId,
sortOrder: p.sortOrder,
translations: p.translations,
};
const index = entries.findIndex((c) => c.id === p.categoryId);
if (index >= 0) {
entries[index] = entry;
} else {
entries.push(entry);
}
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { categories: entries as unknown as Prisma.InputJsonValue },
});
return;
}
if (event.entityType === 'photo') {
const payload = event.payload as unknown as Partial<
PhotoPublishedPayload & PhotoVisibilityChangedPayload
>;
// visibility 推导:hidden/deleted 载荷显式携带;published 载荷缺省即发布。
const visibility =
payload.visibility ?? (event.tombstone === true ? 'deleted' : 'published');
const existing = await tx.photos_ov.findUnique({
where: { photoId: event.entityId },
select: { photoId: true },
});
if (existing === null) {
if (visibility !== 'published') {
// 墓碑/隐藏先于任何 published 到达且副本行不存在:缺席即撤下语义,
// 无行可改——版本账本照常推进(旧 published 之后到会被版本比较拒绝)。
return;
}
const p = payload as PhotoPublishedPayload;
await tx.photos_ov.create({
data: {
photoId: event.entityId,
albumId: event.albumId,
originalObjectKey: p.objectKey,
thumbObjectKey: p.thumbKey,
browseObjectKey: p.browseKey,
width: p.width,
height: p.height,
visibility,
publishedAt: new Date(p.publishedAt),
entityVersion: event.entityVersion,
},
});
return;
}
// update 分支保留 likeTotal/countVersion(ov 权威计数,Task 7/9)。
const data: Prisma.photos_ovUpdateInput = {
visibility,
entityVersion: event.entityVersion,
};
if (visibility === 'published') {
const p = payload as PhotoPublishedPayload;
data.originalObjectKey = p.objectKey;
data.thumbObjectKey = p.thumbKey;
data.browseObjectKey = p.browseKey;
data.width = p.width;
data.height = p.height;
data.publishedAt = new Date(p.publishedAt);
}
await tx.photos_ov.update({
where: { photoId: event.entityId },
data,
});
return;
}
if (event.entityType === 'asset') {
const p = event.payload as unknown as AssetUpdatedPayload;
if (p.kind === 'banner') {
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { bannerAssetKey: p.objectKey },
});
}
// cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。
return;
}
// slot-state 预留:受理即 applied(无投影),版本账本照常推进。
}
/** album 应用后回扫该相册暂存事件(receivedAt 升序),返回补应用数。 */
private async sweepStaged(albumId: string): Promise<number> {
const staged = await this.prisma.sync_inbox.findMany({
where: { appliedAt: null, albumId },
orderBy: [{ receivedAt: 'asc' }, { eventId: 'asc' }],
});
let applied = 0;
for (const row of staged) {
const event: SyncEventWire = {
eventId: row.eventId,
schemaVersion: 1,
entityType: row.entityType as SyncEventWire['entityType'],
entityId: row.entityId,
albumId: row.albumId ?? albumId,
entityVersion: row.entityVersion,
tombstone: row.tombstone,
priority: row.priority as SyncEventWire['priority'],
payload: row.payload as unknown as SyncEventWire['payload'],
};
// 行已存在(eventId 即本行主键),走 ②-⑤ 核心而非 applyOne 整程
// (幂等检查会把回扫误判 duplicate,见 decideAndApply 注释)。
const outcome = await this.prisma.$transaction((tx) =>
this.decideAndApply(tx, event),
);
if (outcome === 'applied') {
applied += 1;
}
}
return applied;
}
}