From fe77f5ef632d72aeade0d5ee4c258de1764771e4 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 17 Jun 2026 11:20:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90QQBot=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +- API.md | 33 +- README.md | 6 +- ...gin-scheduled-tasks-implementation-plan.md | 2322 +++++++++++++++++ k8s/prod/api.yaml | 10 + package.json | 1 + pnpm-lock.yaml | 3 + sql/qqbot-init.sql | 63 +- .../application/plugin-platform.service.ts | 87 +- .../task/qqbot-plugin-task-cron.validator.ts | 6 + .../qqbot-plugin-task-scheduler.service.ts | 76 +- .../qqbot-plugin-task-worker.processor.ts | 32 + .../task/qqbot-plugin-task.service.ts | 7 +- .../plugin-lifecycle-runtime.spec.ts | 120 + .../plugin-task-api-contract.spec.ts | 40 + .../plugin-task-manifest.spec.ts | 6 + .../plugin-task-persistence.spec.ts | 12 + .../plugin-task-scheduler.spec.ts | 235 +- 18 files changed, 3031 insertions(+), 36 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-16-qqbot-plugin-scheduled-tasks-implementation-plan.md diff --git a/.env.example b/.env.example index 46f9890..86088f9 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,12 @@ QQBOT_PLUGIN_QUEUE_REDIS_PREFIX=kt:qqbot:plugin-worker QQBOT_PLUGIN_QUEUE_REMOVE_ON_FAIL=100 QQBOT_PLUGIN_QUEUE_WAIT_BUFFER_MS=5000 QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS=120000 +# QQBot plugin scheduled task queue. Defaults reuse QQBOT_PLUGIN_QUEUE_REDIS_* when host/port/password/db are empty. +QQBOT_PLUGIN_TASK_QUEUE_REDIS_HOST= +QQBOT_PLUGIN_TASK_QUEUE_REDIS_PORT= +QQBOT_PLUGIN_TASK_QUEUE_REDIS_PASSWORD= +QQBOT_PLUGIN_TASK_QUEUE_REDIS_DB= +QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX=kt:qqbot:plugin-task QQBOT_COMMAND_MIN_COOLDOWN_MS=5000 QQBOT_RULE_MIN_COOLDOWN_MS=30000 QQBOT_REPEATER_THRESHOLD=4 @@ -101,7 +107,7 @@ BANGDREAM_TSUGU_MAIN_SERVER=cn BANGDREAM_TSUGU_DISPLAYED_SERVERS=cn,jp BANGDREAM_TSUGU_COMPRESS=true BANGDREAM_TSUGU_USE_EASY_BG=false -BANGDREAM_TSUGU_CACHE_ROOT= +BANGDREAM_TSUGU_CACHE_ROOT=.kt-workspace/cache/bangdream MQTT_URL=mqtt://127.0.0.1:1883 MQTT_USERNAME= diff --git a/API.md b/API.md index 4f1b3d7..286b93b 100644 --- a/API.md +++ b/API.md @@ -85,7 +85,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | | Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | -| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` | +| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | @@ -94,7 +94,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 真实密码、Token、OAuth secret 和生产 env 不提交到 Git。 -QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Service `kt-qqbot-plugin-redis:6379`,用于 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` / `QQBOT_PLUGIN_QUEUE_REDIS_PORT`。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制串行队列的排队等待窗口,避免排队时间挤占插件操作本身的 `timeoutMs` 执行预算。 +QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Service `kt-qqbot-plugin-redis:6379`,用于 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` / `QQBOT_PLUGIN_QUEUE_REDIS_PORT`。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制串行队列的排队等待窗口,避免排队时间挤占插件操作本身的 `timeoutMs` 执行预算。插件定时任务可通过 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 使用独立 BullMQ prefix;未配置 host 时复用插件 worker Redis 连接。 ## Admin 与基础后台 @@ -415,7 +415,20 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT ### Plugin Platform -插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、权限和运行预算;后端会校验路径必须留在插件包内,权限必须命中白名单,安装包 content hash 必须与 manifest 匹配。CLI 入口: +插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、定时任务、权限和运行预算;后端会校验路径必须留在插件包内,权限必须命中白名单,安装包 content hash 必须与 manifest 匹配。`tasks` 字段声明平台托管的定时任务: + +| 字段 | 说明 | +| -------------- | ---------------------------------------------- | +| `key` | 全局唯一任务 key,例如 `bangdream.bestdori.sync-main-data` | +| `name` | Admin 展示名称 | +| `handlerName` | 插件入口暴露的任务处理器名称 | +| `defaultCron` | 5 段 cron 表达式,不允许每分钟执行,并按 BullMQ/cron-parser 语义校验 | +| `timeoutMs` | 单次任务执行预算 | +| `enabled` | 安装/启用时是否默认调度 | +| `permissions` | 任务需要的插件权限,例如 `runtime.http`、`plugin.storage.write` | +| `description` | 可选说明 | + +CLI 入口: ```bash pnpm qqbot-plugin create @@ -441,8 +454,22 @@ pnpm qqbot-plugin install-local | `GET` | `/qqbot/plugin-platform/runtime-events` | 查询插件运行事件 | | `GET` | `/qqbot/plugin-platform/account-bindings` | 查询插件账号绑定 | +定时任务管理接口: + +| 方法 | 路径 | 说明 | +| ------ | ----------------------------------------- | -------------------------------------- | +| `GET` | `/qqbot/plugin-platform/tasks/page` | 插件定时任务分页,支持插件、状态过滤 | +| `GET` | `/qqbot/plugin-platform/tasks/:id` | 任务详情 | +| `POST` | `/qqbot/plugin-platform/tasks/:id/enable` | 启用任务并注册 BullMQ Job Scheduler | +| `POST` | `/qqbot/plugin-platform/tasks/:id/disable` | 停用任务并移除调度 | +| `POST` | `/qqbot/plugin-platform/tasks/:id/cron` | 修改 5 段 cron,校验通过后重建调度 | +| `POST` | `/qqbot/plugin-platform/tasks/:id/run` | 手动提交一次任务 | +| `GET` | `/qqbot/plugin-platform/tasks/:id/runs` | 任务运行记录分页 | + `src/modules/qqbot/plugin-platform/runtime` 当前提供 host-side driver 边界和超时/崩溃事件归档;实际 worker/child-process driver 可以在后续批次接入,但插件侧只能通过受控 SDK 访问发送队列、配置、存储、HTTP、资产和事件上下文。 +Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修改 cron、手动运行和查看运行记录。BangDream 内置任务 `bangdream.bestdori.sync-main-data` 会定期同步 Bestdori 主数据到 `BANGDREAM_TSUGU_CACHE_ROOT`。 + ### OneBot Reverse WebSocket `QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 API,token 使用 `QQBOT_REVERSE_WS_TOKEN`。 diff --git a/README.md b/README.md index 7e97004..3564205 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ | `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | | `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | | `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | -| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 | +| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 | | `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | | `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | | `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | @@ -62,7 +62,7 @@ ci/ Jenkins Agent/Docker 辅助文件 | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | -| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | +| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | | FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | @@ -71,6 +71,8 @@ ci/ Jenkins Agent/Docker 辅助文件 QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的请求。K8s 生产清单包含内部服务 `kt-qqbot-plugin-redis`,生产 env 可将 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` 配为该服务名。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制排队等待窗口,插件 `operation.timeoutMs` 仍表示单次执行预算。 +QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qqbot_plugin_task` / `qqbot_plugin_task_run`,通过 BullMQ Job Scheduler 调度并经插件 worker 的 `executeTask` 边界执行。`sql/qqbot-init.sql` 可为既有环境增量创建任务表和 Admin 菜单。Admin 页面路径为 `/qqbot/plugin-task`。定时任务队列可用 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 单独配置;留空时复用插件 worker 队列的 Redis 连接。BangDream Bestdori 主数据缓存使用 `BANGDREAM_TSUGU_CACHE_ROOT`,生产清单挂载到 `/data/qqbot/plugins/bangdream/cache`。 + ## 启动 ```bash diff --git a/docs/superpowers/plans/2026-06-16-qqbot-plugin-scheduled-tasks-implementation-plan.md b/docs/superpowers/plans/2026-06-16-qqbot-plugin-scheduled-tasks-implementation-plan.md new file mode 100644 index 0000000..be7abef --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-qqbot-plugin-scheduled-tasks-implementation-plan.md @@ -0,0 +1,2322 @@ +# 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` + +```sql +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` + +```sql +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. + +```ts +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: + +```powershell +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`: + +```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`: + +```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`: + +```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`: + +```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`. + +```ts +const parseTasks = ( + source: Record, + issues: QqbotPluginManifestValidationIssue[], +): QqbotPluginTaskManifest[] => { + const tasks = Array.isArray(source.tasks) ? source.tasks : []; + const seenKeys = new Set(); + + return tasks.filter(isPlainObject).map((task, index) => { + const pathPrefix = `tasks[${index}]`; + const key = getString(task, 'key') || ''; + const timeoutMs = getNumber(task, 'timeoutMs'); + let defaultCron = getString(task, 'defaultCron') || ''; + + requireKey(key, `${pathPrefix}.key`, issues); + if (seenKeys.has(key)) { + pushIssue(issues, 'DUPLICATE_TASK_KEY', pathPrefix, `Duplicate task key: ${key}.`); + } + seenKeys.add(key); + + if (!getString(task, 'handlerName')) { + pushIssue(issues, 'MISSING_TASK_HANDLER', `${pathPrefix}.handlerName`, 'Task handlerName is required.'); + } + if (!timeoutMs) { + pushIssue(issues, 'MISSING_TASK_TIMEOUT', `${pathPrefix}.timeoutMs`, 'Task timeoutMs is required.'); + } + try { + defaultCron = normalizeQqbotPluginTaskCron(defaultCron); + } catch (error) { + pushIssue( + issues, + 'INVALID_TASK_CRON', + `${pathPrefix}.defaultCron`, + error instanceof Error ? error.message : 'Task cron is invalid.', + ); + } + + return { + defaultCron, + description: getString(task, 'description'), + enabled: task.enabled !== false, + handlerName: getString(task, 'handlerName') || '', + key, + name: getString(task, 'name') || key, + permissions: normalizePermissions(task.permissions, `${pathPrefix}.permissions`, issues), + timeoutMs: timeoutMs || 1000, + }; + }); +}; +``` + +- [ ] **Step 5: Run GREEN manifest tests** + +Run: + +```powershell +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: + +```powershell +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: + +```ts +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: + +```ts +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: + +```powershell +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`: + +```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; + + @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`: + +```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`: + +```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) { + return vbenSuccess(await this.service.pageTasks(query)); + } + + @Get(':id') + @ApiOperation({ summary: '插件定时任务详情' }) + async detail(@Param('id') id: string) { + return vbenSuccess(await this.service.getTaskDetail(id)); + } + + @Post(':id/enable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '启用插件定时任务' }) + async enable(@Param('id') id: string) { + return vbenSuccess(await this.service.enableTask(id)); + } + + @Post(':id/disable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '停用插件定时任务' }) + async disable(@Param('id') id: string) { + return vbenSuccess(await this.service.disableTask(id)); + } + + @Post(':id/cron') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新插件定时任务 cron' }) + async updateCron( + @Param('id') id: string, + @Body() body: { cronExpression?: string }, + ) { + return vbenSuccess(await this.service.updateTaskCron(id, body)); + } + + @Post(':id/run') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '手动运行插件定时任务' }) + async run(@Param('id') id: string, @Body() body: { input?: Record }) { + return vbenSuccess(await this.service.runTaskOnce(id, body)); + } + + @Get(':id/runs') + @ApiOperation({ summary: '插件定时任务运行记录分页' }) + async runs(@Param('id') id: string, @Query() query: Record) { + return vbenSuccess(await this.service.pageTaskRuns(id, query)); + } +} +``` + +- [ ] **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. + +```ts +@Injectable() +export class QqbotPluginTaskService { + constructor( + @InjectRepository(QqbotPluginTask) + private readonly taskRepository: Repository, + @InjectRepository(QqbotPluginTaskRun) + private readonly runRepository: Repository, + ) {} + + 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 }) { + 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`: + +```ts +controllers: [ + QqbotPluginController, + QqbotPluginPlatformController, + QqbotPluginPlatformTaskController, +], +providers: [ + QqbotPluginTaskManifestSynchronizer, + QqbotPluginTaskService, + ... +], +``` + +- [ ] **Step 8: Run GREEN persistence/API tests** + +Run: + +```powershell +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: + +```powershell +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`: + +```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: + +```powershell +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: + +```ts +export type QqbotPluginWorkerRequestType = + | 'activate' + | 'deactivate' + | 'dispose' + | 'executeOperation' + | 'executeTask' + | 'handleEvent' + | 'health' + | 'load'; + +export type QqbotPluginTaskRequest = { + input: Record; + taskHandlerName: string; + taskId: string; + taskKey: string; + timeoutMs?: number; + triggerType: 'bootstrap' | 'manual' | 'schedule'; +}; +``` + +Add optional request fields: + +```ts +taskHandlerName?: string; +taskId?: string; +taskKey?: string; +triggerType?: 'bootstrap' | 'manual' | 'schedule'; +``` + +In `worker-runtime.ts`, add: + +```ts +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: + +```ts +type RuntimeCommandPlugin = QqbotIntegrationPlugin & { + activate?: () => Promise | unknown; + dispose?: () => Promise | unknown; + tasks?: Array<{ + execute(input: Record): Promise> | Record; + handlerName: string; + key: string; + }>; +}; +``` + +Add switch case: + +```ts +case 'executeTask': + return executeTask(message); +``` + +Add function: + +```ts +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); +} +``` + +- [ ] **Step 4: Run GREEN runtime test** + +Run: + +```powershell +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`: + +```ts +const createdQueues: any[] = []; +const createdWorkers: any[] = []; + +jest.mock('bullmq', () => ({ + Queue: class MockQueue { + readonly schedulers = new Map(); + 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: + +```ts +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: + +```powershell +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`. + +```ts +@Injectable() +export class QqbotPluginTaskSchedulerService implements OnModuleDestroy, OnModuleInit { + private readonly queue: Queue; + + constructor( + private readonly configService: ConfigService, + @InjectRepository(QqbotPluginTask) + private readonly taskRepository: Repository, + ) { + 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) { + 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) { + 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: + +```ts +@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, + @InjectRepository(QqbotPluginTaskRun) + private readonly runRepository: Repository, + ) {} + + 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; 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: + +```ts +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 }) { + 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`: + +```ts +async executeTask(input: { + input: Record; + 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: + +```powershell +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: + +```powershell +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: + +```ts +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: + +```powershell +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`: + +```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) { + 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(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: + +```ts +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`: + +```ts +renameFile?: (from: string, to: string) => Promise; +``` + +In `builtin-plugin-worker.thread.ts` `createBangDreamRuntimeIo()`, provide mkdir/rename through host-safe local worker fs: + +```ts +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: + +```ts +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: + +```json +"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`: + +```ts +import { createBestdoriMainDataSyncTask } from './application/tasks'; + +const tasks = [createBestdoriMainDataSyncTask()]; + +return { + ...existingPluginFields, + tasks, +}; +``` + +- [ ] **Step 6: Run GREEN BangDream tests and manifest tests** + +Run: + +```powershell +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: + +```powershell +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: + +```powershell +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`: + +```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: + +```powershell +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`: + +```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; + startedAt?: null | string; + status: RunStatus; + taskId: string; + taskKey: string; + triggerType: TriggerType; + } + + export interface TaskQuery extends Recordable { + enabled?: boolean; + pageNo?: number; + pageSize?: number; + pluginId?: string; + pluginKey?: string; + status?: RuntimeStatus; + taskKey?: string; + } + + export interface TaskRunQuery extends Recordable { + pageNo?: number; + pageSize?: number; + status?: RunStatus; + triggerType?: TriggerType; + } +} + +export function getQqbotPluginTaskPage(params: QqbotPluginTaskApi.TaskQuery) { + return requestClient.get>( + '/qqbot/plugin-platform/tasks/page', + { params }, + ); +} + +export function enableQqbotPluginTask(id: string) { + return requestClient.post(`/qqbot/plugin-platform/tasks/${id}/enable`); +} + +export function disableQqbotPluginTask(id: string) { + return requestClient.post(`/qqbot/plugin-platform/tasks/${id}/disable`); +} + +export function updateQqbotPluginTaskCron(id: string, cronExpression: string) { + return requestClient.post(`/qqbot/plugin-platform/tasks/${id}/cron`, { + cronExpression, + }); +} + +export function runQqbotPluginTaskOnce(id: string, input: Recordable = {}) { + 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>( + `/qqbot/plugin-platform/tasks/${id}/runs`, + { params }, + ); +} +``` + +- [ ] **Step 4: Add route** + +In `router/routes/modules/qqbot.ts`, add under plugin platform: + +```ts +{ + 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. + +```tsx +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 () => ( + + { + expression.value = event.target.value; + emit('update:value', expression.value); + validate(expression.value); + }} + > + 每 6 小时 + 每天 03:00 + 每周一 03:00 + + { + expression.value = event.target.value; + emit('update:value', expression.value); + validate(expression.value); + }} + /> + {error.value ? : null} + + ); + }, +}); +``` + +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: + +```tsx +const columns: Array> = [ + { 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 = { + list: async (params) => await getQqbotPluginTaskPage(params), +}; +``` + +Actions: + +```tsx +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: + +```ts +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: + +```ts +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: + +```powershell +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: + +```powershell +pnpm -F @vben/web-antdv-next run typecheck +``` + +Expected: PASS. + +- [ ] **Step 9: Commit Admin page** + +Run: + +```powershell +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`: + +```dotenv +# 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: + +```yaml +- 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: + +```sql +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: + +```sql +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: + +```powershell +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: + +```powershell +$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//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//run' -Body (@{ input=@{} } | ConvertTo-Json) -ContentType 'application/json' +Invoke-RestMethod -Headers $headers -Uri 'http://127.0.0.1:48085/qqbot/plugin-platform/tasks//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: + +```powershell +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: + +```powershell +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: + +```powershell +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: + +```powershell +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: + +```powershell +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: + +```powershell +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: + +```powershell +GET /qqbot/plugin-platform/tasks/page?pageNo=1&pageSize=10&taskKey=bangdream.bestdori.sync-main-data +POST /qqbot/plugin-platform/tasks//cron {"cronExpression":"0 */6 * * *"} +POST /qqbot/plugin-platform/tasks//run {"input":{}} +GET /qqbot/plugin-platform/tasks//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: + +```powershell +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`. diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 6e81507..c15ac0a 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -32,6 +32,10 @@ spec: value: Asia/Shanghai - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa + - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX + value: kt:qqbot:plugin-task + - name: BANGDREAM_TSUGU_CACHE_ROOT + value: /data/qqbot/plugins/bangdream/cache # Jenkins 每次发布会从 Agent 私有 .env.production 重建这个 Secret。 envFrom: - secretRef: @@ -40,6 +44,8 @@ spec: - name: napcat-ssh-key mountPath: /app/secrets/napcat-ssh readOnly: true + - name: qqbot-plugin-data + mountPath: /data/qqbot/plugins readinessProbe: tcpSocket: port: 48085 @@ -67,6 +73,10 @@ spec: secretName: kt-qqbot-napcat-ssh-key optional: true defaultMode: 0400 + - name: qqbot-plugin-data + hostPath: + path: /vol1/docker/kt-template-online-api/qqbot-plugins + type: DirectoryOrCreate --- apiVersion: v1 kind: Service diff --git a/package.json b/package.json index 92b3b76..b38c49c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "bullmq": "5.78.1", "chart.js": "^4.5.1", "chartjs-adapter-moment": "^1.0.1", + "cron-parser": "4.9.0", "cross-env": "^7.0.3", "express": "5.2.1", "lodash": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c7b430..ea0b06c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: chartjs-adapter-moment: specifier: ^1.0.1 version: 1.0.1(chart.js@4.5.1)(moment@2.30.1) + cron-parser: + specifier: 4.9.0 + version: 4.9.0 cross-env: specifier: ^7.0.3 version: 7.0.3 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 8e62ee5..152fab5 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -324,6 +324,55 @@ CREATE TABLE IF NOT EXISTS `qqbot_dedupe` ( UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +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 DEFAULT 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 DEFAULT NULL, + `last_run_at` datetime DEFAULT NULL, + `last_status` varchar(32) DEFAULT NULL, + `last_error` text DEFAULT NULL, + `last_duration_ms` int DEFAULT NULL, + `next_run_at` datetime DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + 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 COLLATE=utf8mb4_unicode_ci; + +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) DEFAULT NULL, + `started_at` datetime DEFAULT NULL, + `finished_at` datetime DEFAULT NULL, + `duration_ms` int DEFAULT NULL, + `safe_summary` json DEFAULT NULL, + `error_message` text DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + 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 COLLATE=utf8mb4_unicode_ci; + SET @qqbot_sql = ( SELECT IF( COUNT(*) = 0, @@ -886,12 +935,18 @@ VALUES (2041700000000120444, 2041700000000100408, 'QqBotCommandToggle', NULL, NULL, NULL, 'QqBot:Command:Toggle', 'button', '{"title":"启停"}', 1, 0), (2041700000000120445, 2041700000000100408, 'QqBotCommandTest', NULL, NULL, NULL, 'QqBot:Command:Test', 'button', '{"title":"测试命令"}', 1, 0), (2041700000000100409, 2041700000000100400, 'QqBotPlugin', '/qqbot/plugin', '/qqbot/plugin/list', NULL, 'QqBot:Plugin:List', 'menu', '{"icon":"lucide:plug","title":"插件能力"}', 1, 4), - (2041700000000100404, 2041700000000100400, 'QqBotConversation', '/qqbot/conversation', '/qqbot/conversation/list', NULL, 'QqBot:Conversation:List', 'menu', '{"icon":"lucide:messages-square","title":"会话管理"}', 1, 5), - (2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 6), - (2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 7), + (2041700000000100411, 2041700000000100400, 'QqBotPluginTask', '/qqbot/plugin-task', '/qqbot/plugin-task/list', NULL, 'QqBot:PluginTask:List', 'menu', '{"icon":"lucide:calendar-clock","title":"插件定时任务"}', 1, 5), + (2041700000000120451, 2041700000000100411, 'QqBotPluginTaskUpdateCron', NULL, NULL, NULL, 'QqBot:PluginTask:UpdateCron', 'button', '{"title":"修改 Cron"}', 1, 0), + (2041700000000120452, 2041700000000100411, 'QqBotPluginTaskEnable', NULL, NULL, NULL, 'QqBot:PluginTask:Enable', 'button', '{"title":"启用"}', 1, 0), + (2041700000000120453, 2041700000000100411, 'QqBotPluginTaskDisable', NULL, NULL, NULL, 'QqBot:PluginTask:Disable', 'button', '{"title":"停用"}', 1, 0), + (2041700000000120454, 2041700000000100411, 'QqBotPluginTaskRun', NULL, NULL, NULL, 'QqBot:PluginTask:Run', 'button', '{"title":"手动运行"}', 1, 0), + (2041700000000120455, 2041700000000100411, 'QqBotPluginTaskRunLog', NULL, NULL, NULL, 'QqBot:PluginTask:RunLog', 'button', '{"title":"运行记录"}', 1, 0), + (2041700000000100404, 2041700000000100400, 'QqBotConversation', '/qqbot/conversation', '/qqbot/conversation/list', NULL, 'QqBot:Conversation:List', 'menu', '{"icon":"lucide:messages-square","title":"会话管理"}', 1, 6), + (2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 7), + (2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 8), (2041700000000120421, 2041700000000100406, 'QqBotSendPrivate', NULL, NULL, NULL, 'QqBot:Send:Private', 'button', '{"title":"发送私聊"}', 1, 0), (2041700000000120422, 2041700000000100406, 'QqBotSendGroup', NULL, NULL, NULL, 'QqBot:Send:Group', 'button', '{"title":"发送群聊"}', 1, 0), - (2041700000000100407, 2041700000000100400, 'QqBotPermission', '/qqbot/permission', '/qqbot/permission/list', NULL, 'QqBot:Permission:List', 'menu', '{"icon":"lucide:shield-check","title":"权限名单"}', 1, 8), + (2041700000000100407, 2041700000000100400, 'QqBotPermission', '/qqbot/permission', '/qqbot/permission/list', NULL, 'QqBot:Permission:List', 'menu', '{"icon":"lucide:shield-check","title":"权限名单"}', 1, 9), (2041700000000120431, 2041700000000100407, 'QqBotPermissionCreate', NULL, NULL, NULL, 'QqBot:Permission:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120432, 2041700000000100407, 'QqBotPermissionEdit', NULL, NULL, NULL, 'QqBot:Permission:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120433, 2041700000000100407, 'QqBotPermissionDelete', NULL, NULL, NULL, 'QqBot:Permission:Delete', 'button', '{"title":"common.delete"}', 1, 0) diff --git a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts index 3f8992e..15fe432 100644 --- a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts +++ b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts @@ -639,25 +639,76 @@ export class QqbotPluginPlatformService const persistedInstallation = persistedState.enabledInstallationsByPluginKey.get(manifest.pluginKey); - const installation = - persistedInstallation || - ({ - id: `builtin-${manifest.pluginKey}`, - installedPath: `builtin://${manifest.pluginKey}`, - pluginId: manifest.pluginKey, - runtimeStatus: 'stopped', - status: 'installed', - versionId: `builtin-${manifest.pluginKey}-${manifest.version}`, - } as QqbotPluginInstallation); + const { installation, version } = persistedInstallation + ? this.resolvePersistedBuiltinRuntime(manifest, persistedInstallation) + : await this.ensureBuiltinRuntimePersistence(manifest); - await this.startWorker(installation, { - id: `builtin-${manifest.pluginKey}-${manifest.version}`, + await this.startWorker(installation, version); + } + } + + private resolvePersistedBuiltinRuntime( + manifest: QqbotPluginManifest, + installation: QqbotPluginInstallation, + ) { + return { + installation, + version: { + id: installation.versionId, manifestJson: manifest as unknown as Record, packageHash: `builtin-${manifest.pluginKey}`, - pluginId: manifest.pluginKey, + pluginId: installation.pluginId, version: manifest.version, - } as QqbotPluginVersion); - } + } as QqbotPluginVersion, + }; + } + + private async ensureBuiltinRuntimePersistence(manifest: QqbotPluginManifest) { + const plugin = await this.ensureBuiltinPlugin(manifest); + const version = await this.ensureBuiltinPluginVersion(plugin.id, manifest); + const installation = await this.installationRepository.save({ + installedPath: `builtin://${manifest.pluginKey}`, + pluginId: plugin.id, + runtimeStatus: 'stopped', + status: 'enabled', + versionId: version.id, + }); + + return { installation, version }; + } + + private async ensureBuiltinPlugin(manifest: QqbotPluginManifest) { + const existing = await this.pluginRepository.findOne({ + where: { pluginKey: manifest.pluginKey }, + }); + if (existing) return existing; + + return this.pluginRepository.save({ + description: manifest.description || null, + pluginKey: manifest.pluginKey, + pluginName: manifest.name, + status: 'installed', + }); + } + + private async ensureBuiltinPluginVersion( + pluginId: string, + manifest: QqbotPluginManifest, + ) { + const existing = await this.versionRepository.findOne({ + where: { + pluginId, + version: manifest.version, + }, + }); + if (existing) return existing; + + return this.versionRepository.save({ + manifestJson: manifest as unknown as Record, + packageHash: `builtin-${manifest.pluginKey}`, + pluginId, + version: manifest.version, + }); } private async resolvePersistedPluginRuntimeState(): Promise { @@ -757,6 +808,12 @@ export class QqbotPluginPlatformService scheduleEnabledTasks: boolean, ) { if (!this.taskSynchronizer || !manifest.tasks.length) return []; + if ( + !this.isPersistablePluginId(installation.pluginId) || + !this.isPersistablePluginId(installation.id) + ) { + return []; + } const tasks = await this.taskSynchronizer.syncManifestTasks({ installationId: installation.id, diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts index ab2af21..7c75b3f 100644 --- a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-cron.validator.ts @@ -1,3 +1,4 @@ +import { parseExpression } from 'cron-parser'; import { throwVbenError } from '@/common'; const fieldPattern = /^[\d*/,\-]+$/; @@ -14,6 +15,11 @@ export function normalizeQqbotPluginTaskCron(input: unknown): string { if (fields[0] === '*') { throw new Error('定时任务 cron 不允许每分钟执行'); } + try { + parseExpression(fields.join(' ')); + } catch { + throw new Error('定时任务 cron 表达式不合法'); + } return fields.join(' '); } diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-scheduler.service.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-scheduler.service.ts index 4bd34bc..c9fec9a 100644 --- a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-scheduler.service.ts +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-scheduler.service.ts @@ -2,6 +2,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Queue, type ConnectionOptions } from 'bullmq'; +import { parseExpression } from 'cron-parser'; import { Repository } from 'typeorm'; import { QqbotPluginTask } from '../../infrastructure/persistence'; @@ -30,6 +31,7 @@ export class QqbotPluginTaskSchedulerService async onModuleInit() { await this.queue.waitUntilReady(); + await this.removeUnschedulableTaskSchedulers(); await this.resyncEnabledTasks(); } @@ -38,12 +40,23 @@ export class QqbotPluginTaskSchedulerService } async resyncEnabledTasks() { - const tasks = await this.taskRepository.find({ where: { enabled: true } }); + const tasks = await this.findSchedulableTasks(); for (const task of tasks) { await this.syncTaskScheduler(task); } } + async removeUnschedulableTaskSchedulers() { + const tasks = await this.findUnschedulableEnabledTasks(); + for (const task of tasks) { + await this.removeTaskScheduler(task.id); + await this.taskRepository.update( + { id: task.id }, + { nextRunAt: null, runtimeStatus: 'disabled' }, + ); + } + } + async syncTaskScheduler( task: Pick< QqbotPluginTask, @@ -51,15 +64,14 @@ export class QqbotPluginTaskSchedulerService >, ) { const schedulerId = this.buildSchedulerId(task.id); - if (!task.enabled) { + if (!task.enabled || !(await this.isTaskSchedulable(task.id))) { await this.removeTaskScheduler(task.id); - await this.taskRepository.update( - { id: task.id }, - { nextRunAt: null, runtimeStatus: 'disabled' }, - ); - return; + const state = { nextRunAt: null, runtimeStatus: 'disabled' as const }; + await this.taskRepository.update({ id: task.id }, state); + return state; } + const nextRunAt = resolveNextQqbotPluginTaskRunAt(task.cronExpression); await this.queue.upsertJobScheduler( schedulerId, { pattern: task.cronExpression }, @@ -78,8 +90,12 @@ export class QqbotPluginTaskSchedulerService ); await this.taskRepository.update( { id: task.id }, - { runtimeStatus: 'scheduled' }, + { + nextRunAt: nextRunAt as any, + runtimeStatus: 'scheduled', + }, ); + return { nextRunAt, runtimeStatus: 'scheduled' as const }; } async removeTaskScheduler(taskId: string) { @@ -91,6 +107,10 @@ export class QqbotPluginTaskSchedulerService for (const task of tasks) { await this.removeTaskScheduler(task.id); } + await this.taskRepository.update( + { installationId }, + { nextRunAt: null, runtimeStatus: 'disabled' }, + ); } async enqueueManualRun(taskId: string, input: Record) { @@ -112,6 +132,42 @@ export class QqbotPluginTaskSchedulerService private buildSchedulerId(taskId: string) { return `plugin-task:${taskId}`; } + + private findSchedulableTasks() { + return this.createSchedulableTaskQuery().getMany(); + } + + private findUnschedulableEnabledTasks() { + return this.taskRepository + .createQueryBuilder('task') + .innerJoin( + 'qqbot_plugin_installation', + 'installation', + 'installation.id = task.installation_id', + ) + .where('task.enabled = :enabled', { enabled: true }) + .andWhere('installation.status <> :status', { status: 'enabled' }) + .getMany(); + } + + private async isTaskSchedulable(taskId: string) { + const count = await this.createSchedulableTaskQuery() + .andWhere('task.id = :taskId', { taskId }) + .getCount(); + return count > 0; + } + + private createSchedulableTaskQuery() { + return this.taskRepository + .createQueryBuilder('task') + .innerJoin( + 'qqbot_plugin_installation', + 'installation', + 'installation.id = task.installation_id', + ) + .where('task.enabled = :enabled', { enabled: true }) + .andWhere('installation.status = :status', { status: 'enabled' }); + } } export const QQBOT_PLUGIN_TASK_QUEUE_NAME = 'qqbot-plugin-task'; @@ -197,3 +253,7 @@ function readNumberConfig( const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } + +export function resolveNextQqbotPluginTaskRunAt(cronExpression: string) { + return parseExpression(cronExpression).next().toDate(); +} diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-worker.processor.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-worker.processor.ts index 837b80f..ba1f15e 100644 --- a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-worker.processor.ts +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task-worker.processor.ts @@ -20,6 +20,7 @@ import { QQBOT_PLUGIN_TASK_QUEUE_NAME, readQqbotPluginTaskQueuePrefix, resolveQqbotPluginTaskQueueConnection, + resolveNextQqbotPluginTaskRunAt, type QqbotPluginTaskJobData, } from './qqbot-plugin-task-scheduler.service'; @@ -79,6 +80,17 @@ export class QqbotPluginTaskWorkerProcessor 'task-disabled', ); } + if ( + job.data.triggerType === 'schedule' && + !(await this.isInstallationEnabled(task.id)) + ) { + return this.writeSkippedRun( + task, + `${job.id || ''}`, + job.data.triggerType, + 'installation-disabled', + ); + } const running = await this.runRepository.findOne({ where: { status: 'running', taskId: task.id }, @@ -243,6 +255,7 @@ export class QqbotPluginTaskWorkerProcessor lastRunAt: result.finishedAt, lastRunId: run.id, lastStatus: result.status, + nextRunAt: this.resolveNextRunAt(task), runtimeStatus: result.status === 'failed' ? 'failed' @@ -253,6 +266,25 @@ export class QqbotPluginTaskWorkerProcessor ); } + private resolveNextRunAt(task: QqbotPluginTask) { + if (!task.enabled || !task.cronExpression) return null; + return resolveNextQqbotPluginTaskRunAt(task.cronExpression); + } + + private async isInstallationEnabled(taskId: string) { + const count = await this.taskRepository + .createQueryBuilder('task') + .innerJoin( + 'qqbot_plugin_installation', + 'installation', + 'installation.id = task.installation_id', + ) + .where('task.id = :taskId', { taskId }) + .andWhere('installation.status = :status', { status: 'enabled' }) + .getCount(); + return count > 0; + } + private getOutputKeys(output: unknown) { return output && typeof output === 'object' ? Object.keys(output as Record).sort() diff --git a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts index b3e41ab..a4df188 100644 --- a/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts +++ b/src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts @@ -64,13 +64,15 @@ export class QqbotPluginTaskService { task.enabled = true; task.runtimeStatus = 'scheduled'; const saved = await this.taskRepository.save(task); - await this.requireScheduler().syncTaskScheduler(saved); + const schedulerState = await this.requireScheduler().syncTaskScheduler(saved); + if (schedulerState) Object.assign(saved, schedulerState); return saved; } async disableTask(id: string) { const task = await this.getTaskDetail(id); task.enabled = false; + task.nextRunAt = null; task.runtimeStatus = 'disabled'; const saved = await this.taskRepository.save(task); await this.requireScheduler().removeTaskScheduler(id); @@ -81,7 +83,8 @@ export class QqbotPluginTaskService { const task = await this.getTaskDetail(id); task.cronExpression = requireQqbotPluginTaskCron(body.cronExpression); const saved = await this.taskRepository.save(task); - await this.requireScheduler().syncTaskScheduler(saved); + const schedulerState = await this.requireScheduler().syncTaskScheduler(saved); + if (schedulerState) Object.assign(saved, schedulerState); return saved; } diff --git a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts index fc7d135..8761f81 100644 --- a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts @@ -767,6 +767,126 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { expect(worker.executeOperation).not.toHaveBeenCalled(); }); + it('persists built-in installations before syncing manifest tasks', async () => { + const manifest = { + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [], + legacyAliases: [], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [], + permissions: ['runtime.http'], + pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + tasks: [ + { + defaultCron: '0 */6 * * *', + enabled: true, + handlerName: 'syncDemo', + key: 'demo-plugin.sync', + name: '同步 Demo', + permissions: ['runtime.http'], + timeoutMs: 120000, + }, + ], + version: '0.1.0', + }; + const createRepository = (ids: string[]) => { + const rows: any[] = []; + return { + find: jest.fn(async () => rows), + findAndCount: jest.fn(async () => [rows, rows.length]), + findOne: jest.fn(async ({ where }: any) => + rows.find((row) => + Object.entries(where || {}).every( + ([key, value]) => row[key] === value, + ), + ) || null, + ), + save: jest.fn(async (value: any) => { + const saved = { id: value.id || ids.shift(), ...value }; + rows.push(saved); + return saved; + }), + update: jest.fn(async () => ({ affected: 1 })), + }; + }; + const pluginRepository = createRepository(['2041700000000200001']); + const versionRepository = createRepository(['2041700000000200002']); + const installationRepository = createRepository(['2041700000000200003']); + const worker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + drainRuntimeEvents: jest.fn(() => []), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const taskSynchronizer = { + syncManifestTasks: jest.fn(async () => []), + }; + const service = new (QqbotPluginPlatformService as any)( + pluginRepository, + versionRepository, + installationRepository, + createRepository([]), + createRepository([]), + createRepository([]), + createRepository([]), + createRepository([]), + createRepository([]), + undefined, + { + create: jest.fn(() => worker), + }, + undefined, + undefined, + undefined, + { + loadBuiltinManifests: jest.fn(() => [manifest]), + }, + taskSynchronizer, + { + syncTaskScheduler: jest.fn(), + }, + ) as QqbotPluginPlatformService; + + await service.onModuleInit(); + + expect(pluginRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + pluginKey: 'demo-plugin', + status: 'installed', + }), + ); + expect(versionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: '2041700000000200001', + version: '0.1.0', + }), + ); + expect(installationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: '2041700000000200001', + status: 'enabled', + versionId: '2041700000000200002', + }), + ); + expect(taskSynchronizer.syncManifestTasks).toHaveBeenCalledWith({ + installationId: '2041700000000200003', + manifestTasks: manifest.tasks, + pluginId: '2041700000000200001', + }); + }); + it('clears built-in worker contexts when a persisted installation disables the same plugin', async () => { const manifest = { assets: [], diff --git a/test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts b/test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts index caeaf57..e78cb20 100644 --- a/test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-task-api-contract.spec.ts @@ -102,4 +102,44 @@ describe('QQBot plugin task API contract', () => { }, }); }); + + it('clears stale next run time when disabling one task', async () => { + const taskRepository = createRepositoryMock(); + const runRepository = createRepositoryMock(); + const pluginRepository = createRepositoryMock(); + const scheduler = { + removeTaskScheduler: jest.fn(async () => undefined), + }; + const nextRunAt = new Date('2026-06-17T06:00:00.000Z'); + taskRepository.findOne.mockResolvedValue({ + cronExpression: '0 */6 * * *', + enabled: true, + id: 'task-1', + nextRunAt, + runtimeStatus: 'scheduled', + }); + const service = new QqbotPluginTaskService( + taskRepository as any, + runRepository as any, + pluginRepository as any, + new ToolsService(), + scheduler as any, + ); + + const task = await service.disableTask('task-1'); + + expect(taskRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + nextRunAt: null, + runtimeStatus: 'disabled', + }), + ); + expect(scheduler.removeTaskScheduler).toHaveBeenCalledWith('task-1'); + expect(task).toMatchObject({ + enabled: false, + nextRunAt: null, + runtimeStatus: 'disabled', + }); + }); }); diff --git a/test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts b/test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts index 0763792..98fd7a4 100644 --- a/test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts @@ -100,4 +100,10 @@ describe('QQBot plugin task manifest contract', () => { '定时任务 cron 不允许每分钟执行', ); }); + + it('rejects cron ranges that BullMQ cannot schedule', () => { + expect(() => normalizeQqbotPluginTaskCron('99 99 99 99 99')).toThrow( + '定时任务 cron 表达式不合法', + ); + }); }); diff --git a/test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts b/test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts index 6c45244..b3054d2 100644 --- a/test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-task-persistence.spec.ts @@ -1,4 +1,6 @@ import { getMetadataArgsStorage } from 'typeorm'; +import { readFileSync } from 'fs'; +import { join } from 'path'; import { QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT, QQBOT_PLUGIN_PLATFORM_ENTITIES, @@ -9,6 +11,7 @@ import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; describe('QQBot plugin task persistence contract', () => { const schema = readRefactorV3SqlSchema(); + const repoRoot = join(__dirname, '../../../..'); it('declares task tables in SQL and entity registry', () => { expect(QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables).toEqual( @@ -34,4 +37,13 @@ describe('QQBot plugin task persistence contract', () => { schema.expectTableColumns(tableName || '', columns); } }); + + it('keeps qqbot-init able to incrementally create plugin task tables online', () => { + const sql = readFileSync(join(repoRoot, 'sql/qqbot-init.sql'), 'utf8'); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS `qqbot_plugin_task`'); + expect(sql).toContain('CREATE TABLE IF NOT EXISTS `qqbot_plugin_task_run`'); + expect(sql).toContain('uk_qqbot_plugin_task'); + expect(sql).toContain('idx_qqbot_plugin_task_run_task_time'); + }); }); diff --git a/test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts b/test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts index dce3b31..715d050 100644 --- a/test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts @@ -100,13 +100,151 @@ describe('QQBot plugin task scheduler', () => { ); expect(taskRepository.update).toHaveBeenCalledWith( { id: 'task-1' }, - { runtimeStatus: 'scheduled' }, + expect.objectContaining({ + nextRunAt: expect.any(Date), + runtimeStatus: 'scheduled', + }), + ); + await scheduler.onModuleDestroy(); + }); + + it('resyncs only enabled tasks whose plugin installation is enabled', async () => { + const taskRepository = createTaskRepository([ + { + cronExpression: '0 */6 * * *', + enabled: true, + id: 'task-enabled', + installationId: 'install-enabled', + installationStatus: 'enabled', + taskKey: 'bangdream.bestdori.sync-main-data', + timeoutMs: 120000, + }, + { + cronExpression: '0 */6 * * *', + enabled: true, + id: 'task-disabled-installation', + installationId: 'install-disabled', + installationStatus: 'disabled', + taskKey: 'bangdream.bestdori.sync-main-data', + timeoutMs: 120000, + }, + ]); + const scheduler = new QqbotPluginTaskSchedulerService( + createConfigService(), + taskRepository as any, + ); + + await scheduler.onModuleInit(); + + expect(createdQueues[0].schedulers.has('plugin-task:task-enabled')).toBe( + true, + ); + expect( + createdQueues[0].schedulers.has('plugin-task:task-disabled-installation'), + ).toBe(false); + await scheduler.onModuleDestroy(); + }); + + it('removes stale schedulers for tasks whose installation is no longer enabled on startup', async () => { + const taskRepository = createTaskRepository([ + { + cronExpression: '0 */6 * * *', + enabled: true, + id: 'task-disabled-installation', + installationId: 'install-disabled', + installationStatus: 'disabled', + taskKey: 'bangdream.bestdori.sync-main-data', + timeoutMs: 120000, + }, + ]); + const scheduler = new QqbotPluginTaskSchedulerService( + createConfigService(), + taskRepository as any, + ); + createdQueues[0].schedulers.set( + 'plugin-task:task-disabled-installation', + {}, + ); + + await scheduler.onModuleInit(); + + expect( + createdQueues[0].schedulers.has('plugin-task:task-disabled-installation'), + ).toBe(false); + expect(taskRepository.update).toHaveBeenCalledWith( + { id: 'task-disabled-installation' }, + { nextRunAt: null, runtimeStatus: 'disabled' }, + ); + await scheduler.onModuleDestroy(); + }); + + it('does not upsert a scheduler when direct sync sees a disabled installation', async () => { + const taskRepository = createTaskRepository([ + { + cronExpression: '0 */6 * * *', + enabled: true, + id: 'task-disabled-installation', + installationId: 'install-disabled', + installationStatus: 'disabled', + taskKey: 'bangdream.bestdori.sync-main-data', + timeoutMs: 120000, + }, + ]); + const scheduler = new QqbotPluginTaskSchedulerService( + createConfigService(), + taskRepository as any, + ); + + const state = await scheduler.syncTaskScheduler({ + cronExpression: '0 */6 * * *', + enabled: true, + id: 'task-disabled-installation', + installationId: 'install-disabled', + taskKey: 'bangdream.bestdori.sync-main-data', + timeoutMs: 120000, + } as any); + + expect( + createdQueues[0].schedulers.has('plugin-task:task-disabled-installation'), + ).toBe(false); + expect(state).toEqual({ nextRunAt: null, runtimeStatus: 'disabled' }); + expect(taskRepository.update).toHaveBeenCalledWith( + { id: 'task-disabled-installation' }, + { nextRunAt: null, runtimeStatus: 'disabled' }, + ); + await scheduler.onModuleDestroy(); + }); + + it('marks installation tasks disabled after removing their schedulers', async () => { + const taskRepository = createTaskRepository([ + { + cronExpression: '0 */6 * * *', + enabled: true, + id: 'task-1', + installationId: 'install-1', + taskKey: 'bangdream.bestdori.sync-main-data', + timeoutMs: 120000, + }, + ]); + const scheduler = new QqbotPluginTaskSchedulerService( + createConfigService(), + taskRepository as any, + ); + createdQueues[0].schedulers.set('plugin-task:task-1', {}); + + await scheduler.removeSchedulersForInstallation('install-1'); + + expect(createdQueues[0].schedulers.has('plugin-task:task-1')).toBe(false); + expect(taskRepository.update).toHaveBeenCalledWith( + { installationId: 'install-1' }, + { nextRunAt: null, runtimeStatus: 'disabled' }, ); await scheduler.onModuleDestroy(); }); it('executes a task job through the platform worker and stores only safe output keys', async () => { const task = { + cronExpression: '0 */6 * * *', enabled: true, handlerName: 'syncBestdoriMainData', id: 'task-1', @@ -156,6 +294,13 @@ describe('QQBot plugin task scheduler', () => { status: 'success', }), ); + expect(taskRepository.update).toHaveBeenLastCalledWith( + { id: 'task-1' }, + expect.objectContaining({ + nextRunAt: expect.any(Date), + runtimeStatus: 'scheduled', + }), + ); expect(JSON.stringify(runRepository.save.mock.calls)).not.toContain( 'secret text', ); @@ -166,6 +311,56 @@ describe('QQBot plugin task scheduler', () => { }); await processor.onModuleDestroy(); }); + + it('skips a scheduled task when its installation is disabled even if a stale scheduler fires', async () => { + const task = { + cronExpression: '0 */6 * * *', + enabled: true, + handlerName: 'syncBestdoriMainData', + id: 'task-1', + installationId: 'install-1', + installationStatus: 'disabled', + pluginId: 'plugin-1', + taskKey: 'bangdream.bestdori.sync-main-data', + timeoutMs: 120000, + }; + const taskRepository = createTaskRepository([task]); + const runRepository = createRunRepository(); + const platformService = { + executeTask: jest.fn(async () => ({ syncedKeys: ['songs'] })), + }; + const processor = new QqbotPluginTaskWorkerProcessor( + createConfigService(), + platformService as any, + taskRepository as any, + runRepository as any, + ); + + await processor.onModuleInit(); + const result = await createdWorkers[0].processor({ + data: { + taskId: 'task-1', + triggerType: 'schedule', + }, + id: 'stale-job-1', + }); + + expect(platformService.executeTask).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: true, + reason: 'installation-disabled', + runId: 'run-1', + status: 'skipped', + }); + expect(taskRepository.update).toHaveBeenLastCalledWith( + { id: 'task-1' }, + expect.objectContaining({ + lastError: 'installation-disabled', + runtimeStatus: 'scheduled', + }), + ); + await processor.onModuleDestroy(); + }); }); function createConfigService(): any { @@ -179,7 +374,45 @@ function createConfigService(): any { } function createTaskRepository(tasks: any[]) { + let idFilter: string | undefined; + let queryMode: 'schedulable' | 'stale' = 'schedulable'; + const queryBuilder = { + andWhere: jest.fn((_clause?: string, params?: any) => { + if (params?.taskId) idFilter = params.taskId; + if ( + typeof _clause === 'string' && + _clause.includes('installation.status <>') + ) { + queryMode = 'stale'; + } + return queryBuilder; + }), + getCount: jest.fn(async () => queryBuilder.getMany().then((rows) => rows.length)), + getMany: jest.fn(async () => + tasks.filter( + (task) => { + const matchesId = !idFilter || task.id === idFilter; + const installationEnabled = + !task.installationStatus || task.installationStatus === 'enabled'; + if (queryMode === 'stale') { + return matchesId && task.enabled === true && !installationEnabled; + } + return matchesId && task.enabled === true && installationEnabled; + }, + ), + ), + innerJoin: jest.fn(() => queryBuilder), + where: jest.fn((_clause?: string, params?: any) => { + idFilter = params?.taskId; + queryMode = + typeof _clause === 'string' && _clause.includes('installation.status <>') + ? 'stale' + : 'schedulable'; + return queryBuilder; + }), + }; return { + createQueryBuilder: jest.fn(() => queryBuilder), find: jest.fn(async () => tasks), findOne: jest.fn(async ({ where }: any) => tasks.find((task) => task.id === where.id) || null,