feat: 完成QQBot插件定时任务平台

This commit is contained in:
sunlei 2026-06-17 11:20:17 +08:00
parent 0263b423c2
commit fe77f5ef63
18 changed files with 3031 additions and 36 deletions

View File

@ -64,6 +64,12 @@ QQBOT_PLUGIN_QUEUE_REDIS_PREFIX=kt:qqbot:plugin-worker
QQBOT_PLUGIN_QUEUE_REMOVE_ON_FAIL=100 QQBOT_PLUGIN_QUEUE_REMOVE_ON_FAIL=100
QQBOT_PLUGIN_QUEUE_WAIT_BUFFER_MS=5000 QQBOT_PLUGIN_QUEUE_WAIT_BUFFER_MS=5000
QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS=120000 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_COMMAND_MIN_COOLDOWN_MS=5000
QQBOT_RULE_MIN_COOLDOWN_MS=30000 QQBOT_RULE_MIN_COOLDOWN_MS=30000
QQBOT_REPEATER_THRESHOLD=4 QQBOT_REPEATER_THRESHOLD=4
@ -101,7 +107,7 @@ BANGDREAM_TSUGU_MAIN_SERVER=cn
BANGDREAM_TSUGU_DISPLAYED_SERVERS=cn,jp BANGDREAM_TSUGU_DISPLAYED_SERVERS=cn,jp
BANGDREAM_TSUGU_COMPRESS=true BANGDREAM_TSUGU_COMPRESS=true
BANGDREAM_TSUGU_USE_EASY_BG=false 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_URL=mqtt://127.0.0.1:1883
MQTT_USERNAME= MQTT_USERNAME=

33
API.md
View File

@ -85,7 +85,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | 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 | `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` | | 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_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | 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。 真实密码、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 与基础后台 ## Admin 与基础后台
@ -415,7 +415,20 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
### Plugin Platform ### 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 ```bash
pnpm qqbot-plugin create <pluginKey> pnpm qqbot-plugin create <pluginKey>
@ -441,8 +454,22 @@ pnpm qqbot-plugin install-local <packageFile>
| `GET` | `/qqbot/plugin-platform/runtime-events` | 查询插件运行事件 | | `GET` | `/qqbot/plugin-platform/runtime-events` | 查询插件运行事件 |
| `GET` | `/qqbot/plugin-platform/account-bindings` | 查询插件账号绑定 | | `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、资产和事件上下文。 `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 ### OneBot Reverse WebSocket
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 APItoken 使用 `QQBOT_REVERSE_WS_TOKEN` `QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 APItoken 使用 `QQBOT_REVERSE_WS_TOKEN`

View File

@ -22,7 +22,7 @@
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | | `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | | `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | | `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/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | | `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | | `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` | | 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` | | 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_*` | | 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` | | 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` | | 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` | | 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 插件 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 ```bash

View File

@ -32,6 +32,10 @@ spec:
value: Asia/Shanghai value: Asia/Shanghai
- name: QQBOT_NAPCAT_SSH_KEY_PATH - name: QQBOT_NAPCAT_SSH_KEY_PATH
value: /app/secrets/napcat-ssh/id_rsa 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。 # Jenkins 每次发布会从 Agent 私有 .env.production 重建这个 Secret。
envFrom: envFrom:
- secretRef: - secretRef:
@ -40,6 +44,8 @@ spec:
- name: napcat-ssh-key - name: napcat-ssh-key
mountPath: /app/secrets/napcat-ssh mountPath: /app/secrets/napcat-ssh
readOnly: true readOnly: true
- name: qqbot-plugin-data
mountPath: /data/qqbot/plugins
readinessProbe: readinessProbe:
tcpSocket: tcpSocket:
port: 48085 port: 48085
@ -67,6 +73,10 @@ spec:
secretName: kt-qqbot-napcat-ssh-key secretName: kt-qqbot-napcat-ssh-key
optional: true optional: true
defaultMode: 0400 defaultMode: 0400
- name: qqbot-plugin-data
hostPath:
path: /vol1/docker/kt-template-online-api/qqbot-plugins
type: DirectoryOrCreate
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service

View File

@ -45,6 +45,7 @@
"bullmq": "5.78.1", "bullmq": "5.78.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-adapter-moment": "^1.0.1", "chartjs-adapter-moment": "^1.0.1",
"cron-parser": "4.9.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"express": "5.2.1", "express": "5.2.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -44,6 +44,9 @@ importers:
chartjs-adapter-moment: chartjs-adapter-moment:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1(chart.js@4.5.1)(moment@2.30.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: cross-env:
specifier: ^7.0.3 specifier: ^7.0.3
version: 7.0.3 version: 7.0.3

View File

@ -324,6 +324,55 @@ CREATE TABLE IF NOT EXISTS `qqbot_dedupe` (
UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`) UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 = ( SET @qqbot_sql = (
SELECT IF( SELECT IF(
COUNT(*) = 0, COUNT(*) = 0,
@ -886,12 +935,18 @@ VALUES
(2041700000000120444, 2041700000000100408, 'QqBotCommandToggle', NULL, NULL, NULL, 'QqBot:Command:Toggle', 'button', '{"title":"启停"}', 1, 0), (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), (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), (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), (2041700000000100411, 2041700000000100400, 'QqBotPluginTask', '/qqbot/plugin-task', '/qqbot/plugin-task/list', NULL, 'QqBot:PluginTask:List', 'menu', '{"icon":"lucide:calendar-clock","title":"插件定时任务"}', 1, 5),
(2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 6), (2041700000000120451, 2041700000000100411, 'QqBotPluginTaskUpdateCron', NULL, NULL, NULL, 'QqBot:PluginTask:UpdateCron', 'button', '{"title":"修改 Cron"}', 1, 0),
(2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 7), (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), (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), (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), (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), (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) (2041700000000120433, 2041700000000100407, 'QqBotPermissionDelete', NULL, NULL, NULL, 'QqBot:Permission:Delete', 'button', '{"title":"common.delete"}', 1, 0)

View File

@ -639,25 +639,76 @@ export class QqbotPluginPlatformService
const persistedInstallation = const persistedInstallation =
persistedState.enabledInstallationsByPluginKey.get(manifest.pluginKey); persistedState.enabledInstallationsByPluginKey.get(manifest.pluginKey);
const installation = const { installation, version } = persistedInstallation
persistedInstallation || ? this.resolvePersistedBuiltinRuntime(manifest, persistedInstallation)
({ : await this.ensureBuiltinRuntimePersistence(manifest);
id: `builtin-${manifest.pluginKey}`,
installedPath: `builtin://${manifest.pluginKey}`,
pluginId: manifest.pluginKey,
runtimeStatus: 'stopped',
status: 'installed',
versionId: `builtin-${manifest.pluginKey}-${manifest.version}`,
} as QqbotPluginInstallation);
await this.startWorker(installation, { await this.startWorker(installation, version);
id: `builtin-${manifest.pluginKey}-${manifest.version}`, }
}
private resolvePersistedBuiltinRuntime(
manifest: QqbotPluginManifest,
installation: QqbotPluginInstallation,
) {
return {
installation,
version: {
id: installation.versionId,
manifestJson: manifest as unknown as Record<string, unknown>, manifestJson: manifest as unknown as Record<string, unknown>,
packageHash: `builtin-${manifest.pluginKey}`, packageHash: `builtin-${manifest.pluginKey}`,
pluginId: manifest.pluginKey, pluginId: installation.pluginId,
version: manifest.version, 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<string, unknown>,
packageHash: `builtin-${manifest.pluginKey}`,
pluginId,
version: manifest.version,
});
} }
private async resolvePersistedPluginRuntimeState(): Promise<PersistedPluginRuntimeState> { private async resolvePersistedPluginRuntimeState(): Promise<PersistedPluginRuntimeState> {
@ -757,6 +808,12 @@ export class QqbotPluginPlatformService
scheduleEnabledTasks: boolean, scheduleEnabledTasks: boolean,
) { ) {
if (!this.taskSynchronizer || !manifest.tasks.length) return []; if (!this.taskSynchronizer || !manifest.tasks.length) return [];
if (
!this.isPersistablePluginId(installation.pluginId) ||
!this.isPersistablePluginId(installation.id)
) {
return [];
}
const tasks = await this.taskSynchronizer.syncManifestTasks({ const tasks = await this.taskSynchronizer.syncManifestTasks({
installationId: installation.id, installationId: installation.id,

View File

@ -1,3 +1,4 @@
import { parseExpression } from 'cron-parser';
import { throwVbenError } from '@/common'; import { throwVbenError } from '@/common';
const fieldPattern = /^[\d*/,\-]+$/; const fieldPattern = /^[\d*/,\-]+$/;
@ -14,6 +15,11 @@ export function normalizeQqbotPluginTaskCron(input: unknown): string {
if (fields[0] === '*') { if (fields[0] === '*') {
throw new Error('定时任务 cron 不允许每分钟执行'); throw new Error('定时任务 cron 不允许每分钟执行');
} }
try {
parseExpression(fields.join(' '));
} catch {
throw new Error('定时任务 cron 表达式不合法');
}
return fields.join(' '); return fields.join(' ');
} }

View File

@ -2,6 +2,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Queue, type ConnectionOptions } from 'bullmq'; import { Queue, type ConnectionOptions } from 'bullmq';
import { parseExpression } from 'cron-parser';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { QqbotPluginTask } from '../../infrastructure/persistence'; import { QqbotPluginTask } from '../../infrastructure/persistence';
@ -30,6 +31,7 @@ export class QqbotPluginTaskSchedulerService
async onModuleInit() { async onModuleInit() {
await this.queue.waitUntilReady(); await this.queue.waitUntilReady();
await this.removeUnschedulableTaskSchedulers();
await this.resyncEnabledTasks(); await this.resyncEnabledTasks();
} }
@ -38,12 +40,23 @@ export class QqbotPluginTaskSchedulerService
} }
async resyncEnabledTasks() { async resyncEnabledTasks() {
const tasks = await this.taskRepository.find({ where: { enabled: true } }); const tasks = await this.findSchedulableTasks();
for (const task of tasks) { for (const task of tasks) {
await this.syncTaskScheduler(task); 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( async syncTaskScheduler(
task: Pick< task: Pick<
QqbotPluginTask, QqbotPluginTask,
@ -51,15 +64,14 @@ export class QqbotPluginTaskSchedulerService
>, >,
) { ) {
const schedulerId = this.buildSchedulerId(task.id); const schedulerId = this.buildSchedulerId(task.id);
if (!task.enabled) { if (!task.enabled || !(await this.isTaskSchedulable(task.id))) {
await this.removeTaskScheduler(task.id); await this.removeTaskScheduler(task.id);
await this.taskRepository.update( const state = { nextRunAt: null, runtimeStatus: 'disabled' as const };
{ id: task.id }, await this.taskRepository.update({ id: task.id }, state);
{ nextRunAt: null, runtimeStatus: 'disabled' }, return state;
);
return;
} }
const nextRunAt = resolveNextQqbotPluginTaskRunAt(task.cronExpression);
await this.queue.upsertJobScheduler( await this.queue.upsertJobScheduler(
schedulerId, schedulerId,
{ pattern: task.cronExpression }, { pattern: task.cronExpression },
@ -78,8 +90,12 @@ export class QqbotPluginTaskSchedulerService
); );
await this.taskRepository.update( await this.taskRepository.update(
{ id: task.id }, { id: task.id },
{ runtimeStatus: 'scheduled' }, {
nextRunAt: nextRunAt as any,
runtimeStatus: 'scheduled',
},
); );
return { nextRunAt, runtimeStatus: 'scheduled' as const };
} }
async removeTaskScheduler(taskId: string) { async removeTaskScheduler(taskId: string) {
@ -91,6 +107,10 @@ export class QqbotPluginTaskSchedulerService
for (const task of tasks) { for (const task of tasks) {
await this.removeTaskScheduler(task.id); await this.removeTaskScheduler(task.id);
} }
await this.taskRepository.update(
{ installationId },
{ nextRunAt: null, runtimeStatus: 'disabled' },
);
} }
async enqueueManualRun(taskId: string, input: Record<string, unknown>) { async enqueueManualRun(taskId: string, input: Record<string, unknown>) {
@ -112,6 +132,42 @@ export class QqbotPluginTaskSchedulerService
private buildSchedulerId(taskId: string) { private buildSchedulerId(taskId: string) {
return `plugin-task:${taskId}`; 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'; export const QQBOT_PLUGIN_TASK_QUEUE_NAME = 'qqbot-plugin-task';
@ -197,3 +253,7 @@ function readNumberConfig(
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
export function resolveNextQqbotPluginTaskRunAt(cronExpression: string) {
return parseExpression(cronExpression).next().toDate();
}

View File

@ -20,6 +20,7 @@ import {
QQBOT_PLUGIN_TASK_QUEUE_NAME, QQBOT_PLUGIN_TASK_QUEUE_NAME,
readQqbotPluginTaskQueuePrefix, readQqbotPluginTaskQueuePrefix,
resolveQqbotPluginTaskQueueConnection, resolveQqbotPluginTaskQueueConnection,
resolveNextQqbotPluginTaskRunAt,
type QqbotPluginTaskJobData, type QqbotPluginTaskJobData,
} from './qqbot-plugin-task-scheduler.service'; } from './qqbot-plugin-task-scheduler.service';
@ -79,6 +80,17 @@ export class QqbotPluginTaskWorkerProcessor
'task-disabled', '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({ const running = await this.runRepository.findOne({
where: { status: 'running', taskId: task.id }, where: { status: 'running', taskId: task.id },
@ -243,6 +255,7 @@ export class QqbotPluginTaskWorkerProcessor
lastRunAt: result.finishedAt, lastRunAt: result.finishedAt,
lastRunId: run.id, lastRunId: run.id,
lastStatus: result.status, lastStatus: result.status,
nextRunAt: this.resolveNextRunAt(task),
runtimeStatus: runtimeStatus:
result.status === 'failed' result.status === 'failed'
? '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) { private getOutputKeys(output: unknown) {
return output && typeof output === 'object' return output && typeof output === 'object'
? Object.keys(output as Record<string, unknown>).sort() ? Object.keys(output as Record<string, unknown>).sort()

View File

@ -64,13 +64,15 @@ export class QqbotPluginTaskService {
task.enabled = true; task.enabled = true;
task.runtimeStatus = 'scheduled'; task.runtimeStatus = 'scheduled';
const saved = await this.taskRepository.save(task); 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; return saved;
} }
async disableTask(id: string) { async disableTask(id: string) {
const task = await this.getTaskDetail(id); const task = await this.getTaskDetail(id);
task.enabled = false; task.enabled = false;
task.nextRunAt = null;
task.runtimeStatus = 'disabled'; task.runtimeStatus = 'disabled';
const saved = await this.taskRepository.save(task); const saved = await this.taskRepository.save(task);
await this.requireScheduler().removeTaskScheduler(id); await this.requireScheduler().removeTaskScheduler(id);
@ -81,7 +83,8 @@ export class QqbotPluginTaskService {
const task = await this.getTaskDetail(id); const task = await this.getTaskDetail(id);
task.cronExpression = requireQqbotPluginTaskCron(body.cronExpression); task.cronExpression = requireQqbotPluginTaskCron(body.cronExpression);
const saved = await this.taskRepository.save(task); 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; return saved;
} }

View File

@ -767,6 +767,126 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
expect(worker.executeOperation).not.toHaveBeenCalled(); 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 () => { it('clears built-in worker contexts when a persisted installation disables the same plugin', async () => {
const manifest = { const manifest = {
assets: [], assets: [],

View File

@ -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',
});
});
}); });

View File

@ -100,4 +100,10 @@ describe('QQBot plugin task manifest contract', () => {
'定时任务 cron 不允许每分钟执行', '定时任务 cron 不允许每分钟执行',
); );
}); });
it('rejects cron ranges that BullMQ cannot schedule', () => {
expect(() => normalizeQqbotPluginTaskCron('99 99 99 99 99')).toThrow(
'定时任务 cron 表达式不合法',
);
});
}); });

View File

@ -1,4 +1,6 @@
import { getMetadataArgsStorage } from 'typeorm'; import { getMetadataArgsStorage } from 'typeorm';
import { readFileSync } from 'fs';
import { join } from 'path';
import { import {
QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT, QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT,
QQBOT_PLUGIN_PLATFORM_ENTITIES, QQBOT_PLUGIN_PLATFORM_ENTITIES,
@ -9,6 +11,7 @@ import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper';
describe('QQBot plugin task persistence contract', () => { describe('QQBot plugin task persistence contract', () => {
const schema = readRefactorV3SqlSchema(); const schema = readRefactorV3SqlSchema();
const repoRoot = join(__dirname, '../../../..');
it('declares task tables in SQL and entity registry', () => { it('declares task tables in SQL and entity registry', () => {
expect(QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables).toEqual( expect(QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables).toEqual(
@ -34,4 +37,13 @@ describe('QQBot plugin task persistence contract', () => {
schema.expectTableColumns(tableName || '', columns); 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');
});
}); });

View File

@ -100,13 +100,151 @@ describe('QQBot plugin task scheduler', () => {
); );
expect(taskRepository.update).toHaveBeenCalledWith( expect(taskRepository.update).toHaveBeenCalledWith(
{ id: 'task-1' }, { 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(); await scheduler.onModuleDestroy();
}); });
it('executes a task job through the platform worker and stores only safe output keys', async () => { it('executes a task job through the platform worker and stores only safe output keys', async () => {
const task = { const task = {
cronExpression: '0 */6 * * *',
enabled: true, enabled: true,
handlerName: 'syncBestdoriMainData', handlerName: 'syncBestdoriMainData',
id: 'task-1', id: 'task-1',
@ -156,6 +294,13 @@ describe('QQBot plugin task scheduler', () => {
status: 'success', 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( expect(JSON.stringify(runRepository.save.mock.calls)).not.toContain(
'secret text', 'secret text',
); );
@ -166,6 +311,56 @@ describe('QQBot plugin task scheduler', () => {
}); });
await processor.onModuleDestroy(); 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 { function createConfigService(): any {
@ -179,7 +374,45 @@ function createConfigService(): any {
} }
function createTaskRepository(tasks: 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 { return {
createQueryBuilder: jest.fn(() => queryBuilder),
find: jest.fn(async () => tasks), find: jest.fn(async () => tasks),
findOne: jest.fn(async ({ where }: any) => findOne: jest.fn(async ({ where }: any) =>
tasks.find((task) => task.id === where.id) || null, tasks.find((task) => task.id === where.id) || null,