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,
|
||||
|
||||
Reference in New Issue
Block a user