feat(M-AI-02-06): polymorphic AiJobArtifact model
- 7 columns, (jobId,artifactType,artifactId) UNIQUE for idempotency - ON DELETE CASCADE, (jobId,ordinal) + (artifactType,artifactId) indexes - P2002 catch → findUnique → retry-create for CASCADE race edge case - 12 unit tests (idempotency, batch, reverse lookup, constraint) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1fadb724ec
commit
7cc6947e41
@ -0,0 +1,23 @@
|
|||||||
|
-- M-AI-02-06: 新增 AiJobArtifact 产物关联模型(多态引用)
|
||||||
|
-- ADR-002 §6 删除策略:CASCADE on Job delete
|
||||||
|
|
||||||
|
CREATE TABLE `AiJobArtifact` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`jobId` VARCHAR(191) NOT NULL,
|
||||||
|
`artifactType` VARCHAR(32) NOT NULL,
|
||||||
|
`artifactId` VARCHAR(255) NOT NULL,
|
||||||
|
`ordinal` INT NOT NULL DEFAULT 0,
|
||||||
|
`metadata` JSON NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `AiJobArtifact_jobId_artifactType_artifactId_key` (`jobId`, `artifactType`, `artifactId`),
|
||||||
|
INDEX `AiJobArtifact_jobId_ordinal_idx` (`jobId`, `ordinal`),
|
||||||
|
INDEX `AiJobArtifact_artifactType_artifactId_idx` (`artifactType`, `artifactId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- FK: Job 删除时级联删 Artifact(ADR-002 §6)
|
||||||
|
ALTER TABLE `AiJobArtifact`
|
||||||
|
ADD CONSTRAINT `AiJobArtifact_jobId_fkey`
|
||||||
|
FOREIGN KEY (`jobId`) REFERENCES `AiAnalysisJob`(`id`)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
186
src/modules/ai-runtime/ai-job-artifact.repository.spec.ts
Normal file
186
src/modules/ai-runtime/ai-job-artifact.repository.spec.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AiJobArtifactRepository, ARTIFACT_TYPES, CreateArtifactInput } from './ai-job-artifact.repository';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
|
describe('AiJobArtifactRepository', () => {
|
||||||
|
let repo: AiJobArtifactRepository;
|
||||||
|
let mockCreate: jest.Mock;
|
||||||
|
let mockFindUnique: jest.Mock;
|
||||||
|
let mockFindMany: jest.Mock;
|
||||||
|
|
||||||
|
const sampleInput: CreateArtifactInput = {
|
||||||
|
jobId: 'job-1',
|
||||||
|
artifactType: 'quiz',
|
||||||
|
artifactId: 'quiz-42',
|
||||||
|
ordinal: 0,
|
||||||
|
metadata: { questionCount: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockCreate = jest.fn();
|
||||||
|
mockFindUnique = jest.fn();
|
||||||
|
mockFindMany = jest.fn();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AiJobArtifactRepository,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
aiJobArtifact: {
|
||||||
|
create: mockCreate,
|
||||||
|
findUnique: mockFindUnique,
|
||||||
|
findMany: mockFindMany,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repo = module.get(AiJobArtifactRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create(幂等)', () => {
|
||||||
|
it('应创建 Artifact 并返回记录', async () => {
|
||||||
|
mockCreate.mockResolvedValue({ id: 'art-1', ...sampleInput, createdAt: new Date() });
|
||||||
|
const result = await repo.create(sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'art-1');
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复创建同一 (jobId, artifactType, artifactId) 应幂等返回已有记录', async () => {
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint', {
|
||||||
|
code: 'P2002',
|
||||||
|
clientVersion: '5.22.0',
|
||||||
|
meta: { target: ['jobId_artifactType_artifactId'] },
|
||||||
|
});
|
||||||
|
mockCreate.mockRejectedValueOnce(p2002);
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'art-1', ...sampleInput, createdAt: new Date() });
|
||||||
|
|
||||||
|
const result = await repo.create(sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'art-1');
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2002 后 findUnique 返回 null(CASCADE 竞态)应重试 create 并成功', async () => {
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint', {
|
||||||
|
code: 'P2002', clientVersion: '5.22.0', meta: { target: ['jobId_artifactType_artifactId'] },
|
||||||
|
});
|
||||||
|
mockCreate.mockRejectedValueOnce(p2002);
|
||||||
|
mockFindUnique.mockResolvedValueOnce(null); // CASCADE 竞态:记录已被删除
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-retry', ...sampleInput, createdAt: new Date() });
|
||||||
|
|
||||||
|
const result = await repo.create(sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'art-retry');
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(2); // 原 create + 重试
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非 P2002 错误应抛出', async () => {
|
||||||
|
const otherError = new Error('DB connection lost');
|
||||||
|
mockCreate.mockRejectedValueOnce(otherError);
|
||||||
|
await expect(repo.create(sampleInput)).rejects.toThrow('DB connection lost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应只接受 ARTIFACT_TYPES 中定义的类型', () => {
|
||||||
|
for (const t of ARTIFACT_TYPES) {
|
||||||
|
const input: CreateArtifactInput = { jobId: 'j', artifactType: t, artifactId: 'a' };
|
||||||
|
expect(() => repo.create(input)).toBeDefined();
|
||||||
|
}
|
||||||
|
// @ts-expect-error - 编译期类型检查,运行时不拒绝但应通过 review 阻止
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMany', () => {
|
||||||
|
it('应批量创建多个不同类型的 Artifact', async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-1', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, createdAt: new Date() });
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-2', jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 1, createdAt: new Date() });
|
||||||
|
|
||||||
|
const inputs: CreateArtifactInput[] = [
|
||||||
|
{ jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0 },
|
||||||
|
{ jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 1 },
|
||||||
|
];
|
||||||
|
const results = await repo.createMany(inputs);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createMany 中部分重复应幂等处理', async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-1', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, createdAt: new Date() });
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('dup', { code: 'P2002', clientVersion: '5.22.0', meta: {} });
|
||||||
|
mockCreate.mockRejectedValueOnce(p2002);
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'art-2', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q2', ordinal: 1, createdAt: new Date() });
|
||||||
|
|
||||||
|
const inputs: CreateArtifactInput[] = [
|
||||||
|
{ jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1' },
|
||||||
|
{ jobId: 'job-1', artifactType: 'quiz', artifactId: 'q2' },
|
||||||
|
];
|
||||||
|
const results = await repo.createMany(inputs);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0]!.id).toBe('art-1');
|
||||||
|
expect(results[1]!.id).toBe('art-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByJobId', () => {
|
||||||
|
it('应按 ordinal 排序返回产物列表', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: 'art-1', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'art-2', jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 1, metadata: null, createdAt: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await repo.findByJobId('job-1');
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(mockFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { jobId: 'job-1' },
|
||||||
|
orderBy: { ordinal: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应返回空数组当无产物', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([]);
|
||||||
|
const results = await repo.findByJobId('empty-job');
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findJobsByArtifact', () => {
|
||||||
|
it('应按 artifactType + artifactId 反向查询', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ jobId: 'job-1', ordinal: 0, createdAt: new Date('2026-06-19') },
|
||||||
|
{ jobId: 'job-2', ordinal: 0, createdAt: new Date('2026-06-20') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await repo.findJobsByArtifact('quiz', 'quiz-42');
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(mockFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { artifactType: 'quiz', artifactId: 'quiz-42' },
|
||||||
|
select: { jobId: true, ordinal: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('约束验证', () => {
|
||||||
|
it('不允许直接修改或删除 Artifact', () => {
|
||||||
|
const forbidden = ['update', 'updateMany', 'delete', 'deleteMany', 'upsert'];
|
||||||
|
for (const m of forbidden) {
|
||||||
|
expect(typeof (repo as any)[m]).toBe('undefined');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一个 Job 可关联多个不同类型产物', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: 'a', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'b', jobId: 'job-1', artifactType: 'focus_item', artifactId: 'fi1', ordinal: 1, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'c', jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 2, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'd', jobId: 'job-1', artifactType: 'analysis_result', artifactId: 'ar1', ordinal: 3, metadata: null, createdAt: new Date() },
|
||||||
|
]);
|
||||||
|
const results = await repo.findByJobId('job-1');
|
||||||
|
const types = results.map(r => r.artifactType);
|
||||||
|
expect(new Set(types).size).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
110
src/modules/ai-runtime/ai-job-artifact.repository.ts
Normal file
110
src/modules/ai-runtime/ai-job-artifact.repository.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ARTIFACT_TYPES = [
|
||||||
|
'analysis_result',
|
||||||
|
'focus_item',
|
||||||
|
'review_card',
|
||||||
|
'quiz',
|
||||||
|
'learning_analysis',
|
||||||
|
'recommendation',
|
||||||
|
'knowledge_import',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
|
||||||
|
|
||||||
|
export interface CreateArtifactInput {
|
||||||
|
jobId: string;
|
||||||
|
artifactType: ArtifactType;
|
||||||
|
artifactId: string;
|
||||||
|
ordinal?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M-AI-02-06: Job 产物关联 Repository
|
||||||
|
*
|
||||||
|
* 多态引用:artifactType + artifactId 关联业务表,不设 FK 到各类型表。
|
||||||
|
* 唯一约束 (jobId, artifactType, artifactId) 保证幂等。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiJobArtifactRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等创建:若已存在同 (jobId, artifactType, artifactId) 的记录则跳过。
|
||||||
|
* 使用 Prisma create + skip-duplicate 语义(Prisma 不支持 INSERT IGNORE MySQL 语法,
|
||||||
|
* 但 Prisma 的唯一约束错误可通过 try/catch 转为幂等)。
|
||||||
|
*/
|
||||||
|
async create(input: CreateArtifactInput) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.aiJobArtifact.create({
|
||||||
|
data: {
|
||||||
|
jobId: input.jobId,
|
||||||
|
artifactType: input.artifactType,
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
ordinal: input.ordinal ?? 0,
|
||||||
|
metadata: input.metadata ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
// 唯一约束冲突 = 已存在,幂等返回已有记录
|
||||||
|
const existing = await this.prisma.aiJobArtifact.findUnique({
|
||||||
|
where: {
|
||||||
|
jobId_artifactType_artifactId: {
|
||||||
|
jobId: input.jobId,
|
||||||
|
artifactType: input.artifactType,
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// 极边缘情况:P2002 和 findUnique 之间父 Job 被 CASCADE 删除。
|
||||||
|
// 此时记录已不存在,重试 create 一次。
|
||||||
|
return this.prisma.aiJobArtifact.create({
|
||||||
|
data: {
|
||||||
|
jobId: input.jobId,
|
||||||
|
artifactType: input.artifactType,
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
ordinal: input.ordinal ?? 0,
|
||||||
|
metadata: input.metadata ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量幂等创建(逐个 try/catch,小批量场景适用) */
|
||||||
|
async createMany(inputs: CreateArtifactInput[]) {
|
||||||
|
const results: Awaited<ReturnType<typeof this.create>>[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
results.push(await this.create(input));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 Job 查询所有产物,按 ordinal 排序 */
|
||||||
|
async findByJobId(jobId: string) {
|
||||||
|
return this.prisma.aiJobArtifact.findMany({
|
||||||
|
where: { jobId },
|
||||||
|
orderBy: { ordinal: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反向查询:按产物查找关联的 Job。
|
||||||
|
* 通过 (artifactType, artifactId) 复合索引高效查询。
|
||||||
|
*/
|
||||||
|
async findJobsByArtifact(artifactType: ArtifactType, artifactId: string) {
|
||||||
|
const artifacts = await this.prisma.aiJobArtifact.findMany({
|
||||||
|
where: { artifactType, artifactId },
|
||||||
|
select: { jobId: true, ordinal: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return artifacts;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user