- 幂等保护从 findUnique 预检改为 P2002 catch(消除 TOCTOU 竞态窗口)
- AiJobLifecycleRepository: CreateJobInput 新增 promptKey,createJob 写入
- AiJobCreationService: 从 JobDefinition 传递 promptKey 到 Job
- computeHash: 内联 require('crypto') → 顶层 import * as crypto
- 测试更新: 幂等场景改为 P2002 冲突 → findUniqueOrThrow 返回已有 Job
Co-Authored-By: Claude <noreply@anthropic.com>
169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
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);
|
||
}
|
||
}
|