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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 15:21:41 +08:00
parent 3d6e9ce175
commit 65726702f3
43 changed files with 3333 additions and 181 deletions
+153 -73
View File
@@ -27,6 +27,7 @@ import {
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { buildAlbumUpdatedPayload } from '../sync/payloads';
import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto';
import { PermissionsService } from './permissions.service';
import {
@@ -153,9 +154,24 @@ export class AlbumsService {
defaultLocale: dto.defaultLocale,
downloadEnabled: dto.downloadEnabled ?? true,
originalDownloadEnabled: dto.originalDownloadEnabled ?? false,
// status/uploadEnabled/publicEnabled/watermarkVersion 走 schema 默认。
// status/uploadEnabled/publicEnabled/watermarkVersion/entityVersion
// 走 schema 默认。
},
// P2 Task 3:outbox album.updated v1 载荷需要主行标量全集。
select: {
id: true,
publicToken: true,
status: true,
uploadEnabled: true,
publicEnabled: true,
downloadEnabled: true,
originalDownloadEnabled: true,
defaultLocale: true,
timezone: true,
startAt: true,
endAt: true,
entityVersion: true,
},
select: { id: true },
});
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
@@ -175,6 +191,24 @@ export class AlbumsService {
status: 'active',
},
});
// P2 Task 3(cn 事件补全):同一事务写 outbox album.updated(v1),
// 载荷 = ov 展示全集(payloads.ts 与 bootstrap/reconcile 共用构建器)。
// 分类是独立事件(category.*),不进相册载荷。
const translations = await tx.album_translations.findMany({
where: { albumId: album.id },
orderBy: { locale: 'asc' },
});
await tx.outbox_events.create({
data: {
type: 'album.updated',
albumId: album.id,
entityVersion: album.entityVersion,
payload: buildAlbumUpdatedPayload(
album,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: userId,
@@ -268,83 +302,129 @@ export class AlbumsService {
throw albumForbidden('仅创建者或管理员可修改相册');
}
await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
throw albumNotFound();
}
// 合并校验①:endAt(给值时)晚于合并后的 startAt(单边提交对比存量)。
const startAt = dto.startAt !== undefined ? new Date(dto.startAt) : album.startAt;
const endAt =
dto.endAt === undefined
? album.endAt
: dto.endAt === null
? null
: new Date(dto.endAt);
if (endAt !== null && endAt.getTime() <= startAt.getTime()) {
throw validationFailed('endAt 必须晚于 startAt');
}
// 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。
const defaultLocale = dto.defaultLocale ?? album.defaultLocale;
if (dto.translations !== undefined) {
if (!dto.translations.some((t) => t.locale === defaultLocale)) {
throw validationFailed(
'translations 必须包含(更新后的)defaultLocale 对应语言的条目',
);
// P2 Task 3:条件更新(WHERE entityVersion=读取版本)承担事件版本自增的
// 原子性(同 photos 模式)。并发更新败方(count=0)整事务重跑——update 无
// 状态机语义,重跑合并校验后重试即最终一致;3 次仍败属异常并发,5xx 兜底。
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
throw albumNotFound();
}
} else if (dto.defaultLocale !== undefined) {
// 只切 defaultLocale:存量翻译行必须已含新语言。
const rows = await tx.album_translations.findMany({
// 合并校验①:endAt(给值时)晚于合并后的 startAt(单边提交对比存量)。
const startAt = dto.startAt !== undefined ? new Date(dto.startAt) : album.startAt;
const endAt =
dto.endAt === undefined
? album.endAt
: dto.endAt === null
? null
: new Date(dto.endAt);
if (endAt !== null && endAt.getTime() <= startAt.getTime()) {
throw validationFailed('endAt 必须晚于 startAt');
}
// 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。
const defaultLocale = dto.defaultLocale ?? album.defaultLocale;
if (dto.translations !== undefined) {
if (!dto.translations.some((t) => t.locale === defaultLocale)) {
throw validationFailed(
'translations 必须包含(更新后的)defaultLocale 对应语言的条目',
);
}
} else if (dto.defaultLocale !== undefined) {
// 只切 defaultLocale:存量翻译行必须已含新语言。
const rows = await tx.album_translations.findMany({
where: { albumId },
select: { locale: true },
});
if (!rows.some((r) => r.locale === defaultLocale)) {
throw validationFailed('defaultLocale 必须有对应翻译条目');
}
}
const claimed = await tx.albums.updateMany({
where: { id: albumId, entityVersion: album.entityVersion },
data: {
startAt: dto.startAt === undefined ? undefined : new Date(dto.startAt),
endAt:
dto.endAt === undefined
? undefined
: dto.endAt === null
? null
: new Date(dto.endAt),
timezone: dto.timezone,
defaultLocale: dto.defaultLocale,
uploadEnabled: dto.uploadEnabled,
publicEnabled: dto.publicEnabled,
downloadEnabled: dto.downloadEnabled,
originalDownloadEnabled: dto.originalDownloadEnabled,
entityVersion: { increment: 1 },
},
});
if (claimed.count === 0) {
return 'retry' as const;
}
const nextVersion = album.entityVersion + 1;
if (dto.translations !== undefined) {
await tx.album_translations.deleteMany({ where: { albumId } });
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
albumId,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
});
}
// P2 Task 3(cn 事件补全):同一事务写 outbox album.updated,载荷 =
// 更新后状态全集(分类仍走 category.* 独立事件,相册事件不触碰
// albums_ov.categories)。公开关闭迁移(publicEnabled true→false)为高
// 优先级:ov 观众侧须尽快停止可见(下线 status=offline 同理——当前 API
// 无该迁移入口,入口落地时同样置 high)。已在关闭态的常规更新保持 normal。
const updated = await tx.albums.findUniqueOrThrow({
where: { id: albumId },
});
const translations = await tx.album_translations.findMany({
where: { albumId },
select: { locale: true },
orderBy: { locale: 'asc' },
});
if (!rows.some((r) => r.locale === defaultLocale)) {
throw validationFailed('defaultLocale 必须有对应翻译条目');
}
}
await tx.albums.update({
where: { id: albumId },
data: {
startAt: dto.startAt === undefined ? undefined : new Date(dto.startAt),
endAt:
dto.endAt === undefined
? undefined
: dto.endAt === null
? null
: new Date(dto.endAt),
timezone: dto.timezone,
defaultLocale: dto.defaultLocale,
uploadEnabled: dto.uploadEnabled,
publicEnabled: dto.publicEnabled,
downloadEnabled: dto.downloadEnabled,
originalDownloadEnabled: dto.originalDownloadEnabled,
},
});
if (dto.translations !== undefined) {
await tx.album_translations.deleteMany({ where: { albumId } });
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
await tx.outbox_events.create({
data: {
type: 'album.updated',
albumId,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
entityVersion: nextVersion,
priority:
dto.publicEnabled === false && album.publicEnabled === true
? 'high'
: 'normal',
payload: buildAlbumUpdatedPayload(
updated,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.update',
resourceId: albumId,
metadata: { fields },
},
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.update',
resourceId: albumId,
metadata: { fields },
},
});
return 'done' as const;
});
});
}
if (outcome !== 'done') {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const album = await this.loadAlbum(albumId);
if (album === null) {
+19 -2
View File
@@ -29,8 +29,10 @@
// presign 为 SigV4 本地计算,无网络副作用。
import { Inject, Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import type { AuthUser } from '../auth/session.guard';
import { buildAssetUpdatedPayload } from '../sync/payloads';
import { STORAGE_PORT } from '../uploads/storage.provider';
import {
assetsObjectKey,
@@ -164,13 +166,28 @@ export class AssetsService {
);
}
// ② 事务:条件更新抢占(并发 complete 恰一人胜出)+ 胜者独占 audit。
// ② 事务:条件更新抢占(并发 complete 恰一人胜出,state pending→effective
// 迁移同时自增 entityVersion 并写 outbox asset.updated——P2 Task 3 起仅
// 生效迁移产生事件,幂等重复 complete 不再发)+ 胜者独占 audit。
await this.prisma.$transaction(async (tx) => {
const race = await tx.album_assets.updateMany({
where: { id: asset.id, state: 'pending' },
data: { state: 'effective' },
data: { state: 'effective', entityVersion: { increment: 1 } },
});
if (race.count === 1) {
const effective = await tx.album_assets.findUniqueOrThrow({
where: { id: asset.id },
});
await tx.outbox_events.create({
data: {
type: 'asset.updated',
albumId,
entityVersion: effective.entityVersion,
payload: buildAssetUpdatedPayload(
effective,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
+111 -23
View File
@@ -18,6 +18,10 @@ import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
import {
buildCategoryDeletedPayload,
buildCategoryUpdatedPayload,
} from '../sync/payloads';
import type {
CategoryCreateRequestDto,
CategoryUpdateRequestDto,
@@ -89,7 +93,7 @@ export class CategoriesService {
const categoryId = await this.prisma.$transaction(async (tx) => {
const category = await tx.categories.create({
data: { albumId, sortOrder: dto.sortOrder ?? 0 },
select: { id: true },
select: { id: true, entityVersion: true },
});
await tx.category_translations.createMany({
data: dto.translations.map((t) => ({
@@ -98,6 +102,25 @@ export class CategoriesService {
name: t.name,
})),
});
// P2 Task 3(cn 事件补全):同一事务写 outbox category.updated(v1)。
const created = await tx.categories.findUniqueOrThrow({
where: { id: category.id },
});
const translations = await tx.category_translations.findMany({
where: { categoryId: category.id },
orderBy: { locale: 'asc' },
});
await tx.outbox_events.create({
data: {
type: 'category.updated',
albumId,
entityVersion: created.entityVersion,
payload: buildCategoryUpdatedPayload(
created,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
@@ -139,30 +162,72 @@ export class CategoriesService {
}
const existing = await this.assertCategoryManaged(user, categoryId);
await this.prisma.$transaction(async (tx) => {
await tx.categories.update({
where: { id: categoryId },
data: { sortOrder: dto.sortOrder },
});
if (dto.translations !== undefined) {
await tx.category_translations.deleteMany({ where: { categoryId } });
await tx.category_translations.createMany({
data: dto.translations.map((t) => ({
categoryId,
locale: t.locale,
name: t.name,
})),
// P2 Task 3:条件更新自增 entityVersion(同 albums.update 模式),并发败方
// 整事务重跑;3 次仍败 5xx 兜底。
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const category = await tx.categories.findUnique({
where: { id: categoryId },
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.category.update',
resourceId: existing.albumId,
metadata: { categoryId, fields },
},
if (category === null) {
throw albumNotFound();
}
const claimed = await tx.categories.updateMany({
where: { id: categoryId, entityVersion: category.entityVersion },
data: { sortOrder: dto.sortOrder, entityVersion: { increment: 1 } },
});
if (claimed.count === 0) {
return 'retry' as const;
}
const nextVersion = category.entityVersion + 1;
if (dto.translations !== undefined) {
await tx.category_translations.deleteMany({ where: { categoryId } });
await tx.category_translations.createMany({
data: dto.translations.map((t) => ({
categoryId,
locale: t.locale,
name: t.name,
})),
});
}
// P2 Task 3:同一事务写 outbox category.updated(载荷 = 更新后状态)。
const updated = await tx.categories.findUniqueOrThrow({
where: { id: categoryId },
});
const translations = await tx.category_translations.findMany({
where: { categoryId },
orderBy: { locale: 'asc' },
});
await tx.outbox_events.create({
data: {
type: 'category.updated',
albumId: existing.albumId,
entityVersion: nextVersion,
payload: buildCategoryUpdatedPayload(
updated,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.category.update',
resourceId: existing.albumId,
metadata: { categoryId, fields },
},
});
return 'done' as const;
});
});
}
if (outcome !== 'done') {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const category = await this.loadCategory(categoryId);
if (category === null) {
throw albumNotFound();
@@ -174,8 +239,31 @@ export class CategoriesService {
async remove(user: AuthUser, categoryId: string): Promise<void> {
const existing = await this.assertCategoryManaged(user, categoryId);
await this.prisma.$transaction(async (tx) => {
// 先读版本:硬删除后行不存在,墓碑事件版本 = 删除时刻终值(v+1)。
// 已知边界(Task 3 登记):墓碑后 cn 表不再有该实体,reconcile 清单不
// 含已删除分类——删除语义依赖事件推送,ov 侧墓碑不被旧版本复活(A26)。
const category = await tx.categories.findUnique({
where: { id: categoryId },
});
if (category === null) {
throw albumNotFound();
}
await tx.category_translations.deleteMany({ where: { categoryId } });
await tx.categories.delete({ where: { id: categoryId } });
// P2 Task 3:同一事务写 outbox 墓碑 category.deleted(v+1,normal——
// 分类删除无「即刻从公开面撤下」的紧迫性,照片/相册级墓碑才是 high)。
await tx.outbox_events.create({
data: {
type: 'category.deleted',
albumId: existing.albumId,
entityVersion: category.entityVersion + 1,
payload: buildCategoryDeletedPayload(
categoryId,
existing.albumId,
category.entityVersion + 1,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,