diff --git a/.env.example b/.env.example index 2929cd4..2d75963 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,13 @@ DATABASE_URL_OV=mysql://root:root@127.0.0.1:3309/photo_ov?connection_limit=10 REDIS_URL_OV=redis://127.0.0.1:6381 # P2 Task 2:ov 入口监听端口(main-ov.ts 读取;与 cn PORT=8080 同机双入口)。 PORT_OV=8081 +# P2 Task 3:sync 发送器(services/sync)推送目标——ov 入口基址(不含路径, +# 端点路径由发送器拼接为 /v1/internal/sync/content-batches)。 +OV_API_URL=http://127.0.0.1:8081 # 以下为 P2 决策初值(D07/D11)的 env 覆盖键:默认值见 # packages/contracts/src/constants.ts(HEARTBEAT_MS 见 src/realtime.ts), -# 此处登记供运维可见与调整;代码读取在 Task 3+ 落地(当前为前瞻登记)。 +# 此处登记供运维可见与调整;SYNC_BATCH_SIZE/SYNC_PUSH_INTERVAL_MS 自 Task 3 +# 起被 services/sync 发送器读取,其余为前瞻登记(Task 4+ 逐个接线)。 SYNC_BATCH_SIZE=100 SYNC_PUSH_INTERVAL_MS=2000 SYNC_RECONCILE_INTERVAL_MS=300000 diff --git a/package.json b/package.json index 6431157..56fddaf 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:api": "dotenv -e .env -- pnpm --filter @photo/api-cn start", "dev:ov": "dotenv -e .env -- pnpm --filter @photo/api-cn start:ov", "dev:worker": "dotenv -e .env -- pnpm --filter @photo/worker start", + "dev:sync": "dotenv -e .env -- pnpm --filter @photo/sync start", "e2e": "turbo run test:e2e" }, "devDependencies": { diff --git a/packages/contracts/jest.config.cjs b/packages/contracts/jest.config.cjs index 71a1c24..780ff2a 100644 --- a/packages/contracts/jest.config.cjs +++ b/packages/contracts/jest.config.cjs @@ -14,6 +14,9 @@ module.exports = { target: 'es2022', strict: true, esModuleInterop: true, + // 覆盖包级 tsconfig 的 types 限制(其值仅供 src 构建侧收敛 node 类型): + // 测试编译需要 jest 全局与 node 类型(internal-sync 的 Buffer/测试时钟)。 + types: ['node', 'jest'], }, }, ], diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 068a256..ec65e1b 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -1480,6 +1480,7 @@ components: - PHOTO_NOT_FOUND - PHOTO_NOT_PUBLIC - VALIDATION_FAILED + - SYNC_AUTH_FAILED - INTERNAL_ERROR message: type: string diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2f25c4a..2a0e740 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -26,6 +26,7 @@ "devDependencies": { "@redocly/cli": "^1.0.0", "@types/jest": "^29.5.0", + "@types/node": "^22.10.0", "jest": "^29.7.0", "openapi-typescript": "^7.0.0", "ts-jest": "^29.2.0", diff --git a/packages/contracts/src/__tests__/events.test.ts b/packages/contracts/src/__tests__/events.test.ts index b506768..12f3109 100644 --- a/packages/contracts/src/__tests__/events.test.ts +++ b/packages/contracts/src/__tests__/events.test.ts @@ -59,13 +59,16 @@ describe('OutboxEvent', () => { }); describe('OUTBOX_EVENT_TYPES', () => { - it('恰好包含全部 5 个事件类型字符串', () => { + it('恰好包含全部 8 个事件类型字符串', () => { expect([...OUTBOX_EVENT_TYPES]).toEqual([ 'photo.published', 'photo.hidden', 'photo.deleted', 'photo.likes.updated', 'album.updated', + 'category.updated', + 'category.deleted', + 'asset.updated', ]); }); diff --git a/packages/contracts/src/errors.ts b/packages/contracts/src/errors.ts index 8f7fa4c..3c825a5 100644 --- a/packages/contracts/src/errors.ts +++ b/packages/contracts/src/errors.ts @@ -24,6 +24,10 @@ export const ErrorCodes = { PHOTO_NOT_FOUND: 'PHOTO_NOT_FOUND', PHOTO_NOT_PUBLIC: 'PHOTO_NOT_PUBLIC', VALIDATION_FAILED: 'VALIDATION_FAILED', + // P2 Task 3:内部同步接口(/internal/**)HMAC 鉴权失败的 401。AUTH_TOKEN_EXPIRED + // 语义绑死观众/员工会话,服务间签名失败复用它会对客户端撒谎;与 openapi.yaml + // Error enum 同步增补(1:1 守护见 __tests__/events.test.ts)。 + SYNC_AUTH_FAILED: 'SYNC_AUTH_FAILED', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index befee62..4acb311 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -1,7 +1,7 @@ // packages/contracts/src/events.ts // Outbox 事件契约(docs/03-技术实施方案.md §8.2):P1a 在业务事务内写 outbox, -// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。payload 由各事件 -// 类型自行约定,此处仅落 P2 展示一张照片所需的 photo.published 载荷(YAGNI)。 +// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。P2 Task 3 起补全 +// album/category/asset 载荷(cn 事件补全);photo.* 载荷自 P1a 起落定。 export interface OutboxEvent { eventId: string; // cuid,持久唯一 type: @@ -9,7 +9,10 @@ export interface OutboxEvent { | 'photo.hidden' | 'photo.deleted' | 'photo.likes.updated' - | 'album.updated'; + | 'album.updated' + | 'category.updated' + | 'category.deleted' + | 'asset.updated'; albumId: string; photoId?: string; entityVersion: number; // 单调递增,P2 inbox 消费依据 @@ -30,6 +33,75 @@ export interface PhotoPublishedPayload { entityVersion: number; } +// album.updated 载荷:ov 副本(albums_ov)展示一张相册所需的标量 + 翻译全集。 +// categories 不在此列——分类是独立事件(category.*,各自携带 entityVersion); +// banner 对象键经 asset.updated 事件同步(albums_ov.bannerAssetKey)。 +export interface AlbumUpdatedPayload { + albumId: string; + publicToken: string; + status: string; // active|offline(cn 副本值) + uploadEnabled: boolean; + publicEnabled: boolean; + downloadEnabled: boolean; + originalDownloadEnabled: boolean; + defaultLocale: string; + timezone: string; + startAt: string; // ISO UTC + endAt: string | null; // ISO UTC;null 表示未定 + translations: AlbumTranslationPayload[]; // locale 升序(确定性输出) + entityVersion: number; +} + +export interface AlbumTranslationPayload { + locale: string; + title: string; + description: string | null; + venue: string | null; + address: string | null; +} + +// category.updated 载荷:相册分类(ov 落 albums_ov.categories JSON 数组元素)。 +export interface CategoryUpdatedPayload { + categoryId: string; + albumId: string; + sortOrder: number; + translations: CategoryTranslationPayload[]; // locale 升序 + entityVersion: number; +} + +export interface CategoryTranslationPayload { + locale: string; + name: string; +} + +// category.deleted 载荷:墓碑(ov 从 albums_ov.categories 移除该元素且不被旧 +// 事件复活)。删除后 cn 行已不存在,entityVersion 为删除时刻的终值。 +export interface CategoryDeletedPayload { + categoryId: string; + albumId: string; + entityVersion: number; +} + +// photo.hidden / photo.deleted 载荷:可见性变更最小事实(ov 副本据此置 +// photos_ov.visibility;无展示字段——撤下语义只需标识与版本)。 +export interface PhotoVisibilityChangedPayload { + photoId: string; + albumId: string; + visibility: string; // hidden|deleted + entityVersion: number; +} + +// asset.updated 载荷:相册资产生效(state pending→effective 的迁移才发事件)。 +// ov 侧 kind=banner 投影到 albums_ov.bannerAssetKey;cover/logo 仅推进版本。 +export interface AssetUpdatedPayload { + assetId: string; + albumId: string; + kind: string; // banner|cover|logo + locale: string | null; + objectKey: string; + entityVersion: number; +} + // 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union; // __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。 export const OUTBOX_EVENT_TYPES = [ @@ -38,4 +110,43 @@ export const OUTBOX_EVENT_TYPES = [ 'photo.deleted', 'photo.likes.updated', 'album.updated', + 'category.updated', + 'category.deleted', + 'asset.updated', ] as const satisfies readonly OutboxEvent['type'][]; + +// ── outbox 类型 → 内部同步线格式(openapi SyncEvent.entityType)的映射 ── +// 发送器(services/sync)与 cn reconcile 端点共用,单一实现防两侧漂移; +// exhaustive switch:新增事件类型漏映射会在此编译报错。 + +/** 内部同步实体类型(对齐 openapi SyncEntityType;slot-state 为预留)。 */ +export type SyncEntityType = + | 'album' + | 'category' + | 'photo' + | 'asset' + | 'slot-state'; + +export function syncEntityTypeOfOutboxType( + type: OutboxEvent['type'], +): SyncEntityType { + switch (type) { + case 'album.updated': + return 'album'; + case 'category.updated': + case 'category.deleted': + return 'category'; + case 'asset.updated': + return 'asset'; + case 'photo.published': + case 'photo.hidden': + case 'photo.deleted': + case 'photo.likes.updated': + return 'photo'; + } +} + +/** 墓碑类事件(副本删除且不被旧事件复活,A26)。 */ +export function isTombstoneOutboxType(type: OutboxEvent['type']): boolean { + return type === 'category.deleted' || type === 'photo.deleted'; +} diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index e167162..ced2bc2 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -673,7 +673,7 @@ export interface components { * @description 稳定错误码,与 src/errors.ts 的 ErrorCodes 一一对应。 * @enum {string} */ - readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "INTERNAL_ERROR"; + readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "SYNC_AUTH_FAILED" | "INTERNAL_ERROR"; /** @description 服务器生成的可读消息(诊断用,不作为客户端展示文案依据)。 */ readonly message: string; /** @description 请求追踪 ID。 */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e8b6354..4dd21cd 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,5 +5,6 @@ export * from './constants.js'; export * from './errors.js'; export * from './events.js'; +export * from './internal-sync.js'; export * from './realtime.js'; export type * from './generated/openapi.js'; diff --git a/packages/contracts/src/internal-sync.ts b/packages/contracts/src/internal-sync.ts new file mode 100644 index 0000000..07025b0 --- /dev/null +++ b/packages/contracts/src/internal-sync.ts @@ -0,0 +1,79 @@ +// packages/contracts/src/internal-sync.ts —— /internal/** 服务间同步接口的 +// HMAC 鉴权单一实现(P2 Task 3)。签名规则(openapi.yaml [internal] tag 描述): +// X-Sync-Signature = hex(HMAC-SHA256(原始请求体字节, SYNC_INTERNAL_KEY)) +// X-Sync-Timestamp = Unix 秒,与服务端时差 ±300s 内有效(防重放) +// 发送方(services/sync sender)与接收方(cn bootstrap/reconcile、ov +// content-batches 的 InternalSyncGuard)共用本模块,杜绝两侧规则漂移。 +// 注意签名覆盖的是**原始请求体字节**(rawBody):接收端必须经 body-parser 的 +// verify 钩子(NestFactory rawBody: true)在 JSON 解析前捕获原始字节,重序列 +// 化的 body 与签名不一致会 401。 +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** 内部同步 HMAC 密钥的 env 键名(仅 env 注入,不入库不入码)。 */ +export const SYNC_INTERNAL_KEY_ENV = 'SYNC_INTERNAL_KEY'; + +/** HMAC 签名/时间戳请求头(小写;Node req.headers 已归一小写)。 */ +export const SYNC_SIGNATURE_HEADER = 'x-sync-signature'; +export const SYNC_TIMESTAMP_HEADER = 'x-sync-timestamp'; + +/** 时间戳容忍窗(秒,防重放;契约 [internal] tag 描述 ±300s)。 */ +export const SYNC_TIMESTAMP_TOLERANCE_SECONDS = 300; + +/** + * 计算请求体签名:hex(HMAC-SHA256(rawBody, key))。 + * rawBody 必须是与线上传输完全一致的原始字节(发送侧:JSON.stringify 的产物 + * 原样作为 fetch body;接收侧:body-parser verify 捕获的 Buffer)。 + */ +export function signSyncBody( + key: string, + rawBody: string | Buffer, +): string { + return createHmac('sha256', key).update(rawBody).digest('hex'); +} + +/** 验签输入:头取值原样传入(数组/缺失由本函数判 false,不抛错)。 */ +export interface VerifySyncSignatureInput { + key: string; + rawBody: Buffer | string; + timestampHeader: unknown; + signatureHeader: unknown; + /** 可注入时钟(测试用);缺省 Date.now()。 */ + nowMs?: number; +} + +/** + * 接收端验签:时间戳在窗内 + 签名 hex(64) 形状 + 常时比较全过才 true。 + * key 未配置(空串)恒 false——密钥缺失时内部端点整体不可用而非裸奔。 + */ +export function verifySyncSignature(input: VerifySyncSignatureInput): boolean { + const { key, rawBody, timestampHeader, signatureHeader } = input; + if (key === '') { + return false; + } + const timestamp = + typeof timestampHeader === 'string' + ? Number(timestampHeader.trim()) + : Number.NaN; + if (!Number.isFinite(timestamp)) { + return false; + } + const nowSeconds = Math.floor((input.nowMs ?? Date.now()) / 1000); + if ( + Math.abs(nowSeconds - timestamp) > SYNC_TIMESTAMP_TOLERANCE_SECONDS + ) { + return false; + } + if ( + typeof signatureHeader !== 'string' || + !/^[0-9a-f]{64}$/.test(signatureHeader.trim()) + ) { + return false; + } + const expected = signSyncBody(key, rawBody); + const provided = signatureHeader.trim(); + // 两串均为 64 hex 字符(上方正则保证),等长 Buffer,timingSafeEqual 安全。 + return timingSafeEqual( + Buffer.from(expected, 'hex'), + Buffer.from(provided, 'hex'), + ); +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 2fdfe10..ae528a2 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -8,7 +8,8 @@ "outDir": "dist", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] }, "include": ["src"], "exclude": ["src/__tests__"] diff --git a/packages/db-cn/prisma/migrations/20260918062924_sync_entity_version/migration.sql b/packages/db-cn/prisma/migrations/20260918062924_sync_entity_version/migration.sql new file mode 100644 index 0000000..9529bbf --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260918062924_sync_entity_version/migration.sql @@ -0,0 +1,10 @@ +-- P2 Task 3:cn 事件补全——albums/categories/album_assets 增 entityVersion +-- (事件版本条件自增,同 photos 模式);outbox_events 增 priority +-- (normal|high:相册下线/照片隐藏删除类高优先级,发送器批次内优先投递)。 +-- VARCHAR 列显式 utf8mb4_0900_bin(沿用库级约定:--create-only 后手工定校对, +-- Prisma 生成 DDL 固定 unicode_ci)。INTEGER 列无校对问题;新列全部带默认值, +-- 存量行与 P1 流程不受影响(相册/分类/资产存量行版本从 1 起算)。 +ALTER TABLE `album_assets` ADD COLUMN `entityVersion` INTEGER NOT NULL DEFAULT 1; +ALTER TABLE `albums` ADD COLUMN `entityVersion` INTEGER NOT NULL DEFAULT 1; +ALTER TABLE `categories` ADD COLUMN `entityVersion` INTEGER NOT NULL DEFAULT 1; +ALTER TABLE `outbox_events` ADD COLUMN `priority` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL DEFAULT 'normal'; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index dfb2aed..21b11b9 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -66,6 +66,7 @@ model albums { downloadEnabled Boolean @default(true) originalDownloadEnabled Boolean @default(false) watermarkVersion Int @default(0) + entityVersion Int @default(1) // P2 Task 3:album.updated 事件版本(同 photos 模式) createdAt DateTime @default(now()) @db.DateTime(6) updatedAt DateTime @updatedAt @db.DateTime(6) @@ -99,10 +100,11 @@ model album_members { } model categories { - id String @id @default(cuid()) - albumId String @db.VarChar(64) - sortOrder Int @default(0) - createdAt DateTime @default(now()) @db.DateTime(6) + id String @id @default(cuid()) + albumId String @db.VarChar(64) + sortOrder Int @default(0) + entityVersion Int @default(1) // P2 Task 3:category.* 事件版本(同 photos 模式) + createdAt DateTime @default(now()) @db.DateTime(6) @@index([albumId, sortOrder]) } @@ -125,6 +127,7 @@ model album_assets { state String @default("pending") @db.VarChar(16) // pending|effective(行内一次性上传会话,P1b Task 3) expectedBytes Int @default(0) // init 申明字节数(complete head 核验依据) contentType String @default("") @db.VarChar(64) // init 申明 MIME(complete head 核验依据) + entityVersion Int @default(1) // P2 Task 3:asset.updated 事件版本(仅 effective 迁移自增) createdAt DateTime @default(now()) @db.DateTime(6) @@index([albumId, kind]) @@ -198,6 +201,7 @@ model outbox_events { albumId String @db.VarChar(64) photoId String? @db.VarChar(64) entityVersion Int + priority String @default("normal") @db.VarChar(16) // normal|high(P2 Task 3:下线/隐藏/删除类 high,发送器批次内优先) payload Json occurredAt DateTime @default(now()) @db.DateTime(6) deliveredAt DateTime? @db.DateTime(6) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab0b376..7f5172e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: '@types/jest': specifier: ^29.5.0 version: 29.5.14 + '@types/node': + specifier: ^22.10.0 + version: 22.20.3 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) @@ -393,6 +396,61 @@ importers: specifier: ^5.6.0 version: 5.9.3 + services/sync: + dependencies: + '@photo/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@photo/db-cn': + specifier: workspace:* + version: link:../../packages/db-cn + pino: + specifier: ^10.0.0 + version: 10.3.1 + devDependencies: + '@nestjs/common': + specifier: ^10.4.6 + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.6 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^10.4.6 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@photo/db-ov': + specifier: workspace:* + version: link:../../packages/db-ov + '@photo/storage': + specifier: workspace:^ + version: link:../../packages/storage + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^22.10.0 + version: 22.20.3 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + sharp: + specifier: ^0.35.0 + version: 0.35.4(@types/node@22.20.3) + supertest: + specifier: ^7.0.0 + version: 7.2.2 + ts-jest: + specifier: ^29.2.5 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)))(typescript@5.6.3) + typescript: + specifier: ^5.6.0 + version: 5.6.3 + services/worker: dependencies: '@photo/contracts': @@ -6657,12 +6715,47 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.13.5 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@24.13.5)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 @@ -6670,14 +6763,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@24.13.5)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -6702,7 +6795,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -6720,7 +6813,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.20.3 + '@types/node': 24.13.5 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -6742,7 +6835,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 22.20.3 + '@types/node': 24.13.5 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -6812,7 +6905,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.20.3 + '@types/node': 24.13.5 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -7535,7 +7628,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 22.20.3 + '@types/node': 24.13.5 '@types/istanbul-lib-coverage@2.0.6': {} @@ -7578,7 +7671,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 22.20.3 + '@types/node': 24.13.5 form-data: 4.0.6 '@types/supertest@6.0.3': @@ -8579,6 +8672,21 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + create-jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + create-jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 @@ -9450,7 +9558,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -9470,6 +9578,25 @@ snapshots: - babel-plugin-macros - supports-color + jest-cli@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) @@ -9489,6 +9616,37 @@ snapshots: - supports-color - ts-node + jest-config@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.20.3 + ts-node: 10.9.2(@types/node@22.20.3)(typescript@5.6.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 @@ -9520,6 +9678,68 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@24.13.5)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.13.5 + ts-node: 10.9.2(@types/node@22.20.3)(typescript@5.6.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@24.13.5)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.13.5 + ts-node: 10.9.2(@types/node@22.20.3)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -9544,7 +9764,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -9554,7 +9774,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 22.20.3 + '@types/node': 24.13.5 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -9593,7 +9813,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -9628,7 +9848,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -9656,7 +9876,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -9702,7 +9922,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -9721,7 +9941,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.20.3 + '@types/node': 24.13.5 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -9730,11 +9950,23 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 22.20.3 + '@types/node': 24.13.5 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 + jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) @@ -11037,6 +11269,26 @@ snapshots: dependencies: typescript: 5.9.3 + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)))(typescript@5.6.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.6.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-util: 29.7.0 + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -11057,6 +11309,25 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.7) jest-util: 29.7.0 + ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.13 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.20.3 + acorn: 8.18.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.6.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 diff --git a/services/api/src/albums/albums.service.ts b/services/api/src/albums/albums.service.ts index d82a5e8..d238c52 100644 --- a/services/api/src/albums/albums.service.ts +++ b/services/api/src/albums/albums.service.ts @@ -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) { diff --git a/services/api/src/albums/assets.service.ts b/services/api/src/albums/assets.service.ts index e59841a..0f1c30d 100644 --- a/services/api/src/albums/assets.service.ts +++ b/services/api/src/albums/assets.service.ts @@ -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, diff --git a/services/api/src/albums/categories.service.ts b/services/api/src/albums/categories.service.ts index c15c483..be62dac 100644 --- a/services/api/src/albums/categories.service.ts +++ b/services/api/src/albums/categories.service.ts @@ -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 { 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, diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index 4b5bf5d..ee8d8dd 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -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], diff --git a/services/api/src/app-ov.module.ts b/services/api/src/app-ov.module.ts index af17fb4..349662e 100644 --- a/services/api/src/app-ov.module.ts +++ b/services/api/src/app-ov.module.ts @@ -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('*'); } } + diff --git a/services/api/src/main-cn.ts b/services/api/src/main-cn.ts index a3f0a58..c9cf4f0 100644 --- a/services/api/src/main-cn.ts +++ b/services/api/src/main-cn.ts @@ -11,6 +11,11 @@ import { configureApp } from './shared/configure-app'; async function bootstrap(): Promise { const app = await NestFactory.create(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); diff --git a/services/api/src/main-ov.ts b/services/api/src/main-ov.ts index 3d174ef..41bf46a 100644 --- a/services/api/src/main-ov.ts +++ b/services/api/src/main-ov.ts @@ -14,6 +14,10 @@ import { configureApp } from './shared/configure-app'; async function bootstrap(): Promise { const app = await NestFactory.create(AppOvModule, { bufferLogs: true, + // 内部 sync 端点(content-batches)HMAC 验签需要「原始请求体字节」: + // rawBody 让 body-parser 在 JSON 解析前把 Buffer 挂到 req.rawBody + // (InternalSyncGuard 读取)。发送器对同一字符串只签一次、原样发送。 + rawBody: true, }); app.useLogger(app.get(Logger)); configureApp(app); diff --git a/services/api/src/photos/photos.service.ts b/services/api/src/photos/photos.service.ts index 2c8e5b7..5108c92 100644 --- a/services/api/src/photos/photos.service.ts +++ b/services/api/src/photos/photos.service.ts @@ -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; -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, - }; - } } diff --git a/services/api/src/prisma-ov/ov-prisma.module.ts b/services/api/src/prisma-ov/ov-prisma.module.ts new file mode 100644 index 0000000..c1340aa --- /dev/null +++ b/services/api/src/prisma-ov/ov-prisma.module.ts @@ -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 { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} + +@Module({ + providers: [OvPrismaService], + exports: [OvPrismaService], +}) +export class OvPrismaModule {} diff --git a/services/api/src/shared/internal-sync.guard.ts b/services/api/src/shared/internal-sync.guard.ts new file mode 100644 index 0000000..2e58e71 --- /dev/null +++ b/services/api/src/shared/internal-sync.guard.ts @@ -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; +} + +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(); + // 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; + } +} diff --git a/services/api/src/sync-inbox/dto.ts b/services/api/src/sync-inbox/dto.ts new file mode 100644 index 0000000..e4fb2a2 --- /dev/null +++ b/services/api/src/sync-inbox/dto.ts @@ -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; +} + +export class ContentBatchRequestDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => SyncEventDto) + batch!: SyncEventDto[]; +} diff --git a/services/api/src/sync-inbox/sync-inbox.controller.ts b/services/api/src/sync-inbox/sync-inbox.controller.ts new file mode 100644 index 0000000..3cebeb3 --- /dev/null +++ b/services/api/src/sync-inbox/sync-inbox.controller.ts @@ -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); + } +} diff --git a/services/api/src/sync-inbox/sync-inbox.module.ts b/services/api/src/sync-inbox/sync-inbox.module.ts new file mode 100644 index 0000000..fba7fb3 --- /dev/null +++ b/services/api/src/sync-inbox/sync-inbox.module.ts @@ -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 {} diff --git a/services/api/src/sync-inbox/sync-inbox.service.ts b/services/api/src/sync-inbox/sync-inbox.service.ts new file mode 100644 index 0000000..283a99c --- /dev/null +++ b/services/api/src/sync-inbox/sync-inbox.service.ts @@ -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 { + 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 { + 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 { + // ② 版本单调:旧版本裁决即终(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 { + 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 { + 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; + } +} diff --git a/services/api/src/sync/dto.ts b/services/api/src/sync/dto.ts new file mode 100644 index 0000000..fa8625e --- /dev/null +++ b/services/api/src/sync/dto.ts @@ -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; +} diff --git a/services/api/src/sync/payloads.ts b/services/api/src/sync/payloads.ts new file mode 100644 index 0000000..af8338f --- /dev/null +++ b/services/api/src/sync/payloads.ts @@ -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, + | 'id' + | 'publicToken' + | 'status' + | 'uploadEnabled' + | 'publicEnabled' + | 'downloadEnabled' + | 'originalDownloadEnabled' + | 'defaultLocale' + | 'timezone' + | 'startAt' + | 'endAt' + | 'entityVersion' +>; +type AlbumTranslationRow = Prisma.album_translationsGetPayload< + Prisma.album_translationsDefaultArgs +>; +type CategoryRow = Prisma.categoriesGetPayload; +type CategoryTranslationRow = Prisma.category_translationsGetPayload< + Prisma.category_translationsDefaultArgs +>; +type AssetRow = Prisma.album_assetsGetPayload; +type PhotoRow = Prisma.photosGetPayload; +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, + }; +} diff --git a/services/api/src/sync/sync.controller.ts b/services/api/src/sync/sync.controller.ts new file mode 100644 index 0000000..102f2ec --- /dev/null +++ b/services/api/src/sync/sync.controller.ts @@ -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); + } +} diff --git a/services/api/src/sync/sync.module.ts b/services/api/src/sync/sync.module.ts new file mode 100644 index 0000000..917f0ab --- /dev/null +++ b/services/api/src/sync/sync.module.ts @@ -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 {} diff --git a/services/api/src/sync/sync.service.ts b/services/api/src/sync/sync.service.ts new file mode 100644 index 0000000..13ce636 --- /dev/null +++ b/services/api/src/sync/sync.service.ts @@ -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::: +// ):与真实 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 { + 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(); + 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(); + 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 { + 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(); + 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(); + 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(); + 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'], + }; + } +} diff --git a/services/sync/jest.config.cjs b/services/sync/jest.config.cjs new file mode 100644 index 0000000..4a6665d --- /dev/null +++ b/services/sync/jest.config.cjs @@ -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$': '/../../packages/contracts/src/index.ts', + '^@photo/db-cn$': '/../../packages/db-cn/src/index.ts', + // e2e 直连 ov 库断言副本(PrismaClient from '@photo/db-ov')。 + '^@photo/db-ov$': '/../../packages/db-ov/src/index.ts', + // 驱动 worker PublishService(上传即公开事务)需要 storage 适配器。 + '^@photo/storage$': '/../../packages/storage/src/index.ts', + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + testMatch: ['/src/**/*.spec.ts', '/test/**/*.spec.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + passWithNoTests: true, +}; diff --git a/services/sync/jest.e2e.config.cjs b/services/sync/jest.e2e.config.cjs new file mode 100644 index 0000000..e673c25 --- /dev/null +++ b/services/sync/jest.e2e.config.cjs @@ -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: ['/test/**/*.e2e-spec.ts'], +}; diff --git a/services/sync/package.json b/services/sync/package.json new file mode 100644 index 0000000..de1b91d --- /dev/null +++ b/services/sync/package.json @@ -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" + } +} diff --git a/services/sync/src/main.ts b/services/sync/src/main.ts new file mode 100644 index 0000000..7f659be --- /dev/null +++ b/services/sync/src/main.ts @@ -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 => + new Promise((resolve) => setTimeout(resolve, ms)); + +async function main(): Promise { + 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 | null = null; + const loop = async (): Promise => { + 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(); diff --git a/services/sync/src/sender.ts b/services/sync/src/sender.ts new file mode 100644 index 0000000..baf8771 --- /dev/null +++ b/services/sync/src/sender.ts @@ -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 { + 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 { + 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; + 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 { + 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; + } +} diff --git a/services/sync/test/load-env.ts b/services/sync/test/load-env.ts new file mode 100644 index 0000000..513fbfc --- /dev/null +++ b/services/sync/test/load-env.ts @@ -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') }); diff --git a/services/sync/test/sync.e2e-spec.ts b/services/sync/test/sync.e2e-spec.ts new file mode 100644 index 0000000..4ef019e --- /dev/null +++ b/services/sync/test/sync.e2e-spec.ts @@ -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 { + 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 { + const raw = JSON.stringify(body); + const headers: Record = { '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; +} + +/** 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; + + /** 排空发送器:循环 runOnce 直至无未投递事件(每轮整批推送)。 */ + async function drainSender(): Promise { + 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 }; + }; + 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(AppCnModule, { + rawBody: true, + }); + configureApp(cnApp); + await cnApp.listen(0); + cnBase = await cnApp.getUrl(); + + ovApp = await NestFactory.create(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, + }, + ]); + 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, + }, + ]); + 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; + + 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, + }, + ]); + 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): Promise => { + 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([]); + }); +}); diff --git a/services/sync/tsconfig.build.json b/services/sync/tsconfig.build.json new file mode 100644 index 0000000..fc2329d --- /dev/null +++ b/services/sync/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"] +} diff --git a/services/sync/tsconfig.json b/services/sync/tsconfig.json new file mode 100644 index 0000000..a7ba04e --- /dev/null +++ b/services/sync/tsconfig.json @@ -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"] +}