feat: 增加QQBot插件定时任务manifest契约
This commit is contained in:
parent
24b63c7332
commit
250bb654f0
@ -0,0 +1,2 @@
|
||||
export * from './qqbot-plugin-task-cron.validator';
|
||||
export * from './qqbot-plugin-task.types';
|
||||
@ -0,0 +1,28 @@
|
||||
import { throwVbenError } from '@/common';
|
||||
|
||||
const fieldPattern = /^[\d*/,\-]+$/;
|
||||
|
||||
export function normalizeQqbotPluginTaskCron(input: unknown): string {
|
||||
const value = `${input || ''}`.trim().replace(/\s+/g, ' ');
|
||||
const fields = value.split(' ').filter(Boolean);
|
||||
if (fields.length !== 5) {
|
||||
throw new Error('定时任务 cron 必须是 5 段表达式');
|
||||
}
|
||||
if (!fields.every((field) => fieldPattern.test(field))) {
|
||||
throw new Error('定时任务 cron 只能包含数字、星号、斜杠、逗号和横线');
|
||||
}
|
||||
if (fields[0] === '*') {
|
||||
throw new Error('定时任务 cron 不允许每分钟执行');
|
||||
}
|
||||
return fields.join(' ');
|
||||
}
|
||||
|
||||
export function requireQqbotPluginTaskCron(input: unknown): string {
|
||||
try {
|
||||
return normalizeQqbotPluginTaskCron(input);
|
||||
} catch (error) {
|
||||
throwVbenError(
|
||||
error instanceof Error ? error.message : '定时任务 cron 不合法',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
export type QqbotPluginTaskRuntimeStatus =
|
||||
| 'disabled'
|
||||
| 'failed'
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'scheduled';
|
||||
|
||||
export type QqbotPluginTaskRunStatus =
|
||||
| 'failed'
|
||||
| 'running'
|
||||
| 'skipped'
|
||||
| 'success';
|
||||
|
||||
export type QqbotPluginTaskTriggerType = 'bootstrap' | 'manual' | 'schedule';
|
||||
|
||||
export type QqbotPluginTaskPageQuery = {
|
||||
enabled?: boolean | string;
|
||||
pageNo?: number | string;
|
||||
pageSize?: number | string;
|
||||
pluginId?: string;
|
||||
pluginKey?: string;
|
||||
status?: QqbotPluginTaskRuntimeStatus;
|
||||
taskKey?: string;
|
||||
};
|
||||
|
||||
export type QqbotPluginTaskRunPageQuery = {
|
||||
endTime?: string;
|
||||
pageNo?: number | string;
|
||||
pageSize?: number | string;
|
||||
startTime?: string;
|
||||
status?: QqbotPluginTaskRunStatus;
|
||||
triggerType?: QqbotPluginTaskTriggerType;
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
import * as path from 'path';
|
||||
import { normalizeQqbotPluginTaskCron } from '../../application/task';
|
||||
import {
|
||||
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
|
||||
QQBOT_PLUGIN_WORKER_TYPES,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
type QqbotPluginOperationManifest,
|
||||
type QqbotPluginPermission,
|
||||
type QqbotPluginRuntimeManifest,
|
||||
type QqbotPluginTaskManifest,
|
||||
type QqbotPluginWorkerType,
|
||||
} from './manifest.types';
|
||||
|
||||
@ -317,6 +319,74 @@ const parseEvents = (
|
||||
});
|
||||
};
|
||||
|
||||
const parseTasks = (
|
||||
source: Record<string, unknown>,
|
||||
issues: QqbotPluginManifestValidationIssue[],
|
||||
): QqbotPluginTaskManifest[] => {
|
||||
const tasks = Array.isArray(source.tasks) ? source.tasks : [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
return tasks.filter(isPlainObject).map((task, index) => {
|
||||
const pathPrefix = `tasks[${index}]`;
|
||||
const key = getString(task, 'key') || '';
|
||||
const timeoutMs = getNumber(task, 'timeoutMs');
|
||||
let defaultCron = getString(task, 'defaultCron') || '';
|
||||
|
||||
requireKey(key, `${pathPrefix}.key`, issues);
|
||||
if (seenKeys.has(key)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'DUPLICATE_TASK_KEY',
|
||||
pathPrefix,
|
||||
`Duplicate task key: ${key}.`,
|
||||
);
|
||||
}
|
||||
seenKeys.add(key);
|
||||
|
||||
if (!getString(task, 'handlerName')) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'MISSING_TASK_HANDLER',
|
||||
`${pathPrefix}.handlerName`,
|
||||
'Task handlerName is required.',
|
||||
);
|
||||
}
|
||||
if (!timeoutMs) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'MISSING_TASK_TIMEOUT',
|
||||
`${pathPrefix}.timeoutMs`,
|
||||
'Task timeoutMs is required.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
defaultCron = normalizeQqbotPluginTaskCron(defaultCron);
|
||||
} catch (error) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'INVALID_TASK_CRON',
|
||||
`${pathPrefix}.defaultCron`,
|
||||
error instanceof Error ? error.message : 'Task cron is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
defaultCron,
|
||||
description: getString(task, 'description'),
|
||||
enabled: task.enabled !== false,
|
||||
handlerName: getString(task, 'handlerName') || '',
|
||||
key,
|
||||
name: getString(task, 'name') || key,
|
||||
permissions: normalizePermissions(
|
||||
task.permissions,
|
||||
`${pathPrefix}.permissions`,
|
||||
issues,
|
||||
),
|
||||
timeoutMs: timeoutMs || 1000,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const parseAssets = (
|
||||
source: Record<string, unknown>,
|
||||
issues: QqbotPluginManifestValidationIssue[],
|
||||
@ -435,6 +505,7 @@ export const parseQqbotPluginManifest = (
|
||||
),
|
||||
pluginKey,
|
||||
runtime: parseRuntime(manifestLike, issues),
|
||||
tasks: parseTasks(manifestLike, issues),
|
||||
version,
|
||||
};
|
||||
|
||||
|
||||
@ -47,6 +47,17 @@ export type QqbotPluginEventManifest = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type QqbotPluginTaskManifest = {
|
||||
defaultCron: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
handlerName: string;
|
||||
key: string;
|
||||
name: string;
|
||||
permissions: QqbotPluginPermission[];
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type QqbotPluginAssetManifest = {
|
||||
contentHash?: string;
|
||||
key: string;
|
||||
@ -75,6 +86,7 @@ export type QqbotPluginManifest = {
|
||||
permissions: QqbotPluginPermission[];
|
||||
pluginKey: string;
|
||||
runtime: QqbotPluginRuntimeManifest;
|
||||
tasks: QqbotPluginTaskManifest[];
|
||||
version: string;
|
||||
};
|
||||
|
||||
|
||||
103
test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Normal file
103
test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
parseQqbotPluginManifest,
|
||||
QqbotPluginManifestValidationError,
|
||||
} from '../../../../src/modules/qqbot/plugin-platform/domain/manifest';
|
||||
import { normalizeQqbotPluginTaskCron } from '../../../../src/modules/qqbot/plugin-platform/application/task';
|
||||
|
||||
const createManifestWithTask = () => ({
|
||||
assets: [],
|
||||
configSchema: { type: 'object' },
|
||||
entry: 'src/index.ts',
|
||||
events: [],
|
||||
legacyAliases: [],
|
||||
migrations: [],
|
||||
minApiSdkVersion: '1.0.0',
|
||||
name: 'BangDream',
|
||||
operations: [],
|
||||
permissions: ['runtime.http', 'plugin.storage.read', 'plugin.storage.write'],
|
||||
pluginKey: 'bangdream',
|
||||
runtime: {
|
||||
maxConcurrency: 1,
|
||||
memoryMb: 512,
|
||||
timeoutMs: 30000,
|
||||
workerType: 'node-worker',
|
||||
},
|
||||
tasks: [
|
||||
{
|
||||
defaultCron: '0 */6 * * *',
|
||||
description: '同步 BangDream 主数据',
|
||||
enabled: true,
|
||||
handlerName: 'syncBestdoriMainData',
|
||||
key: 'bangdream.bestdori.sync-main-data',
|
||||
name: '同步 Bestdori 主数据',
|
||||
permissions: [
|
||||
'runtime.http',
|
||||
'plugin.storage.read',
|
||||
'plugin.storage.write',
|
||||
],
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
],
|
||||
version: '2.0.0',
|
||||
});
|
||||
|
||||
describe('QQBot plugin task manifest contract', () => {
|
||||
it('parses manifest tasks and normalizes cron whitespace', () => {
|
||||
const manifest = createManifestWithTask();
|
||||
manifest.tasks[0].defaultCron = ' 0 */6 * * * ';
|
||||
|
||||
const parsed = parseQqbotPluginManifest(manifest);
|
||||
|
||||
expect(parsed.tasks).toEqual([
|
||||
expect.objectContaining({
|
||||
defaultCron: '0 */6 * * *',
|
||||
enabled: true,
|
||||
handlerName: 'syncBestdoriMainData',
|
||||
key: 'bangdream.bestdori.sync-main-data',
|
||||
timeoutMs: 120000,
|
||||
}),
|
||||
]);
|
||||
expect(normalizeQqbotPluginTaskCron('0 */6 * * *')).toBe('0 */6 * * *');
|
||||
});
|
||||
|
||||
it('rejects invalid task metadata', () => {
|
||||
const manifest = createManifestWithTask();
|
||||
manifest.tasks.push({
|
||||
...manifest.tasks[0],
|
||||
handlerName: 'syncBestdoriMainDataAgain',
|
||||
});
|
||||
manifest.tasks.push({
|
||||
...manifest.tasks[0],
|
||||
handlerName: '',
|
||||
key: 'BangDream.Bad',
|
||||
permissions: ['host.fs.read'],
|
||||
timeoutMs: undefined,
|
||||
} as any);
|
||||
|
||||
expect(() => parseQqbotPluginManifest(manifest)).toThrow(
|
||||
QqbotPluginManifestValidationError,
|
||||
);
|
||||
try {
|
||||
parseQqbotPluginManifest(manifest);
|
||||
} catch (error) {
|
||||
expect((error as QqbotPluginManifestValidationError).issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'DUPLICATE_TASK_KEY' }),
|
||||
expect.objectContaining({ code: 'INVALID_CAPABILITY_KEY' }),
|
||||
expect.objectContaining({ code: 'MISSING_TASK_HANDLER' }),
|
||||
expect.objectContaining({ code: 'MISSING_TASK_TIMEOUT' }),
|
||||
expect.objectContaining({ code: 'UNKNOWN_PERMISSION' }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects six-field cron and too-frequent task cron', () => {
|
||||
expect(() => normalizeQqbotPluginTaskCron('* * * * * *')).toThrow(
|
||||
'定时任务 cron 必须是 5 段表达式',
|
||||
);
|
||||
expect(() => normalizeQqbotPluginTaskCron('* * * * *')).toThrow(
|
||||
'定时任务 cron 不允许每分钟执行',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user