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