From 7775fd580db0e4ed86da6e53e9cc206d8a16d8a8 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 17 Jun 2026 09:19:12 +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=A1=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/refactor-v3/00-full-schema.sql | 47 ++++++ sql/refactor-v3/99-verify.sql | 2 + .../plugin-platform/application/task/index.ts | 2 + ...qqbot-plugin-task-manifest.synchronizer.ts | 49 ++++++ .../task/qqbot-plugin-task.service.ts | 138 +++++++++++++++++ .../plugin-platform-task.controller.ts | 74 +++++++++ .../persistence/plugin-platform.contract.ts | 3 + .../persistence/plugin-platform.entities.ts | 145 ++++++++++++++++++ .../plugin-platform/plugin-platform.module.ts | 13 +- .../persistence-contract.spec.ts | 4 + .../plugin-platform-api-contract.spec.ts | 2 +- .../plugin-task-api-contract.spec.ts | 105 +++++++++++++ .../plugin-task-persistence.spec.ts | 37 +++++ 13 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-manifest.synchronizer.ts create mode 100644 src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts create mode 100644 src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller.ts create mode 100644 test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts create mode 100644 test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index 2d7474b..237a638 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -875,6 +875,53 @@ CREATE TABLE IF NOT EXISTS qqbot_plugin_runtime_event ( KEY idx_qqbot_plugin_runtime_event_plugin (plugin_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS qqbot_plugin_task ( + id BIGINT NOT NULL PRIMARY KEY, + plugin_id BIGINT NOT NULL, + installation_id BIGINT NOT NULL, + task_key VARCHAR(128) NOT NULL, + task_name VARCHAR(128) NOT NULL, + handler_name VARCHAR(128) NOT NULL, + description TEXT NULL, + default_cron VARCHAR(64) NOT NULL, + cron_expression VARCHAR(64) NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + timeout_ms INT NOT NULL, + runtime_status VARCHAR(32) NOT NULL DEFAULT 'idle', + last_run_id BIGINT NULL, + last_run_at DATETIME NULL, + last_status VARCHAR(32) NULL, + last_error TEXT NULL, + last_duration_ms INT NULL, + next_run_at DATETIME NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_qqbot_plugin_task (installation_id, task_key), + KEY idx_qqbot_plugin_task_plugin (plugin_id), + KEY idx_qqbot_plugin_task_enabled (enabled), + KEY idx_qqbot_plugin_task_status (runtime_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qqbot_plugin_task_run ( + id BIGINT NOT NULL PRIMARY KEY, + task_id BIGINT NOT NULL, + plugin_id BIGINT NOT NULL, + installation_id BIGINT NOT NULL, + task_key VARCHAR(128) NOT NULL, + trigger_type VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + job_id VARCHAR(191) NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + duration_ms INT NULL, + safe_summary JSON NULL, + error_message TEXT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_qqbot_plugin_task_run_task_time (task_id, create_time), + KEY idx_qqbot_plugin_task_run_plugin_time (plugin_id, create_time), + KEY idx_qqbot_plugin_task_run_status_time (status, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS napcat_container ( id BIGINT NOT NULL PRIMARY KEY, account_id BIGINT NULL, diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index 4db9d28..e6d97cc 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -11,6 +11,8 @@ SELECT 'napcat_account_binding' AS table_name, COUNT(*) AS row_count FROM napcat SELECT 'napcat_login_session' AS table_name, COUNT(*) AS row_count FROM napcat_login_session; SELECT 'napcat_login_challenge' AS table_name, COUNT(*) AS row_count FROM napcat_login_challenge; SELECT 'napcat_runtime_cleanup' AS table_name, COUNT(*) AS row_count FROM napcat_runtime_cleanup; +SELECT 'qqbot_plugin_task' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task; +SELECT 'qqbot_plugin_task_run' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task_run; SELECT 'seed_admin_user' AS check_name, COUNT(*) AS matched_rows FROM admin_user diff --git a/src/modules/qqbot/plugin-platform/application/task/index.ts b/src/modules/qqbot/plugin-platform/application/task/index.ts index ae16b14..b7b71f8 100644 --- a/src/modules/qqbot/plugin-platform/application/task/index.ts +++ b/src/modules/qqbot/plugin-platform/application/task/index.ts @@ -1,2 +1,4 @@ export * from './qqbot-plugin-task-cron.validator'; +export * from './qqbot-plugin-task-manifest.synchronizer'; +export * from './qqbot-plugin-task.service'; export * from './qqbot-plugin-task.types'; diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-manifest.synchronizer.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-manifest.synchronizer.ts new file mode 100644 index 0000000..06e9567 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-manifest.synchronizer.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import type { QqbotPluginTaskManifest } from '../../domain/manifest'; +import { QqbotPluginTask } from '../../infrastructure/persistence'; + +export type SyncPluginManifestTasksInput = { + installationId: string; + manifestTasks: QqbotPluginTaskManifest[]; + pluginId: string; +}; + +@Injectable() +export class QqbotPluginTaskManifestSynchronizer { + constructor( + @InjectRepository(QqbotPluginTask) + private readonly taskRepository: Repository, + ) {} + + async syncManifestTasks(input: SyncPluginManifestTasksInput) { + const tasks: QqbotPluginTask[] = []; + for (const manifestTask of input.manifestTasks) { + const existing = await this.taskRepository.findOne({ + where: { + installationId: input.installationId, + taskKey: manifestTask.key, + }, + }); + const task = this.taskRepository.create({ + ...(existing || {}), + cronExpression: existing?.cronExpression || manifestTask.defaultCron, + defaultCron: manifestTask.defaultCron, + description: manifestTask.description || null, + enabled: existing?.enabled ?? manifestTask.enabled, + handlerName: manifestTask.handlerName, + installationId: input.installationId, + pluginId: input.pluginId, + runtimeStatus: + existing?.runtimeStatus || + (manifestTask.enabled ? 'scheduled' : 'disabled'), + taskKey: manifestTask.key, + taskName: manifestTask.name, + timeoutMs: manifestTask.timeoutMs, + }); + tasks.push(await this.taskRepository.save(task)); + } + return tasks; + } +} diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts new file mode 100644 index 0000000..1ec67b7 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + Between, + FindOptionsWhere, + LessThanOrEqual, + MoreThanOrEqual, + Repository, +} from 'typeorm'; +import { throwVbenError, ToolsService } from '@/common'; +import { + QqbotPlugin, + QqbotPluginTask, + QqbotPluginTaskRun, +} from '../../infrastructure/persistence'; +import { requireQqbotPluginTaskCron } from './qqbot-plugin-task-cron.validator'; +import type { + QqbotPluginTaskPageQuery, + QqbotPluginTaskRunPageQuery, +} from './qqbot-plugin-task.types'; + +@Injectable() +export class QqbotPluginTaskService { + constructor( + @InjectRepository(QqbotPluginTask) + private readonly taskRepository: Repository, + @InjectRepository(QqbotPluginTaskRun) + private readonly runRepository: Repository, + @InjectRepository(QqbotPlugin) + private readonly pluginRepository: Repository, + private readonly toolsService: ToolsService, + ) {} + + async pageTasks(query: QqbotPluginTaskPageQuery) { + const { pageNo, pageSize, skip } = this.toolsService.getPageParams(query); + const where: FindOptionsWhere = {}; + const pluginId = await this.resolvePluginIdFilter(query); + if (pluginId) where.pluginId = pluginId; + if (query.taskKey) where.taskKey = query.taskKey; + if (query.status) where.runtimeStatus = query.status; + if (query.enabled !== undefined) { + where.enabled = this.toolsService.normalizeBoolean(query.enabled); + } + const [list, total] = await this.taskRepository.findAndCount({ + order: { createTime: 'DESC' }, + skip, + take: pageSize, + where, + }); + return { list, pageNo, pageSize, total }; + } + + async getTaskDetail(id: string) { + const task = await this.taskRepository.findOne({ where: { id } }); + if (!task) throwVbenError('插件定时任务不存在'); + return task; + } + + async enableTask(id: string) { + await this.taskRepository.update( + { id }, + { enabled: true, runtimeStatus: 'scheduled' }, + ); + return this.getTaskDetail(id); + } + + async disableTask(id: string) { + await this.taskRepository.update( + { id }, + { enabled: false, runtimeStatus: 'disabled' }, + ); + return this.getTaskDetail(id); + } + + async updateTaskCron(id: string, body: { cronExpression?: string }) { + const cronExpression = requireQqbotPluginTaskCron(body.cronExpression); + await this.taskRepository.update({ id }, { cronExpression }); + return this.getTaskDetail(id); + } + + async runTaskOnce(id: string, body: { input?: Record }) { + void body; + const task = await this.getTaskDetail(id); + const run = await this.runRepository.save({ + installationId: task.installationId, + pluginId: task.pluginId, + status: 'running', + taskId: task.id, + taskKey: task.taskKey, + triggerType: 'manual', + }); + return { jobId: run.jobId || `${run.id || ''}`, taskId: task.id }; + } + + async pageTaskRuns(id: string, query: QqbotPluginTaskRunPageQuery) { + const { pageNo, pageSize, skip } = this.toolsService.getPageParams(query); + const where: FindOptionsWhere = { taskId: id }; + if (query.status) where.status = query.status; + if (query.triggerType) where.triggerType = query.triggerType; + Object.assign(where, this.buildRunTimeFilter(query)); + const [list, total] = await this.runRepository.findAndCount({ + order: { createTime: 'DESC' }, + skip, + take: pageSize, + where, + }); + return { list, pageNo, pageSize, total }; + } + + private async resolvePluginIdFilter(query: QqbotPluginTaskPageQuery) { + if (query.pluginId) return query.pluginId; + if (!query.pluginKey) return undefined; + + const plugin = await this.pluginRepository.findOne({ + where: { pluginKey: query.pluginKey }, + }); + return plugin?.id || '__missing_plugin__'; + } + + private buildRunTimeFilter(query: QqbotPluginTaskRunPageQuery) { + if (query.startTime && query.endTime) { + return { + createTime: Between(query.startTime, query.endTime), + }; + } + if (query.startTime) { + return { + createTime: MoreThanOrEqual(query.startTime), + }; + } + if (query.endTime) { + return { + createTime: LessThanOrEqual(query.endTime), + }; + } + return {}; + } +} diff --git a/src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller.ts b/src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller.ts new file mode 100644 index 0000000..df22a39 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; +import { QqbotPluginTaskService } from '../application/task'; + +@ApiTags('QQBot - 插件定时任务') +@Controller('qqbot/plugin-platform/tasks') +@UseGuards(JwtAuthGuard) +export class QqbotPluginPlatformTaskController { + constructor(private readonly service: QqbotPluginTaskService) {} + + @Get('page') + @ApiOperation({ summary: '插件定时任务分页' }) + async page(@Query() query: Record) { + return vbenSuccess(await this.service.pageTasks(query)); + } + + @Get(':id') + @ApiOperation({ summary: '插件定时任务详情' }) + async detail(@Param('id') id: string) { + return vbenSuccess(await this.service.getTaskDetail(id)); + } + + @Post(':id/enable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '启用插件定时任务' }) + async enable(@Param('id') id: string) { + return vbenSuccess(await this.service.enableTask(id)); + } + + @Post(':id/disable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '停用插件定时任务' }) + async disable(@Param('id') id: string) { + return vbenSuccess(await this.service.disableTask(id)); + } + + @Post(':id/cron') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新插件定时任务 cron' }) + async updateCron( + @Param('id') id: string, + @Body() body: { cronExpression?: string }, + ) { + return vbenSuccess(await this.service.updateTaskCron(id, body)); + } + + @Post(':id/run') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '手动运行插件定时任务' }) + async run( + @Param('id') id: string, + @Body() body: { input?: Record }, + ) { + return vbenSuccess(await this.service.runTaskOnce(id, body)); + } + + @Get(':id/runs') + @ApiOperation({ summary: '插件定时任务运行记录分页' }) + async runs(@Param('id') id: string, @Query() query: Record) { + return vbenSuccess(await this.service.pageTaskRuns(id, query)); + } +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.contract.ts b/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.contract.ts index 8700586..318b3ab 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.contract.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.contract.ts @@ -4,6 +4,7 @@ export const QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT = { installLocal: '/qqbot/plugin-platform/install-local', installations: '/qqbot/plugin-platform/installations', runtimeEvents: '/qqbot/plugin-platform/runtime-events', + tasks: '/qqbot/plugin-platform/tasks', validate: '/qqbot/plugin-platform/validate', }, tables: [ @@ -16,5 +17,7 @@ export const QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT = { 'qqbot_plugin_config', 'qqbot_plugin_asset', 'qqbot_plugin_runtime_event', + 'qqbot_plugin_task', + 'qqbot_plugin_task_run', ], } as const; diff --git a/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts b/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts index e90028d..c02fbc1 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts @@ -3,6 +3,7 @@ import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, + KtDateTimeColumn, KtUpdateDateColumn, } from '@/common'; @@ -25,6 +26,21 @@ export type QqbotPluginRuntimeStatus = export type QqbotPluginRuntimeEventLevel = 'error' | 'info' | 'warn'; +export type QqbotPluginTaskRuntimeStatus = + | 'disabled' + | 'failed' + | 'idle' + | 'running' + | 'scheduled'; + +export type QqbotPluginTaskRunStatus = + | 'failed' + | 'running' + | 'skipped' + | 'success'; + +export type QqbotPluginTaskTriggerType = 'bootstrap' | 'manual' | 'schedule'; + @Entity('qqbot_plugin') @Index('uk_qqbot_plugin_key', ['pluginKey'], { unique: true }) export class QqbotPlugin { @@ -291,6 +307,133 @@ export class QqbotPluginRuntimeEvent { } } +@Entity('qqbot_plugin_task') +@Index('uk_qqbot_plugin_task', ['installationId', 'taskKey'], { + unique: true, +}) +@Index('idx_qqbot_plugin_task_plugin', ['pluginId']) +@Index('idx_qqbot_plugin_task_enabled', ['enabled']) +@Index('idx_qqbot_plugin_task_status', ['runtimeStatus']) +export class QqbotPluginTask { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'plugin_id', type: 'bigint' }) + pluginId: string; + + @Column({ name: 'installation_id', type: 'bigint' }) + installationId: string; + + @Column({ length: 128, name: 'task_key' }) + taskKey: string; + + @Column({ length: 128, name: 'task_name' }) + taskName: string; + + @Column({ length: 128, name: 'handler_name' }) + handlerName: string; + + @Column({ name: 'description', nullable: true, type: 'text' }) + description: null | string; + + @Column({ length: 64, name: 'default_cron' }) + defaultCron: string; + + @Column({ length: 64, name: 'cron_expression' }) + cronExpression: string; + + @Column({ default: true }) + enabled: boolean; + + @Column({ name: 'timeout_ms', type: 'int' }) + timeoutMs: number; + + @Column({ length: 32, name: 'runtime_status' }) + runtimeStatus: QqbotPluginTaskRuntimeStatus; + + @Column({ name: 'last_run_id', nullable: true, type: 'bigint' }) + lastRunId: null | string; + + @KtDateTimeColumn({ name: 'last_run_at', nullable: true }) + lastRunAt: null | KtDateTime; + + @Column({ length: 32, name: 'last_status', nullable: true }) + lastStatus: null | QqbotPluginTaskRunStatus; + + @Column({ name: 'last_error', nullable: true, type: 'text' }) + lastError: null | string; + + @Column({ name: 'last_duration_ms', nullable: true, type: 'int' }) + lastDurationMs: null | number; + + @KtDateTimeColumn({ name: 'next_run_at', nullable: true }) + nextRunAt: null | KtDateTime; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} + +@Entity('qqbot_plugin_task_run') +@Index('idx_qqbot_plugin_task_run_task_time', ['taskId', 'createTime']) +@Index('idx_qqbot_plugin_task_run_plugin_time', ['pluginId', 'createTime']) +@Index('idx_qqbot_plugin_task_run_status_time', ['status', 'createTime']) +export class QqbotPluginTaskRun { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'task_id', type: 'bigint' }) + taskId: string; + + @Column({ name: 'plugin_id', type: 'bigint' }) + pluginId: string; + + @Column({ name: 'installation_id', type: 'bigint' }) + installationId: string; + + @Column({ length: 128, name: 'task_key' }) + taskKey: string; + + @Column({ length: 32, name: 'trigger_type' }) + triggerType: QqbotPluginTaskTriggerType; + + @Column({ length: 32 }) + status: QqbotPluginTaskRunStatus; + + @Column({ length: 191, name: 'job_id', nullable: true }) + jobId: null | string; + + @KtDateTimeColumn({ name: 'started_at', nullable: true }) + startedAt: null | KtDateTime; + + @KtDateTimeColumn({ name: 'finished_at', nullable: true }) + finishedAt: null | KtDateTime; + + @Column({ name: 'duration_ms', nullable: true, type: 'int' }) + durationMs: null | number; + + @Column({ name: 'safe_summary', nullable: true, type: 'simple-json' }) + safeSummary: null | Record; + + @Column({ name: 'error_message', nullable: true, type: 'text' }) + errorMessage: null | string; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} + export const QQBOT_PLUGIN_PLATFORM_ENTITIES = [ QqbotPlugin, QqbotPluginVersion, @@ -301,4 +444,6 @@ export const QQBOT_PLUGIN_PLATFORM_ENTITIES = [ QqbotPluginConfig, QqbotPluginAsset, QqbotPluginRuntimeEvent, + QqbotPluginTask, + QqbotPluginTaskRun, ] as const; diff --git a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts index f9fb994..5bdee06 100644 --- a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts +++ b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts @@ -11,6 +11,11 @@ import { QqbotEventPluginRegistryService } from './application/registry/qqbot-ev import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service'; import { QqbotPluginExecutionAdapter } from './application/plugin-execution.adapter'; import { QqbotPluginPlatformService } from './application/plugin-platform.service'; +import { + QqbotPluginTaskManifestSynchronizer, + QqbotPluginTaskService, +} from './application/task'; +import { QqbotPluginPlatformTaskController } from './contract/plugin-platform-task.controller'; import { QqbotPluginPlatformController } from './contract/plugin-platform.controller'; import { QqbotPluginController } from './contract/qqbot-plugin.controller'; import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './infrastructure/persistence'; @@ -25,7 +30,11 @@ import { import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.service'; @Module({ - controllers: [QqbotPluginController, QqbotPluginPlatformController], + controllers: [ + QqbotPluginController, + QqbotPluginPlatformController, + QqbotPluginPlatformTaskController, + ], exports: [ QQBOT_PLUGIN_EXECUTION_PORT, QqbotPluginHttpClientService, @@ -53,6 +62,8 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv QqbotBuiltinPluginPackageLoaderService, QqbotBuiltinPluginWorkerRuntimeFactoryService, QqbotPluginPackageReaderService, + QqbotPluginTaskManifestSynchronizer, + QqbotPluginTaskService, { provide: QQBOT_PLUGIN_EXECUTION_PORT, useExisting: QqbotPluginExecutionAdapter, diff --git a/test/modules/qqbot/plugin-platform/persistence-contract.spec.ts b/test/modules/qqbot/plugin-platform/persistence-contract.spec.ts index d94184c..2374a92 100644 --- a/test/modules/qqbot/plugin-platform/persistence-contract.spec.ts +++ b/test/modules/qqbot/plugin-platform/persistence-contract.spec.ts @@ -33,6 +33,8 @@ describe('QQBot plugin platform persistence contract', () => { 'qqbot_plugin_config', 'qqbot_plugin_asset', 'qqbot_plugin_runtime_event', + 'qqbot_plugin_task', + 'qqbot_plugin_task_run', ]); for (const table of QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables) { @@ -52,6 +54,8 @@ describe('QQBot plugin platform persistence contract', () => { 'QqbotPluginConfig', 'QqbotPluginAsset', 'QqbotPluginRuntimeEvent', + 'QqbotPluginTask', + 'QqbotPluginTaskRun', ], ); diff --git a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts index 0509b59..d287aa8 100644 --- a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts @@ -273,7 +273,7 @@ describe('QQBot plugin platform API contract', () => { it('keeps TypeORM entity registration aligned with the persistence contract', () => { expect(QqbotPluginPlatformModule).toBeDefined(); - expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(9); + expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(11); }); it('passes runtime-event filters to persistence', async () => { diff --git a/test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts b/test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts new file mode 100644 index 0000000..caeaf57 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts @@ -0,0 +1,105 @@ +import { QqbotPluginPlatformTaskController } from '../../../../src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller'; +import { ToolsService } from '../../../../src/common'; +import { QqbotPluginTaskService } from '../../../../src/modules/qqbot/plugin-platform/application/task'; +import { + collectControllerRoutes, + routeKey, +} from '../../../helpers/controller-route.helper'; + +const createRepositoryMock = () => ({ + findAndCount: jest.fn(async () => [[], 0]), + findOne: jest.fn(async () => null), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), +}); + +describe('QQBot plugin task API contract', () => { + it('exposes task management routes under plugin-platform ownership', () => { + expect( + collectControllerRoutes([QqbotPluginPlatformTaskController]).map( + routeKey, + ), + ).toEqual( + expect.arrayContaining([ + 'GET /qqbot/plugin-platform/tasks/page', + 'GET /qqbot/plugin-platform/tasks/:id', + 'POST /qqbot/plugin-platform/tasks/:id/enable', + 'POST /qqbot/plugin-platform/tasks/:id/disable', + 'POST /qqbot/plugin-platform/tasks/:id/cron', + 'POST /qqbot/plugin-platform/tasks/:id/run', + 'GET /qqbot/plugin-platform/tasks/:id/runs', + ]), + ); + }); + + it('applies task page filters and safe pagination defaults', async () => { + const taskRepository = createRepositoryMock(); + const runRepository = createRepositoryMock(); + const pluginRepository = createRepositoryMock(); + pluginRepository.findOne.mockResolvedValue({ id: 'plugin-1' }); + const service = new QqbotPluginTaskService( + taskRepository as any, + runRepository as any, + pluginRepository as any, + new ToolsService(), + ); + + const page = await service.pageTasks({ + enabled: 'true', + pageNo: 'bad', + pageSize: 'also-bad', + pluginKey: 'bangdream', + status: 'scheduled', + taskKey: 'bangdream.bestdori.sync-main-data', + }); + + expect(pluginRepository.findOne).toHaveBeenCalledWith({ + where: { pluginKey: 'bangdream' }, + }); + expect(taskRepository.findAndCount).toHaveBeenCalledWith({ + order: { createTime: 'DESC' }, + skip: 0, + take: 10, + where: { + enabled: true, + pluginId: 'plugin-1', + runtimeStatus: 'scheduled', + taskKey: 'bangdream.bestdori.sync-main-data', + }, + }); + expect(page).toMatchObject({ pageNo: 1, pageSize: 10, total: 0 }); + }); + + it('applies task run status, trigger, and time-range filters', async () => { + const taskRepository = createRepositoryMock(); + const runRepository = createRepositoryMock(); + const pluginRepository = createRepositoryMock(); + const service = new QqbotPluginTaskService( + taskRepository as any, + runRepository as any, + pluginRepository as any, + new ToolsService(), + ); + + await service.pageTaskRuns('task-1', { + endTime: '2026-06-17 23:59:59', + pageNo: 2, + pageSize: 20, + startTime: '2026-06-17 00:00:00', + status: 'success', + triggerType: 'schedule', + }); + + expect(runRepository.findAndCount).toHaveBeenCalledWith({ + order: { createTime: 'DESC' }, + skip: 20, + take: 20, + where: { + createTime: expect.any(Object), + status: 'success', + taskId: 'task-1', + triggerType: 'schedule', + }, + }); + }); +}); diff --git a/test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts b/test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts new file mode 100644 index 0000000..6c45244 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts @@ -0,0 +1,37 @@ +import { getMetadataArgsStorage } from 'typeorm'; +import { + QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT, + QQBOT_PLUGIN_PLATFORM_ENTITIES, + QqbotPluginTask, + QqbotPluginTaskRun, +} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/persistence'; +import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; + +describe('QQBot plugin task persistence contract', () => { + const schema = readRefactorV3SqlSchema(); + + it('declares task tables in SQL and entity registry', () => { + expect(QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables).toEqual( + expect.arrayContaining(['qqbot_plugin_task', 'qqbot_plugin_task_run']), + ); + expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toEqual( + expect.arrayContaining([QqbotPluginTask, QqbotPluginTaskRun]), + ); + expect(schema.hasTable('qqbot_plugin_task')).toBe(true); + expect(schema.hasTable('qqbot_plugin_task_run')).toBe(true); + }); + + it('maps task entity columns to SQL schema', () => { + for (const entity of [QqbotPluginTask, QqbotPluginTaskRun]) { + const tableName = getMetadataArgsStorage().tables.find( + (table) => table.target === entity, + )?.name; + const columns = getMetadataArgsStorage() + .columns.filter((column) => column.target === entity) + .map((column) => `${column.options.name || column.propertyName}`); + + expect(tableName).toBeTruthy(); + schema.expectTableColumns(tableName || '', columns); + } + }); +});