From 1fadb724ecebd54894c58e070769f87cb6b68650 Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 20 Jun 2026 12:24:28 +0800 Subject: [PATCH] feat(M-AI-02-05): immutable AiJobSnapshot model - 9 columns, jobId UNIQUE, ON DELETE CASCADE - Repository: create + findByJobId only (no update/upsert) - 14 unit tests (immutability, security, delete strategy) Co-Authored-By: Claude --- .../migration.sql | 26 +++ .../ai-job-snapshot.repository.spec.ts | 172 ++++++++++++++++++ .../ai-runtime/ai-job-snapshot.repository.ts | 46 +++++ 3 files changed, 244 insertions(+) create mode 100644 prisma/migrations/20260620000002_m_ai_02_05_ai_job_snapshot/migration.sql create mode 100644 src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts create mode 100644 src/modules/ai-runtime/ai-job-snapshot.repository.ts diff --git a/prisma/migrations/20260620000002_m_ai_02_05_ai_job_snapshot/migration.sql b/prisma/migrations/20260620000002_m_ai_02_05_ai_job_snapshot/migration.sql new file mode 100644 index 0000000..5311aea --- /dev/null +++ b/prisma/migrations/20260620000002_m_ai_02_05_ai_job_snapshot/migration.sql @@ -0,0 +1,26 @@ +-- M-AI-02-05: 新增不可变 AiJobSnapshot 数据模型 +-- ADR-002 §6 删除策略:CASCADE on Job delete + +CREATE TABLE `AiJobSnapshot` ( + `id` VARCHAR(191) NOT NULL, + `jobId` VARCHAR(191) NOT NULL, + `jobType` VARCHAR(64) NOT NULL, + `schemaVersion` VARCHAR(100) NOT NULL, + `redactionVersion` VARCHAR(100) NULL, + `content` JSON NOT NULL, + `contentHash` VARCHAR(255) NULL, + `expiresAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `AiJobSnapshot_jobId_key` (`jobId`), + INDEX `AiJobSnapshot_jobType_idx` (`jobType`), + INDEX `AiJobSnapshot_expiresAt_idx` (`expiresAt`), + INDEX `AiJobSnapshot_createdAt_idx` (`createdAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- FK: Job 删除时级联删 Snapshot(ADR-002 §6) +ALTER TABLE `AiJobSnapshot` + ADD CONSTRAINT `AiJobSnapshot_jobId_fkey` + FOREIGN KEY (`jobId`) REFERENCES `AiAnalysisJob`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts b/src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts new file mode 100644 index 0000000..fa12397 --- /dev/null +++ b/src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts @@ -0,0 +1,172 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AiJobSnapshotRepository } from './ai-job-snapshot.repository'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +describe('AiJobSnapshotRepository', () => { + let repo: AiJobSnapshotRepository; + let prisma: PrismaService; + + const mockCreate = jest.fn(); + const mockFindUnique = jest.fn(); + const mockDeleteMany = jest.fn(); + + beforeEach(async () => { + mockCreate.mockReset(); + mockFindUnique.mockReset(); + mockDeleteMany.mockReset(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AiJobSnapshotRepository, + { + provide: PrismaService, + useValue: { + aiJobSnapshot: { + create: mockCreate, + findUnique: mockFindUnique, + deleteMany: mockDeleteMany, + }, + }, + }, + ], + }).compile(); + + repo = module.get(AiJobSnapshotRepository); + prisma = module.get(PrismaService); + }); + + describe('create', () => { + it('应创建快照并返回完整记录', async () => { + const input = { + jobId: 'job-1', + jobType: 'learning_state_analysis', + schemaVersion: 'snapshot_v1', + content: { targetType: 'knowledge_base', targetId: 'kb-1' }, + contentHash: 'sha256:abc', + }; + const expected = { id: 'snap-1', ...input, createdAt: new Date() }; + mockCreate.mockResolvedValue(expected); + + const result = await repo.create(input); + expect(result).toEqual(expected); + expect(mockCreate).toHaveBeenCalledWith({ data: input }); + }); + + it('应创建带 expiresAt 的快照', async () => { + const expiresAt = new Date('2026-09-01'); + const input = { + jobId: 'job-2', + jobType: 'quiz_generation', + schemaVersion: 'snapshot_v1', + content: {}, + expiresAt, + }; + mockCreate.mockResolvedValue({ id: 'snap-2', ...input, createdAt: new Date() }); + + const result = await repo.create(input); + expect(result.expiresAt).toEqual(expiresAt); + }); + }); + + describe('findByJobId', () => { + it('应返回匹配的快照', async () => { + const snap = { id: 'snap-1', jobId: 'job-1', jobType: 'test', schemaVersion: 'v1', content: {}, createdAt: new Date() }; + mockFindUnique.mockResolvedValue(snap); + + const result = await repo.findByJobId('job-1'); + expect(result).toEqual(snap); + expect(mockFindUnique).toHaveBeenCalledWith({ where: { jobId: 'job-1' } }); + }); + + it('应返回 null 当快照不存在', async () => { + mockFindUnique.mockResolvedValue(null); + const result = await repo.findByJobId('nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('deleteExpired', () => { + it('应删除过期快照并返回删除数量', async () => { + const before = new Date('2026-06-01'); + mockDeleteMany.mockResolvedValue({ count: 3 }); + + const count = await repo.deleteExpired(before); + expect(count).toBe(3); + expect(mockDeleteMany).toHaveBeenCalledWith({ + where: { expiresAt: { lt: before } }, + }); + }); + + it('应返回 0 当无过期快照', async () => { + mockDeleteMany.mockResolvedValue({ count: 0 }); + const count = await repo.deleteExpired(new Date()); + expect(count).toBe(0); + }); + }); + + describe('不可变约束', () => { + it('Repository 不应暴露 update 方法', () => { + expect(typeof (repo as any).update).toBe('undefined'); + expect(typeof (repo as any).updateContent).toBe('undefined'); + expect(typeof (repo as any).upsert).toBe('undefined'); + }); + + it('Repository 不应暴露 overwrite 方法', () => { + expect(typeof (repo as any).overwrite).toBe('undefined'); + expect(typeof (repo as any).save).toBe('undefined'); + expect(typeof (repo as any).replace).toBe('undefined'); + }); + }); + + describe('安全约束', () => { + it('Repository 不应暴露直接修改 content 的路径', () => { + // 不可变核心:写入后 content 不可变更。唯一写入口是 create()。 + const dangerousMethods = ['update', 'updateContent', 'upsert', 'patch', 'save', 'replace', 'overwrite', 'setContent', 'modify']; + for (const m of dangerousMethods) { + expect(typeof (repo as any)[m]).toBe('undefined'); + } + }); + + it('create 方法签名要求结构化 content(不可传裸字符串)', () => { + // content 类型为 Record,编译期阻止裸字符串。 + // 此处运行时验证方法存在且参数名称暗示结构化输入。 + const createFn = repo.create as Function; + expect(createFn).toBeDefined(); + expect(createFn.length).toBe(1); // 单参数 data object + }); + + it('仅有的写入口 create() 返回类型不包含 update/upsert 方法', () => { + // 验证 Repository 的公开 API 只暴露有限操作。 + // 运行时无法阻止访问私有字段(JS 无 # 语法),保护靠 TypeScript 编译 + code review。 + const allowedMethods = ['create', 'findByJobId', 'deleteExpired']; + const allProtoMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(repo)) + .filter(n => n !== 'constructor'); + const unexpected = allProtoMethods.filter(m => !allowedMethods.includes(m)); + expect(unexpected).toEqual([]); + }); + + it('敏感字段扫描在 SnapshotBuilder 层实现(设计意图文档化)', () => { + // M-AI-02-05 范围:Repository 基础设施 + // M-AI-02-10 范围:SnapshotBuilder 实现脱敏 + 拒绝 + // 10 类禁止字段:apiKey, jwt, authorization, cookie, databaseUrl, + // connectionString, password, secretKey, credential, + // 以及任何匹配 /Bearer\s+|sk-[a-zA-Z0-9]{32,}/ 模式的值 + expect(repo).toBeDefined(); + }); + }); + + describe('删除策略', () => { + it('应通过 CASCADE 跟随 Job 删除(Prisma schema 定义 onDelete: Cascade)', () => { + // FK 在 Prisma schema 中定义: + // job AiAnalysisJob @relation(..., onDelete: Cascade) + // 此测试确认 Repository 不实现手动级联删除(由 DB 处理) + expect(typeof (repo as any).deleteByJobId).toBe('undefined'); + }); + + it('deleteExpired 应允许独立清理过期快照', async () => { + mockDeleteMany.mockResolvedValue({ count: 1 }); + const count = await repo.deleteExpired(new Date()); + expect(count).toBe(1); + }); + }); +}); diff --git a/src/modules/ai-runtime/ai-job-snapshot.repository.ts b/src/modules/ai-runtime/ai-job-snapshot.repository.ts new file mode 100644 index 0000000..1a42e67 --- /dev/null +++ b/src/modules/ai-runtime/ai-job-snapshot.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +/** + * M-AI-02-05: 不可变 Job 输入快照 Repository + * + * 约束: + * - 只允许 create + findByJobId + * - 禁止 update / upsert / 覆盖已有 Snapshot + * - Snapshot 内容必须在应用层通过安全扫描后方可写入 + */ +@Injectable() +export class AiJobSnapshotRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * 创建快照。每个 Job 最多一个 Snapshot(jobId UNIQUE)。 + * 若已存在则抛出 Prisma 唯一约束错误,调用方应处理。 + */ + async create(data: { + jobId: string; + jobType: string; + schemaVersion: string; + redactionVersion?: string; + content: Record; + contentHash?: string; + expiresAt?: Date; + }) { + return this.prisma.aiJobSnapshot.create({ data }); + } + + /** 按 jobId 查询快照 */ + async findByJobId(jobId: string) { + return this.prisma.aiJobSnapshot.findUnique({ + where: { jobId }, + }); + } + + /** 删除过期的快照(由 cleanup service 调用) */ + async deleteExpired(before: Date) { + const result = await this.prisma.aiJobSnapshot.deleteMany({ + where: { expiresAt: { lt: before } }, + }); + return result.count; + } +}