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:
parent
f4259e70a0
commit
1fadb724ec
@ -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;
|
||||
172
src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts
Normal file
172
src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/modules/ai-runtime/ai-job-snapshot.repository.ts
Normal file
46
src/modules/ai-runtime/ai-job-snapshot.repository.ts
Normal 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 最多一个 Snapshot(jobId 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user