diff --git a/packages/db-cn/prisma/migrations/20260917120827_photo_upload_domain/migration.sql b/packages/db-cn/prisma/migrations/20260917120827_photo_upload_domain/migration.sql new file mode 100644 index 0000000..cb88b49 --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260917120827_photo_upload_domain/migration.sql @@ -0,0 +1,95 @@ +-- 同 20260917114313_init_identity:Prisma 生成的 MySQL DDL 固定写 +-- utf8mb4_unicode_ci,会覆盖库级 utf8mb4_0900_bin;照片与上传域 +-- (photos.objectKey、upload_sessions.objectKey、outbox_events.type 等) +-- 同样依赖 bin 校对的大小写敏感语义,故逐表改写为 utf8mb4_0900_bin。 + +-- CreateTable +CREATE TABLE `photos` ( + `id` VARCHAR(191) NOT NULL, + `albumId` VARCHAR(64) NOT NULL, + `uploaderId` VARCHAR(64) NOT NULL, + `categoryId` VARCHAR(64) NULL, + `objectKey` VARCHAR(255) NOT NULL, + `bytes` INTEGER NOT NULL DEFAULT 0, + `width` INTEGER NULL, + `height` INTEGER NULL, + `shotAt` DATETIME(6) NULL, + `uploadedAt` DATETIME(6) NULL, + `publishedAt` DATETIME(6) NULL, + `processingState` VARCHAR(191) NOT NULL DEFAULT 'pending_upload', + `errorCode` VARCHAR(64) NULL, + `visibility` VARCHAR(191) NOT NULL DEFAULT 'unpublished', + `likeCount` INTEGER NOT NULL DEFAULT 0, + `entityVersion` INTEGER NOT NULL DEFAULT 1, + + UNIQUE INDEX `photos_objectKey_key`(`objectKey`), + INDEX `photos_albumId_visibility_publishedAt_id_idx`(`albumId`, `visibility`, `publishedAt`, `id`), + INDEX `photos_albumId_uploaderId_uploadedAt_idx`(`albumId`, `uploaderId`, `uploadedAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; + +-- CreateTable +CREATE TABLE `photo_variants` ( + `photoId` VARCHAR(64) NOT NULL, + `variant` VARCHAR(32) NOT NULL, + `objectKey` VARCHAR(255) NOT NULL, + `bytes` INTEGER NOT NULL, + `version` INTEGER NOT NULL DEFAULT 1, + `width` INTEGER NULL, + `height` INTEGER NULL, + + UNIQUE INDEX `photo_variants_objectKey_key`(`objectKey`), + UNIQUE INDEX `photo_variants_photoId_variant_key`(`photoId`, `variant`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; + +-- CreateTable +CREATE TABLE `upload_sessions` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(64) NOT NULL, + `albumId` VARCHAR(64) NOT NULL, + `clientTaskId` VARCHAR(128) NOT NULL, + `photoId` VARCHAR(64) NOT NULL, + `objectKey` VARCHAR(255) NOT NULL, + `expectedBytes` INTEGER NOT NULL, + `contentType` VARCHAR(128) NOT NULL, + `multipartId` VARCHAR(255) NULL, + `state` VARCHAR(191) NOT NULL DEFAULT 'pending', + `expiresAt` DATETIME(6) NOT NULL, + `createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + + UNIQUE INDEX `upload_sessions_photoId_key`(`photoId`), + UNIQUE INDEX `upload_sessions_objectKey_key`(`objectKey`), + UNIQUE INDEX `upload_sessions_userId_clientTaskId_key`(`userId`, `clientTaskId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; + +-- CreateTable +CREATE TABLE `photo_jobs` ( + `id` VARCHAR(191) NOT NULL, + `photoId` VARCHAR(64) NOT NULL, + `type` VARCHAR(32) NOT NULL DEFAULT 'process', + `state` VARCHAR(191) NOT NULL DEFAULT 'pending', + `attempt` INTEGER NOT NULL DEFAULT 0, + `errorCode` VARCHAR(64) NULL, + `nextRetryAt` DATETIME(6) NULL, + `updatedAt` DATETIME(6) NOT NULL, + + INDEX `photo_jobs_state_nextRetryAt_idx`(`state`, `nextRetryAt`), + UNIQUE INDEX `photo_jobs_photoId_type_key`(`photoId`, `type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; + +-- CreateTable +CREATE TABLE `outbox_events` ( + `id` VARCHAR(191) NOT NULL, + `type` VARCHAR(64) NOT NULL, + `albumId` VARCHAR(64) NOT NULL, + `photoId` VARCHAR(64) NULL, + `entityVersion` INTEGER NOT NULL, + `payload` JSON NOT NULL, + `occurredAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `deliveredAt` DATETIME(6) NULL, + + INDEX `outbox_events_deliveredAt_occurredAt_idx`(`deliveredAt`, `occurredAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index fbca9ad..9d61b1d 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -126,3 +126,77 @@ model album_assets { @@index([albumId, kind]) } + +// —— 照片与上传域 + outbox(Task 10)—— +// 同相册域约定:扁平字符串外键、无 Prisma relation/FK 约束。 +// photos.objectKey 是 originals 正式键(Task 18 上传确认时写入); +// upload_sessions.objectKey 是 staging 键;outbox_events 承载领域事件 +// (契约见 packages/contracts),由 worker 轮询投递。 +model photos { + id String @id @default(cuid()) + albumId String @db.VarChar(64) + uploaderId String @db.VarChar(64) + categoryId String? @db.VarChar(64) + objectKey String @unique @db.VarChar(255) // originals 正式键 + bytes Int @default(0) + width Int? + height Int? + shotAt DateTime? @db.DateTime(6) + uploadedAt DateTime? @db.DateTime(6) + publishedAt DateTime? @db.DateTime(6) + processingState String @default("pending_upload") // pending_upload|uploaded|processing|ready|processing_failed + errorCode String? @db.VarChar(64) + visibility String @default("unpublished") // unpublished|published|hidden|deleted + likeCount Int @default(0) // P2 起为统计副本 + entityVersion Int @default(1) + @@index([albumId, visibility, publishedAt, id]) + @@index([albumId, uploaderId, uploadedAt]) +} +model photo_variants { + photoId String @db.VarChar(64) + variant String @db.VarChar(32) // thumb|browse + objectKey String @unique @db.VarChar(255) + bytes Int + version Int @default(1) + width Int? + height Int? + @@unique([photoId, variant]) +} +model upload_sessions { + id String @id @default(cuid()) + userId String @db.VarChar(64) + albumId String @db.VarChar(64) + clientTaskId String @db.VarChar(128) + photoId String @unique @db.VarChar(64) + objectKey String @unique @db.VarChar(255) // staging 键 + expectedBytes Int + contentType String @db.VarChar(128) + multipartId String? @db.VarChar(255) + state String @default("pending") // pending|confirmed|cancelled|expired(对齐契约 UploadSessionState;核验失败是请求级错误不改会话态,expired 由 expiresAt 判定或 sweep 置位) + expiresAt DateTime @db.DateTime(6) + createdAt DateTime @default(now()) @db.DateTime(6) + @@unique([userId, clientTaskId]) // 幂等锚点(A03) +} +model photo_jobs { + id String @id @default(cuid()) + photoId String @db.VarChar(64) + type String @default("process") @db.VarChar(32) + state String @default("pending") // pending|active|done|failed + attempt Int @default(0) + errorCode String? @db.VarChar(64) + nextRetryAt DateTime? @db.DateTime(6) + updatedAt DateTime @updatedAt @db.DateTime(6) + @@unique([photoId, type]) + @@index([state, nextRetryAt]) +} +model outbox_events { + id String @id @default(cuid()) + type String @db.VarChar(64) + albumId String @db.VarChar(64) + photoId String? @db.VarChar(64) + entityVersion Int + payload Json + occurredAt DateTime @default(now()) @db.DateTime(6) + deliveredAt DateTime? @db.DateTime(6) + @@index([deliveredAt, occurredAt]) +} diff --git a/services/api/test/db.semantics.e2e-spec.ts b/services/api/test/db.semantics.e2e-spec.ts index 00a2fa7..b190661 100644 --- a/services/api/test/db.semantics.e2e-spec.ts +++ b/services/api/test/db.semantics.e2e-spec.ts @@ -9,6 +9,9 @@ // 5) 相册域(Task 9 增补):多语言翻译按 @@unique([albumId, locale]) 双向 // 精确命中、publicToken 唯一约束拒绝重复(P2002)——相册域为扁平字符串 // 外键(无 Prisma relation),完整 CRUD 语义由 Task 15 展开。 +// 6) 照片与上传域(Task 10 增补):photo_jobs 按 @@unique([photoId, type]) +// 拒绝重复作业、upload_sessions 按 @@unique([userId, clientTaskId]) 拒绝 +// 重复会话(P2002)——后者是断点续传幂等锚点(A03)。 import { PrismaClient } from '@photo/db-cn'; import { resetDb } from './reset'; @@ -164,4 +167,55 @@ describe('db semantics (e2e)', () => { }), ).rejects.toMatchObject({ code: 'P2002' }); }); + + it('photo_jobs and upload_sessions enforce their unique anchors (Task 10)', async () => { + const photo = await prisma.photos.create({ + data: { + albumId: 'album-jobs-e2e', + uploaderId: 'uploader-flat-fk', + objectKey: 'originals/album-jobs-e2e/photo-001.jpg', + bytes: 1024, + }, + }); + await prisma.photo_jobs.create({ + data: { photoId: photo.id, type: 'process' }, + }); + + // 同一照片同类作业唯一(worker 任务领取的防重入锚点)。 + await expect( + prisma.photo_jobs.create({ + data: { photoId: photo.id, type: 'process' }, + }), + ).rejects.toMatchObject({ code: 'P2002' }); + + await prisma.upload_sessions.create({ + data: { + userId: 'uploader-flat-fk', + albumId: 'album-jobs-e2e', + clientTaskId: 'task-0001', + photoId: photo.id, + objectKey: 'staging/album-jobs-e2e/task-0001.jpg', + expectedBytes: 1024, + contentType: 'image/jpeg', + expiresAt: new Date(Date.now() + 60_000), + }, + }); + + // 同一 (userId, clientTaskId) 唯一——断点续传幂等锚点(A03)。 + // photoId/objectKey 换新值,确保 P2002 只可能来自复合唯一键本身。 + await expect( + prisma.upload_sessions.create({ + data: { + userId: 'uploader-flat-fk', + albumId: 'album-jobs-e2e', + clientTaskId: 'task-0001', + photoId: 'other-photo-cuid', + objectKey: 'staging/album-jobs-e2e/task-0002.jpg', + expectedBytes: 2048, + contentType: 'image/jpeg', + expiresAt: new Date(Date.now() + 60_000), + }, + }), + ).rejects.toMatchObject({ code: 'P2002' }); + }); }); diff --git a/services/api/test/reset.ts b/services/api/test/reset.ts index 1c484ca..f8e42ec 100644 --- a/services/api/test/reset.ts +++ b/services/api/test/reset.ts @@ -10,6 +10,13 @@ import { PrismaClient } from '@photo/db-cn'; export const RESET_TABLES: readonly string[] = [ + // 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束), + // 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。 + 'outbox_events', + 'photo_jobs', + 'photo_variants', + 'upload_sessions', + 'photos', // 相册域(Task 9):子表(翻译/资产/成员)在前,albums 在后;相册域为扁平 // 字符串外键(无 FK 约束),此处顺序仅为逻辑可读,不依赖 FK 检查开关。 'category_translations',