import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import * as crypto from 'crypto'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository'; import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service'; import { AiJobLifecycleRepository, CreateJobInput } from './ai-job-lifecycle.repository'; import { JobDefinitionRegistry } from './job-definition-registry'; import { Prisma } from '@prisma/client'; /** * M-AI-03-06: AiJobCreationService * * 统一 Job 创建入口(internal-only)。 * 在一个 Prisma Transaction 内原子创建: * 1. AiJob(lifecycleStatus = 'queued') * 2. AiJobSnapshot(不可变输入快照) * 3. OutboxEvent(eventType = 'ai.job.enqueue') * * 幂等性:AiJob 表的 @@unique([userId, jobType, idempotencyKey]) 保证 * 重复 (userId, jobType, idempotencyKey) 返回已有 Job,不重复创建。 * * 约束: * - 不向 BullMQ 直接入队(由 OutboxDispatcher 异步投递) * - 不调用 Provider * - 不创建业务结果 */ export interface CreateAiJobInput { userId: string; jobType: string; triggerType: 'user_api' | 'admin_manual' | 'system_scheduled' | 'parent_job'; targetType: string; targetId: string; /** 父 Job ID(Job 链场景) */ parentJobId?: string; /** 幂等 Key。提供时启用幂等;不提供时每次创建新 Job */ idempotencyKey?: string; /** 覆盖 Definition 默认值 */ overrides?: { priority?: number; credentialMode?: 'platform_key' | 'user_deepseek_key'; credentialId?: string; }; } @Injectable() export class AiJobCreationService { private readonly logger = new Logger(AiJobCreationService.name); constructor( private readonly prisma: PrismaService, private readonly lifecycleRepo: AiJobLifecycleRepository, private readonly registry: JobDefinitionRegistry, private readonly snapshotBuilder: SnapshotBuilderService, private readonly outboxRepo: OutboxRepository, ) {} /** * 创建 Job + Snapshot + Outbox(一个 Prisma Transaction 内)。 * * 幂等:若 (userId, jobType, idempotencyKey) 已存在,返回已有 Job。 */ async createJob(input: CreateAiJobInput) { // 1. Resolve Definition(fail-fast:未知 JobType 在此拦截) const def = this.registry.get(input.jobType); // 2. Validate triggerType(TypeScript 编译期 + 运行时双重保护) if (input.triggerType && !['user_api', 'admin_manual', 'system_scheduled', 'parent_job'].includes(input.triggerType)) { throw new BadRequestException(`Invalid triggerType: ${input.triggerType}`); } // 3. Build snapshot(事务外:依赖外部 DB 读取,不应占用事务时间) const snapshot = await this.snapshotBuilder.buildSnapshot( input.userId, input.targetType, input.targetId, ); const contentHash = this.computeHash(JSON.stringify(snapshot)); // 4. Transaction: Job + Snapshot + Outbox(含幂等保护) const createFn = async (tx: Prisma.TransactionClient) => { // 4a. Create AiJob const jobInput: CreateJobInput = { userId: input.userId, jobType: input.jobType, triggerType: input.triggerType, queueName: def.queue.queueName, targetType: input.targetType, targetId: input.targetId, parentJobId: input.parentJobId, idempotencyKey: input.idempotencyKey, priority: input.overrides?.priority ?? def.queue.defaultPriority, credentialMode: input.overrides?.credentialMode ?? def.credential.defaultMode, credentialId: input.overrides?.credentialId, modelProvider: def.model.modelProvider, modelName: def.model.modelName, promptKey: def.prompt.promptKey, promptVersion: def.prompt.promptVersion, outputSchemaVersion: def.output.schemaVersion, inputSchemaVersion: def.input.schemaVersion, maxAttempts: def.execution.maxRetries, timeoutMs: def.execution.timeoutMs, }; const job = await this.lifecycleRepo.createJob(tx, jobInput); // 4b. Create AiJobSnapshot await tx.aiJobSnapshot.create({ data: { jobId: job.id, jobType: input.jobType, schemaVersion: def.input.schemaVersion, redactionVersion: '1.0', content: snapshot as any, contentHash, }, }); // 4c. Create OutboxEvent const dedupeKey = `ai.job.enqueue:${job.id}`; await this.outboxRepo.createInTransaction(tx, { eventType: 'ai.job.enqueue', aggregateType: 'AiJob', aggregateId: job.id, dedupeKey, payload: { jobId: job.id }, availableAt: new Date(), }); return job; // job 在事务成功路径中使用,P2002 catch 路径中不用 }; try { const job = await this.prisma.$transaction(createFn); this.logger.log( `Job created: id=${job.id} type=${input.jobType} ` + `queue=${def.queue.queueName} snapshotHash=${contentHash}`, ); return job; } catch (err: any) { // 幂等:并发重复创建 → UNIQUE 冲突(P2002)→ 返回已有 Job if (err?.code === 'P2002' && input.idempotencyKey) { const existing = await this.prisma.aiJob.findUniqueOrThrow({ where: { userId_jobType_idempotencyKey: { userId: input.userId, jobType: input.jobType, idempotencyKey: input.idempotencyKey, }, }, }); this.logger.log( `Idempotent return (P2002): jobId=${existing.id} ` + `key=${input.idempotencyKey}`, ); return existing; } throw err; } } // ── helpers ── private computeHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16); } }