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 * as path from 'path';
|
||||||
|
import { normalizeQqbotPluginTaskCron } from '../../application/task';
|
||||||
import {
|
import {
|
||||||
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
|
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
|
||||||
QQBOT_PLUGIN_WORKER_TYPES,
|
QQBOT_PLUGIN_WORKER_TYPES,
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
type QqbotPluginOperationManifest,
|
type QqbotPluginOperationManifest,
|
||||||
type QqbotPluginPermission,
|
type QqbotPluginPermission,
|
||||||
type QqbotPluginRuntimeManifest,
|
type QqbotPluginRuntimeManifest,
|
||||||
|
type QqbotPluginTaskManifest,
|
||||||
type QqbotPluginWorkerType,
|
type QqbotPluginWorkerType,
|
||||||
} from './manifest.types';
|
} 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 = (
|
const parseAssets = (
|
||||||
source: Record<string, unknown>,
|
source: Record<string, unknown>,
|
||||||
issues: QqbotPluginManifestValidationIssue[],
|
issues: QqbotPluginManifestValidationIssue[],
|
||||||
@ -435,6 +505,7 @@ export const parseQqbotPluginManifest = (
|
|||||||
),
|
),
|
||||||
pluginKey,
|
pluginKey,
|
||||||
runtime: parseRuntime(manifestLike, issues),
|
runtime: parseRuntime(manifestLike, issues),
|
||||||
|
tasks: parseTasks(manifestLike, issues),
|
||||||
version,
|
version,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,17 @@ export type QqbotPluginEventManifest = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QqbotPluginTaskManifest = {
|
||||||
|
defaultCron: string;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
handlerName: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
permissions: QqbotPluginPermission[];
|
||||||
|
timeoutMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type QqbotPluginAssetManifest = {
|
export type QqbotPluginAssetManifest = {
|
||||||
contentHash?: string;
|
contentHash?: string;
|
||||||
key: string;
|
key: string;
|
||||||
@ -75,6 +86,7 @@ export type QqbotPluginManifest = {
|
|||||||
permissions: QqbotPluginPermission[];
|
permissions: QqbotPluginPermission[];
|
||||||
pluginKey: string;
|
pluginKey: string;
|
||||||
runtime: QqbotPluginRuntimeManifest;
|
runtime: QqbotPluginRuntimeManifest;
|
||||||
|
tasks: QqbotPluginTaskManifest[];
|
||||||
version: string;
|
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