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 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-20 12:24:28 +08:00
parent f4259e70a0
commit 1fadb724ec
3 changed files with 244 additions and 0 deletions

View File

@ -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 删除时级联删 SnapshotADR-002 §6
ALTER TABLE `AiJobSnapshot`
ADD CONSTRAINT `AiJobSnapshot_jobId_fkey`
FOREIGN KEY (`jobId`) REFERENCES `AiAnalysisJob`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -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<string, unknown>,编译期阻止裸字符串。
// 此处运行时验证方法存在且参数名称暗示结构化输入。
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);
});
});
});

View File

@ -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 SnapshotjobId UNIQUE
* Prisma
*/
async create(data: {
jobId: string;
jobType: string;
schemaVersion: string;
redactionVersion?: string;
content: Record<string, unknown>;
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;
}
}