- 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>
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
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;
|
||
}
|
||
}
|