feat(api): identity and audit schema with mysql semantics tests
packages/db-cn:@photo/db-cn(prisma 5.22,api/worker 共享 client)+ schema(users/sessions/audit_logs,DateTime(6)、cuid、账号唯一)。 迁移 SQL 手工改写表级 collation 为 utf8mb4_0900_bin——Prisma 生成的 MySQL DDL 固定 utf8mb4_unicode_ci 会覆盖库级 bin collation,账号/token 大小写 敏感语义依赖它(后续迁移需同样复核)。services/api:@Global PrismaModule (PrismaService 封装 $connect/$disconnect,池上限仍由 DATABASE_URL_CN ?connection_limit=10 承担);reset.ts 落地 FK 包裹式 TRUNCATE;e2e 语义复核 4 例(UTC 往返/大小写敏感/audit Json+actor/ session FK Restrict P2003)。pnpm 10 未拦截 prisma engines(平台包内置), 无需 onlyBuiltDependencies。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+79
-3
@@ -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
|
||||
|
||||
@@ -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$': '<rootDir>/../../packages/contracts/src/index.ts',
|
||||
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
testMatch: ['<rootDir>/src/**/*.spec.ts'],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user