From 250bb654f092011a869bb2b996a8b5a04caf587e Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 17 Jun 2026 09:09:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0QQBot=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1manifest=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugin-platform/application/task/index.ts | 2 + .../task/qqbot-plugin-task-cron.validator.ts | 28 +++++ .../task/qqbot-plugin-task.types.ts | 33 ++++++ .../domain/manifest/manifest.parser.ts | 71 ++++++++++++ .../domain/manifest/manifest.types.ts | 12 ++ .../plugin-task-manifest.spec.ts | 103 ++++++++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 src/modules/qqbot/plugin-platform/application/task/index.ts create mode 100644 src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts create mode 100644 src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.types.ts create mode 100644 test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts diff --git a/src/modules/qqbot/plugin-platform/application/task/index.ts b/src/modules/qqbot/plugin-platform/application/task/index.ts new file mode 100644 index 0000000..ae16b14 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/application/task/index.ts @@ -0,0 +1,2 @@ +export * from './qqbot-plugin-task-cron.validator'; +export * from './qqbot-plugin-task.types'; diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts new file mode 100644 index 0000000..ab2af21 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts @@ -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 不合法', + ); + } +} diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.types.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.types.ts new file mode 100644 index 0000000..d7f8399 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.types.ts @@ -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; +}; diff --git a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts index eaa0bb0..53f486b 100644 --- a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts +++ b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts @@ -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, + issues: QqbotPluginManifestValidationIssue[], +): QqbotPluginTaskManifest[] => { + const tasks = Array.isArray(source.tasks) ? source.tasks : []; + const seenKeys = new Set(); + + 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, issues: QqbotPluginManifestValidationIssue[], @@ -435,6 +505,7 @@ export const parseQqbotPluginManifest = ( ), pluginKey, runtime: parseRuntime(manifestLike, issues), + tasks: parseTasks(manifestLike, issues), version, }; diff --git a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts index cffea99..8e261e3 100644 --- a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts +++ b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts @@ -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; }; diff --git a/test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts b/test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts new file mode 100644 index 0000000..0763792 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts @@ -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 不允许每分钟执行', + ); + }); +});