feat(api): photo, upload, outbox schema
Task 10: photos/photo_variants/upload_sessions/photo_jobs/outbox_events on the CN schema (flat string FKs, no Prisma relations); migration 20260917120827_photo_upload_domain rewritten to utf8mb4_0900_bin per the 固化 collation recipe; reset.ts table order extended; one semantics e2e case for photo_jobs @@unique([photoId, type]) and the upload_sessions A03 idempotency anchor @@unique([userId, clientTaskId]). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -126,3 +126,77 @@ model album_assets {
|
|||||||
|
|
||||||
@@index([albumId, kind])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
// 5) 相册域(Task 9 增补):多语言翻译按 @@unique([albumId, locale]) 双向
|
// 5) 相册域(Task 9 增补):多语言翻译按 @@unique([albumId, locale]) 双向
|
||||||
// 精确命中、publicToken 唯一约束拒绝重复(P2002)——相册域为扁平字符串
|
// 精确命中、publicToken 唯一约束拒绝重复(P2002)——相册域为扁平字符串
|
||||||
// 外键(无 Prisma relation),完整 CRUD 语义由 Task 15 展开。
|
// 外键(无 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 { PrismaClient } from '@photo/db-cn';
|
||||||
import { resetDb } from './reset';
|
import { resetDb } from './reset';
|
||||||
|
|
||||||
@@ -164,4 +167,55 @@ describe('db semantics (e2e)', () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({ code: 'P2002' });
|
).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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,13 @@
|
|||||||
import { PrismaClient } from '@photo/db-cn';
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
|
|
||||||
export const RESET_TABLES: readonly string[] = [
|
export const RESET_TABLES: readonly string[] = [
|
||||||
|
// 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束),
|
||||||
|
// 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。
|
||||||
|
'outbox_events',
|
||||||
|
'photo_jobs',
|
||||||
|
'photo_variants',
|
||||||
|
'upload_sessions',
|
||||||
|
'photos',
|
||||||
// 相册域(Task 9):子表(翻译/资产/成员)在前,albums 在后;相册域为扁平
|
// 相册域(Task 9):子表(翻译/资产/成员)在前,albums 在后;相册域为扁平
|
||||||
// 字符串外键(无 FK 约束),此处顺序仅为逻辑可读,不依赖 FK 检查开关。
|
// 字符串外键(无 FK 约束),此处顺序仅为逻辑可读,不依赖 FK 检查开关。
|
||||||
'category_translations',
|
'category_translations',
|
||||||
|
|||||||
Reference in New Issue
Block a user