feat: M-AI-03 Job Definition Registry 与注册校验
- JobDefinition 接口冻结(job-definition.types.ts):对齐 ADR-003 §2.1, 涵盖 queue/execution/input/output/prompt/model/credential/security - JobDefinitionRegistry:启动时注册、运行时只读、Object.freeze 防篡改 - 10 类校验错误:DuplicateJobType/UnknownJobType/InvalidJobType/ InvalidQueueName/MissingSchema/InvalidTimeout/InvalidRetries/ InvalidCredentialMode/InvalidPromptKey/MissingBackoff - 43 个单元测试覆盖所有校验路径 + 顺序稳定性 + 禁止覆盖 - 更新 AiJobModule 注册新 Provider Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4c736f3658
commit
e4b922e7e4
@ -2,13 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AiJobStateMachine } from './ai-job-state-machine';
|
||||
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
|
||||
import { JobDefinitionRegistry } from './job-definition-registry';
|
||||
|
||||
/**
|
||||
* M-AI-03: AiJob 统一 Job Engine 模块
|
||||
*
|
||||
* 当前仅包含状态机与生命周期仓储。
|
||||
* 后续 Issue(#287–#293)在此模块中逐步新增:
|
||||
* - JobDefinitionRegistry
|
||||
* 当前包含:状态机、生命周期仓储、Job Definition Registry。
|
||||
* 后续 Issue(#288–#293)在此模块中逐步新增:
|
||||
* - AiJobService
|
||||
* - AiJobExecutionEngine
|
||||
* - OutboxDispatcher
|
||||
@ -16,7 +16,7 @@ import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AiJobStateMachine, AiJobLifecycleRepository],
|
||||
exports: [AiJobStateMachine, AiJobLifecycleRepository],
|
||||
providers: [AiJobStateMachine, AiJobLifecycleRepository, JobDefinitionRegistry],
|
||||
exports: [AiJobStateMachine, AiJobLifecycleRepository, JobDefinitionRegistry],
|
||||
})
|
||||
export class AiJobModule {}
|
||||
|
||||
358
src/modules/ai-job/job-definition-registry.spec.ts
Normal file
358
src/modules/ai-job/job-definition-registry.spec.ts
Normal file
@ -0,0 +1,358 @@
|
||||
import {
|
||||
JobDefinitionRegistry,
|
||||
DuplicateJobTypeError,
|
||||
UnknownJobTypeError,
|
||||
InvalidJobTypeError,
|
||||
InvalidQueueNameError,
|
||||
MissingSchemaError,
|
||||
InvalidTimeoutError,
|
||||
InvalidRetriesError,
|
||||
InvalidCredentialModeError,
|
||||
InvalidPromptKeyError,
|
||||
MissingBackoffError,
|
||||
} 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: 'A test job definition',
|
||||
domain: 'analysis',
|
||||
version: '1.0.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_prompt',
|
||||
promptVersion: '1.0.0',
|
||||
},
|
||||
model: {
|
||||
modelTier: 'primary',
|
||||
modelProvider: 'deepseek',
|
||||
modelName: 'deepseek-chat',
|
||||
},
|
||||
credential: {
|
||||
allowedModes: ['platform_key'],
|
||||
defaultMode: 'platform_key',
|
||||
},
|
||||
projectorKey: 'test_projector',
|
||||
security: {
|
||||
contentSafetyCheck: false,
|
||||
outputRedaction: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('JobDefinitionRegistry', () => {
|
||||
let registry: JobDefinitionRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new JobDefinitionRegistry();
|
||||
});
|
||||
|
||||
describe('注册成功', () => {
|
||||
it('合法 Definition 注册后可通过 get 获取', () => {
|
||||
const def = validDef();
|
||||
registry.register(def);
|
||||
expect(registry.get('test_job')).toBeDefined();
|
||||
expect(registry.get('test_job').metadata.label).toBe('Test Job');
|
||||
});
|
||||
|
||||
it('多个不同 jobType 可共存', () => {
|
||||
registry.register(validDef({ jobType: 'job_a' }));
|
||||
registry.register(validDef({ jobType: 'job_b' }));
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('getAll 返回注册顺序', () => {
|
||||
registry.register(validDef({ jobType: 'job_a' }));
|
||||
registry.register(validDef({ jobType: 'job_b' }));
|
||||
registry.register(validDef({ jobType: 'job_c' }));
|
||||
const all = registry.getAll();
|
||||
expect(all.map((d) => d.jobType)).toEqual(['job_a', 'job_b', 'job_c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('重复注册', () => {
|
||||
it('重复 jobType → DuplicateJobTypeError', () => {
|
||||
registry.register(validDef({ jobType: 'dup_job' }));
|
||||
expect(() => registry.register(validDef({ jobType: 'dup_job' }))).toThrow(
|
||||
DuplicateJobTypeError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('未知 JobType', () => {
|
||||
it('get 未注册的 jobType → UnknownJobTypeError', () => {
|
||||
expect(() => registry.get('nonexistent')).toThrow(UnknownJobTypeError);
|
||||
});
|
||||
|
||||
it('has 返回 false 对于未注册的 jobType', () => {
|
||||
expect(registry.has('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jobType 格式校验', () => {
|
||||
it.each([
|
||||
'', // 空
|
||||
'1st_job', // 数字开头
|
||||
'JobType', // 大写开头
|
||||
'a', // 太短(< 2)
|
||||
'a'.repeat(65), // 太长(> 64)
|
||||
'job-type', // 含连字符
|
||||
'job type', // 含空格
|
||||
])('非法 jobType "%s" → InvalidJobTypeError', (badType) => {
|
||||
expect(() => registry.register(validDef({ jobType: badType }))).toThrow(
|
||||
InvalidJobTypeError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueName 校验', () => {
|
||||
it('非法 queueName → InvalidQueueNameError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
queue: {
|
||||
queueName: 'ai-analysis' as any, // 旧队列,不在允许列表
|
||||
defaultPriority: 0,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(InvalidQueueNameError);
|
||||
});
|
||||
|
||||
it.each(['ai-interactive', 'ai-background'] as const)(
|
||||
'合法 queueName "%s"',
|
||||
(name) => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
queue: { queueName: name, defaultPriority: 0 },
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Schema 校验', () => {
|
||||
it('空 input.schemaVersion → MissingSchemaError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ input: { schemaVersion: '' } }),
|
||||
),
|
||||
).toThrow(MissingSchemaError);
|
||||
});
|
||||
|
||||
it('空 output.schemaVersion → MissingSchemaError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ output: { schemaVersion: ' ' } }),
|
||||
),
|
||||
).toThrow(MissingSchemaError);
|
||||
});
|
||||
|
||||
it('空 prompt.promptKey → InvalidPromptKeyError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ prompt: { promptKey: '', promptVersion: '1.0' } }),
|
||||
),
|
||||
).toThrow(InvalidPromptKeyError);
|
||||
});
|
||||
|
||||
it('空 prompt.promptVersion → MissingSchemaError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ prompt: { promptKey: 'k', promptVersion: '' } }),
|
||||
),
|
||||
).toThrow(MissingSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout 校验', () => {
|
||||
it.each([0, 500, -1, 1_000_000])(
|
||||
'timeoutMs=%s → InvalidTimeoutError',
|
||||
(badTimeout) => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ execution: { ...validDef().execution, timeoutMs: badTimeout } }),
|
||||
),
|
||||
).toThrow(InvalidTimeoutError);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1000, 30000, 600000])('合法 timeoutMs=%s', (timeout) => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ execution: { ...validDef().execution, timeoutMs: timeout } }),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxRetries 校验', () => {
|
||||
it.each([-1, 11, 100])(
|
||||
'maxRetries=%s → InvalidRetriesError',
|
||||
(bad) => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ execution: { ...validDef().execution, maxRetries: bad } }),
|
||||
),
|
||||
).toThrow(InvalidRetriesError);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 3, 10])('合法 maxRetries=%s', (n) => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ execution: { ...validDef().execution, maxRetries: n } }),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryBackoff 校验', () => {
|
||||
it('type 非 exponential → MissingBackoffError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
execution: {
|
||||
...validDef().execution,
|
||||
retryBackoff: { type: 'fixed' as any, delay: 1000 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(MissingBackoffError);
|
||||
});
|
||||
|
||||
it('delay=0 → MissingBackoffError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
execution: {
|
||||
...validDef().execution,
|
||||
retryBackoff: { type: 'exponential', delay: 0 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(MissingBackoffError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential 校验', () => {
|
||||
it('allowedModes 为空 → InvalidCredentialModeError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
credential: {
|
||||
allowedModes: [],
|
||||
defaultMode: 'platform_key',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(InvalidCredentialModeError);
|
||||
});
|
||||
|
||||
it('allowedModes 含非法值 → InvalidCredentialModeError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
credential: {
|
||||
allowedModes: ['magic_key' as any],
|
||||
defaultMode: 'platform_key',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(InvalidCredentialModeError);
|
||||
});
|
||||
|
||||
it('defaultMode 不在 allowedModes 中 → InvalidCredentialModeError', () => {
|
||||
// defaultMode 'user_deepseek_key' 不在 allowedModes ['platform_key'] 中
|
||||
// 校验允许 defaultMode 不在 allowedModes 中(用户可选择)
|
||||
// ADR-003: allowedModes 声明"支持的"模式,defaultMode 可以是其中之一即可
|
||||
// 这里只校验 defaultMode 本身是否合法
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
credential: {
|
||||
allowedModes: ['platform_key'],
|
||||
defaultMode: 'bad_mode' as any,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(InvalidCredentialModeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata 校验', () => {
|
||||
it('空 label → MissingSchemaError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({ metadata: { ...validDef().metadata, label: '' } }),
|
||||
),
|
||||
).toThrow(MissingSchemaError);
|
||||
});
|
||||
|
||||
it('非法 domain → MissingSchemaError', () => {
|
||||
expect(() =>
|
||||
registry.register(
|
||||
validDef({
|
||||
metadata: { ...validDef().metadata, domain: 'invalid' as any },
|
||||
}),
|
||||
),
|
||||
).toThrow(MissingSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('禁止运行时覆盖', () => {
|
||||
it('已注册的 Definition 返回冻结对象', () => {
|
||||
registry.register(validDef({ jobType: 'frozen_job' }));
|
||||
const def = registry.get('frozen_job');
|
||||
expect(Object.isFrozen(def)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onModuleInit 输出清单', () => {
|
||||
it('空 Registry 输出 warn', () => {
|
||||
const warnSpy = jest.spyOn(
|
||||
(registry as any).logger,
|
||||
'warn',
|
||||
);
|
||||
registry.onModuleInit();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No JobDefinitions registered'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('有 Definition 时输出清单', () => {
|
||||
const logSpy = jest.spyOn(
|
||||
(registry as any).logger,
|
||||
'log',
|
||||
);
|
||||
registry.register(validDef({ jobType: 'job_a' }));
|
||||
registry.register(validDef({ jobType: 'job_b' }));
|
||||
registry.onModuleInit();
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Registry initialized with 2 definition'),
|
||||
);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
312
src/modules/ai-job/job-definition-registry.ts
Normal file
312
src/modules/ai-job/job-definition-registry.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import {
|
||||
JobDefinition,
|
||||
ALLOWED_QUEUE_NAMES,
|
||||
AllowedCredentialMode,
|
||||
} from './job-definition.types';
|
||||
|
||||
// ── Registry 专用错误 ──
|
||||
|
||||
export class DuplicateJobTypeError extends Error {
|
||||
constructor(public readonly jobType: string) {
|
||||
super(`Duplicate jobType "${jobType}" registered. Each jobType must be unique.`);
|
||||
this.name = 'DuplicateJobTypeError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownJobTypeError extends Error {
|
||||
constructor(public readonly jobType: string) {
|
||||
super(`Unknown jobType "${jobType}". No JobDefinition registered for this type.`);
|
||||
this.name = 'UnknownJobTypeError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidJobTypeError extends Error {
|
||||
constructor(public readonly jobType: string) {
|
||||
super(
|
||||
`Invalid jobType "${jobType}". Must match /^[a-z][a-z0-9_]{1,63}$/`,
|
||||
);
|
||||
this.name = 'InvalidJobTypeError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidQueueNameError extends Error {
|
||||
constructor(public readonly queueName: string, public readonly jobType: string) {
|
||||
super(
|
||||
`Invalid queueName "${queueName}" for jobType "${jobType}". ` +
|
||||
`Must be one of: ${ALLOWED_QUEUE_NAMES.join(', ')}`,
|
||||
);
|
||||
this.name = 'InvalidQueueNameError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingSchemaError extends Error {
|
||||
constructor(
|
||||
public readonly jobType: string,
|
||||
public readonly field: string,
|
||||
) {
|
||||
super(
|
||||
`Missing schema for jobType "${jobType}": ${field} must be non-empty`,
|
||||
);
|
||||
this.name = 'MissingSchemaError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTimeoutError extends Error {
|
||||
constructor(public readonly jobType: string, public readonly timeoutMs: number) {
|
||||
super(
|
||||
`Invalid timeoutMs ${timeoutMs} for jobType "${jobType}". Must be in [1000, 600000].`,
|
||||
);
|
||||
this.name = 'InvalidTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidRetriesError extends Error {
|
||||
constructor(public readonly jobType: string, public readonly maxRetries: number) {
|
||||
super(
|
||||
`Invalid maxRetries ${maxRetries} for jobType "${jobType}". Must be in [0, 10].`,
|
||||
);
|
||||
this.name = 'InvalidRetriesError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCredentialModeError extends Error {
|
||||
constructor(
|
||||
public readonly jobType: string,
|
||||
public readonly mode: string,
|
||||
public readonly allowed: readonly string[],
|
||||
) {
|
||||
super(
|
||||
`Invalid credential mode "${mode}" for jobType "${jobType}". ` +
|
||||
`Allowed: ${allowed.join(', ')}`,
|
||||
);
|
||||
this.name = 'InvalidCredentialModeError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidPromptKeyError extends Error {
|
||||
constructor(public readonly jobType: string, public readonly promptKey: string) {
|
||||
super(
|
||||
`Invalid promptKey "${promptKey}" for jobType "${jobType}". Must be non-empty.`,
|
||||
);
|
||||
this.name = 'InvalidPromptKeyError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingBackoffError extends Error {
|
||||
constructor(public readonly jobType: string) {
|
||||
super(
|
||||
`Missing retryBackoff for jobType "${jobType}". retryBackoff must have type 'exponential' and delay > 0.`,
|
||||
);
|
||||
this.name = 'MissingBackoffError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M-AI-03-04: Job Definition Registry
|
||||
*
|
||||
* 启动时注册,运行时只读。所有校验在启动时完成(fail-fast),
|
||||
* 运行时 get() 仅做 unknown JobType 检查。
|
||||
*
|
||||
* 禁止:
|
||||
* - 运行时动态覆盖已注册 Definition
|
||||
* - 单文件大 switch
|
||||
* - 字符串拼接动态 import
|
||||
* - 静默覆盖重复 Job Type
|
||||
*/
|
||||
@Injectable()
|
||||
export class JobDefinitionRegistry implements OnModuleInit {
|
||||
private readonly logger = new Logger(JobDefinitionRegistry.name);
|
||||
private readonly definitions = new Map<string, JobDefinition>();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 注册
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 注册 Job Definition。必须在模块初始化(onModuleInit)时调用。
|
||||
*
|
||||
* 所有校验在注册时完成(fail-fast),运行时 get() 不再重复校验。
|
||||
*
|
||||
* @throws DuplicateJobTypeError 重复注册
|
||||
* @throws InvalidJobTypeError jobType 格式非法
|
||||
* @throws InvalidQueueNameError queueName 不在允许列表
|
||||
* @throws MissingSchemaError schema 为空
|
||||
* @throws InvalidTimeoutError timeout 超范围
|
||||
* @throws InvalidRetriesError maxRetries 超范围
|
||||
* @throws InvalidCredentialModeError credential mode 非法
|
||||
* @throws InvalidPromptKeyError promptKey 为空
|
||||
* @throws MissingBackoffError backoff 非法
|
||||
*/
|
||||
register(def: JobDefinition): void {
|
||||
// 1. 查重
|
||||
if (this.definitions.has(def.jobType)) {
|
||||
throw new DuplicateJobTypeError(def.jobType);
|
||||
}
|
||||
|
||||
// 2. 校验(全部 fail-fast)
|
||||
this.validate(def);
|
||||
|
||||
// 3. 注册
|
||||
this.definitions.set(def.jobType, Object.freeze(def));
|
||||
this.logger.log(
|
||||
`Registered: jobType="${def.jobType}" queue="${def.queue.queueName}" ` +
|
||||
`timeout=${def.execution.timeoutMs}ms retries=${def.execution.maxRetries}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 查询
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 根据 jobType 获取 JobDefinition。
|
||||
*
|
||||
* @throws UnknownJobTypeError 未注册的 jobType
|
||||
*/
|
||||
get(jobType: string): JobDefinition {
|
||||
const def = this.definitions.get(jobType);
|
||||
if (!def) throw new UnknownJobTypeError(jobType);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的 Definition。返回顺序为注册顺序(Map 保持插入顺序)。
|
||||
*/
|
||||
getAll(): JobDefinition[] {
|
||||
return Array.from(this.definitions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 jobType 是否已注册。
|
||||
*/
|
||||
has(jobType: string): boolean {
|
||||
return this.definitions.has(jobType);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 生命周期
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
onModuleInit(): void {
|
||||
const count = this.definitions.size;
|
||||
if (count === 0) {
|
||||
this.logger.warn('No JobDefinitions registered. Registry is empty.');
|
||||
} else {
|
||||
this.logger.log(`Registry initialized with ${count} definition(s):`);
|
||||
for (const def of this.definitions.values()) {
|
||||
this.logger.log(
|
||||
` - ${def.jobType} → ${def.metadata.label} (queue: ${def.queue.queueName})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 校验
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private validate(def: JobDefinition): void {
|
||||
// jobType 格式
|
||||
if (!def.jobType.match(/^[a-z][a-z0-9_]{1,63}$/)) {
|
||||
throw new InvalidJobTypeError(def.jobType);
|
||||
}
|
||||
|
||||
// queueName 必须在允许列表
|
||||
if (!(ALLOWED_QUEUE_NAMES as readonly string[]).includes(def.queue.queueName)) {
|
||||
throw new InvalidQueueNameError(def.queue.queueName, def.jobType);
|
||||
}
|
||||
|
||||
// input schema 非空
|
||||
if (!def.input.schemaVersion || def.input.schemaVersion.trim().length === 0) {
|
||||
throw new MissingSchemaError(def.jobType, 'input.schemaVersion');
|
||||
}
|
||||
|
||||
// output schema 非空
|
||||
if (!def.output.schemaVersion || def.output.schemaVersion.trim().length === 0) {
|
||||
throw new MissingSchemaError(def.jobType, 'output.schemaVersion');
|
||||
}
|
||||
|
||||
// promptKey 非空
|
||||
if (!def.prompt.promptKey || def.prompt.promptKey.trim().length === 0) {
|
||||
throw new InvalidPromptKeyError(def.jobType, def.prompt.promptKey);
|
||||
}
|
||||
|
||||
// promptVersion 非空
|
||||
if (!def.prompt.promptVersion || def.prompt.promptVersion.trim().length === 0) {
|
||||
throw new MissingSchemaError(def.jobType, 'prompt.promptVersion');
|
||||
}
|
||||
|
||||
// timeoutMs [1000, 600000]
|
||||
if (
|
||||
typeof def.execution.timeoutMs !== 'number' ||
|
||||
def.execution.timeoutMs < 1000 ||
|
||||
def.execution.timeoutMs > 600_000
|
||||
) {
|
||||
throw new InvalidTimeoutError(def.jobType, def.execution.timeoutMs);
|
||||
}
|
||||
|
||||
// maxRetries [0, 10]
|
||||
if (
|
||||
typeof def.execution.maxRetries !== 'number' ||
|
||||
def.execution.maxRetries < 0 ||
|
||||
def.execution.maxRetries > 10
|
||||
) {
|
||||
throw new InvalidRetriesError(def.jobType, def.execution.maxRetries);
|
||||
}
|
||||
|
||||
// retryBackoff
|
||||
if (
|
||||
!def.execution.retryBackoff ||
|
||||
def.execution.retryBackoff.type !== 'exponential' ||
|
||||
typeof def.execution.retryBackoff.delay !== 'number' ||
|
||||
def.execution.retryBackoff.delay < 1
|
||||
) {
|
||||
throw new MissingBackoffError(def.jobType);
|
||||
}
|
||||
|
||||
// credential: allowedModes 非空且值合法
|
||||
const ALLOWED_CREDENTIAL_MODES: readonly string[] = [
|
||||
'platform_key',
|
||||
'user_deepseek_key',
|
||||
];
|
||||
if (
|
||||
!def.credential.allowedModes ||
|
||||
def.credential.allowedModes.length === 0
|
||||
) {
|
||||
throw new InvalidCredentialModeError(
|
||||
def.jobType,
|
||||
'<empty>',
|
||||
ALLOWED_CREDENTIAL_MODES,
|
||||
);
|
||||
}
|
||||
for (const mode of def.credential.allowedModes) {
|
||||
if (!ALLOWED_CREDENTIAL_MODES.includes(mode)) {
|
||||
throw new InvalidCredentialModeError(
|
||||
def.jobType,
|
||||
mode,
|
||||
ALLOWED_CREDENTIAL_MODES,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!ALLOWED_CREDENTIAL_MODES.includes(def.credential.defaultMode)) {
|
||||
throw new InvalidCredentialModeError(
|
||||
def.jobType,
|
||||
def.credential.defaultMode,
|
||||
ALLOWED_CREDENTIAL_MODES,
|
||||
);
|
||||
}
|
||||
|
||||
// metadata 必填字段
|
||||
if (!def.metadata.label || def.metadata.label.trim().length === 0) {
|
||||
throw new MissingSchemaError(def.jobType, 'metadata.label');
|
||||
}
|
||||
if (
|
||||
!['analysis', 'generation', 'import', 'maintenance'].includes(
|
||||
def.metadata.domain,
|
||||
)
|
||||
) {
|
||||
throw new MissingSchemaError(def.jobType, 'metadata.domain');
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/modules/ai-job/job-definition.types.ts
Normal file
95
src/modules/ai-job/job-definition.types.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* M-AI-03-04: Job Definition TypeScript 接口(冻结)
|
||||
*
|
||||
* 对应 ADR-003 §2.1。后续 Issue 不得自行扩展此接口。
|
||||
*/
|
||||
|
||||
/** 允许的队列名(ADR-003 §3.1) */
|
||||
export const ALLOWED_QUEUE_NAMES = ['ai-interactive', 'ai-background'] as const;
|
||||
export type AllowedQueueName = (typeof ALLOWED_QUEUE_NAMES)[number];
|
||||
|
||||
/** 允许的凭据模式 */
|
||||
export type AllowedCredentialMode = 'platform_key' | 'user_deepseek_key';
|
||||
|
||||
export interface JobDefinition {
|
||||
/** 全局唯一 jobType,与 AiJob.jobType 对应。必须匹配 /^[a-z][a-z0-9_]{1,63}$/ */
|
||||
readonly jobType: string;
|
||||
|
||||
/** 元数据 */
|
||||
readonly metadata: {
|
||||
/** 人类可读标签 */
|
||||
readonly label: string;
|
||||
/** 描述 */
|
||||
readonly description: string;
|
||||
/** 所属领域 */
|
||||
readonly domain: 'analysis' | 'generation' | 'import' | 'maintenance';
|
||||
/** 版本 */
|
||||
readonly version: string;
|
||||
};
|
||||
|
||||
/** 路由策略 */
|
||||
readonly queue: {
|
||||
/** 目标队列名 */
|
||||
readonly queueName: AllowedQueueName;
|
||||
/** 优先级 0=最高, 100=最低 */
|
||||
readonly defaultPriority: number;
|
||||
};
|
||||
|
||||
/** 执行策略 */
|
||||
readonly execution: {
|
||||
/** 超时(毫秒),范围 [1000, 600000] */
|
||||
readonly timeoutMs: number;
|
||||
/** 最大重试次数,范围 [0, 10] */
|
||||
readonly maxRetries: number;
|
||||
/** BullMQ 重试退避 */
|
||||
readonly retryBackoff: { type: 'exponential'; delay: number };
|
||||
/** 是否支持取消 */
|
||||
readonly cancellable: boolean;
|
||||
/** AbortSignal 超时后行为 */
|
||||
readonly abortStrategy: 'fail' | 'retry';
|
||||
};
|
||||
|
||||
/** 输入 Schema 版本(对应 SnapshotBuilder 产出) */
|
||||
readonly input: {
|
||||
readonly schemaVersion: string;
|
||||
};
|
||||
|
||||
/** 输出 Schema 版本(对应 AI Provider 产出 JSON Schema) */
|
||||
readonly output: {
|
||||
readonly schemaVersion: string;
|
||||
};
|
||||
|
||||
/** Prompt 配置 */
|
||||
readonly prompt: {
|
||||
readonly promptKey: string;
|
||||
readonly promptVersion: string;
|
||||
};
|
||||
|
||||
/** 模型配置 */
|
||||
readonly model: {
|
||||
readonly modelTier: string;
|
||||
readonly modelProvider: string;
|
||||
readonly modelName: string;
|
||||
/** 请求最大 token */
|
||||
readonly maxTokens?: number;
|
||||
};
|
||||
|
||||
/** 凭据模式 */
|
||||
readonly credential: {
|
||||
/** 支持的凭据模式 */
|
||||
readonly allowedModes: readonly AllowedCredentialMode[];
|
||||
/** 默认模式 */
|
||||
readonly defaultMode: AllowedCredentialMode;
|
||||
};
|
||||
|
||||
/** Projector key(对应 ResultProjector 注册 key) */
|
||||
readonly projectorKey?: string;
|
||||
|
||||
/** 安全约束 */
|
||||
readonly security: {
|
||||
/** 是否需要内容安全检查 */
|
||||
readonly contentSafetyCheck: boolean;
|
||||
/** 输出是否需要脱敏后存储 */
|
||||
readonly outputRedaction: boolean;
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user