feat(api): album domain schema

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 20:05:09 +08:00
parent 2801e4f158
commit a99b8f200b
5 changed files with 240 additions and 11 deletions
+10 -11
View File
@@ -362,24 +362,21 @@ model audit_logs {
}
```
- [ ] **Step 2: 迁移**
```bash
pnpm db:migrate -- --name init_identity
```
- [x] **Step 2: 迁移**(完成:20260917114313_init_identity 已应用。**重要发现 A**:Prisma 生成的 MySQL DDL 每表硬编码 `utf8mb4_unicode_ci`,覆盖库级 `utf8mb4_0900_bin`——已手工改 migration.sql 为 bin 校对并经 information_schema 验证;**Task 9/10 每张新表必须重复:`--create-only` 生成→改 COLLATE→应用**。**重要发现 B**:`pnpm db:migrate -- --name x` 会转发字面 `--` 使 prisma 交互挂起——正确语法 `pnpm db:migrate --name xxx`)
Expected: migration SQL 生成并应用。
- [ ] **Step 3: 写语义复核 E2E 测试**(TDD:先写测试)——UTC 往返(写入+读回同为 UTC 瞬间)、`utf8mb4_0900_bin` 下 `public_token` 大小写敏感查询、audit 插入。运行 `pnpm --filter @photo/api-cn test:e2e`,Expected: PASS。
- [ ] **Step 4: Commit** `feat(api): identity and audit schema with mysql semantics tests`
- [x] **Step 3: 写语义复核 E2E 测试**(完成:7/7 通过——UTC DateTime(6) 毫秒往返、bin 校对大小写敏感查找、audit Json+actor 关系、带会话删用户被 P2003 拒绝;reset.ts 已实现真实 truncate)
- [x] **Step 4: Commit** `feat(api): identity and audit schema with mysql semantics tests`(完成:2801e4f)
### Task 9: Prisma cn schema —— 相册域
**Files:**
- Modify: `packages/db-cn/prisma/schema.prisma`
- [ ] **Step 1: 模型**:`albums`(publicToken `@unique @db.VarChar(32)`、ownerId、startAt/endAt、timezone、status active|offline、defaultLocale、uploadEnabled/publicEnabled/downloadEnabled/originalDownloadEnabled 默认 true/true/true/false、watermarkVersion 默认 0)、`album_translations`(`@@unique([albumId, locale])`,title/description/venue/address)、`album_members`(`@@unique([albumId, userId])`,role owner|collaborator,status)、`categories`(albumId、sortOrder)+ `category_translations`(`@@unique([categoryId, locale])`)、`album_assets`(kind banner|cover|logo、locale?、objectKey)。
- [ ] **Step 2: 迁移** `--name album_domain`;Step 3: Commit `feat(api): album domain schema`
- [x] **Step 1: 模型**(完成:六模型入 schema,相册域全量扁平字符串外键、无 Prisma relation;Task 10 照片域沿用):`albums`(publicToken `@unique @db.VarChar(32)`、ownerId、startAt/endAt、timezone、status active|offline、defaultLocale、uploadEnabled/publicEnabled/downloadEnabled/originalDownloadEnabled 默认 true/true/true/false、watermarkVersion 默认 0)、`album_translations`(`@@unique([albumId, locale])`,title/description/venue/address)、`album_members`(`@@unique([albumId, userId])`,role owner|collaborator,status)、`categories`(albumId、sortOrder)+ `category_translations`(`@@unique([categoryId, locale])`)、`album_assets`(kind banner|cover|logo、locale?、objectKey)。
- [x] **Step 2: 迁移**(完成:20260917120158_album_domain。流程固化:`pnpm db:migrate --create-only --name album_domain` 生成→sed 改 6 表 COLLATE→`pnpm db:migrate`(无参,非交互应用 pending)→information_schema 验证 10 张域表全 utf8mb4_0900_bin。补充发现 C:`pnpm db:migrate --create-only --name x` 参数可直传,无需进包目录)
- [x] **Step 3: 语义冒烟 + Commit**(完成:reset.ts 增 6 表(翻译/资产/成员在前、albums 在后);db.semantics.e2e-spec.ts 增相册用例——(albumId, locale) 复合唯一双向命中 + 同键重复与 publicToken 重复均 P2002;e2e 8/8、lint/typecheck/test 全绿)
### Task 10: Prisma cn schema —— 照片与上传域 + outbox
@@ -459,7 +456,7 @@ model outbox_events {
}
```
- [ ] **Step 2: 迁移** `--name photo_upload_domain`;Step 3: Commit `feat(api): photo, upload, outbox schema`
- [ ] **Step 2: 迁移** `pnpm db:migrate --name photo_upload_domain`(同 Task 9:`--create-only` → 逐表改 COLLATE `utf8mb4_0900_bin` → 应用 → information_schema 验证);Step 3: Commit `feat(api): photo, upload, outbox schema`
### Task 11: StoragePort 接口 + MinIO 适配器
@@ -496,6 +493,8 @@ export interface StoragePort {
- Create: `services/api/src/auth/password.ts`、`services/api/scripts/seed-admin.ts`
- Test: `services/api/test/password.spec.ts`
> Task 7 质量审查遗留(本任务一并落地):① `ApiException extends HttpException` 携带 `{code: ErrorCode, message, httpStatus}`,异常过滤器优先识别(状态码表无法表达同状态多错误码的契约空间)+ 未映射状态分支单测;② 5xx/INTERNAL_ERROR 强制通用文案(堵住 InternalServerErrorException(message) 泄露);③ pino redact 增 cookie/set-cookie;④ x-request-id 回显加长度上限(128);⑤ LOG_LEVEL 非法值启动期明确报错;⑥ 抽出 `API_PREFIX='v1'` 与 `RequestWithId` 共享类型;⑦ jest ts-jest 改 `tsconfig: 'tsconfig.json'` 复用;⑧ 增加 `start:dev`(tsc --watch + node --watch 或 nodemon 任一)。计划文本笔误修正:request-id 生成为 UUID v4(非 cuid)。
- [ ] **Step 1: 失败测试**:hash/verify(@node-rs/argon2,memoryCost 默认)、错误口令 verify=false。
- [ ] **Step 2: 实现 + seed 脚本**(读 env `ADMIN_ACCOUNT/ADMIN_PASSWORD`,不存在则插入 admin 角色,存在则跳过——幂等)。
- [ ] **Step 3: 测试通过;Commit** `feat(api): argon2 password and idempotent admin seed`
@@ -645,7 +644,7 @@ export interface StoragePort {
- 每任务 TDD 节奏不可跳步:先失败测试→实现→通过→提交;集成/E2E 测试统一跑在 compose 依赖上(`docker compose up -d` 前置)。
- e2e 隔离:每个 e2e spec `beforeAll` 调 `test/reset.ts`(truncate cn 全表 + 清 MinIO `photo-live/dev` 前缀);worker/storage 测试沿用同约定(各自包内复制 reset 工具或引用)。
- 基础设施注意:minio 镜像统一用 `quay.io/minio/*`(Docker Hub 官方仓库已删除,P2 ov 侧同样);跑 prisma/集成测试前先确认宿主端口实际指向 photo-live 容器(`docker port photo-live-mysql-cn-1`),Docker Desktop 重启可能拉起其他项目容器抢占 3306/6379(已对其设 restart=no,但新冲突可能来自其他来源)。
- env:命令一律在仓库根执行;根 `.env` 为唯一 env 文件,脚本经 dotenv-cli 注入、测试经 `test/load-env.ts` 载入(Task 7 约定;`services/worker/test` 与 `packages/storage/test` 复制同约定 load-env.ts——两者到仓库根同为三级向上,路径一致);新增 env 变量必须同步 `.env.example`;密钥不入库。
- env:命令一律在仓库根执行;根 `.env` 为唯一 env 文件,脚本经 dotenv-cli 注入、测试经 `test/load-env.ts` 载入(Task 7 约定;注意路径深度:`services/api/test`、`services/worker/test` 为三级向上(`../../../.env`),`packages/storage/test` 为**两级**向上(`../../.env`)——Task 11 复制时按包调整,或改为向上查找 pnpm-workspace.yaml 定位仓库根);新增 env 变量必须同步 `.env.example`;密钥不入库。
- 发现契约需变更时:先改 `packages/contracts` 并重新生成类型、在任务提交信息中登记,再改消费端(契约单一事实源)。
- 与总纲冲突处以总纲为准并回写修订;HEIC、MySQL 行值比较等 D12 复核项结论写入验收记录。
- P1a 完成后:编写 P1b 计划(viewer 基础页 + admin 最小页 + **宣传图/封面/Logo 上传链路(album_assets 的 presign+确认,复用照片上传机制,R09 依赖)** + **GET /albums/{albumId}/photos 管理端照片列表**(含未发布/隐藏态,照片管理入口)+ **用户账号查询接口**(邀请协作者时按 account 搜索)+ Playwright 冒烟)。
@@ -0,0 +1,92 @@
-- 同 20260917114313_init_identity:Prisma 生成的 MySQL DDL 固定写
-- utf8mb4_unicode_ci,会覆盖库级 utf8mb4_0900_bin;相册域(publicToken、
-- locale 键、objectKey)同样依赖 bin 校对的大小写敏感语义,故逐表改写为
-- utf8mb4_0900_bin。
-- CreateTable
CREATE TABLE `albums` (
`id` VARCHAR(191) NOT NULL,
`publicToken` VARCHAR(32) NOT NULL,
`ownerId` VARCHAR(64) NOT NULL,
`startAt` DATETIME(6) NOT NULL,
`endAt` DATETIME(6) NULL,
`timezone` VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
`status` VARCHAR(191) NOT NULL DEFAULT 'active',
`defaultLocale` VARCHAR(16) NOT NULL,
`uploadEnabled` BOOLEAN NOT NULL DEFAULT true,
`publicEnabled` BOOLEAN NOT NULL DEFAULT true,
`downloadEnabled` BOOLEAN NOT NULL DEFAULT true,
`originalDownloadEnabled` BOOLEAN NOT NULL DEFAULT false,
`watermarkVersion` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updatedAt` DATETIME(6) NOT NULL,
UNIQUE INDEX `albums_publicToken_key`(`publicToken`),
INDEX `albums_ownerId_idx`(`ownerId`),
INDEX `albums_status_publicEnabled_idx`(`status`, `publicEnabled`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `album_translations` (
`id` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`locale` VARCHAR(16) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`venue` VARCHAR(255) NULL,
`address` VARCHAR(255) NULL,
INDEX `album_translations_albumId_idx`(`albumId`),
UNIQUE INDEX `album_translations_albumId_locale_key`(`albumId`, `locale`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `album_members` (
`id` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`userId` VARCHAR(64) NOT NULL,
`role` VARCHAR(191) NOT NULL DEFAULT 'collaborator',
`status` VARCHAR(191) NOT NULL DEFAULT 'active',
`addedAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX `album_members_userId_idx`(`userId`),
UNIQUE INDEX `album_members_albumId_userId_key`(`albumId`, `userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `categories` (
`id` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX `categories_albumId_sortOrder_idx`(`albumId`, `sortOrder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `category_translations` (
`id` VARCHAR(191) NOT NULL,
`categoryId` VARCHAR(64) NOT NULL,
`locale` VARCHAR(16) NOT NULL,
`name` VARCHAR(128) NOT NULL,
UNIQUE INDEX `category_translations_categoryId_locale_key`(`categoryId`, `locale`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `album_assets` (
`id` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`kind` VARCHAR(16) NOT NULL,
`locale` VARCHAR(16) NULL,
`objectKey` VARCHAR(255) NOT NULL,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX `album_assets_albumId_kind_idx`(`albumId`, `kind`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
+80
View File
@@ -46,3 +46,83 @@ model audit_logs {
createdAt DateTime @default(now()) @db.DateTime(6)
actor users? @relation(fields: [actorId], references: [id])
}
// —— 相册域(Task 9)——
// 约定:与身份域不同,相册域一律使用扁平字符串外键(albumId/ownerId/userId/
// categoryId 为纯字符串,不建 Prisma relation/FK 约束)——Task 10 的照片域同此。
// 原因:跨域删除语义(用户注销→相册归属、相册下线→照片保留)由应用层显式
// 决策,不做数据库级联;也避免 TRUNCATE 隔离工具为绕 FK 检查承担更多顺序负担。
model albums {
id String @id @default(cuid())
publicToken String @unique @db.VarChar(32)
ownerId String @db.VarChar(64)
startAt DateTime @db.DateTime(6)
endAt DateTime? @db.DateTime(6)
timezone String @default("Asia/Shanghai") @db.VarChar(64)
status String @default("active") // active|offline
defaultLocale String @db.VarChar(16)
uploadEnabled Boolean @default(true)
publicEnabled Boolean @default(true)
downloadEnabled Boolean @default(true)
originalDownloadEnabled Boolean @default(false)
watermarkVersion Int @default(0)
createdAt DateTime @default(now()) @db.DateTime(6)
updatedAt DateTime @updatedAt @db.DateTime(6)
@@index([ownerId])
@@index([status, publicEnabled])
}
model album_translations {
id String @id @default(cuid())
albumId String @db.VarChar(64)
locale String @db.VarChar(16)
title String @db.VarChar(255)
description String? @db.Text
venue String? @db.VarChar(255)
address String? @db.VarChar(255)
@@unique([albumId, locale])
@@index([albumId])
}
model album_members {
id String @id @default(cuid())
albumId String @db.VarChar(64)
userId String @db.VarChar(64)
role String @default("collaborator") // owner|collaborator
status String @default("active")
addedAt DateTime @default(now()) @db.DateTime(6)
@@unique([albumId, userId])
@@index([userId])
}
model categories {
id String @id @default(cuid())
albumId String @db.VarChar(64)
sortOrder Int @default(0)
createdAt DateTime @default(now()) @db.DateTime(6)
@@index([albumId, sortOrder])
}
model category_translations {
id String @id @default(cuid())
categoryId String @db.VarChar(64)
locale String @db.VarChar(16)
name String @db.VarChar(128)
@@unique([categoryId, locale])
}
model album_assets {
id String @id @default(cuid())
albumId String @db.VarChar(64)
kind String @db.VarChar(16) // banner|cover|logo
locale String? @db.VarChar(16)
objectKey String @db.VarChar(255)
createdAt DateTime @default(now()) @db.DateTime(6)
@@index([albumId, kind])
}
@@ -6,6 +6,9 @@
// 3) audit_logs 的 Json 元数据落库与 actor 关系;
// 4) 必选关系默认 Restrict:删除仍持有 session 的用户抛 P2003——保留可审计性,
// 刻意不做 onDelete: Cascade(用户决策,见计划 Task 8)。
// 5) 相册域(Task 9 增补):多语言翻译按 @@unique([albumId, locale]) 双向
// 精确命中、publicToken 唯一约束拒绝重复(P2002)——相册域为扁平字符串
// 外键(无 Prisma relation),完整 CRUD 语义由 Task 15 展开。
import { PrismaClient } from '@photo/db-cn';
import { resetDb } from './reset';
@@ -115,4 +118,50 @@ describe('db semantics (e2e)', () => {
prisma.users.delete({ where: { id: user.id } }),
).rejects.toMatchObject({ code: 'P2003' });
});
it('album translations resolve by (albumId, locale) both ways and publicToken is unique (Task 9)', async () => {
const album = await prisma.albums.create({
data: {
publicToken: 'album-tok-e2e-01',
ownerId: 'owner-flat-fk', // 相册域扁平字符串外键,无 FK 约束
startAt: new Date('2026-10-01T00:00:00Z'),
defaultLocale: 'zh-CN',
},
});
await prisma.album_translations.create({
data: { albumId: album.id, locale: 'zh-CN', title: '婚礼相册' },
});
await prisma.album_translations.create({
data: { albumId: album.id, locale: 'en', title: 'Wedding Album' },
});
// 复合唯一键两个方向都能精确命中(bin 校对:locale 大小写敏感)。
const zh = await prisma.album_translations.findUnique({
where: { albumId_locale: { albumId: album.id, locale: 'zh-CN' } },
});
expect(zh?.title).toBe('婚礼相册');
const en = await prisma.album_translations.findUnique({
where: { albumId_locale: { albumId: album.id, locale: 'en' } },
});
expect(en?.title).toBe('Wedding Album');
// 同一 (albumId, locale) 重复写入被唯一约束拒绝(P2002)。
await expect(
prisma.album_translations.create({
data: { albumId: album.id, locale: 'en', title: 'Dup' },
}),
).rejects.toMatchObject({ code: 'P2002' });
// publicToken 全局唯一:第二个相册复用同 token 抛 P2002。
await expect(
prisma.albums.create({
data: {
publicToken: 'album-tok-e2e-01',
ownerId: 'owner-flat-fk',
startAt: new Date('2026-11-01T00:00:00Z'),
defaultLocale: 'zh-CN',
},
}),
).rejects.toMatchObject({ code: 'P2002' });
});
});
+9
View File
@@ -10,6 +10,15 @@
import { PrismaClient } from '@photo/db-cn';
export const RESET_TABLES: readonly string[] = [
// 相册域(Task 9):子表(翻译/资产/成员)在前,albums 在后;相册域为扁平
// 字符串外键(无 FK 约束),此处顺序仅为逻辑可读,不依赖 FK 检查开关。
'category_translations',
'categories',
'album_assets',
'album_translations',
'album_members',
'albums',
// 身份域(Task 8)。
'audit_logs',
'sessions',
'users',