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