From 7cc6947e415edff87da6d387e6a36afeb655b276 Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 20 Jun 2026 12:24:33 +0800 Subject: [PATCH] feat(M-AI-02-06): polymorphic AiJobArtifact model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migration.sql | 23 +++ .../ai-job-artifact.repository.spec.ts | 186 ++++++++++++++++++ .../ai-runtime/ai-job-artifact.repository.ts | 110 +++++++++++ 3 files changed, 319 insertions(+) create mode 100644 prisma/migrations/20260620000003_m_ai_02_06_ai_job_artifact/migration.sql create mode 100644 src/modules/ai-runtime/ai-job-artifact.repository.spec.ts create mode 100644 src/modules/ai-runtime/ai-job-artifact.repository.ts diff --git a/prisma/migrations/20260620000003_m_ai_02_06_ai_job_artifact/migration.sql b/prisma/migrations/20260620000003_m_ai_02_06_ai_job_artifact/migration.sql new file mode 100644 index 0000000..55f110e --- /dev/null +++ b/prisma/migrations/20260620000003_m_ai_02_06_ai_job_artifact/migration.sql @@ -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; diff --git a/src/modules/ai-runtime/ai-job-artifact.repository.spec.ts b/src/modules/ai-runtime/ai-job-artifact.repository.spec.ts new file mode 100644 index 0000000..9b0a9bc --- /dev/null +++ b/src/modules/ai-runtime/ai-job-artifact.repository.spec.ts @@ -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); + }); + }); +}); diff --git a/src/modules/ai-runtime/ai-job-artifact.repository.ts b/src/modules/ai-runtime/ai-job-artifact.repository.ts new file mode 100644 index 0000000..ec4a521 --- /dev/null +++ b/src/modules/ai-runtime/ai-job-artifact.repository.ts @@ -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; +} + +/** + * 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>[] = []; + 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; + } +}