77 KiB
QQBot Plugin Scheduled Tasks Implementation Plan
Execution note: Execute this plan task-by-task with the KT-local workflow and use the checkboxes to track plan state.
Goal: Build a unified QQBot plugin scheduled-task platform, expose it in Admin, and land BangDream Bestdori main-data sync as the first managed plugin task.
Architecture: Plugin manifests declare tasks; plugin-platform persists task definitions, schedules enabled tasks with BullMQ Job Scheduler, executes them through the existing worker-thread boundary via executeTask, and stores task run records. BangDream owns its own sync task under its plugin package, while Admin gets a dedicated /qqbot/plugin-task page with paged task management, cron editing, manual run, and run-log drawer.
Tech Stack: NestJS 11, TypeORM, BullMQ 5.78.1 Queue.upsertJobScheduler, MySQL, Jest, Vben Admin, Vue 3 TSX, antdv-next, @vue-js-cron/core, Vitest.
Current Evidence
- API repo:
D:\MyFiles\KT\Node\kt-template-online-api - Admin repo:
D:\MyFiles\KT\Vue\kt-template-admin - Design spec:
D:\MyFiles\KT\Node\kt-template-online-api\docs\specs\2026-06-16-qqbot-plugin-scheduled-tasks-design.md - Online interface stage is already closed: final smoke artifact
.kt-workspace/test-artifacts/online-full-smoke/20260616-164531/api-full-interface-smoke-20260616091328.jsonreported 175 OpenAPI operations, 102 pass, 4 expectedBlocked, 69 skipped, 0 failed. QQBot concurrent/查分数表 cn+/查卡 472both passed after the queue-wait timeout fix deployed as API14e2ec9. - Existing plugin-platform owns manifest parsing, worker runtime, persistence entities, controller, and operation/event capabilities under
src/modules/qqbot/plugin-platform/**. - Existing BangDream package root is
src/modules/qqbot/plugins/bangdream; do not create transfer layers orbuiltins/**. - Existing Admin QQBot app root is
apps/web-antdv-next/src/views/qqbot.
File Map
API Files To Create
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.types.ts
DTO/query/result types for task list, run list, enable/disable/update cron/manual run, and task run status.src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts
Five-field cron validator shared by manifest parsing and Admin update endpoints.src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-manifest.synchronizer.ts
Sync manifesttasksintoqqbot_plugin_taskrows for install, enable, upgrade, and builtin bootstrap.src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts
Admin-facing task query/mutation/run-log service.src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-scheduler.service.ts
BullMQ Job Scheduler bridge; registers, removes, and resyncs task schedulers.src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-worker.processor.ts
BullMQ worker that createsqqbot_plugin_task_run, prevents overlapping runs, callsexecuteTask, and writes final state.src/modules/qqbot/plugin-platform/application/task/index.ts
Task-domain exports.src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller.ts
Admin task endpoints under/qqbot/plugin-platform/tasks.src/modules/qqbot/plugins/bangdream/src/application/tasks/bestdori-main-data-sync.task.ts
BangDream managed task implementation.src/modules/qqbot/plugins/bangdream/src/application/tasks/index.ts
BangDream task exports.test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Manifest task parsing and validation contract tests.test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts
Entity/schema contract tests for task tables.test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts
BullMQ scheduler bridge tests.test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts
Controller route and service contract tests.test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts
BangDream sync task tests.
API Files To Modify
src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts
AddQqbotPluginTaskManifestandtaskstoQqbotPluginManifest.src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts
Parse and validate manifesttasks.src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts
AddQqbotPluginTaskandQqbotPluginTaskRunentities.src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.contract.ts
Add task routes and tables to the domain contract.src/modules/qqbot/plugin-platform/plugin-platform.module.ts
Register new controller, repositories, scheduler queue, and task services.src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts
IncludeexecuteTaskin runtime factory type, call task synchronizer from install/enable/upgrade/builtin bootstrap, pause task schedulers on disable/uninstall.src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types.ts
AddexecuteTaskrequest type and request DTO.src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts
AddexecuteTask()runtime method with safe input summary and timeout handling.src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts
DispatchexecuteTaskto plugin task handlers.src/modules/qqbot/plugins/bangdream/plugin.json
Declarebangdream.bestdori.sync-main-data.src/modules/qqbot/plugins/bangdream/src/index.ts
Expose BangDreamtasksarray from the plugin entry.src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts
Add atomic JSON write support where needed by the task.src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts
Export a refresh/load helper for explicit task-driven catalog refresh.sql/refactor-v3/00-full-schema.sql
Addqqbot_plugin_taskandqqbot_plugin_task_run.sql/refactor-v3/99-verify.sql
Add task table verification rows.sql/qqbot-init.sql
Add Admin menu and permission rows for plugin scheduled tasks..env.example,README.md,API.md,k8s/prod/api.yaml,Jenkinsfile
Add task scheduler env and BangDream cache root runtime notes.
Admin Files To Create
apps/web-antdv-next/src/api/qqbot/plugin-task.ts
Task API caller and types.apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts
Caller path/params tests.apps/web-antdv-next/src/views/qqbot/plugin-task/list.tsx
Dedicated KtTable page.apps/web-antdv-next/src/views/qqbot/plugin-task/components/CronEditorAntdvNext.tsx
Thin@vue-js-cron/core+ antdv-next adapter.apps/web-antdv-next/src/views/qqbot/plugin-task/components/TaskRunDrawer.tsx
Run-log drawer.apps/web-antdv-next/src/views/qqbot/plugin-task/components/TaskCronModal.tsx
Cron edit modal.apps/web-antdv-next/src/views/qqbot/plugin-task/plugin-task.spec.tsx
Page interaction tests.
Admin Files To Modify
apps/web-antdv-next/src/router/routes/modules/qqbot.ts
Add/qqbot/plugin-task.apps/web-antdv-next/src/api/qqbot/index.ts
Export shared task page result/status types if needed.apps/web-antdv-next/package.json,pnpm-lock.yaml,pnpm-workspace.yaml
Add@vue-js-cron/corethrough workspace catalog and app dependency.README.md
Add one-line Admin page note if the repo currently documents QQBot pages.
Data Model
qqbot_plugin_task
CREATE TABLE IF NOT EXISTS qqbot_plugin_task (
id BIGINT NOT NULL,
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(1) 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,
PRIMARY KEY (id),
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;
qqbot_plugin_task_run
CREATE TABLE IF NOT EXISTS qqbot_plugin_task_run (
id BIGINT NOT NULL,
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,
PRIMARY KEY (id),
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;
Task 1: Manifest Task Contract And Cron Validator
Files:
-
Modify:
src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts -
Modify:
src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts -
Create:
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts -
Create:
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.types.ts -
Create:
src/modules/qqbot/plugin-platform/application/task/index.ts -
Create:
test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts -
Step 1: Write RED manifest task tests
Add tests that prove valid tasks parse and invalid task definitions fail.
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: [],
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: '',
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 不允许每分钟执行',
);
});
});
- Step 2: Run RED test
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Expected: FAIL because tasks and normalizeQqbotPluginTaskCron do not exist.
- Step 3: Implement task types and cron validator
Add to qqbot-plugin-task.types.ts:
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;
};
Add to qqbot-plugin-task-cron.validator.ts:
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 不合法');
}
}
Add to index.ts:
export * from './qqbot-plugin-task-cron.validator';
export * from './qqbot-plugin-task.types';
- Step 4: Extend manifest parsing
Add QqbotPluginTaskManifest to manifest.types.ts:
export type QqbotPluginTaskManifest = {
defaultCron: string;
description?: string;
enabled: boolean;
handlerName: string;
key: string;
name: string;
permissions: QqbotPluginPermission[];
timeoutMs: number;
};
Add tasks: QqbotPluginTaskManifest[]; to QqbotPluginManifest.
In manifest.parser.ts, import normalizeQqbotPluginTaskCron, add parseTasks(), and include tasks: parseTasks(manifestLike, issues) in parsedManifest.
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,
};
});
};
- Step 5: Run GREEN manifest tests
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/manifest.spec.ts test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Expected: PASS.
- Step 6: Commit API manifest contract
Run:
git add src/modules/qqbot/plugin-platform/domain/manifest src/modules/qqbot/plugin-platform/application/task test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
git commit -m "feat: 增加QQBot插件定时任务manifest契约"
Task 2: Task Persistence, Schema, And API Routes
Files:
-
Modify:
src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts -
Modify:
src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.contract.ts -
Modify:
src/modules/qqbot/plugin-platform/plugin-platform.module.ts -
Create:
src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller.ts -
Create:
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts -
Create:
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-manifest.synchronizer.ts -
Modify:
sql/refactor-v3/00-full-schema.sql -
Modify:
sql/refactor-v3/99-verify.sql -
Create:
test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts -
Create:
test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts -
Step 1: Write RED persistence and route tests
Add persistence assertions:
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);
}
});
});
Add route assertions:
import { QqbotPluginPlatformTaskController } from '../../../../src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller';
import {
collectControllerRoutes,
routeKey,
} from '../../../helpers/controller-route.helper';
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',
]),
);
});
});
- Step 2: Run RED persistence/API tests
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts
Expected: FAIL because entities and controller do not exist.
- Step 3: Add task entities
Add to plugin-platform.entities.ts:
export type QqbotPluginTaskRuntimeStatus =
| 'disabled'
| 'failed'
| 'idle'
| 'running'
| 'scheduled';
export type QqbotPluginTaskRunStatus =
| 'failed'
| 'running'
| 'skipped'
| 'success';
export type QqbotPluginTaskTriggerType =
| 'bootstrap'
| 'manual'
| 'schedule';
@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;
@Column({ name: 'last_run_at', nullable: true, type: 'datetime' })
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;
@Column({ name: 'next_run_at', nullable: true, type: 'datetime' })
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;
@Column({ name: 'started_at', nullable: true, type: 'datetime' })
startedAt: null | KtDateTime;
@Column({ name: 'finished_at', nullable: true, type: 'datetime' })
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<string, unknown>;
@Column({ name: 'error_message', nullable: true, type: 'text' })
errorMessage: null | string;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
Append both entities to QQBOT_PLUGIN_PLATFORM_ENTITIES.
- Step 4: Add SQL schema and verify checks
Insert the two SQL tables after qqbot_plugin_runtime_event in sql/refactor-v3/00-full-schema.sql using the table definitions from the Data Model section.
Append to sql/refactor-v3/99-verify.sql:
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;
- Step 5: Add task controller with Vben wrappers
Add plugin-platform-task.controller.ts:
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<string, unknown>) {
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<string, unknown> }) {
return vbenSuccess(await this.service.runTaskOnce(id, body));
}
@Get(':id/runs')
@ApiOperation({ summary: '插件定时任务运行记录分页' })
async runs(@Param('id') id: string, @Query() query: Record<string, unknown>) {
return vbenSuccess(await this.service.pageTaskRuns(id, query));
}
}
- Step 6: Implement minimal service/synchronizer for route tests
Create service methods with repository-backed pagination and clear errors. The initial implementation can call scheduler placeholders injected in Task 3; keep public method names stable.
@Injectable()
export class QqbotPluginTaskService {
constructor(
@InjectRepository(QqbotPluginTask)
private readonly taskRepository: Repository<QqbotPluginTask>,
@InjectRepository(QqbotPluginTaskRun)
private readonly runRepository: Repository<QqbotPluginTaskRun>,
) {}
async pageTasks(query: QqbotPluginTaskPageQuery) {
const pageNo = Math.max(1, Number(query.pageNo || 1));
const pageSize = Math.max(1, Number(query.pageSize || 10));
const [list, total] = await this.taskRepository.findAndCount({
order: { createTime: 'DESC' },
skip: (pageNo - 1) * pageSize,
take: pageSize,
});
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<string, unknown> }) {
void body;
const task = await this.getTaskDetail(id);
return this.runRepository.save({
installationId: task.installationId,
pluginId: task.pluginId,
status: 'running',
taskId: task.id,
taskKey: task.taskKey,
triggerType: 'manual',
});
}
async pageTaskRuns(id: string, query: QqbotPluginTaskRunPageQuery) {
const pageNo = Math.max(1, Number(query.pageNo || 1));
const pageSize = Math.max(1, Number(query.pageSize || 10));
const [list, total] = await this.runRepository.findAndCount({
order: { createTime: 'DESC' },
skip: (pageNo - 1) * pageSize,
take: pageSize,
where: { taskId: id },
});
return { list, pageNo, pageSize, total };
}
}
- Step 7: Register controller/entities/services
Modify plugin-platform.module.ts:
controllers: [
QqbotPluginController,
QqbotPluginPlatformController,
QqbotPluginPlatformTaskController,
],
providers: [
QqbotPluginTaskManifestSynchronizer,
QqbotPluginTaskService,
...
],
- Step 8: Run GREEN persistence/API tests
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/persistence-contract.spec.ts test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts
Expected: PASS.
- Step 9: Commit persistence/API contract
Run:
git add src/modules/qqbot/plugin-platform sql/refactor-v3 test/modules/qqbot/plugin-platform
git commit -m "feat: 增加QQBot插件定时任务持久化接口"
Task 3: BullMQ Scheduler Bridge And Worker executeTask
Files:
-
Create:
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-scheduler.service.ts -
Create:
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-worker.processor.ts -
Modify:
src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts -
Modify:
src/modules/qqbot/plugin-platform/application/task/index.ts -
Modify:
src/modules/qqbot/plugin-platform/plugin-platform.module.ts -
Modify:
src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts -
Modify:
src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types.ts -
Modify:
src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts -
Modify:
src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts -
Create:
test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts -
Modify:
test/modules/qqbot/plugin-platform/worker-runtime.spec.ts -
Step 1: Write RED runtime test for
executeTask
Append to worker-runtime.spec.ts:
it('sends executeTask RPC with safe input summary and timeout', async () => {
const { driver, runtime } = createRuntime();
driver.responses.set('executeTask', { syncedKeys: ['songs'] });
await expect(
runtime.executeTask({
input: { fullPayload: 'secret', force: true },
taskHandlerName: 'syncBestdoriMainData',
taskId: 'task-1',
taskKey: 'bangdream.bestdori.sync-main-data',
timeoutMs: 120000,
triggerType: 'manual',
}),
).resolves.toEqual({ syncedKeys: ['songs'] });
expect(driver.requests[0]).toMatchObject({
safeInputSummary: { fieldCount: 2, keys: ['force', 'fullPayload'] },
taskHandlerName: 'syncBestdoriMainData',
taskId: 'task-1',
taskKey: 'bangdream.bestdori.sync-main-data',
timeoutMs: 120000,
triggerType: 'manual',
type: 'executeTask',
});
expect(JSON.stringify(driver.requests[0].safeInputSummary)).not.toContain('secret');
});
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/worker-runtime.spec.ts -t executeTask
Expected: FAIL because executeTask does not exist.
- Step 2: Implement runtime
executeTask
In worker-runtime.types.ts, add request type and DTO:
export type QqbotPluginWorkerRequestType =
| 'activate'
| 'deactivate'
| 'dispose'
| 'executeOperation'
| 'executeTask'
| 'handleEvent'
| 'health'
| 'load';
export type QqbotPluginTaskRequest = {
input: Record<string, unknown>;
taskHandlerName: string;
taskId: string;
taskKey: string;
timeoutMs?: number;
triggerType: 'bootstrap' | 'manual' | 'schedule';
};
Add optional request fields:
taskHandlerName?: string;
taskId?: string;
taskKey?: string;
triggerType?: 'bootstrap' | 'manual' | 'schedule';
In worker-runtime.ts, add:
async executeTask(request: QqbotPluginTaskRequest) {
return this.request(
'executeTask',
{
input: request.input,
safeInputSummary: summarizeInput(request.input),
taskHandlerName: request.taskHandlerName,
taskId: request.taskId,
taskKey: request.taskKey,
triggerType: request.triggerType,
},
request.timeoutMs,
);
}
In QqbotPluginRuntimeFactory pick list inside plugin-platform.service.ts, add 'executeTask'.
- Step 3: Implement worker thread task dispatch
In builtin-plugin-worker.thread.ts, extend plugin type:
type RuntimeCommandPlugin = QqbotIntegrationPlugin & {
activate?: () => Promise<unknown> | unknown;
dispose?: () => Promise<unknown> | unknown;
tasks?: Array<{
execute(input: Record<string, unknown>): Promise<Record<string, unknown>> | Record<string, unknown>;
handlerName: string;
key: string;
}>;
};
Add switch case:
case 'executeTask':
return executeTask(message);
Add function:
async function executeTask(message: QqbotPluginWorkerRequest) {
const task = commandPlugin?.tasks?.find(
(item) =>
item.key === message.taskKey ||
item.handlerName === message.taskHandlerName,
);
if (!task) {
throw new Error(`QQBot 插件定时任务不存在:${message.taskKey}`);
}
return task.execute((message.input || {}) as Record<string, unknown>);
}
- Step 4: Run GREEN runtime test
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/worker-runtime.spec.ts -t executeTask
Expected: PASS.
- Step 5: Write RED scheduler tests
Add plugin-task-scheduler.spec.ts:
const createdQueues: any[] = [];
const createdWorkers: any[] = [];
jest.mock('bullmq', () => ({
Queue: class MockQueue {
readonly schedulers = new Map<string, unknown>();
constructor(public name: string, public options: unknown) {
createdQueues.push(this);
}
async add(name: string, data: unknown, opts?: unknown) {
return { data, id: `${name}-job`, name, opts };
}
async close() {}
async removeJobScheduler(id: string) {
this.schedulers.delete(id);
return 1;
}
async upsertJobScheduler(id: string, repeat: unknown, template: unknown) {
this.schedulers.set(id, { repeat, template });
return { id };
}
async waitUntilReady() {}
},
Worker: class MockWorker {
constructor(public name: string, public processor: Function, public options: unknown) {
createdWorkers.push(this);
}
on() { return this; }
async close() {}
async waitUntilReady() {}
},
}));
import { QqbotPluginTaskSchedulerService } from '../../../../src/modules/qqbot/plugin-platform/application/task';
describe('QQBot plugin task scheduler', () => {
beforeEach(() => {
createdQueues.length = 0;
createdWorkers.length = 0;
});
it('registers cron through BullMQ Job Scheduler with a stable scheduler id', async () => {
const scheduler = new QqbotPluginTaskSchedulerService(
createConfigService(),
createTaskRepository([{ id: 'task-1', cronExpression: '0 */6 * * *', enabled: true }]),
} as any);
await scheduler.syncTaskScheduler({
cronExpression: '0 */6 * * *',
enabled: true,
id: 'task-1',
installationId: 'install-1',
taskKey: 'bangdream.bestdori.sync-main-data',
timeoutMs: 120000,
} as any);
expect(createdQueues[0].schedulers.get('plugin-task:task-1')).toMatchObject({
repeat: { pattern: '0 */6 * * *' },
template: {
data: { taskId: 'task-1', triggerType: 'schedule' },
name: 'execute-plugin-task',
},
});
});
});
Use helpers in the same file:
function createConfigService() {
return {
get: (key: string) =>
({
QQBOT_PLUGIN_QUEUE_REDIS_HOST: 'redis.local',
QQBOT_PLUGIN_TASK_QUEUE_PREFIX: 'kt:qqbot:plugin-task',
})[key],
};
}
function createTaskRepository(tasks: any[]) {
return {
find: jest.fn(async () => tasks),
findOne: jest.fn(async ({ where }: any) =>
tasks.find((task) => task.id === where.id) || null,
),
update: jest.fn(async () => ({ affected: 1 })),
};
}
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts
Expected: FAIL because scheduler service does not exist.
- Step 6: Implement scheduler bridge
Create QqbotPluginTaskSchedulerService using BullMQ Queue.upsertJobScheduler.
@Injectable()
export class QqbotPluginTaskSchedulerService implements OnModuleDestroy, OnModuleInit {
private readonly queue: Queue;
constructor(
private readonly configService: ConfigService,
@InjectRepository(QqbotPluginTask)
private readonly taskRepository: Repository<QqbotPluginTask>,
) {
this.queue = new Queue('qqbot-plugin-task', {
connection: resolveQqbotPluginQueueConnection(configService),
prefix: readTaskQueuePrefix(configService),
});
}
async onModuleInit() {
await this.resyncEnabledTasks();
}
async onModuleDestroy() {
await this.queue.close();
}
async resyncEnabledTasks() {
const tasks = await this.taskRepository.find({ where: { enabled: true } });
for (const task of tasks) {
await this.syncTaskScheduler(task);
}
}
async syncTaskScheduler(task: Pick<QqbotPluginTask, 'cronExpression' | 'enabled' | 'id' | 'installationId' | 'taskKey' | 'timeoutMs'>) {
const schedulerId = this.buildSchedulerId(task.id);
if (!task.enabled) {
await this.queue.removeJobScheduler(schedulerId);
await this.taskRepository.update({ id: task.id }, { nextRunAt: null, runtimeStatus: 'disabled' });
return;
}
await this.queue.upsertJobScheduler(
schedulerId,
{ pattern: task.cronExpression },
{
data: {
taskId: task.id,
triggerType: 'schedule',
},
name: 'execute-plugin-task',
opts: {
attempts: 1,
removeOnComplete: true,
removeOnFail: 100,
},
},
);
await this.taskRepository.update({ id: task.id }, { runtimeStatus: 'scheduled' });
}
async removeTaskScheduler(taskId: string) {
await this.queue.removeJobScheduler(this.buildSchedulerId(taskId));
}
async enqueueManualRun(taskId: string, input: Record<string, unknown>) {
return this.queue.add('execute-plugin-task', {
input,
taskId,
triggerType: 'manual',
});
}
private buildSchedulerId(taskId: string) {
return `plugin-task:${taskId}`;
}
}
- Step 7: Implement task worker processor
Create processor with one-running-run guard:
@Injectable()
export class QqbotPluginTaskWorkerProcessor implements OnModuleDestroy, OnModuleInit {
private worker?: Worker;
constructor(
private readonly configService: ConfigService,
private readonly platformService: QqbotPluginPlatformService,
@InjectRepository(QqbotPluginTask)
private readonly taskRepository: Repository<QqbotPluginTask>,
@InjectRepository(QqbotPluginTaskRun)
private readonly runRepository: Repository<QqbotPluginTaskRun>,
) {}
async onModuleInit() {
this.worker = new Worker(
'qqbot-plugin-task',
async (job) => this.processJob(job),
{
concurrency: 1,
connection: resolveQqbotPluginQueueConnection(this.configService),
prefix: readTaskQueuePrefix(this.configService),
},
);
this.worker.on('error', (error) => Logger.error(error.message, error.stack, QqbotPluginTaskWorkerProcessor.name));
await this.worker.waitUntilReady();
}
async onModuleDestroy() {
await this.worker?.close();
}
private async processJob(job: Job<{ input?: Record<string, unknown>; taskId: string; triggerType: QqbotPluginTaskTriggerType }>) {
const task = await this.taskRepository.findOne({ where: { id: job.data.taskId } });
if (!task) return { skipped: true, reason: 'task-not-found' };
if (job.data.triggerType === 'schedule' && !task.enabled) {
return this.writeSkippedRun(task, job.id, job.data.triggerType, 'task-disabled');
}
const running = await this.runRepository.findOne({
where: { status: 'running', taskId: task.id },
});
if (running) {
return this.writeSkippedRun(task, job.id, job.data.triggerType, 'previous-run-running');
}
return this.executeTaskRun(task, `${job.id || ''}`, job.data.triggerType, job.data.input || {});
}
}
executeTaskRun() must:
-
create a
runningrun row withstartedAt; -
update task
runtimeStatus='running'; -
call
platformService.executeTask({ input, taskId, taskKey, taskHandlerName, timeoutMs, triggerType, pluginId, installationId }); -
store success safe summary with output keys only;
-
store failed error message without external response bodies;
-
update
lastRunId,lastRunAt,lastStatus,lastError,lastDurationMs,runtimeStatus. -
Step 8: Wire scheduler into service mutations
Change QqbotPluginTaskService constructor to inject scheduler. Update:
async enableTask(id: string) {
const task = await this.getTaskDetail(id);
task.enabled = true;
task.runtimeStatus = 'scheduled';
const saved = await this.taskRepository.save(task);
await this.scheduler.syncTaskScheduler(saved);
return saved;
}
async disableTask(id: string) {
const task = await this.getTaskDetail(id);
task.enabled = false;
task.runtimeStatus = 'disabled';
const saved = await this.taskRepository.save(task);
await this.scheduler.removeTaskScheduler(id);
return saved;
}
async updateTaskCron(id: string, body: { cronExpression?: string }) {
const task = await this.getTaskDetail(id);
task.cronExpression = requireQqbotPluginTaskCron(body.cronExpression);
const saved = await this.taskRepository.save(task);
await this.scheduler.syncTaskScheduler(saved);
return saved;
}
async runTaskOnce(id: string, body: { input?: Record<string, unknown> }) {
await this.getTaskDetail(id);
const job = await this.scheduler.enqueueManualRun(id, body.input || {});
return { jobId: `${job.id || ''}`, taskId: id };
}
- Step 9: Add platform
executeTaskmethod and manifest sync calls
Add to QqbotPluginPlatformService:
async executeTask(input: {
input: Record<string, unknown>;
installationId: string;
taskHandlerName: string;
taskId: string;
taskKey: string;
timeoutMs: number;
triggerType: QqbotPluginTaskTriggerType;
}) {
const workerContext = this.activeWorkerContexts.get(input.installationId);
if (!workerContext) {
throwVbenError('插件运行时未启用');
}
try {
return await workerContext.worker.executeTask({
input: input.input,
taskHandlerName: input.taskHandlerName,
taskId: input.taskId,
taskKey: input.taskKey,
timeoutMs: input.timeoutMs,
triggerType: input.triggerType,
});
} finally {
await this.flushWorkerRuntimeEvents(workerContext);
}
}
Inject QqbotPluginTaskManifestSynchronizer and QqbotPluginTaskSchedulerService. Call synchronizer after persistManifestCapabilities, registerActiveWorker, enableInstallation, upgradeInstallation, and builtin bootstrap. On disable/uninstall, remove schedulers for that installation.
- Step 10: Run GREEN scheduler/runtime tests
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/worker-runtime.spec.ts test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts
Expected: PASS.
- Step 11: Commit scheduler/runtime bridge
Run:
git add src/modules/qqbot/plugin-platform test/modules/qqbot/plugin-platform
git commit -m "feat: 接入QQBot插件定时任务调度桥"
Task 4: BangDream Bestdori Main-Data Sync Task
Files:
-
Modify:
src/modules/qqbot/plugins/bangdream/plugin.json -
Modify:
src/modules/qqbot/plugins/bangdream/src/index.ts -
Create:
src/modules/qqbot/plugins/bangdream/src/application/tasks/bestdori-main-data-sync.task.ts -
Create:
src/modules/qqbot/plugins/bangdream/src/application/tasks/index.ts -
Modify:
src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts -
Modify:
src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts -
Create:
test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts -
Step 1: Write RED BangDream sync test
Add test:
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createBestdoriMainDataSyncTask,
BANGDREAM_BESTDORI_MAIN_DATA_KEYS,
} from '../../../../../src/modules/qqbot/plugins/bangdream/src/application/tasks';
import { configureBangDreamRuntimeIo } from '../../../../../src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
describe('BangDream Bestdori main-data sync task', () => {
const cacheRoot = mkdtempSync(join(tmpdir(), 'bangdream-sync-'));
afterEach(() => {
rmSync(cacheRoot, { force: true, recursive: true });
});
it('downloads main JSON data, writes cache atomically, and returns safe summary', async () => {
const requestedUrls: string[] = [];
configureBangDreamRuntimeIo({
getConfig: (key) =>
key === 'BANGDREAM_TSUGU_CACHE_ROOT' ? cacheRoot : undefined,
requestJson: async (url) => {
requestedUrls.push(`${url}`);
return { body: { ok: true, url } };
},
});
const task = createBestdoriMainDataSyncTask();
const output = await task.execute({ keys: ['songs', 'meta'] });
expect(output).toMatchObject({
failedCount: 0,
successCount: 2,
syncedKeys: ['songs', 'meta'],
});
expect(readFileSync(join(cacheRoot, 'bestdori', 'songs.json'), 'utf8')).toContain('"ok":true');
expect(readFileSync(join(cacheRoot, 'bestdori', 'meta.json'), 'utf8')).toContain('"ok":true');
expect(requestedUrls).toHaveLength(2);
});
it('keeps existing cache file when one key fails', async () => {
configureBangDreamRuntimeIo({
getConfig: (key) =>
key === 'BANGDREAM_TSUGU_CACHE_ROOT' ? cacheRoot : undefined,
requestJson: async (url) => {
if (`${url}`.includes('/api/songs/meta/')) throw new Error('network failed');
return { body: { ok: true } };
},
});
const task = createBestdoriMainDataSyncTask();
await expect(task.execute({ keys: ['songs', 'meta'] })).rejects.toThrow(
'BangDream Bestdori 主数据同步失败',
);
expect(BANGDREAM_BESTDORI_MAIN_DATA_KEYS).toEqual(
expect.arrayContaining(['songs', 'meta', 'cards', 'skills', 'events']),
);
});
});
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts
Expected: FAIL because task module does not exist.
- Step 2: Implement BangDream task module
Create bestdori-main-data-sync.task.ts:
import { dirname, join } from 'node:path';
import {
bestdoriApiPath,
bestdoriUrl,
} from '../../config/runtime-config';
import { BANGDREAM_TSUGU_ENV_KEYS } from '../../config/runtime-options';
import {
readBangDreamRuntimeConfig,
requestBangDreamJson,
writeBangDreamJsonFileAtomic,
} from '../../infrastructure/integration/runtime-io';
import { refreshBangDreamCatalogFromCache } from '../catalog/bangdream-catalog-cache';
export const BANGDREAM_BESTDORI_MAIN_DATA_KEYS = [
'songs',
'meta',
'cards',
'skills',
'events',
'gacha',
'costumes',
'bands',
'characters',
'areaItems',
] as const;
type MainDataKey = (typeof BANGDREAM_BESTDORI_MAIN_DATA_KEYS)[number];
export function createBestdoriMainDataSyncTask() {
return {
handlerName: 'syncBestdoriMainData',
key: 'bangdream.bestdori.sync-main-data',
execute: syncBestdoriMainData,
};
}
async function syncBestdoriMainData(input: Record<string, unknown>) {
const startedAt = Date.now();
const keys = normalizeKeys(input.keys);
const cacheRoot = resolveCacheRoot();
const failures: Array<{ key: string; message: string }> = [];
const syncedKeys: string[] = [];
for (const key of keys) {
try {
const path = bestdoriApiPath[key];
const url = new URL(path, bestdoriUrl).toString();
const response = await requestBangDreamJson(url, { timeoutMs: 30000 });
await writeBangDreamJsonFileAtomic(resolveCachePath(cacheRoot, key), response.body);
syncedKeys.push(key);
} catch (error) {
failures.push({
key,
message: error instanceof Error ? error.message : `${error}`,
});
}
}
if (syncedKeys.length > 0) {
await refreshBangDreamCatalogFromCache(syncedKeys as MainDataKey[]);
}
if (failures.length > 0) {
throw new Error(
`BangDream Bestdori 主数据同步失败:${failures
.map((failure) => `${failure.key}:${failure.message}`)
.join('; ')}`,
);
}
return {
cacheRootConfigured: Boolean(readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.cacheRoot)),
durationMs: Date.now() - startedAt,
failedCount: failures.length,
successCount: syncedKeys.length,
syncedKeys,
};
}
function normalizeKeys(input: unknown): MainDataKey[] {
const requested = Array.isArray(input) ? input : BANGDREAM_BESTDORI_MAIN_DATA_KEYS;
const allowed = new Set<string>(BANGDREAM_BESTDORI_MAIN_DATA_KEYS);
return [...new Set(requested.filter((key): key is MainDataKey => typeof key === 'string' && allowed.has(key)))];
}
function resolveCacheRoot() {
return (
readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.cacheRoot) ||
join(process.cwd(), '.kt-workspace', 'cache', 'bangdream')
);
}
function resolveCachePath(cacheRoot: string, key: MainDataKey) {
return join(cacheRoot, 'bestdori', `${key}.json`);
}
- Step 3: Add atomic write IO
In runtime-io.ts, add:
export async function writeBangDreamJsonFileAtomic(filePath: string, data: unknown) {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeBangDreamJsonFile(tempPath, data);
await runtimeIo.renameFile?.(tempPath, filePath);
}
Extend BangDreamRuntimeIo:
renameFile?: (from: string, to: string) => Promise<void>;
In builtin-plugin-worker.thread.ts createBangDreamRuntimeIo(), provide mkdir/rename through host-safe local worker fs:
renameFile: async (from, to) => {
mkdirSync(dirname(to), { recursive: true });
renameSync(from, to);
},
writeJsonFile: async (filePath, data) => {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(data));
},
Use Node fs only inside worker thread runtime boundary, not inside Nest services.
- Step 4: Refresh catalog from synced cache
In bangdream-catalog-cache.ts, export:
export async function refreshBangDreamCatalogFromCache(
keys?: readonly BangDreamCatalogKey[],
) {
const catalogKeys = normalizeCatalogKeys(keys);
for (const key of catalogKeys) {
bangdreamCatalogCache[key] = {};
}
await loadCatalogData(catalogKeys, true);
}
- Step 5: Expose task from plugin entry and manifest
In plugin.json, add:
"tasks": [
{
"key": "bangdream.bestdori.sync-main-data",
"name": "同步 Bestdori 主数据",
"handlerName": "syncBestdoriMainData",
"description": "同步 BangDream 重命令依赖的 Bestdori JSON 主数据。",
"defaultCron": "0 */6 * * *",
"enabled": true,
"timeoutMs": 120000,
"permissions": ["runtime.http", "plugin.storage.read", "plugin.storage.write"]
}
]
In src/index.ts:
import { createBestdoriMainDataSyncTask } from './application/tasks';
const tasks = [createBestdoriMainDataSyncTask()];
return {
...existingPluginFields,
tasks,
};
- Step 6: Run GREEN BangDream tests and manifest tests
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Expected: PASS.
- Step 7: Commit BangDream task
Run:
git add src/modules/qqbot/plugins/bangdream test/modules/qqbot/plugins/bangdream
git commit -m "feat: 增加BangDream主数据同步任务"
Task 5: Admin Task Caller, Cron Component, And Page
Files:
-
Modify:
D:\MyFiles\KT\Vue\kt-template-admin\pnpm-workspace.yaml -
Modify:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\package.json -
Modify:
D:\MyFiles\KT\Vue\kt-template-admin\pnpm-lock.yaml -
Create:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\api\qqbot\plugin-task.ts -
Create:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\api\qqbot\plugin-task.spec.ts -
Modify:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\router\routes\modules\qqbot.ts -
Create:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\qqbot\plugin-task\list.tsx -
Create:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\qqbot\plugin-task\components\CronEditorAntdvNext.tsx -
Create:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\qqbot\plugin-task\components\TaskCronModal.tsx -
Create:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\qqbot\plugin-task\components\TaskRunDrawer.tsx -
Create:
D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\qqbot\plugin-task\plugin-task.spec.tsx -
Step 1: Add dependency
Run:
pnpm add @vue-js-cron/core --filter @vben/web-antdv-next
Expected: apps/web-antdv-next/package.json includes @vue-js-cron/core; lockfile updates. If workspace catalog is used for all external frontend dependencies, move the version into pnpm-workspace.yaml catalog and keep app dependency as catalog:.
- Step 2: Write RED caller test
Create plugin-task.spec.ts:
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { requestClient } from '#/api/request';
import {
disableQqbotPluginTask,
enableQqbotPluginTask,
getQqbotPluginTaskPage,
getQqbotPluginTaskRunPage,
runQqbotPluginTaskOnce,
updateQqbotPluginTaskCron,
} from './plugin-task';
vi.mock('#/api/request', () => ({
requestClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('qqbot plugin task API wrappers', () => {
beforeEach(() => vi.clearAllMocks());
it('uses plugin-platform task endpoints', async () => {
vi.mocked(requestClient.get).mockResolvedValue({ list: [], total: 0 });
vi.mocked(requestClient.post).mockResolvedValue({});
await getQqbotPluginTaskPage({ enabled: true, pageNo: 1, pageSize: 10, taskKey: 'bangdream.bestdori.sync-main-data' });
await enableQqbotPluginTask('task-1');
await disableQqbotPluginTask('task-1');
await updateQqbotPluginTaskCron('task-1', '0 */6 * * *');
await runQqbotPluginTaskOnce('task-1', { force: true });
await getQqbotPluginTaskRunPage('task-1', { pageNo: 1, pageSize: 20 });
expect(requestClient.get).toHaveBeenCalledWith('/qqbot/plugin-platform/tasks/page', {
params: { enabled: true, pageNo: 1, pageSize: 10, taskKey: 'bangdream.bestdori.sync-main-data' },
});
expect(requestClient.post).toHaveBeenCalledWith('/qqbot/plugin-platform/tasks/task-1/enable');
expect(requestClient.post).toHaveBeenCalledWith('/qqbot/plugin-platform/tasks/task-1/disable');
expect(requestClient.post).toHaveBeenCalledWith('/qqbot/plugin-platform/tasks/task-1/cron', {
cronExpression: '0 */6 * * *',
});
expect(requestClient.post).toHaveBeenCalledWith('/qqbot/plugin-platform/tasks/task-1/run', {
input: { force: true },
});
expect(requestClient.get).toHaveBeenCalledWith('/qqbot/plugin-platform/tasks/task-1/runs', {
params: { pageNo: 1, pageSize: 20 },
});
});
});
Run:
pnpm -F @vben/web-antdv-next exec vitest run apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts
Expected: FAIL because caller does not exist.
- Step 3: Implement Admin caller
Create plugin-task.ts:
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
import type { QqbotApi } from './index';
export namespace QqbotPluginTaskApi {
export type RuntimeStatus = 'disabled' | 'failed' | 'idle' | 'running' | 'scheduled';
export type RunStatus = 'failed' | 'running' | 'skipped' | 'success';
export type TriggerType = 'bootstrap' | 'manual' | 'schedule';
export interface Task {
cronExpression: string;
defaultCron: string;
description?: null | string;
enabled: boolean;
id: string;
installationId: string;
lastDurationMs?: null | number;
lastError?: null | string;
lastRunAt?: null | string;
lastStatus?: null | RunStatus;
nextRunAt?: null | string;
pluginId: string;
pluginKey?: string;
pluginName?: string;
runtimeStatus: RuntimeStatus;
taskKey: string;
taskName: string;
}
export interface TaskRun {
createTime?: string;
durationMs?: null | number;
errorMessage?: null | string;
finishedAt?: null | string;
id: string;
jobId?: null | string;
safeSummary?: null | Recordable<any>;
startedAt?: null | string;
status: RunStatus;
taskId: string;
taskKey: string;
triggerType: TriggerType;
}
export interface TaskQuery extends Recordable<any> {
enabled?: boolean;
pageNo?: number;
pageSize?: number;
pluginId?: string;
pluginKey?: string;
status?: RuntimeStatus;
taskKey?: string;
}
export interface TaskRunQuery extends Recordable<any> {
pageNo?: number;
pageSize?: number;
status?: RunStatus;
triggerType?: TriggerType;
}
}
export function getQqbotPluginTaskPage(params: QqbotPluginTaskApi.TaskQuery) {
return requestClient.get<QqbotApi.PageResult<QqbotPluginTaskApi.Task>>(
'/qqbot/plugin-platform/tasks/page',
{ params },
);
}
export function enableQqbotPluginTask(id: string) {
return requestClient.post<QqbotPluginTaskApi.Task>(`/qqbot/plugin-platform/tasks/${id}/enable`);
}
export function disableQqbotPluginTask(id: string) {
return requestClient.post<QqbotPluginTaskApi.Task>(`/qqbot/plugin-platform/tasks/${id}/disable`);
}
export function updateQqbotPluginTaskCron(id: string, cronExpression: string) {
return requestClient.post<QqbotPluginTaskApi.Task>(`/qqbot/plugin-platform/tasks/${id}/cron`, {
cronExpression,
});
}
export function runQqbotPluginTaskOnce(id: string, input: Recordable<any> = {}) {
return requestClient.post<{ jobId: string; taskId: string }>(`/qqbot/plugin-platform/tasks/${id}/run`, {
input,
});
}
export function getQqbotPluginTaskRunPage(id: string, params: QqbotPluginTaskApi.TaskRunQuery) {
return requestClient.get<QqbotApi.PageResult<QqbotPluginTaskApi.TaskRun>>(
`/qqbot/plugin-platform/tasks/${id}/runs`,
{ params },
);
}
- Step 4: Add route
In router/routes/modules/qqbot.ts, add under plugin platform:
{
component: () => import('#/views/qqbot/plugin-task/list'),
meta: {
icon: 'lucide:calendar-clock',
title: '插件定时任务',
},
name: 'QqBotPluginTask',
path: '/qqbot/plugin-task',
},
- Step 5: Build Cron editor thin adapter
Create CronEditorAntdvNext.tsx with antdv-next controls and @vue-js-cron/core state. Keep output to five-field cron.
import { defineComponent, ref, watch } from 'vue';
import { Alert, Input, Radio, Space } from 'antdv-next';
import { parseCronExpression } from '@vue-js-cron/core';
export default defineComponent({
name: 'CronEditorAntdvNext',
props: {
value: { default: '0 */6 * * *', type: String },
},
emits: ['update:value', 'validChange'],
setup(props, { emit }) {
const expression = ref(props.value);
const error = ref('');
function validate(value: string) {
const fields = value.trim().split(/\s+/);
if (fields.length !== 5) {
error.value = '请输入 5 段 cron 表达式';
emit('validChange', false);
return;
}
try {
parseCronExpression(value);
error.value = '';
emit('validChange', true);
} catch {
error.value = 'cron 表达式不合法';
emit('validChange', false);
}
}
watch(
() => props.value,
(value) => {
expression.value = value;
validate(value);
},
{ immediate: true },
);
return () => (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Radio.Group
buttonStyle="solid"
value={expression.value}
onChange={(event: any) => {
expression.value = event.target.value;
emit('update:value', expression.value);
validate(expression.value);
}}
>
<Radio.Button value="0 */6 * * *">每 6 小时</Radio.Button>
<Radio.Button value="0 3 * * *">每天 03:00</Radio.Button>
<Radio.Button value="0 3 * * 1">每周一 03:00</Radio.Button>
</Radio.Group>
<Input
value={expression.value}
onChange={(event: any) => {
expression.value = event.target.value;
emit('update:value', expression.value);
validate(expression.value);
}}
/>
{error.value ? <Alert message={error.value} showIcon type="error" /> : null}
</Space>
);
},
});
If @vue-js-cron/core exposes a different exported function in the installed version, use its documented validation primitive and keep this component API unchanged.
- Step 6: Build page and drawers
list.tsx must use KtTable with task pagination and row actions:
const columns: Array<TableColumnType<QqbotPluginTaskApi.Task>> = [
{ dataIndex: 'pluginName', key: 'pluginName', title: '插件', width: 160 },
{ dataIndex: 'taskKey', key: 'taskKey', title: '任务 Key', width: 260 },
{ dataIndex: 'taskName', key: 'taskName', title: '任务名称', width: 180 },
{ dataIndex: 'cronExpression', key: 'cronExpression', title: 'Cron', width: 140 },
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'runtimeStatus', key: 'runtimeStatus', title: '运行状态', width: 120 },
{ dataIndex: 'lastStatus', key: 'lastStatus', title: '最近结果', width: 120 },
{ dataIndex: 'nextRunAt', key: 'nextRunAt', title: '下次运行', width: 180 },
];
const api: KtTableApi<QqbotPluginTaskApi.Task> = {
list: async (params) => await getQqbotPluginTaskPage(params),
};
Actions:
const rowActions = [
{ label: '运行一次', onClick: (row) => runOnce(row) },
{ label: '修改 Cron', onClick: (row) => openCron(row) },
{ label: '运行记录', onClick: (row) => openRuns(row) },
{ label: row.enabled ? '停用' : '启用', onClick: (row) => toggle(row) },
];
Use Tag colors:
const statusColor = {
disabled: 'default',
failed: 'error',
idle: 'default',
running: 'processing',
scheduled: 'success',
};
TaskCronModal.tsx wraps Modal + CronEditorAntdvNext; save calls updateQqbotPluginTaskCron.
TaskRunDrawer.tsx loads getQqbotPluginTaskRunPage(task.id, { pageNo: 1, pageSize: 20 }) and shows status, triggerType, durationMs, safeSummary JSON, and errorMessage.
- Step 7: Write and run page tests
Test route and component calls:
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import QqBotPluginTaskList from './list';
vi.mock('#/api/qqbot/plugin-task', () => ({
getQqbotPluginTaskPage: vi.fn(async () => ({ list: [], total: 0 })),
getQqbotPluginTaskRunPage: vi.fn(async () => ({ list: [], total: 0 })),
}));
describe('QQBot plugin task page', () => {
it('renders a single route root and task table shell', () => {
const wrapper = mount(QqBotPluginTaskList, {
global: { stubs: ['Page', 'KtTable'] },
});
expect(wrapper.exists()).toBe(true);
expect(wrapper.element.nodeType).toBe(Node.ELEMENT_NODE);
});
});
Run:
pnpm -F @vben/web-antdv-next exec vitest run apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts apps/web-antdv-next/src/views/qqbot/plugin-task/plugin-task.spec.tsx
Expected: PASS.
- Step 8: Run Admin typecheck
Run:
pnpm -F @vben/web-antdv-next run typecheck
Expected: PASS.
- Step 9: Commit Admin page
Run:
git -C D:\MyFiles\KT\Vue\kt-template-admin add pnpm-workspace.yaml pnpm-lock.yaml apps/web-antdv-next/package.json apps/web-antdv-next/src/api/qqbot apps/web-antdv-next/src/router/routes/modules/qqbot.ts apps/web-antdv-next/src/views/qqbot/plugin-task
git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "feat: 增加QQBot插件定时任务页面"
Task 6: Env, Menu Permissions, Local HTTP Smoke, And Docs
Files:
-
Modify:
D:\MyFiles\KT\Node\kt-template-online-api\.env.example -
Modify:
D:\MyFiles\KT\Node\kt-template-online-api\README.md -
Modify:
D:\MyFiles\KT\Node\kt-template-online-api\API.md -
Modify:
D:\MyFiles\KT\Node\kt-template-online-api\Jenkinsfile -
Modify:
D:\MyFiles\KT\Node\kt-template-online-api\k8s\prod\api.yaml -
Modify:
D:\MyFiles\KT\Node\kt-template-online-api\sql\qqbot-init.sql -
Modify:
D:\MyFiles\KT\TASKS.md -
Step 1: Add runtime env documentation
Add to .env.example:
# QQBot plugin scheduled task queue. Defaults reuse QQBOT_PLUGIN_QUEUE_REDIS_* when not set.
QQBOT_PLUGIN_TASK_QUEUE_REDIS_HOST=
QQBOT_PLUGIN_TASK_QUEUE_REDIS_PORT=6379
QQBOT_PLUGIN_TASK_QUEUE_REDIS_DB=0
QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX=kt:qqbot:plugin-task
# BangDream persistent main-data cache root.
BANGDREAM_TSUGU_CACHE_ROOT=.kt-workspace/cache/bangdream
In k8s/prod/api.yaml, mount/cache env must point to a persistent runtime path:
- name: BANGDREAM_TSUGU_CACHE_ROOT
value: /data/qqbot/plugins/bangdream/cache
- name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX
value: kt:qqbot:plugin-task
If the API pod currently has no persistent volume for /data/qqbot/plugins, add a hostPath or existing PVC mount consistent with current K8s style.
- Step 2: Add SQL menu permissions
Append sql/qqbot-init.sql menu rows under the QQBot group:
INSERT INTO admin_menu (id, parent_id, name, path, component, redirect, permission, type, meta, status, sort)
VALUES
(2041700000000100410, 2041700000000100400, 'QqBotPluginTask', '/qqbot/plugin-task', '/qqbot/plugin-task/list', NULL, 'QqBot:PluginTask:List', 'menu', '{"icon":"lucide:calendar-clock","title":"插件定时任务"}', 1, 5)
ON DUPLICATE KEY UPDATE
path = VALUES(path),
component = VALUES(component),
permission = VALUES(permission),
meta = VALUES(meta),
status = VALUES(status),
sort = VALUES(sort);
Add button permission rows if current menu table represents buttons separately:
INSERT INTO admin_menu (id, parent_id, name, path, component, redirect, permission, type, meta, status, sort)
VALUES
(2041700000000100411, 2041700000000100410, 'QqBotPluginTaskUpdateCron', '', '', NULL, 'QqBot:PluginTask:UpdateCron', 'button', '{"title":"修改 Cron"}', 1, 1),
(2041700000000100412, 2041700000000100410, 'QqBotPluginTaskEnable', '', '', NULL, 'QqBot:PluginTask:Enable', 'button', '{"title":"启用"}', 1, 2),
(2041700000000100413, 2041700000000100410, 'QqBotPluginTaskDisable', '', '', NULL, 'QqBot:PluginTask:Disable', 'button', '{"title":"停用"}', 1, 3),
(2041700000000100414, 2041700000000100410, 'QqBotPluginTaskRun', '', '', NULL, 'QqBot:PluginTask:Run', 'button', '{"title":"手动运行"}', 1, 4),
(2041700000000100415, 2041700000000100410, 'QqBotPluginTaskRunLog', '', '', NULL, 'QqBot:PluginTask:RunLog', 'button', '{"title":"运行记录"}', 1, 5)
ON DUPLICATE KEY UPDATE
permission = VALUES(permission),
meta = VALUES(meta),
status = VALUES(status),
sort = VALUES(sort);
- Step 3: Run full backend verification
Run:
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/manifest.spec.ts test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts test/modules/qqbot/plugin-platform/worker-runtime.spec.ts test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts
pnpm run typecheck
pnpm exec eslint src/modules/qqbot/plugin-platform src/modules/qqbot/plugins/bangdream/src test/modules/qqbot/plugin-platform test/modules/qqbot/plugins/bangdream
pnpm run build
git diff --check
Expected: Jest suites pass; typecheck passes; ESLint passes; build passes; diff check has no whitespace errors beyond known CRLF warnings if present.
- Step 4: Run local API HTTP smoke
Start or reuse local API according to repo conventions. With an admin token, call:
$headers = @{ Authorization = "Bearer $env:KT_ADMIN_TOKEN" }
Invoke-RestMethod -Headers $headers -Uri 'http://127.0.0.1:48085/qqbot/plugin-platform/tasks/page?pageNo=1&pageSize=10'
Invoke-RestMethod -Headers $headers -Method Post -Uri 'http://127.0.0.1:48085/qqbot/plugin-platform/tasks/<taskId>/cron' -Body (@{ cronExpression='0 */6 * * *' } | ConvertTo-Json) -ContentType 'application/json'
Invoke-RestMethod -Headers $headers -Method Post -Uri 'http://127.0.0.1:48085/qqbot/plugin-platform/tasks/<taskId>/run' -Body (@{ input=@{} } | ConvertTo-Json) -ContentType 'application/json'
Invoke-RestMethod -Headers $headers -Uri 'http://127.0.0.1:48085/qqbot/plugin-platform/tasks/<taskId>/runs?pageNo=1&pageSize=10'
Expected: task page returns BangDream task; cron update returns normalized cron; manual run returns job id; run list eventually includes running/success or failed with safe error.
- Step 5: Run Admin verification
Run:
pnpm -F @vben/web-antdv-next exec vitest run apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts apps/web-antdv-next/src/views/qqbot/plugin-task/plugin-task.spec.tsx
pnpm -F @vben/web-antdv-next run typecheck
Start Admin dev server and use Browser/Playwright to verify:
-
/qqbot/plugin-taskloads without route blank page. -
Task table fetches
/qqbot/plugin-platform/tasks/page. -
Cron modal opens, edits
0 */6 * * *, saves. -
Run-log drawer opens and lists rows.
-
Step 6: Update docs and TASKS
Update README.md and API.md with:
- task manifest
tasksfields; - task endpoints;
- BullMQ task queue env;
- BangDream cache root env;
- Admin page path
/qqbot/plugin-task.
Update root D:\MyFiles\KT\TASKS.md recent record with scope, keywords, and verification evidence only.
- Step 7: Run KT review and cleanup gates
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --repo D:\MyFiles\KT\Node\kt-template-online-api --changed
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --repo D:\MyFiles\KT\Vue\kt-template-admin --changed
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run
If cleanup dry-run lists stale artifacts:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --execute
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run
Expected: review findings empty or fixed; cleanup final deleted=[].
- Step 8: Commit final docs/env/menu
Run:
git -C D:\MyFiles\KT\Node\kt-template-online-api add .env.example README.md API.md Jenkinsfile k8s/prod/api.yaml sql/qqbot-init.sql sql/refactor-v3 src test
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 完成QQBot插件定时任务平台"
git -C D:\MyFiles\KT add TASKS.md
git -C D:\MyFiles\KT commit -m "docs: 记录QQBot插件定时任务闭环"
Task 7: Push, Deploy, And Online Closure
Files:
-
No planned code files. Runtime evidence must stay under
.kt-workspace/test-artifacts/qqbot-plugin-task/**. -
Step 1: Push changed repos
Run only after local verification and review pass:
git -C D:\MyFiles\KT\Node\kt-template-online-api status --short --branch
git -C D:\MyFiles\KT\Vue\kt-template-admin status --short --branch
git -C D:\MyFiles\KT\Node\kt-template-online-api push
git -C D:\MyFiles\KT\Vue\kt-template-admin push
Expected: API/Admin branches push successfully.
- Step 2: Observe Jenkins/K8s rollout
Use the stabilized deploy observation workflow:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --execute
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --project admin --job KT-Template/KT-Template-Admin/main --execute
Expected for API: Jenkins build success, Deployment observedGeneration matches generation, desired=ready=updated=1, pod Running, image tag matches pushed commit, restartCount stable.
Expected for Admin: Jenkins build success and static deployment points to pushed commit/build.
- Step 3: Run online API task smoke
Through the existing online tunnel/admin token flow, call:
GET /qqbot/plugin-platform/tasks/page?pageNo=1&pageSize=10&taskKey=bangdream.bestdori.sync-main-data
POST /qqbot/plugin-platform/tasks/<taskId>/cron {"cronExpression":"0 */6 * * *"}
POST /qqbot/plugin-platform/tasks/<taskId>/run {"input":{}}
GET /qqbot/plugin-platform/tasks/<taskId>/runs?pageNo=1&pageSize=10
POST /qqbot/command/test {"commandId":"<查分数表命令ID>","text":"/查分数表 cn","targetType":"private","targetId":"KT_TEST","userId":"KT_TEST"}
Expected:
-
task exists and is enabled;
-
cron update succeeds;
-
manual run creates a run row and finishes
successorfailedwith explicit safe error; -
BangDream command still succeeds after task run;
-
response summaries redact
replyText, image/base64 payloads, tokens, cookies, and QR data. -
Step 4: Run online Admin page smoke
Use Browser/Playwright:
- Open Admin
/qqbot/plugin-task. - Confirm table rows render.
- Open Cron modal and save
0 */6 * * *. - Click manual run.
- Open run-log drawer and verify latest run row appears.
- Switch away and back to ensure route has a single stable root and no blank page.
Expected: all page actions complete without console route-root warning or failed network request.
- Step 5: Final closeout
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --repo D:\MyFiles\KT\Node\kt-template-online-api --changed
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --repo D:\MyFiles\KT\Vue\kt-template-admin --changed
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run
Expected: findings empty; cleanup final deleted=[]; final answer reports local tests, online API smoke, online Admin smoke, deployment evidence, remaining risks, and exact artifact paths.
Self-Review
- Spec coverage: manifest
tasks, DB design, BullMQ scheduler bridge, workerexecuteTask, Admin dedicated page, cron editor with@vue-js-cron/core, BangDream Bestdori main-data sync, menu/permissions, env, local/online verification are all mapped to tasks. - Placeholder scan: no task step uses TBD, broad "handle errors" placeholders, or out-of-order references without file paths.
- Type consistency: task status names are
idle | scheduled | running | failed | disabled; run status names arerunning | success | failed | skipped; trigger types areschedule | manual | bootstrap; route base is/qqbot/plugin-platform/tasks.