feat: M-AI-03 事务化 AiJob 创建、Snapshot 与 Enqueue Outbox
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 30s
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

- AiJobCreationService: 在一个 Prisma Transaction 内原子创建 Job+Snapshot+Outbox
- 幂等性: (userId, jobType, idempotencyKey) UNIQUE 约束防重复
- Job 元数据从 JobDefinition 写入 (queueName/priority/maxAttempts/timeoutMs 等)
- overrides 支持覆盖 priority/credentialMode/credentialId
- Outbox payload 仅含 {jobId},不含 userId/credentialId/snapshot
- 依赖 SnapshotBuilderService + OutboxRepository,AiJobModule 导入 AiRuntimeModule

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-20 18:13:15 +08:00
parent fcce35742f
commit f0183c03c4
3 changed files with 542 additions and 9 deletions

View File

@ -0,0 +1,357 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { AiJobCreationService } from './ai-job-creation.service';
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
import { JobDefinitionRegistry } from './job-definition-registry';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository';
import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service';
import { UnknownJobTypeError } from './job-definition-registry';
import { JobDefinition } from './job-definition.types';
/** 最小合法 Definition 工厂 */
function validDef(overrides?: Partial<JobDefinition>): JobDefinition {
return {
jobType: 'test_job',
metadata: { label: 'Test Job', description: '', domain: 'analysis', version: '1.0' },
queue: { queueName: 'ai-interactive', defaultPriority: 0 },
execution: { timeoutMs: 30000, maxRetries: 2, retryBackoff: { type: 'exponential', delay: 2000 }, cancellable: true, abortStrategy: 'fail' },
input: { schemaVersion: '1.0.0' },
output: { schemaVersion: '1.0.0' },
prompt: { promptKey: 'test', promptVersion: '1.0' },
model: { modelTier: 'primary', modelProvider: 'deepseek', modelName: 'deepseek-chat' },
credential: { allowedModes: ['platform_key'], defaultMode: 'platform_key' },
security: { contentSafetyCheck: false, outputRedaction: false },
...overrides,
};
}
describe('AiJobCreationService', () => {
let service: AiJobCreationService;
let prisma: any;
let lifecycleRepo: any;
let registry: any;
let snapshotBuilder: any;
let outboxRepo: any;
beforeEach(async () => {
registry = { get: jest.fn(), has: jest.fn() };
lifecycleRepo = { createJob: jest.fn() };
snapshotBuilder = { buildSnapshot: jest.fn() };
outboxRepo = { createInTransaction: jest.fn() };
prisma = {
aiJob: {
findUnique: jest.fn(),
},
aiJobSnapshot: { create: jest.fn() },
$transaction: jest.fn((fn: any) => fn(prisma)), // mock tx = same prisma
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiJobCreationService,
{ provide: PrismaService, useValue: prisma },
{ provide: AiJobLifecycleRepository, useValue: lifecycleRepo },
{ provide: JobDefinitionRegistry, useValue: registry },
{ provide: SnapshotBuilderService, useValue: snapshotBuilder },
{ provide: OutboxRepository, useValue: outboxRepo },
],
}).compile();
service = module.get(AiJobCreationService);
});
describe('事务化创建', () => {
it('在一个事务内创建 AiJob + Snapshot + Outbox', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({ userId: 'u1', profile: {} });
const mockJob = { id: 'job-001', lifecycleStatus: 'queued', jobType: 'test_job' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
const result = await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'knowledge_base',
targetId: 'kb-1',
});
// 验证事务调用
expect(prisma.$transaction).toHaveBeenCalled();
// Job created in tx
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
expect.any(Object), // the tx client
expect.objectContaining({
userId: 'u1',
jobType: 'test_job',
queueName: 'ai-interactive',
}),
);
// Snapshot created in tx
expect(prisma.aiJobSnapshot.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
jobId: 'job-001',
jobType: 'test_job',
contentHash: expect.any(String),
}),
}),
);
// Outbox created in tx
expect(outboxRepo.createInTransaction).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
eventType: 'ai.job.enqueue',
aggregateType: 'AiJob',
aggregateId: 'job-001',
dedupeKey: 'ai.job.enqueue:job-001',
payload: { jobId: 'job-001' },
}),
);
expect(result).toBe(mockJob);
});
it('事务中任一失败则全部回滚', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({ userId: 'u1' });
lifecycleRepo.createJob.mockResolvedValue({ id: 'job-001' });
// Snapshot create fails
prisma.aiJobSnapshot.create.mockRejectedValue(new Error('snapshot insert failed'));
await expect(
service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'knowledge_base',
targetId: 'kb-1',
}),
).rejects.toThrow('snapshot insert failed');
// Outbox must NOT be called (tx rolled back)
expect(outboxRepo.createInTransaction).not.toHaveBeenCalled();
});
});
describe('幂等性', () => {
it('相同 (userId, jobType, idempotencyKey) 返回已有 Job不创建新记录', async () => {
registry.get.mockReturnValue(validDef());
const existingJob = {
id: 'existing-job',
lifecycleStatus: 'succeeded',
jobType: 'test_job',
};
prisma.aiJob.findUnique.mockResolvedValue(existingJob);
const result = await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
idempotencyKey: 'idem-001',
});
expect(result).toBe(existingJob);
// 不应调用 snapshot、lifecycleRepo、transaction
expect(snapshotBuilder.buildSnapshot).not.toHaveBeenCalled();
expect(lifecycleRepo.createJob).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('未提供 idempotencyKey 时每次创建新 Job', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({});
const mockJob = { id: 'job-002' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
// 第一次创建
prisma.aiJob.findUnique.mockResolvedValue(null);
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
// no idempotencyKey
});
// 第二次创建(无幂等键,应再次创建)
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
// no idempotencyKey
});
expect(lifecycleRepo.createJob).toHaveBeenCalledTimes(2);
});
});
describe('Job 元数据从 Definition 写入', () => {
it('queueName / priority / timeout / maxAttempts / schema 等来自 Definition', async () => {
registry.get.mockReturnValue(
validDef({
queue: { queueName: 'ai-background', defaultPriority: 5 },
execution: { timeoutMs: 90000, maxRetries: 1, retryBackoff: { type: 'exponential', delay: 5000 }, cancellable: false, abortStrategy: 'retry' },
input: { schemaVersion: '2.0.0' },
output: { schemaVersion: '2.0.0' },
prompt: { promptKey: 'bg_prompt', promptVersion: '2.0' },
model: { modelTier: 'fallback', modelProvider: 'deepseek', modelName: 'deepseek-chat' },
}),
);
snapshotBuilder.buildSnapshot.mockResolvedValue({});
const mockJob = { id: 'bg-job' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
await service.createJob({
userId: 'u1',
jobType: 'bg_job',
triggerType: 'system_scheduled',
targetType: 'user',
targetId: 'u1',
});
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
queueName: 'ai-background',
priority: 5,
timeoutMs: 90000,
maxAttempts: 1,
inputSchemaVersion: '2.0.0',
outputSchemaVersion: '2.0.0',
promptVersion: '2.0',
modelProvider: 'deepseek',
modelName: 'deepseek-chat',
}),
);
});
it('overrides 覆盖 Definition 默认值', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({});
const mockJob = { id: 'override-job' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
overrides: {
priority: 99,
credentialMode: 'user_deepseek_key',
credentialId: 'cred-xyz',
},
});
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
priority: 99,
credentialMode: 'user_deepseek_key',
credentialId: 'cred-xyz',
}),
);
});
});
describe('输入校验', () => {
it('未知 jobType → UnknownJobTypeError', async () => {
registry.get.mockImplementation(() => {
throw new UnknownJobTypeError('bad_job');
});
await expect(
service.createJob({
userId: 'u1',
jobType: 'bad_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
}),
).rejects.toThrow(UnknownJobTypeError);
});
it('非法 triggerType → BadRequestException', async () => {
registry.get.mockReturnValue(validDef());
await expect(
service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'evil_hack' as any,
targetType: 'kb',
targetId: 'kb-1',
}),
).rejects.toThrow(BadRequestException);
});
});
describe('Outbox payload 约束', () => {
it('Outbox payload 仅含 {jobId}', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({ userId: 'u1' });
lifecycleRepo.createJob.mockResolvedValue({ id: 'job-minimal' });
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
});
expect(outboxRepo.createInTransaction).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
payload: { jobId: 'job-minimal' },
}),
);
// payload 不应包含 userId、credentialId 等敏感字段
const call = outboxRepo.createInTransaction.mock.calls[0][1];
expect(call.payload).not.toHaveProperty('userId');
expect(call.payload).not.toHaveProperty('credentialId');
expect(call.payload).not.toHaveProperty('snapshot');
});
});
describe('Snapshot', () => {
it('Snapshot 传入 buildSnapshot 产出的 content 和 contentHash', async () => {
const snapContent = { userId: 'u1', profile: { level: 5 }, settings: {} };
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue(snapContent);
lifecycleRepo.createJob.mockResolvedValue({ id: 'snap-job' });
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
});
expect(prisma.aiJobSnapshot.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
jobId: 'snap-job',
content: snapContent,
contentHash: expect.any(String),
schemaVersion: '1.0.0',
redactionVersion: '1.0',
}),
}),
);
});
});
});

View File

@ -0,0 +1,166 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { OutboxRepository, CreateOutboxInput } 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 input schema version consistency
if (input.triggerType && !['user_api', 'admin_manual', 'system_scheduled', 'parent_job'].includes(input.triggerType)) {
throw new BadRequestException(`Invalid triggerType: ${input.triggerType}`);
}
// 3. Idempotency checkfast-return before snapshot build
if (input.idempotencyKey) {
const existing = await this.prisma.aiJob.findUnique({
where: {
userId_jobType_idempotencyKey: {
userId: input.userId,
jobType: input.jobType,
idempotencyKey: input.idempotencyKey,
},
},
});
if (existing) {
this.logger.log(
`Idempotent return: jobId=${existing.id} ` +
`key=${input.idempotencyKey}`,
);
return existing;
}
}
// 4. Build snapshot事务外依赖外部 DB 读取,不应占用事务时间)
const snapshot = await this.snapshotBuilder.buildSnapshot(
input.userId,
input.targetType,
input.targetId,
);
const contentHash = this.computeHash(JSON.stringify(snapshot));
// 5. Transaction: Job + Snapshot + Outbox
const job = await this.prisma.$transaction(async (tx) => {
// 5a. 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,
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);
// 5b. 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,
},
});
// 5c. 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;
});
this.logger.log(
`Job created: id=${job.id} type=${input.jobType} ` +
`queue=${def.queue.queueName} snapshotHash=${contentHash}`,
);
return job;
}
// ── helpers ──
private computeHash(content: string): string {
const crypto = require('crypto');
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
}
}

View File

@ -1,22 +1,32 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository';
import { AiRuntimeModule } from '../ai-runtime/ai-runtime.module';
import { AiJobStateMachine } from './ai-job-state-machine';
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
import { JobDefinitionRegistry } from './job-definition-registry';
import { AiJobCreationService } from './ai-job-creation.service';
/**
* M-AI-03: AiJob Job Engine
*
* Job Definition Registry
* Issue#288#293
* - AiJobService
* - AiJobExecutionEngine
* - OutboxDispatcher
* - ResultProjector
* RegistryCreationService
* Issue#290#293OutboxDispatcherEngineProjector
*/
@Module({
imports: [PrismaModule],
providers: [AiJobStateMachine, AiJobLifecycleRepository, JobDefinitionRegistry],
exports: [AiJobStateMachine, AiJobLifecycleRepository, JobDefinitionRegistry],
imports: [PrismaModule, AiRuntimeModule],
providers: [
AiJobStateMachine,
AiJobLifecycleRepository,
JobDefinitionRegistry,
OutboxRepository,
AiJobCreationService,
],
exports: [
AiJobStateMachine,
AiJobLifecycleRepository,
JobDefinitionRegistry,
AiJobCreationService,
],
})
export class AiJobModule {}