api-server/src/modules/ai-job/ai-job-creation.service.ts
wangdl 471aa2b872
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 29s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
fix: #289 检修 — TOCTOU 幂等 + promptKey 传递 + require 清理
- 幂等保护从 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>
2026-06-20 18:22:39 +08:00

169 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. AiJoblifecycleStatus = 'queued'
* 2. AiJobSnapshot不可变输入快照
* 3. OutboxEventeventType = '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 IDJob 链场景) */
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 Definitionfail-fast未知 JobType 在此拦截)
const def = this.registry.get(input.jobType);
// 2. Validate triggerTypeTypeScript 编译期 + 运行时双重保护)
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);
}
}