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:
@@ -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