feat: M-AI-03 Job Definition Registry 与注册校验
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 32s
Deploy API Server / current-integration (push) Successful in 28s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 58s

- 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:
wangdl 2026-06-20 17:24:57 +08:00
parent 4c736f3658
commit e4b922e7e4
4 changed files with 770 additions and 5 deletions

View File

@ -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 {}

View 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();
});
});
});

View 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 DefinitiononModuleInit
*
* 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;
}
/**
* DefinitionMap
*/
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');
}
}
}

View 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;
};
}