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:
浅念
2026-09-17 19:51:56 +08:00
parent 7d250ec342
commit 2801e4f158
14 changed files with 417 additions and 14 deletions
+4
View File
@@ -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'],
+1
View File
@@ -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",
+6 -2
View File
@@ -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,
+12
View File
@@ -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 {}
+23
View File
@@ -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();
}
}
+118
View File
@@ -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' });
});
});
+21 -9
View File
@@ -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',
);
}