diff --git a/packages/db-cn/package.json b/packages/db-cn/package.json new file mode 100644 index 0000000..b90f3e8 --- /dev/null +++ b/packages/db-cn/package.json @@ -0,0 +1,32 @@ +{ + "name": "@photo/db-cn", + "version": "0.1.0", + "private": true, + "description": "CN Prisma schema + shared client (api 与 worker 共用;MySQL 8.4 utf8mb4_0900_bin,迁移经根 db:* 脚本注入 .env)", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "generate": "prisma generate", + "migrate": "prisma migrate dev", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit", + "lint": "eslint ." + }, + "dependencies": { + "@prisma/client": "^5.22.0" + }, + "devDependencies": { + "prisma": "^5.22.0", + "typescript": "^5.6.0" + }, + "prisma": { + "schema": "prisma/schema.prisma" + } +} diff --git a/packages/db-cn/prisma/migrations/20260917114313_init_identity/migration.sql b/packages/db-cn/prisma/migrations/20260917114313_init_identity/migration.sql new file mode 100644 index 0000000..36905db --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260917114313_init_identity/migration.sql @@ -0,0 +1,50 @@ +-- 注意:Prisma 生成的 MySQL DDL 固定写 utf8mb4_unicode_ci,会覆盖库级 +-- utf8mb4_0900_bin;账号/token 的大小写敏感语义依赖 bin collation,故手工 +-- 改写为 utf8mb4_0900_bin(后续迁移同样需要复核 CREATE TABLE 的 collation)。 + +-- CreateTable +CREATE TABLE `users` ( + `id` VARCHAR(191) NOT NULL, + `account` VARCHAR(64) NOT NULL, + `passwordHash` VARCHAR(255) NOT NULL, + `displayName` VARCHAR(128) NOT NULL, + `role` VARCHAR(191) NOT NULL DEFAULT 'staff', + `sourceStatus` VARCHAR(191) NOT NULL DEFAULT 'local', + `localLocked` BOOLEAN NOT NULL DEFAULT false, + `credentialStatus` VARCHAR(191) NOT NULL DEFAULT 'active', + `locale` VARCHAR(16) NULL, + `createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + + UNIQUE INDEX `users_account_key`(`account`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; + +-- CreateTable +CREATE TABLE `sessions` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `tokenHash` VARCHAR(64) NOT NULL, + `expiresAt` DATETIME(6) NOT NULL, + `revokedAt` DATETIME(6) NULL, + + UNIQUE INDEX `sessions_tokenHash_key`(`tokenHash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; + +-- CreateTable +CREATE TABLE `audit_logs` ( + `id` VARCHAR(191) NOT NULL, + `actorId` VARCHAR(191) NULL, + `action` VARCHAR(64) NOT NULL, + `resourceId` VARCHAR(64) NULL, + `metadata` JSON NULL, + `createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; + +-- AddForeignKey +ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `audit_logs` ADD CONSTRAINT `audit_logs_actorId_fkey` FOREIGN KEY (`actorId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db-cn/prisma/migrations/migration_lock.toml b/packages/db-cn/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5a788a --- /dev/null +++ b/packages/db-cn/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "mysql" \ No newline at end of file diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma new file mode 100644 index 0000000..ad2cc62 --- /dev/null +++ b/packages/db-cn/prisma/schema.prisma @@ -0,0 +1,48 @@ +// packages/db-cn/prisma/schema.prisma —— CN 侧唯一 schema(Task 8 起步:身份与审计域)。 +// 约定:MySQL 8.4、库级 collation utf8mb4_0900_bin(大小写敏感,账号/token 语义依赖它); +// 时间一律 @db.DateTime(6);ID cuid;连接池上限由 DATABASE_URL_CN 的 ?connection_limit=10 +// 承担,不在应用侧另行放大。 +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL_CN") +} + +model users { + id String @id @default(cuid()) + account String @unique @db.VarChar(64) + passwordHash String @db.VarChar(255) + displayName String @db.VarChar(128) + role String @default("staff") // staff|admin(公司角色;相册角色另表) + sourceStatus String @default("local") // local|active|disabled(P4a 同步用) + localLocked Boolean @default(false) + credentialStatus String @default("active") // active|pending + locale String? @db.VarChar(16) + createdAt DateTime @default(now()) @db.DateTime(6) + sessions sessions[] + auditLogs audit_logs[] +} + +model sessions { + id String @id @default(cuid()) + userId String + tokenHash String @unique @db.VarChar(64) + // 长效会话期限(默认 30 天,AUTH_SESSION_TTL_SECONDS 可调;不做短时效 + // access/refresh 双层——用户决策 2026-09-17) + expiresAt DateTime @db.DateTime(6) + revokedAt DateTime? @db.DateTime(6) // 注销/停用撤销 + user users @relation(fields: [userId], references: [id]) +} + +model audit_logs { + id String @id @default(cuid()) + actorId String? + action String @db.VarChar(64) + resourceId String? @db.VarChar(64) + metadata Json? + createdAt DateTime @default(now()) @db.DateTime(6) + actor users? @relation(fields: [actorId], references: [id]) +} diff --git a/packages/db-cn/src/index.ts b/packages/db-cn/src/index.ts new file mode 100644 index 0000000..87595fd --- /dev/null +++ b/packages/db-cn/src/index.ts @@ -0,0 +1,5 @@ +// packages/db-cn/src/index.ts —— CN 侧唯一 Prisma client 出口。 +// api(PrismaService)与 worker(Task 20)都从本包导入,schema/迁移只此一份。 +// commonjs 包:消费方(api/worker)均为 CJS,tsc 直出无需打包。 +export { PrismaClient } from '@prisma/client'; +export type { Prisma } from '@prisma/client'; diff --git a/packages/db-cn/tsconfig.json b/packages/db-cn/tsconfig.json new file mode 100644 index 0000000..8e9c91c --- /dev/null +++ b/packages/db-cn/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "moduleResolution": "node10", + "declaration": true, + "outDir": "dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 624d46a..df485b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,20 @@ importers: version: 7.13.0(typescript@5.9.3) ts-jest: specifier: ^29.2.0 - 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.9.3)))(typescript@5.9.3) + 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)(typescript@5.9.3) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + + packages/db-cn: + dependencies: + '@prisma/client': + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) + devDependencies: + prisma: + specifier: ^5.22.0 + version: 5.22.0 typescript: specifier: ^5.6.0 version: 5.9.3 @@ -65,6 +78,9 @@ importers: '@photo/contracts': specifier: workspace:* version: link:../../packages/contracts + '@photo/db-cn': + specifier: workspace:* + version: link:../../packages/db-cn nestjs-pino: specifier: ^4.6.1 version: 4.6.1(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) @@ -104,7 +120,7 @@ importers: 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.9.3)))(typescript@5.9.3) + 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)(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3) @@ -623,6 +639,30 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -2260,6 +2300,11 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3509,6 +3554,31 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -5414,6 +5484,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + prismjs@1.30.0: {} process-warning@5.1.0: {} @@ -5838,7 +5914,7 @@ 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.9.3)))(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)(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 diff --git a/services/api/jest.config.ts b/services/api/jest.config.ts index 2fd8115..9bfe47f 100644 --- a/services/api/jest.config.ts +++ b/services/api/jest.config.ts @@ -5,6 +5,9 @@ // moduleNameMapper 直接指向其 TS 源码,由 ts-jest 以 CJS 编译(同 // packages/contracts/jest.config.cjs 的 .js -> $1 映射模式);运行时(node dist) // 则经 require(esm) 消费其 dist(Node >= 22.12 支持)。 +// @photo/db-cn 为 CJS 包(含 @prisma/client 再导出),同样映射到 TS 源码, +// 免依赖先 build dist;@prisma/client 及其生成的 .prisma/client 由 Node +// 解析规则从 packages/db-cn/node_modules 向上命中。 import type { Config } from 'jest'; const config: Config = { @@ -30,6 +33,7 @@ const config: Config = { }, moduleNameMapper: { '^@photo/contracts$': '/../../packages/contracts/src/index.ts', + '^@photo/db-cn$': '/../../packages/db-cn/src/index.ts', '^(\\.{1,2}/.*)\\.js$': '$1', }, testMatch: ['/src/**/*.spec.ts'], diff --git a/services/api/package.json b/services/api/package.json index d520cac..cd4227f 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -16,6 +16,7 @@ "@nestjs/core": "^10.4.6", "@nestjs/platform-express": "^10.4.6", "@photo/contracts": "workspace:*", + "@photo/db-cn": "workspace:*", "nestjs-pino": "^4.6.1", "pino": "^10.0.0", "pino-http": "^11.0.0", diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index a773865..a9cae4c 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -1,6 +1,7 @@ // services/api/src/app-cn.module.ts -// 根模块:日志(nestjs-pino / pino-http)、request-id 中间件、全局异常过滤器、 -// /v1/health。功能模块(auth/albums/uploads/photos/public)自 Task 12+ 起逐个加入。 +// 根模块:Prisma(@Global,封装 @photo/db-cn)、日志(nestjs-pino / pino-http)、 +// request-id 中间件、全局异常过滤器、/v1/health。 +// 功能模块(auth/albums/uploads/photos/public)自 Task 12+ 起逐个加入。 // 过滤器经 APP_FILTER、中间件经 NestModule 在模块层注册——e2e 直接 import 本模块 // 即可获得与生产一致的管线(全局前缀除外,由入口设置)。 import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; @@ -9,10 +10,13 @@ import { LoggerModule } from 'nestjs-pino'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { AllExceptionsFilter } from './all-exceptions.filter'; import { HealthController } from './health.controller'; +import { PrismaModule } from './prisma/prisma.module'; import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware'; @Module({ imports: [ + // @Global:功能模块(Task 12+)直接注入 PrismaService,无需 re-import。 + PrismaModule, LoggerModule.forRoot({ pinoHttp: { // 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id, diff --git a/services/api/src/prisma/prisma.module.ts b/services/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..fb2fb77 --- /dev/null +++ b/services/api/src/prisma/prisma.module.ts @@ -0,0 +1,12 @@ +// services/api/src/prisma/prisma.module.ts —— 封装 @photo/db-cn 供注入。 +// @Global:Task 12+ 的功能模块(auth/albums/uploads/photos/public)直接注入 +// PrismaService,无需逐模块 re-import;本模块只在 AppCnModule 挂载一次。 +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/services/api/src/prisma/prisma.service.ts b/services/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..55b5ef3 --- /dev/null +++ b/services/api/src/prisma/prisma.service.ts @@ -0,0 +1,23 @@ +// services/api/src/prisma/prisma.service.ts —— @photo/db-cn 的可注入封装。 +// 连接池上限由 DATABASE_URL_CN 的 ?connection_limit=10 承担,这里不另行放大; +// 不使用 enableShutdownHooks(Nest 10 经 onModuleDestroy 生命周期即可优雅断开)。 +import { + Injectable, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { PrismaClient } from '@photo/db-cn'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/services/api/test/db.semantics.e2e-spec.ts b/services/api/test/db.semantics.e2e-spec.ts new file mode 100644 index 0000000..9b2eabb --- /dev/null +++ b/services/api/test/db.semantics.e2e-spec.ts @@ -0,0 +1,118 @@ +// services/api/test/db.semantics.e2e-spec.ts —— Task 8 语义复核(真实 MySQL 8.4, +// 库级 utf8mb4_0900_bin;连接经 test/load-env.ts 载入根 .env 的 DATABASE_URL_CN)。 +// 复核项是后续 auth/album 语义的依赖前提: +// 1) DateTime(6) 的 UTC 瞬间往返(写入+读回毫秒精度不丢、时区不改); +// 2) bin collation 下账号查找大小写敏感(token/account 语义同此); +// 3) audit_logs 的 Json 元数据落库与 actor 关系; +// 4) 必选关系默认 Restrict:删除仍持有 session 的用户抛 P2003——保留可审计性, +// 刻意不做 onDelete: Cascade(用户决策,见计划 Task 8)。 +import { PrismaClient } from '@photo/db-cn'; +import { resetDb } from './reset'; + +describe('db semantics (e2e)', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + await resetDb(); + prisma = new PrismaClient(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it('DateTime(6) round-trips a UTC instant at millisecond precision', async () => { + const createdAt = new Date('2026-09-17T08:00:00.123Z'); + const created = await prisma.users.create({ + data: { + account: 'utc-roundtrip', + passwordHash: 'x', + displayName: 'UTC 往返', + createdAt, + }, + select: { createdAt: true }, + }); + expect(created.createdAt.getTime()).toBe(createdAt.getTime()); + + const reread = await prisma.users.findUnique({ + where: { account: 'utc-roundtrip' }, + select: { createdAt: true }, + }); + expect(reread?.createdAt.getTime()).toBe(createdAt.getTime()); + expect(reread?.createdAt.toISOString()).toBe('2026-09-17T08:00:00.123Z'); + }); + + it('account lookup is case-sensitive under utf8mb4_0900_bin', async () => { + await prisma.users.create({ + data: { + account: 'AdminUser', + passwordHash: 'x', + displayName: '大小写敏感', + }, + }); + + const lowered = await prisma.users.findUnique({ + where: { account: 'adminuser' }, + }); + expect(lowered).toBeNull(); + + const exact = await prisma.users.findUnique({ + where: { account: 'AdminUser' }, + }); + expect(exact?.account).toBe('AdminUser'); + }); + + it('stores audit log Json metadata and joins back to the actor', async () => { + const actor = await prisma.users.create({ + data: { + account: 'audit-actor', + passwordHash: 'x', + displayName: '审计者', + }, + }); + const log = await prisma.audit_logs.create({ + data: { + actorId: actor.id, + action: 'user.login', + resourceId: 'res-001', + metadata: { ip: '127.0.0.1', userAgent: 'e2e', attempts: 1 }, + }, + include: { actor: true }, + }); + + expect(log.id).toBeTruthy(); + expect(log.metadata).toEqual({ ip: '127.0.0.1', userAgent: 'e2e', attempts: 1 }); + expect(log.actor?.id).toBe(actor.id); + expect(log.actor?.account).toBe('audit-actor'); + + // 反向关系:经 actor.auditLogs 能取回审计记录。 + const viaActor = await prisma.users.findUnique({ + where: { id: actor.id }, + include: { auditLogs: true }, + }); + expect(viaActor?.auditLogs.map((l) => l.id)).toContain(log.id); + }); + + it('rejects deleting a user that still has a session (FK Restrict, no cascade)', async () => { + const user = await prisma.users.create({ + data: { + account: 'restricted', + passwordHash: 'x', + displayName: '受保护删除', + }, + }); + await prisma.sessions.create({ + data: { + userId: user.id, + tokenHash: 'a'.repeat(64), + expiresAt: new Date(Date.now() + 60_000), + }, + }); + + // Prisma 对必选关系默认 onDelete: Restrict —— 删除被引用的 users 抛 P2003 + // (外键约束失败)。这正是预期:会话/审计可追溯,不允许级联抹除。 + await expect( + prisma.users.delete({ where: { id: user.id } }), + ).rejects.toMatchObject({ code: 'P2003' }); + }); +}); diff --git a/services/api/test/reset.ts b/services/api/test/reset.ts index e21de62..dbe26f3 100644 --- a/services/api/test/reset.ts +++ b/services/api/test/reset.ts @@ -1,17 +1,29 @@ // services/api/test/reset.ts // e2e 共用隔离工具:每个 e2e spec 的 beforeAll 调用 resetDb(),防止 spec 间 // 唯一约束冲突与脏数据。 -// TODO(Task 8): 用 Prisma 实现 RESET_TABLES 中全部表的 truncate-if-exists -// (Task 8-10 逐步登记表名,届时空列表不再成立)。 +// 实现:SET FOREIGN_KEY_CHECKS=0 包裹逐表 TRUNCATE(users 被 sessions/audit_logs +// 引用,即便子表已清空,存在 FK 约束本身即令 TRUNCATE 失败,必须关检查); +// 表序仍按 FK 安全顺序排列(子表在前),关检查失败时也不至于中途卡死。 +// 顺序 await 在 Prisma 连接池上复用同一空闲连接,会话级 FOREIGN_KEY_CHECKS +// 对后续 TRUNCATE 生效;finally 恢复检查并断开,失败也不泄漏连接。 // TODO(Task 11): 增加 MinIO `photo-live/dev` 前缀对象清理(同一函数内追加)。 -export const RESET_TABLES: readonly string[] = []; +import { PrismaClient } from '@photo/db-cn'; + +export const RESET_TABLES: readonly string[] = [ + 'audit_logs', + 'sessions', + 'users', +]; export async function resetDb(): Promise { - if (RESET_TABLES.length === 0) { - // 尚未登记任何表(Task 8 前数据库无表):干净地跳过。 - return; + const prisma = new PrismaClient(); + try { + await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0'); + for (const table of RESET_TABLES) { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table}`); + } + } finally { + await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1'); + await prisma.$disconnect(); } - throw new Error( - 'resetDb: RESET_TABLES is non-empty but truncation is not implemented until Task 8', - ); }