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:
wangdl 2026-06-20 12:24:33 +08:00
parent 1fadb724ec
commit 7cc6947e41
3 changed files with 319 additions and 0 deletions

View File

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

View 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 返回 nullCASCADE 竞态)应重试 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);
});
});
});

View 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;
}
}