kt-template-online-api/docs/superpowers/plans/2026-06-16-qqbot-plugin-scheduled-tasks-implementation-plan.md

77 KiB

QQBot Plugin Scheduled Tasks Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

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\superpowers\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.json reported 175 OpenAPI operations, 102 pass, 4 expectedBlocked, 69 skipped, 0 failed. QQBot concurrent /查分数表 cn + /查卡 472 both passed after the queue-wait timeout fix deployed as API 14e2ec9.
  • 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 or builtins/**.
  • 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 manifest tasks into qqbot_plugin_task rows 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 creates qqbot_plugin_task_run, prevents overlapping runs, calls executeTask, 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
    Add QqbotPluginTaskManifest and tasks to QqbotPluginManifest.
  • src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts
    Parse and validate manifest tasks.
  • src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts
    Add QqbotPluginTask and QqbotPluginTaskRun entities.
  • 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
    Include executeTask in 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
    Add executeTask request type and request DTO.
  • src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts
    Add executeTask() runtime method with safe input summary and timeout handling.
  • src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts
    Dispatch executeTask to plugin task handlers.
  • src/modules/qqbot/plugins/bangdream/plugin.json
    Declare bangdream.bestdori.sync-main-data.
  • src/modules/qqbot/plugins/bangdream/src/index.ts
    Expose BangDream tasks array 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
    Add qqbot_plugin_task and qqbot_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/core through 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 running run row with startedAt;

  • 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 executeTask method 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-task loads 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 tasks fields;
  • 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 success or failed with 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, worker executeTask, 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 are running | success | failed | skipped; trigger types are schedule | manual | bootstrap; route base is /qqbot/plugin-platform/tasks.