diff --git a/docs/plans/2026-09-17-P1a-后端核心链路.md b/docs/plans/2026-09-17-P1a-后端核心链路.md index a34688e..058bb32 100644 --- a/docs/plans/2026-09-17-P1a-后端核心链路.md +++ b/docs/plans/2026-09-17-P1a-后端核心链路.md @@ -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 冒烟)。 diff --git a/packages/db-cn/prisma/migrations/20260917120158_album_domain/migration.sql b/packages/db-cn/prisma/migrations/20260917120158_album_domain/migration.sql new file mode 100644 index 0000000..33d1d5b --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260917120158_album_domain/migration.sql @@ -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; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index ad2cc62..fbca9ad 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -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]) +} diff --git a/services/api/test/db.semantics.e2e-spec.ts b/services/api/test/db.semantics.e2e-spec.ts index 9b2eabb..00a2fa7 100644 --- a/services/api/test/db.semantics.e2e-spec.ts +++ b/services/api/test/db.semantics.e2e-spec.ts @@ -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' }); + }); }); diff --git a/services/api/test/reset.ts b/services/api/test/reset.ts index dbe26f3..1c484ca 100644 --- a/services/api/test/reset.ts +++ b/services/api/test/reset.ts @@ -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',