Compare commits
5 Commits
24b63c7332
...
fe77f5ef63
| Author | SHA1 | Date | |
|---|---|---|---|
| fe77f5ef63 | |||
| 0263b423c2 | |||
| 62161bac8f | |||
| 7775fd580d | |||
| 250bb654f0 |
@ -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=
|
||||
|
||||
33
API.md
33
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 <pluginKey>
|
||||
@ -441,8 +454,22 @@ pnpm qqbot-plugin install-local <packageFile>
|
||||
| `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`。
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -875,6 +875,53 @@ CREATE TABLE IF NOT EXISTS qqbot_plugin_runtime_event (
|
||||
KEY idx_qqbot_plugin_runtime_event_plugin (plugin_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qqbot_plugin_task (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
plugin_id BIGINT NOT NULL,
|
||||
installation_id BIGINT NOT NULL,
|
||||
task_key VARCHAR(128) NOT NULL,
|
||||
task_name VARCHAR(128) NOT NULL,
|
||||
handler_name VARCHAR(128) NOT NULL,
|
||||
description TEXT NULL,
|
||||
default_cron VARCHAR(64) NOT NULL,
|
||||
cron_expression VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
timeout_ms INT NOT NULL,
|
||||
runtime_status VARCHAR(32) NOT NULL DEFAULT 'idle',
|
||||
last_run_id BIGINT NULL,
|
||||
last_run_at DATETIME NULL,
|
||||
last_status VARCHAR(32) NULL,
|
||||
last_error TEXT NULL,
|
||||
last_duration_ms INT NULL,
|
||||
next_run_at DATETIME NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_qqbot_plugin_task (installation_id, task_key),
|
||||
KEY idx_qqbot_plugin_task_plugin (plugin_id),
|
||||
KEY idx_qqbot_plugin_task_enabled (enabled),
|
||||
KEY idx_qqbot_plugin_task_status (runtime_status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qqbot_plugin_task_run (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL,
|
||||
plugin_id BIGINT NOT NULL,
|
||||
installation_id BIGINT NOT NULL,
|
||||
task_key VARCHAR(128) NOT NULL,
|
||||
trigger_type VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
job_id VARCHAR(191) NULL,
|
||||
started_at DATETIME NULL,
|
||||
finished_at DATETIME NULL,
|
||||
duration_ms INT NULL,
|
||||
safe_summary JSON NULL,
|
||||
error_message TEXT NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_qqbot_plugin_task_run_task_time (task_id, create_time),
|
||||
KEY idx_qqbot_plugin_task_run_plugin_time (plugin_id, create_time),
|
||||
KEY idx_qqbot_plugin_task_run_status_time (status, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS napcat_container (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
account_id BIGINT NULL,
|
||||
|
||||
@ -11,6 +11,8 @@ SELECT 'napcat_account_binding' AS table_name, COUNT(*) AS row_count FROM napcat
|
||||
SELECT 'napcat_login_session' AS table_name, COUNT(*) AS row_count FROM napcat_login_session;
|
||||
SELECT 'napcat_login_challenge' AS table_name, COUNT(*) AS row_count FROM napcat_login_challenge;
|
||||
SELECT 'napcat_runtime_cleanup' AS table_name, COUNT(*) AS row_count FROM napcat_runtime_cleanup;
|
||||
SELECT 'qqbot_plugin_task' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task;
|
||||
SELECT 'qqbot_plugin_task_run' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task_run;
|
||||
|
||||
SELECT 'seed_admin_user' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM admin_user
|
||||
|
||||
@ -20,6 +20,9 @@ import type {
|
||||
QqbotPluginRuntimeEvent as QqbotPluginWorkerRuntimeEvent,
|
||||
QqbotPluginWorkerRuntime,
|
||||
} from '../infrastructure/integration/runtime';
|
||||
import { QqbotPluginTaskManifestSynchronizer } from './task/qqbot-plugin-task-manifest.synchronizer';
|
||||
import { QqbotPluginTaskSchedulerService } from './task/qqbot-plugin-task-scheduler.service';
|
||||
import type { QqbotPluginTaskTriggerType } from './task/qqbot-plugin-task.types';
|
||||
import { QqbotPluginArgumentParserService } from './argument/qqbot-plugin-argument-parser.service';
|
||||
import { QqbotBuiltinPluginPackageLoaderService } from '../infrastructure/integration/package/builtin-plugin-package-loader.service';
|
||||
import { QqbotPluginPackageReaderService } from '../infrastructure/integration/package/plugin-package-reader.service';
|
||||
@ -56,6 +59,7 @@ export type QqbotPluginRuntimeFactory = {
|
||||
| 'dispose'
|
||||
| 'drainRuntimeEvents'
|
||||
| 'executeOperation'
|
||||
| 'executeTask'
|
||||
| 'handleEvent'
|
||||
| 'health'
|
||||
| 'load'
|
||||
@ -159,6 +163,10 @@ export class QqbotPluginPlatformService
|
||||
private readonly packageReader?: QqbotPluginPackageReaderService,
|
||||
@Optional()
|
||||
private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService,
|
||||
@Optional()
|
||||
private readonly taskSynchronizer?: QqbotPluginTaskManifestSynchronizer,
|
||||
@Optional()
|
||||
private readonly taskScheduler?: QqbotPluginTaskSchedulerService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@ -254,13 +262,15 @@ export class QqbotPluginPlatformService
|
||||
|
||||
await this.persistManifestCapabilities(plugin.id, manifest);
|
||||
|
||||
return this.installationRepository.save({
|
||||
const installation = await this.installationRepository.save({
|
||||
installedPath: pluginPackage.packagePath,
|
||||
pluginId: plugin.id,
|
||||
runtimeStatus: 'stopped',
|
||||
status: 'installed',
|
||||
versionId: version.id,
|
||||
});
|
||||
await this.syncManifestTasksForInstallation(installation, manifest, false);
|
||||
return installation;
|
||||
}
|
||||
|
||||
async enableInstallation(body: InstallationActionBody) {
|
||||
@ -281,6 +291,7 @@ export class QqbotPluginPlatformService
|
||||
async disableInstallation(body: InstallationActionBody) {
|
||||
const installation = await this.requireInstallation(body);
|
||||
await this.stopWorkersForInstallation(installation);
|
||||
await this.taskScheduler?.removeSchedulersForInstallation(installation.id);
|
||||
await this.refreshActiveRegistries(installation, false);
|
||||
await this.updateInstallationRuntime(installation, 'disabled', 'stopped');
|
||||
await this.recordRuntimeEvent(installation, 'disable-finished');
|
||||
@ -329,6 +340,7 @@ export class QqbotPluginPlatformService
|
||||
}
|
||||
|
||||
await this.refreshActiveRegistries(installation, false);
|
||||
await this.taskScheduler?.removeSchedulersForInstallation(installation.id);
|
||||
await this.updateInstallationRuntime(
|
||||
installation,
|
||||
'uninstalled',
|
||||
@ -410,6 +422,34 @@ export class QqbotPluginPlatformService
|
||||
return handled;
|
||||
}
|
||||
|
||||
async executeTask(input: {
|
||||
input: Record<string, unknown>;
|
||||
installationId: string;
|
||||
pluginId: 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);
|
||||
}
|
||||
}
|
||||
|
||||
async listActiveOperations() {
|
||||
const workerOperations = this.listActiveWorkerOperations();
|
||||
if (workerOperations.length > 0) return workerOperations;
|
||||
@ -599,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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
packageHash: `builtin-${manifest.pluginKey}`,
|
||||
pluginId,
|
||||
version: manifest.version,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolvePersistedPluginRuntimeState(): Promise<PersistedPluginRuntimeState> {
|
||||
@ -708,6 +799,33 @@ export class QqbotPluginPlatformService
|
||||
this.activeWorkerPluginAliases.set(alias, manifest.pluginKey);
|
||||
this.activeWorkersByPluginKey.set(alias, workerContext);
|
||||
}
|
||||
await this.syncManifestTasksForInstallation(installation, manifest, true);
|
||||
}
|
||||
|
||||
private async syncManifestTasksForInstallation(
|
||||
installation: QqbotPluginInstallation,
|
||||
manifest: QqbotPluginManifest,
|
||||
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,
|
||||
manifestTasks: manifest.tasks,
|
||||
pluginId: installation.pluginId,
|
||||
});
|
||||
if (scheduleEnabledTasks && this.taskScheduler) {
|
||||
for (const task of tasks) {
|
||||
await this.taskScheduler.syncTaskScheduler(task);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private async stopExistingWorkersForManifest(manifest: QqbotPluginManifest) {
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
export * from './qqbot-plugin-task-cron.validator';
|
||||
export * from './qqbot-plugin-task-manifest.synchronizer';
|
||||
export * from './qqbot-plugin-task-scheduler.service';
|
||||
export * from './qqbot-plugin-task.service';
|
||||
export * from './qqbot-plugin-task.types';
|
||||
export * from './qqbot-plugin-task-worker.processor';
|
||||
@ -0,0 +1,34 @@
|
||||
import { parseExpression } from 'cron-parser';
|
||||
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 不允许每分钟执行');
|
||||
}
|
||||
try {
|
||||
parseExpression(fields.join(' '));
|
||||
} catch {
|
||||
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 不合法',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { QqbotPluginTaskManifest } from '../../domain/manifest';
|
||||
import { QqbotPluginTask } from '../../infrastructure/persistence';
|
||||
|
||||
export type SyncPluginManifestTasksInput = {
|
||||
installationId: string;
|
||||
manifestTasks: QqbotPluginTaskManifest[];
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class QqbotPluginTaskManifestSynchronizer {
|
||||
constructor(
|
||||
@InjectRepository(QqbotPluginTask)
|
||||
private readonly taskRepository: Repository<QqbotPluginTask>,
|
||||
) {}
|
||||
|
||||
async syncManifestTasks(input: SyncPluginManifestTasksInput) {
|
||||
const tasks: QqbotPluginTask[] = [];
|
||||
for (const manifestTask of input.manifestTasks) {
|
||||
const existing = await this.taskRepository.findOne({
|
||||
where: {
|
||||
installationId: input.installationId,
|
||||
taskKey: manifestTask.key,
|
||||
},
|
||||
});
|
||||
const task = this.taskRepository.create({
|
||||
...(existing || {}),
|
||||
cronExpression: existing?.cronExpression || manifestTask.defaultCron,
|
||||
defaultCron: manifestTask.defaultCron,
|
||||
description: manifestTask.description || null,
|
||||
enabled: existing?.enabled ?? manifestTask.enabled,
|
||||
handlerName: manifestTask.handlerName,
|
||||
installationId: input.installationId,
|
||||
pluginId: input.pluginId,
|
||||
runtimeStatus:
|
||||
existing?.runtimeStatus ||
|
||||
(manifestTask.enabled ? 'scheduled' : 'disabled'),
|
||||
taskKey: manifestTask.key,
|
||||
taskName: manifestTask.name,
|
||||
timeoutMs: manifestTask.timeoutMs,
|
||||
});
|
||||
tasks.push(await this.taskRepository.save(task));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
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';
|
||||
|
||||
export type QqbotPluginTaskJobData = {
|
||||
input?: Record<string, unknown>;
|
||||
taskId: string;
|
||||
triggerType: 'manual' | 'schedule';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class QqbotPluginTaskSchedulerService
|
||||
implements OnModuleDestroy, OnModuleInit
|
||||
{
|
||||
private readonly queue: Queue<QqbotPluginTaskJobData>;
|
||||
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
@InjectRepository(QqbotPluginTask)
|
||||
private readonly taskRepository: Repository<QqbotPluginTask>,
|
||||
) {
|
||||
this.queue = new Queue(QQBOT_PLUGIN_TASK_QUEUE_NAME, {
|
||||
connection: resolveQqbotPluginTaskQueueConnection(configService),
|
||||
prefix: readQqbotPluginTaskQueuePrefix(configService),
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.queue.waitUntilReady();
|
||||
await this.removeUnschedulableTaskSchedulers();
|
||||
await this.resyncEnabledTasks();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.queue.close();
|
||||
}
|
||||
|
||||
async resyncEnabledTasks() {
|
||||
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,
|
||||
'cronExpression' | 'enabled' | 'id' | 'installationId' | 'taskKey' | 'timeoutMs'
|
||||
>,
|
||||
) {
|
||||
const schedulerId = this.buildSchedulerId(task.id);
|
||||
if (!task.enabled || !(await this.isTaskSchedulable(task.id))) {
|
||||
await this.removeTaskScheduler(task.id);
|
||||
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 },
|
||||
{
|
||||
data: {
|
||||
taskId: task.id,
|
||||
triggerType: 'schedule',
|
||||
},
|
||||
name: QQBOT_PLUGIN_TASK_JOB_NAME,
|
||||
opts: {
|
||||
attempts: 1,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
await this.taskRepository.update(
|
||||
{ id: task.id },
|
||||
{
|
||||
nextRunAt: nextRunAt as any,
|
||||
runtimeStatus: 'scheduled',
|
||||
},
|
||||
);
|
||||
return { nextRunAt, runtimeStatus: 'scheduled' as const };
|
||||
}
|
||||
|
||||
async removeTaskScheduler(taskId: string) {
|
||||
await this.queue.removeJobScheduler(this.buildSchedulerId(taskId));
|
||||
}
|
||||
|
||||
async removeSchedulersForInstallation(installationId: string) {
|
||||
const tasks = await this.taskRepository.find({ where: { installationId } });
|
||||
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<string, unknown>) {
|
||||
return this.queue.add(
|
||||
QQBOT_PLUGIN_TASK_JOB_NAME,
|
||||
{
|
||||
input,
|
||||
taskId,
|
||||
triggerType: 'manual',
|
||||
},
|
||||
{
|
||||
attempts: 1,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
export const QQBOT_PLUGIN_TASK_JOB_NAME = 'execute-plugin-task';
|
||||
|
||||
export function readQqbotPluginTaskQueuePrefix(
|
||||
configService: ConfigService,
|
||||
) {
|
||||
return readStringConfig(
|
||||
configService,
|
||||
[
|
||||
'QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX',
|
||||
'QQBOT_PLUGIN_TASK_QUEUE_PREFIX',
|
||||
'QQBOT_PLUGIN_QUEUE_REDIS_PREFIX',
|
||||
],
|
||||
'kt:qqbot:plugin-task',
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveQqbotPluginTaskQueueConnection(
|
||||
configService: ConfigService,
|
||||
): ConnectionOptions {
|
||||
const host = readStringConfig(configService, [
|
||||
'QQBOT_PLUGIN_TASK_QUEUE_REDIS_HOST',
|
||||
'QQBOT_PLUGIN_QUEUE_REDIS_HOST',
|
||||
'REDIS_HOST',
|
||||
]);
|
||||
if (!host) {
|
||||
throw new Error('QQBot 插件定时任务队列缺少 Redis 主机配置');
|
||||
}
|
||||
|
||||
const password = readStringConfig(configService, [
|
||||
'QQBOT_PLUGIN_TASK_QUEUE_REDIS_PASSWORD',
|
||||
'QQBOT_PLUGIN_QUEUE_REDIS_PASSWORD',
|
||||
'REDIS_PASSWORD',
|
||||
]);
|
||||
|
||||
return {
|
||||
db: readNumberConfig(
|
||||
configService,
|
||||
[
|
||||
'QQBOT_PLUGIN_TASK_QUEUE_REDIS_DB',
|
||||
'QQBOT_PLUGIN_QUEUE_REDIS_DB',
|
||||
'REDIS_DB',
|
||||
],
|
||||
0,
|
||||
),
|
||||
host,
|
||||
password: password || undefined,
|
||||
port: readNumberConfig(
|
||||
configService,
|
||||
[
|
||||
'QQBOT_PLUGIN_TASK_QUEUE_REDIS_PORT',
|
||||
'QQBOT_PLUGIN_QUEUE_REDIS_PORT',
|
||||
'REDIS_PORT',
|
||||
],
|
||||
6379,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function readStringConfig(
|
||||
configService: ConfigService,
|
||||
keys: string[],
|
||||
fallback = '',
|
||||
) {
|
||||
for (const key of keys) {
|
||||
const value = configService.get<string | number | undefined>(key);
|
||||
if (value !== undefined && value !== null && `${value}`.trim()) {
|
||||
return `${value}`.trim();
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readNumberConfig(
|
||||
configService: ConfigService,
|
||||
keys: string[],
|
||||
fallback: number,
|
||||
) {
|
||||
const value = readStringConfig(configService, keys);
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function resolveNextQqbotPluginTaskRunAt(cronExpression: string) {
|
||||
return parseExpression(cronExpression).next().toDate();
|
||||
}
|
||||
@ -0,0 +1,293 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Worker } from 'bullmq';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QqbotPluginPlatformService } from '../plugin-platform.service';
|
||||
import {
|
||||
QqbotPluginTask,
|
||||
QqbotPluginTaskRun,
|
||||
type QqbotPluginTaskRunStatus,
|
||||
type QqbotPluginTaskTriggerType,
|
||||
} from '../../infrastructure/persistence';
|
||||
import {
|
||||
QQBOT_PLUGIN_TASK_JOB_NAME,
|
||||
QQBOT_PLUGIN_TASK_QUEUE_NAME,
|
||||
readQqbotPluginTaskQueuePrefix,
|
||||
resolveQqbotPluginTaskQueueConnection,
|
||||
resolveNextQqbotPluginTaskRunAt,
|
||||
type QqbotPluginTaskJobData,
|
||||
} from './qqbot-plugin-task-scheduler.service';
|
||||
|
||||
@Injectable()
|
||||
export class QqbotPluginTaskWorkerProcessor
|
||||
implements OnModuleDestroy, OnModuleInit
|
||||
{
|
||||
private readonly logger = new Logger(QqbotPluginTaskWorkerProcessor.name);
|
||||
private worker?: Worker<QqbotPluginTaskJobData>;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly platformService: QqbotPluginPlatformService,
|
||||
@InjectRepository(QqbotPluginTask)
|
||||
private readonly taskRepository: Repository<QqbotPluginTask>,
|
||||
@InjectRepository(QqbotPluginTaskRun)
|
||||
private readonly runRepository: Repository<QqbotPluginTaskRun>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.worker = new Worker<QqbotPluginTaskJobData>(
|
||||
QQBOT_PLUGIN_TASK_QUEUE_NAME,
|
||||
async (job) => this.processJob(job),
|
||||
{
|
||||
concurrency: 1,
|
||||
connection: resolveQqbotPluginTaskQueueConnection(this.configService),
|
||||
prefix: readQqbotPluginTaskQueuePrefix(this.configService),
|
||||
},
|
||||
);
|
||||
this.worker.on('error', (error) => {
|
||||
this.logger.error(error.message, error.stack);
|
||||
});
|
||||
await this.worker.waitUntilReady();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.worker?.close();
|
||||
}
|
||||
|
||||
private async processJob(job: Job<QqbotPluginTaskJobData>) {
|
||||
if (job.name && job.name !== QQBOT_PLUGIN_TASK_JOB_NAME) {
|
||||
return { ok: false, reason: 'unknown-job', skipped: true };
|
||||
}
|
||||
|
||||
const task = await this.taskRepository.findOne({
|
||||
where: { id: job.data.taskId },
|
||||
});
|
||||
if (!task) {
|
||||
return { ok: false, reason: 'task-not-found', skipped: true };
|
||||
}
|
||||
|
||||
if (job.data.triggerType === 'schedule' && !task.enabled) {
|
||||
return this.writeSkippedRun(
|
||||
task,
|
||||
`${job.id || ''}`,
|
||||
job.data.triggerType,
|
||||
'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 },
|
||||
});
|
||||
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 || {},
|
||||
);
|
||||
}
|
||||
|
||||
private async executeTaskRun(
|
||||
task: QqbotPluginTask,
|
||||
jobId: string,
|
||||
triggerType: QqbotPluginTaskTriggerType,
|
||||
input: Record<string, unknown>,
|
||||
) {
|
||||
const startedAt = new Date();
|
||||
const run = await this.runRepository.save({
|
||||
installationId: task.installationId,
|
||||
jobId,
|
||||
pluginId: task.pluginId,
|
||||
safeSummary: {
|
||||
inputKeys: Object.keys(input).sort(),
|
||||
},
|
||||
startedAt,
|
||||
status: 'running',
|
||||
taskId: task.id,
|
||||
taskKey: task.taskKey,
|
||||
triggerType,
|
||||
});
|
||||
await this.taskRepository.update(
|
||||
{ id: task.id },
|
||||
{
|
||||
lastRunId: run.id,
|
||||
runtimeStatus: 'running',
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const output = await this.platformService.executeTask({
|
||||
input,
|
||||
installationId: task.installationId,
|
||||
pluginId: task.pluginId,
|
||||
taskHandlerName: task.handlerName,
|
||||
taskId: task.id,
|
||||
taskKey: task.taskKey,
|
||||
timeoutMs: task.timeoutMs,
|
||||
triggerType,
|
||||
});
|
||||
const durationMs = Date.now() - startedAt.getTime();
|
||||
const finishedAt = new Date();
|
||||
const saved = await this.finishRun(run, {
|
||||
durationMs,
|
||||
finishedAt,
|
||||
safeSummary: {
|
||||
outputKeys: this.getOutputKeys(output),
|
||||
},
|
||||
status: 'success',
|
||||
});
|
||||
await this.finishTask(task, saved, {
|
||||
durationMs,
|
||||
errorMessage: null,
|
||||
finishedAt,
|
||||
status: 'success',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
runId: saved.id,
|
||||
status: 'success',
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startedAt.getTime();
|
||||
const finishedAt = new Date();
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : `${error || ''}`;
|
||||
const saved = await this.finishRun(run, {
|
||||
durationMs,
|
||||
errorMessage,
|
||||
finishedAt,
|
||||
status: 'failed',
|
||||
});
|
||||
await this.finishTask(task, saved, {
|
||||
durationMs,
|
||||
errorMessage,
|
||||
finishedAt,
|
||||
status: 'failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSkippedRun(
|
||||
task: QqbotPluginTask,
|
||||
jobId: string,
|
||||
triggerType: QqbotPluginTaskTriggerType,
|
||||
reason: string,
|
||||
) {
|
||||
const now = new Date();
|
||||
const run = await this.runRepository.save({
|
||||
durationMs: 0,
|
||||
finishedAt: now,
|
||||
installationId: task.installationId,
|
||||
jobId,
|
||||
pluginId: task.pluginId,
|
||||
safeSummary: { reason },
|
||||
startedAt: now,
|
||||
status: 'skipped',
|
||||
taskId: task.id,
|
||||
taskKey: task.taskKey,
|
||||
triggerType,
|
||||
});
|
||||
await this.finishTask(task, run, {
|
||||
durationMs: 0,
|
||||
errorMessage: reason,
|
||||
finishedAt: now,
|
||||
status: 'skipped',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
reason,
|
||||
runId: run.id,
|
||||
status: 'skipped',
|
||||
};
|
||||
}
|
||||
|
||||
private async finishRun(
|
||||
run: QqbotPluginTaskRun,
|
||||
patch: Partial<QqbotPluginTaskRun>,
|
||||
) {
|
||||
return this.runRepository.save({
|
||||
...run,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
private async finishTask(
|
||||
task: QqbotPluginTask,
|
||||
run: QqbotPluginTaskRun,
|
||||
result: {
|
||||
durationMs: number;
|
||||
errorMessage: null | string;
|
||||
finishedAt: Date;
|
||||
status: QqbotPluginTaskRunStatus;
|
||||
},
|
||||
) {
|
||||
await this.taskRepository.update(
|
||||
{ id: task.id },
|
||||
{
|
||||
lastDurationMs: result.durationMs,
|
||||
lastError: result.errorMessage,
|
||||
lastRunAt: result.finishedAt,
|
||||
lastRunId: run.id,
|
||||
lastStatus: result.status,
|
||||
nextRunAt: this.resolveNextRunAt(task),
|
||||
runtimeStatus:
|
||||
result.status === 'failed'
|
||||
? 'failed'
|
||||
: task.enabled
|
||||
? 'scheduled'
|
||||
: 'idle',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>).sort()
|
||||
: [];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import {
|
||||
Between,
|
||||
FindOptionsWhere,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
import { throwVbenError, ToolsService } from '@/common';
|
||||
import {
|
||||
QqbotPlugin,
|
||||
QqbotPluginTask,
|
||||
QqbotPluginTaskRun,
|
||||
} from '../../infrastructure/persistence';
|
||||
import { requireQqbotPluginTaskCron } from './qqbot-plugin-task-cron.validator';
|
||||
import type {
|
||||
QqbotPluginTaskPageQuery,
|
||||
QqbotPluginTaskRunPageQuery,
|
||||
} from './qqbot-plugin-task.types';
|
||||
import { QqbotPluginTaskSchedulerService } from './qqbot-plugin-task-scheduler.service';
|
||||
|
||||
@Injectable()
|
||||
export class QqbotPluginTaskService {
|
||||
constructor(
|
||||
@InjectRepository(QqbotPluginTask)
|
||||
private readonly taskRepository: Repository<QqbotPluginTask>,
|
||||
@InjectRepository(QqbotPluginTaskRun)
|
||||
private readonly runRepository: Repository<QqbotPluginTaskRun>,
|
||||
@InjectRepository(QqbotPlugin)
|
||||
private readonly pluginRepository: Repository<QqbotPlugin>,
|
||||
private readonly toolsService: ToolsService,
|
||||
@Optional()
|
||||
private readonly scheduler?: QqbotPluginTaskSchedulerService,
|
||||
) {}
|
||||
|
||||
async pageTasks(query: QqbotPluginTaskPageQuery) {
|
||||
const { pageNo, pageSize, skip } = this.toolsService.getPageParams(query);
|
||||
const where: FindOptionsWhere<QqbotPluginTask> = {};
|
||||
const pluginId = await this.resolvePluginIdFilter(query);
|
||||
if (pluginId) where.pluginId = pluginId;
|
||||
if (query.taskKey) where.taskKey = query.taskKey;
|
||||
if (query.status) where.runtimeStatus = query.status;
|
||||
if (query.enabled !== undefined) {
|
||||
where.enabled = this.toolsService.normalizeBoolean(query.enabled);
|
||||
}
|
||||
const [list, total] = await this.taskRepository.findAndCount({
|
||||
order: { createTime: 'DESC' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
where,
|
||||
});
|
||||
return { list, pageNo, pageSize, total };
|
||||
}
|
||||
|
||||
async getTaskDetail(id: string) {
|
||||
const task = await this.taskRepository.findOne({ where: { id } });
|
||||
if (!task) throwVbenError('插件定时任务不存在');
|
||||
return task;
|
||||
}
|
||||
|
||||
async enableTask(id: string) {
|
||||
const task = await this.getTaskDetail(id);
|
||||
task.enabled = true;
|
||||
task.runtimeStatus = 'scheduled';
|
||||
const saved = await this.taskRepository.save(task);
|
||||
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);
|
||||
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);
|
||||
const schedulerState = await this.requireScheduler().syncTaskScheduler(saved);
|
||||
if (schedulerState) Object.assign(saved, schedulerState);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async runTaskOnce(id: string, body: { input?: Record<string, unknown> }) {
|
||||
await this.getTaskDetail(id);
|
||||
const job = await this.requireScheduler().enqueueManualRun(
|
||||
id,
|
||||
body.input || {},
|
||||
);
|
||||
return { jobId: `${job.id || ''}`, taskId: id };
|
||||
}
|
||||
|
||||
async pageTaskRuns(id: string, query: QqbotPluginTaskRunPageQuery) {
|
||||
const { pageNo, pageSize, skip } = this.toolsService.getPageParams(query);
|
||||
const where: FindOptionsWhere<QqbotPluginTaskRun> = { taskId: id };
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.triggerType) where.triggerType = query.triggerType;
|
||||
Object.assign(where, this.buildRunTimeFilter(query));
|
||||
const [list, total] = await this.runRepository.findAndCount({
|
||||
order: { createTime: 'DESC' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
where,
|
||||
});
|
||||
return { list, pageNo, pageSize, total };
|
||||
}
|
||||
|
||||
private async resolvePluginIdFilter(query: QqbotPluginTaskPageQuery) {
|
||||
if (query.pluginId) return query.pluginId;
|
||||
if (!query.pluginKey) return undefined;
|
||||
|
||||
const plugin = await this.pluginRepository.findOne({
|
||||
where: { pluginKey: query.pluginKey },
|
||||
});
|
||||
return plugin?.id || '__missing_plugin__';
|
||||
}
|
||||
|
||||
private buildRunTimeFilter(query: QqbotPluginTaskRunPageQuery) {
|
||||
if (query.startTime && query.endTime) {
|
||||
return {
|
||||
createTime: Between(query.startTime, query.endTime),
|
||||
};
|
||||
}
|
||||
if (query.startTime) {
|
||||
return {
|
||||
createTime: MoreThanOrEqual(query.startTime),
|
||||
};
|
||||
}
|
||||
if (query.endTime) {
|
||||
return {
|
||||
createTime: LessThanOrEqual(query.endTime),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private requireScheduler() {
|
||||
if (!this.scheduler) {
|
||||
throwVbenError('插件定时任务调度器未初始化');
|
||||
}
|
||||
return this.scheduler;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
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;
|
||||
};
|
||||
@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { vbenSuccess } from '@/common';
|
||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { QqbotPluginTaskService } from '../application/task';
|
||||
|
||||
@ApiTags('QQBot - 插件定时任务')
|
||||
@Controller('qqbot/plugin-platform/tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class QqbotPluginPlatformTaskController {
|
||||
constructor(private readonly service: QqbotPluginTaskService) {}
|
||||
|
||||
@Get('page')
|
||||
@ApiOperation({ summary: '插件定时任务分页' })
|
||||
async page(@Query() query: Record<string, unknown>) {
|
||||
return vbenSuccess(await this.service.pageTasks(query));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '插件定时任务详情' })
|
||||
async detail(@Param('id') id: string) {
|
||||
return vbenSuccess(await this.service.getTaskDetail(id));
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '启用插件定时任务' })
|
||||
async enable(@Param('id') id: string) {
|
||||
return vbenSuccess(await this.service.enableTask(id));
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '停用插件定时任务' })
|
||||
async disable(@Param('id') id: string) {
|
||||
return vbenSuccess(await this.service.disableTask(id));
|
||||
}
|
||||
|
||||
@Post(':id/cron')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '更新插件定时任务 cron' })
|
||||
async updateCron(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { cronExpression?: string },
|
||||
) {
|
||||
return vbenSuccess(await this.service.updateTaskCron(id, body));
|
||||
}
|
||||
|
||||
@Post(':id/run')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '手动运行插件定时任务' })
|
||||
async run(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { input?: Record<string, unknown> },
|
||||
) {
|
||||
return vbenSuccess(await this.service.runTaskOnce(id, body));
|
||||
}
|
||||
|
||||
@Get(':id/runs')
|
||||
@ApiOperation({ summary: '插件定时任务运行记录分页' })
|
||||
async runs(@Param('id') id: string, @Query() query: Record<string, unknown>) {
|
||||
return vbenSuccess(await this.service.pageTaskRuns(id, query));
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import * as path from 'path';
|
||||
import { normalizeQqbotPluginTaskCron } from '../../application/task';
|
||||
import {
|
||||
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
|
||||
QQBOT_PLUGIN_WORKER_TYPES,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
type QqbotPluginOperationManifest,
|
||||
type QqbotPluginPermission,
|
||||
type QqbotPluginRuntimeManifest,
|
||||
type QqbotPluginTaskManifest,
|
||||
type QqbotPluginWorkerType,
|
||||
} from './manifest.types';
|
||||
|
||||
@ -317,6 +319,74 @@ const parseEvents = (
|
||||
});
|
||||
};
|
||||
|
||||
const parseTasks = (
|
||||
source: Record<string, unknown>,
|
||||
issues: QqbotPluginManifestValidationIssue[],
|
||||
): QqbotPluginTaskManifest[] => {
|
||||
const tasks = Array.isArray(source.tasks) ? source.tasks : [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
return tasks.filter(isPlainObject).map((task, index) => {
|
||||
const pathPrefix = `tasks[${index}]`;
|
||||
const key = getString(task, 'key') || '';
|
||||
const timeoutMs = getNumber(task, 'timeoutMs');
|
||||
let defaultCron = getString(task, 'defaultCron') || '';
|
||||
|
||||
requireKey(key, `${pathPrefix}.key`, issues);
|
||||
if (seenKeys.has(key)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'DUPLICATE_TASK_KEY',
|
||||
pathPrefix,
|
||||
`Duplicate task key: ${key}.`,
|
||||
);
|
||||
}
|
||||
seenKeys.add(key);
|
||||
|
||||
if (!getString(task, 'handlerName')) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'MISSING_TASK_HANDLER',
|
||||
`${pathPrefix}.handlerName`,
|
||||
'Task handlerName is required.',
|
||||
);
|
||||
}
|
||||
if (!timeoutMs) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'MISSING_TASK_TIMEOUT',
|
||||
`${pathPrefix}.timeoutMs`,
|
||||
'Task timeoutMs is required.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
defaultCron = normalizeQqbotPluginTaskCron(defaultCron);
|
||||
} catch (error) {
|
||||
pushIssue(
|
||||
issues,
|
||||
'INVALID_TASK_CRON',
|
||||
`${pathPrefix}.defaultCron`,
|
||||
error instanceof Error ? error.message : 'Task cron is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
defaultCron,
|
||||
description: getString(task, 'description'),
|
||||
enabled: task.enabled !== false,
|
||||
handlerName: getString(task, 'handlerName') || '',
|
||||
key,
|
||||
name: getString(task, 'name') || key,
|
||||
permissions: normalizePermissions(
|
||||
task.permissions,
|
||||
`${pathPrefix}.permissions`,
|
||||
issues,
|
||||
),
|
||||
timeoutMs: timeoutMs || 1000,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const parseAssets = (
|
||||
source: Record<string, unknown>,
|
||||
issues: QqbotPluginManifestValidationIssue[],
|
||||
@ -435,6 +505,7 @@ export const parseQqbotPluginManifest = (
|
||||
),
|
||||
pluginKey,
|
||||
runtime: parseRuntime(manifestLike, issues),
|
||||
tasks: parseTasks(manifestLike, issues),
|
||||
version,
|
||||
};
|
||||
|
||||
|
||||
@ -47,6 +47,17 @@ export type QqbotPluginEventManifest = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type QqbotPluginTaskManifest = {
|
||||
defaultCron: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
handlerName: string;
|
||||
key: string;
|
||||
name: string;
|
||||
permissions: QqbotPluginPermission[];
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type QqbotPluginAssetManifest = {
|
||||
contentHash?: string;
|
||||
key: string;
|
||||
@ -75,6 +86,7 @@ export type QqbotPluginManifest = {
|
||||
permissions: QqbotPluginPermission[];
|
||||
pluginKey: string;
|
||||
runtime: QqbotPluginRuntimeManifest;
|
||||
tasks: QqbotPluginTaskManifest[];
|
||||
version: string;
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type {
|
||||
@ -36,6 +42,11 @@ import type { QqbotPluginWorkerRequest } from './worker-runtime.types';
|
||||
type RuntimeCommandPlugin = QqbotIntegrationPlugin & {
|
||||
activate?: () => Promise<unknown> | unknown;
|
||||
dispose?: () => Promise<unknown> | unknown;
|
||||
tasks?: Array<{
|
||||
execute(input: Record<string, unknown>): Promise<unknown> | unknown;
|
||||
handlerName: string;
|
||||
key: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RuntimeEventPlugin = {
|
||||
@ -139,6 +150,8 @@ async function handleWorkerRequest(message: QqbotPluginWorkerRequest) {
|
||||
return health();
|
||||
case 'executeOperation':
|
||||
return executeOperation(message);
|
||||
case 'executeTask':
|
||||
return executeTask(message);
|
||||
case 'handleEvent':
|
||||
return handleEvent(message);
|
||||
case 'deactivate':
|
||||
@ -193,6 +206,18 @@ async function executeOperation(message: QqbotPluginWorkerRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
async function executeTask(message: QqbotPluginWorkerRequest) {
|
||||
const task = commandPlugin?.tasks?.find(
|
||||
(item) =>
|
||||
item.key === message.taskKey ||
|
||||
item.handlerName === message.taskHandlerName,
|
||||
);
|
||||
if (!task) {
|
||||
throw new Error(`QQBot 插件定时任务不存在:${message.taskKey}`);
|
||||
}
|
||||
return task.execute((message.input || {}) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function handleEvent(message: QqbotPluginWorkerRequest) {
|
||||
if (!eventPlugin) return false;
|
||||
const definition = eventPlugin.getDefinition();
|
||||
@ -311,6 +336,10 @@ function createBangDreamRuntimeIo(): BangDreamRuntimeIo {
|
||||
readExcelRows: async (filePath) => readExcelRows(filePath),
|
||||
readJsonFile: async (filePath) => readJsonFile(filePath),
|
||||
readJsonFileSync: (filePath) => readJsonFile(filePath),
|
||||
renameFile: async (from, to) => {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
renameSync(from, to);
|
||||
},
|
||||
requestArrayBuffer: async (url, options) => ({
|
||||
body: Buffer.from(
|
||||
await callHost<Uint8Array>('requestBuffer', {
|
||||
@ -342,8 +371,10 @@ function createBangDreamRuntimeIo(): BangDreamRuntimeIo {
|
||||
}),
|
||||
sleep: async (ms) =>
|
||||
await new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
writeJsonFile: async (filePath, data) =>
|
||||
writeFileSync(filePath, JSON.stringify(data)),
|
||||
writeJsonFile: async (filePath, data) => {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, JSON.stringify(data));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
type QqbotPluginRuntimeEvent,
|
||||
type QqbotPluginRuntimeStatus,
|
||||
type QqbotPluginSafeInputSummary,
|
||||
type QqbotPluginTaskRequest,
|
||||
type QqbotPluginWorkerRequestQueue,
|
||||
type QqbotPluginWorkerRequest,
|
||||
type QqbotPluginWorkerRequestType,
|
||||
@ -135,6 +136,21 @@ export class QqbotPluginWorkerRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
async health() {
|
||||
return this.request('health');
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ export type QqbotPluginWorkerRequestType =
|
||||
| 'deactivate'
|
||||
| 'dispose'
|
||||
| 'executeOperation'
|
||||
| 'executeTask'
|
||||
| 'handleEvent'
|
||||
| 'health'
|
||||
| 'load';
|
||||
@ -29,7 +30,11 @@ export type QqbotPluginWorkerRequest = {
|
||||
input?: unknown;
|
||||
pluginKey: string;
|
||||
safeInputSummary?: QqbotPluginSafeInputSummary;
|
||||
taskHandlerName?: string;
|
||||
taskId?: string;
|
||||
taskKey?: string;
|
||||
timeoutMs: number;
|
||||
triggerType?: 'bootstrap' | 'manual' | 'schedule';
|
||||
type: QqbotPluginWorkerRequestType;
|
||||
};
|
||||
|
||||
@ -75,3 +80,12 @@ export type QqbotPluginEventRequest = {
|
||||
eventKey: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type QqbotPluginTaskRequest = {
|
||||
input: Record<string, unknown>;
|
||||
taskHandlerName: string;
|
||||
taskId: string;
|
||||
taskKey: string;
|
||||
timeoutMs?: number;
|
||||
triggerType: 'bootstrap' | 'manual' | 'schedule';
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ export const QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT = {
|
||||
installLocal: '/qqbot/plugin-platform/install-local',
|
||||
installations: '/qqbot/plugin-platform/installations',
|
||||
runtimeEvents: '/qqbot/plugin-platform/runtime-events',
|
||||
tasks: '/qqbot/plugin-platform/tasks',
|
||||
validate: '/qqbot/plugin-platform/validate',
|
||||
},
|
||||
tables: [
|
||||
@ -16,5 +17,7 @@ export const QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT = {
|
||||
'qqbot_plugin_config',
|
||||
'qqbot_plugin_asset',
|
||||
'qqbot_plugin_runtime_event',
|
||||
'qqbot_plugin_task',
|
||||
'qqbot_plugin_task_run',
|
||||
],
|
||||
} as const;
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
ensureSnowflakeId,
|
||||
KtCreateDateColumn,
|
||||
KtDateTime,
|
||||
KtDateTimeColumn,
|
||||
KtUpdateDateColumn,
|
||||
} from '@/common';
|
||||
|
||||
@ -25,6 +26,21 @@ export type QqbotPluginRuntimeStatus =
|
||||
|
||||
export type QqbotPluginRuntimeEventLevel = 'error' | 'info' | 'warn';
|
||||
|
||||
export type QqbotPluginTaskRuntimeStatus =
|
||||
| 'disabled'
|
||||
| 'failed'
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'scheduled';
|
||||
|
||||
export type QqbotPluginTaskRunStatus =
|
||||
| 'failed'
|
||||
| 'running'
|
||||
| 'skipped'
|
||||
| 'success';
|
||||
|
||||
export type QqbotPluginTaskTriggerType = 'bootstrap' | 'manual' | 'schedule';
|
||||
|
||||
@Entity('qqbot_plugin')
|
||||
@Index('uk_qqbot_plugin_key', ['pluginKey'], { unique: true })
|
||||
export class QqbotPlugin {
|
||||
@ -291,6 +307,133 @@ export class QqbotPluginRuntimeEvent {
|
||||
}
|
||||
}
|
||||
|
||||
@Entity('qqbot_plugin_task')
|
||||
@Index('uk_qqbot_plugin_task', ['installationId', 'taskKey'], {
|
||||
unique: true,
|
||||
})
|
||||
@Index('idx_qqbot_plugin_task_plugin', ['pluginId'])
|
||||
@Index('idx_qqbot_plugin_task_enabled', ['enabled'])
|
||||
@Index('idx_qqbot_plugin_task_status', ['runtimeStatus'])
|
||||
export class QqbotPluginTask {
|
||||
@PrimaryColumn({ type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'plugin_id', type: 'bigint' })
|
||||
pluginId: string;
|
||||
|
||||
@Column({ name: 'installation_id', type: 'bigint' })
|
||||
installationId: string;
|
||||
|
||||
@Column({ length: 128, name: 'task_key' })
|
||||
taskKey: string;
|
||||
|
||||
@Column({ length: 128, name: 'task_name' })
|
||||
taskName: string;
|
||||
|
||||
@Column({ length: 128, name: 'handler_name' })
|
||||
handlerName: string;
|
||||
|
||||
@Column({ name: 'description', nullable: true, type: 'text' })
|
||||
description: null | string;
|
||||
|
||||
@Column({ length: 64, name: 'default_cron' })
|
||||
defaultCron: string;
|
||||
|
||||
@Column({ length: 64, name: 'cron_expression' })
|
||||
cronExpression: string;
|
||||
|
||||
@Column({ default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'timeout_ms', type: 'int' })
|
||||
timeoutMs: number;
|
||||
|
||||
@Column({ length: 32, name: 'runtime_status' })
|
||||
runtimeStatus: QqbotPluginTaskRuntimeStatus;
|
||||
|
||||
@Column({ name: 'last_run_id', nullable: true, type: 'bigint' })
|
||||
lastRunId: null | string;
|
||||
|
||||
@KtDateTimeColumn({ name: 'last_run_at', nullable: true })
|
||||
lastRunAt: null | KtDateTime;
|
||||
|
||||
@Column({ length: 32, name: 'last_status', nullable: true })
|
||||
lastStatus: null | QqbotPluginTaskRunStatus;
|
||||
|
||||
@Column({ name: 'last_error', nullable: true, type: 'text' })
|
||||
lastError: null | string;
|
||||
|
||||
@Column({ name: 'last_duration_ms', nullable: true, type: 'int' })
|
||||
lastDurationMs: null | number;
|
||||
|
||||
@KtDateTimeColumn({ name: 'next_run_at', nullable: true })
|
||||
nextRunAt: null | KtDateTime;
|
||||
|
||||
@KtCreateDateColumn({ name: 'create_time' })
|
||||
createTime: KtDateTime;
|
||||
|
||||
@KtUpdateDateColumn({ name: 'update_time' })
|
||||
updateTime: KtDateTime;
|
||||
|
||||
@BeforeInsert()
|
||||
createId() {
|
||||
ensureSnowflakeId(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Entity('qqbot_plugin_task_run')
|
||||
@Index('idx_qqbot_plugin_task_run_task_time', ['taskId', 'createTime'])
|
||||
@Index('idx_qqbot_plugin_task_run_plugin_time', ['pluginId', 'createTime'])
|
||||
@Index('idx_qqbot_plugin_task_run_status_time', ['status', 'createTime'])
|
||||
export class QqbotPluginTaskRun {
|
||||
@PrimaryColumn({ type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'task_id', type: 'bigint' })
|
||||
taskId: string;
|
||||
|
||||
@Column({ name: 'plugin_id', type: 'bigint' })
|
||||
pluginId: string;
|
||||
|
||||
@Column({ name: 'installation_id', type: 'bigint' })
|
||||
installationId: string;
|
||||
|
||||
@Column({ length: 128, name: 'task_key' })
|
||||
taskKey: string;
|
||||
|
||||
@Column({ length: 32, name: 'trigger_type' })
|
||||
triggerType: QqbotPluginTaskTriggerType;
|
||||
|
||||
@Column({ length: 32 })
|
||||
status: QqbotPluginTaskRunStatus;
|
||||
|
||||
@Column({ length: 191, name: 'job_id', nullable: true })
|
||||
jobId: null | string;
|
||||
|
||||
@KtDateTimeColumn({ name: 'started_at', nullable: true })
|
||||
startedAt: null | KtDateTime;
|
||||
|
||||
@KtDateTimeColumn({ name: 'finished_at', nullable: true })
|
||||
finishedAt: null | KtDateTime;
|
||||
|
||||
@Column({ name: 'duration_ms', nullable: true, type: 'int' })
|
||||
durationMs: null | number;
|
||||
|
||||
@Column({ name: 'safe_summary', nullable: true, type: 'simple-json' })
|
||||
safeSummary: null | Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'error_message', nullable: true, type: 'text' })
|
||||
errorMessage: null | string;
|
||||
|
||||
@KtCreateDateColumn({ name: 'create_time' })
|
||||
createTime: KtDateTime;
|
||||
|
||||
@BeforeInsert()
|
||||
createId() {
|
||||
ensureSnowflakeId(this);
|
||||
}
|
||||
}
|
||||
|
||||
export const QQBOT_PLUGIN_PLATFORM_ENTITIES = [
|
||||
QqbotPlugin,
|
||||
QqbotPluginVersion,
|
||||
@ -301,4 +444,6 @@ export const QQBOT_PLUGIN_PLATFORM_ENTITIES = [
|
||||
QqbotPluginConfig,
|
||||
QqbotPluginAsset,
|
||||
QqbotPluginRuntimeEvent,
|
||||
QqbotPluginTask,
|
||||
QqbotPluginTaskRun,
|
||||
] as const;
|
||||
|
||||
@ -11,6 +11,13 @@ import { QqbotEventPluginRegistryService } from './application/registry/qqbot-ev
|
||||
import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service';
|
||||
import { QqbotPluginExecutionAdapter } from './application/plugin-execution.adapter';
|
||||
import { QqbotPluginPlatformService } from './application/plugin-platform.service';
|
||||
import {
|
||||
QqbotPluginTaskManifestSynchronizer,
|
||||
QqbotPluginTaskSchedulerService,
|
||||
QqbotPluginTaskService,
|
||||
QqbotPluginTaskWorkerProcessor,
|
||||
} from './application/task';
|
||||
import { QqbotPluginPlatformTaskController } from './contract/plugin-platform-task.controller';
|
||||
import { QqbotPluginPlatformController } from './contract/plugin-platform.controller';
|
||||
import { QqbotPluginController } from './contract/qqbot-plugin.controller';
|
||||
import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './infrastructure/persistence';
|
||||
@ -25,7 +32,11 @@ import {
|
||||
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.service';
|
||||
|
||||
@Module({
|
||||
controllers: [QqbotPluginController, QqbotPluginPlatformController],
|
||||
controllers: [
|
||||
QqbotPluginController,
|
||||
QqbotPluginPlatformController,
|
||||
QqbotPluginPlatformTaskController,
|
||||
],
|
||||
exports: [
|
||||
QQBOT_PLUGIN_EXECUTION_PORT,
|
||||
QqbotPluginHttpClientService,
|
||||
@ -53,6 +64,10 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv
|
||||
QqbotBuiltinPluginPackageLoaderService,
|
||||
QqbotBuiltinPluginWorkerRuntimeFactoryService,
|
||||
QqbotPluginPackageReaderService,
|
||||
QqbotPluginTaskManifestSynchronizer,
|
||||
QqbotPluginTaskSchedulerService,
|
||||
QqbotPluginTaskService,
|
||||
QqbotPluginTaskWorkerProcessor,
|
||||
{
|
||||
provide: QQBOT_PLUGIN_EXECUTION_PORT,
|
||||
useExisting: QqbotPluginExecutionAdapter,
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
"runtime.http",
|
||||
"asset.read",
|
||||
"plugin.config.read",
|
||||
"plugin.storage.read"
|
||||
"plugin.storage.read",
|
||||
"plugin.storage.write"
|
||||
],
|
||||
"runtime": {
|
||||
"workerType": "node-worker",
|
||||
@ -257,6 +258,22 @@
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [],
|
||||
"assets": [
|
||||
{
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { join } from 'node:path';
|
||||
import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
|
||||
import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider';
|
||||
import { bangdreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider';
|
||||
@ -7,6 +8,7 @@ import {
|
||||
normalizeBangDreamPositiveInteger,
|
||||
} from '@/modules/qqbot/plugins/bangdream/src/config/runtime-options';
|
||||
import {
|
||||
readBangDreamJsonFile,
|
||||
readBangDreamRuntimeConfig,
|
||||
sleepBangDreamRuntime,
|
||||
} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
|
||||
@ -82,12 +84,18 @@ async function loadCatalogKey(key: BangDreamCatalogKey, useCache: boolean) {
|
||||
|
||||
const promise = (async () => {
|
||||
if (useCache) {
|
||||
bangdreamCatalogCache[key] = await bangdreamBestdoriProvider.getJson(
|
||||
bestdoriApiPath[key],
|
||||
{
|
||||
cacheTime: 1 / 0,
|
||||
},
|
||||
);
|
||||
try {
|
||||
bangdreamCatalogCache[key] = await readBangDreamCatalogDataFromCache(
|
||||
key,
|
||||
);
|
||||
} catch {
|
||||
bangdreamCatalogCache[key] = await bangdreamBestdoriProvider.getJson(
|
||||
bestdoriApiPath[key],
|
||||
{
|
||||
cacheTime: 1 / 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -184,4 +192,34 @@ export async function waitForBangDreamCatalogReady(
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshBangDreamCatalogFromCache(
|
||||
keys?: readonly BangDreamCatalogKey[],
|
||||
) {
|
||||
const catalogKeys = normalizeCatalogKeys(keys);
|
||||
for (const key of catalogKeys) {
|
||||
bangdreamCatalogCache[key] = {};
|
||||
}
|
||||
await loadCatalogData(catalogKeys, true);
|
||||
}
|
||||
|
||||
export function resolveBangDreamMainDataCachePath(
|
||||
cacheRoot: string,
|
||||
key: BangDreamCatalogKey,
|
||||
) {
|
||||
return join(cacheRoot, 'bestdori', `${key}.json`);
|
||||
}
|
||||
|
||||
function resolveBangDreamCatalogCacheRoot() {
|
||||
return (
|
||||
readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.cacheRoot) ||
|
||||
join(process.cwd(), '.kt-workspace', 'cache', 'bangdream')
|
||||
);
|
||||
}
|
||||
|
||||
async function readBangDreamCatalogDataFromCache(key: BangDreamCatalogKey) {
|
||||
return readBangDreamJsonFile(
|
||||
resolveBangDreamMainDataCachePath(resolveBangDreamCatalogCacheRoot(), key),
|
||||
);
|
||||
}
|
||||
|
||||
export default bangdreamCatalogCache;
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
import { 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,
|
||||
resolveBangDreamMainDataCachePath,
|
||||
type BangDreamCatalogKey,
|
||||
} from '../catalog/bangdream-catalog-cache';
|
||||
|
||||
export const BANGDREAM_BESTDORI_MAIN_DATA_KEYS = [
|
||||
'songs',
|
||||
'meta',
|
||||
'cards',
|
||||
'skills',
|
||||
'events',
|
||||
'gacha',
|
||||
'costumes',
|
||||
'bands',
|
||||
'characters',
|
||||
'areaItems',
|
||||
] as const satisfies readonly BangDreamCatalogKey[];
|
||||
|
||||
type BangDreamBestdoriMainDataKey =
|
||||
(typeof BANGDREAM_BESTDORI_MAIN_DATA_KEYS)[number];
|
||||
|
||||
type BangDreamBestdoriMainDataSyncOutput = {
|
||||
cacheRootConfigured: boolean;
|
||||
durationMs: number;
|
||||
failedCount: number;
|
||||
successCount: number;
|
||||
syncedKeys: BangDreamBestdoriMainDataKey[];
|
||||
};
|
||||
|
||||
export function createBestdoriMainDataSyncTask() {
|
||||
return {
|
||||
execute: syncBestdoriMainData,
|
||||
handlerName: 'syncBestdoriMainData',
|
||||
key: 'bangdream.bestdori.sync-main-data',
|
||||
};
|
||||
}
|
||||
|
||||
async function syncBestdoriMainData(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<BangDreamBestdoriMainDataSyncOutput> {
|
||||
const startedAt = Date.now();
|
||||
const keys = normalizeKeys(input.keys);
|
||||
const cacheRoot = resolveCacheRoot();
|
||||
const failures: Array<{ key: string; message: string }> = [];
|
||||
const syncedKeys: BangDreamBestdoriMainDataKey[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const response = await requestBangDreamJson(
|
||||
new URL(bestdoriApiPath[key], bestdoriUrl).toString(),
|
||||
{ timeoutMs: 30000 },
|
||||
);
|
||||
await writeBangDreamJsonFileAtomic(
|
||||
resolveBangDreamMainDataCachePath(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);
|
||||
}
|
||||
|
||||
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): BangDreamBestdoriMainDataKey[] {
|
||||
const requested = Array.isArray(input) ? input : [];
|
||||
const allowed = new Set<string>(BANGDREAM_BESTDORI_MAIN_DATA_KEYS);
|
||||
const keys =
|
||||
requested.length > 0
|
||||
? requested.filter(
|
||||
(key): key is BangDreamBestdoriMainDataKey =>
|
||||
typeof key === 'string' && allowed.has(key),
|
||||
)
|
||||
: BANGDREAM_BESTDORI_MAIN_DATA_KEYS;
|
||||
const uniqueKeys = [...new Set(keys)];
|
||||
return uniqueKeys.length > 0
|
||||
? uniqueKeys
|
||||
: [...BANGDREAM_BESTDORI_MAIN_DATA_KEYS];
|
||||
}
|
||||
|
||||
function resolveCacheRoot() {
|
||||
return (
|
||||
readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.cacheRoot) ||
|
||||
join(process.cwd(), '.kt-workspace', 'cache', 'bangdream')
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from './bestdori-main-data-sync.task';
|
||||
@ -11,6 +11,7 @@ import {
|
||||
configureBangDreamRuntimeIo,
|
||||
type BangDreamRuntimeIo,
|
||||
} from './infrastructure/integration/runtime-io';
|
||||
import { createBestdoriMainDataSyncTask } from './application/tasks';
|
||||
import {
|
||||
getBangDreamOperationsByHandlerName,
|
||||
type BangDreamOperationModule,
|
||||
@ -58,6 +59,7 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
|
||||
createBangDreamOperationLogObserver(),
|
||||
]);
|
||||
const operationsByKey = resolveBangDreamOperations(options.operations);
|
||||
const tasks = [createBestdoriMainDataSyncTask()];
|
||||
const normalizeError =
|
||||
options.normalizeError ||
|
||||
((error: unknown) =>
|
||||
@ -120,6 +122,7 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
|
||||
execute: async (input: BangDreamCommandInput) =>
|
||||
await executeOperation(operation.key, input),
|
||||
})),
|
||||
tasks,
|
||||
version: options.version || '2.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ export type BangDreamRuntimeIo = {
|
||||
) => Promise<T[]>;
|
||||
readJsonFile?: <T = unknown>(filePath: string) => Promise<T>;
|
||||
readJsonFileSync?: <T = unknown>(filePath: string) => T;
|
||||
renameFile?: (from: string, to: string) => Promise<void>;
|
||||
requestArrayBuffer?: (
|
||||
url: string,
|
||||
options?: {
|
||||
@ -111,6 +112,18 @@ export async function writeBangDreamJsonFile(filePath: string, data: unknown) {
|
||||
await runtimeIo.writeJsonFile(filePath, data);
|
||||
}
|
||||
|
||||
export async function writeBangDreamJsonFileAtomic(
|
||||
filePath: string,
|
||||
data: unknown,
|
||||
) {
|
||||
if (!runtimeIo.writeJsonFile || !runtimeIo.renameFile) {
|
||||
throw new Error('BangDream JSON 原子写入器未初始化');
|
||||
}
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await runtimeIo.writeJsonFile(tempPath, data);
|
||||
await runtimeIo.renameFile(tempPath, filePath);
|
||||
}
|
||||
|
||||
export async function sleepBangDreamRuntime(ms: number) {
|
||||
if (ms <= 0) return;
|
||||
if (runtimeIo.sleep) {
|
||||
|
||||
@ -33,6 +33,8 @@ describe('QQBot plugin platform persistence contract', () => {
|
||||
'qqbot_plugin_config',
|
||||
'qqbot_plugin_asset',
|
||||
'qqbot_plugin_runtime_event',
|
||||
'qqbot_plugin_task',
|
||||
'qqbot_plugin_task_run',
|
||||
]);
|
||||
|
||||
for (const table of QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables) {
|
||||
@ -52,6 +54,8 @@ describe('QQBot plugin platform persistence contract', () => {
|
||||
'QqbotPluginConfig',
|
||||
'QqbotPluginAsset',
|
||||
'QqbotPluginRuntimeEvent',
|
||||
'QqbotPluginTask',
|
||||
'QqbotPluginTaskRun',
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -273,7 +273,7 @@ describe('QQBot plugin platform API contract', () => {
|
||||
|
||||
it('keeps TypeORM entity registration aligned with the persistence contract', () => {
|
||||
expect(QqbotPluginPlatformModule).toBeDefined();
|
||||
expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(9);
|
||||
expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(11);
|
||||
});
|
||||
|
||||
it('passes runtime-event filters to persistence', async () => {
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
import { QqbotPluginPlatformTaskController } from '../../../../src/modules/qqbot/plugin-platform/contract/plugin-platform-task.controller';
|
||||
import { ToolsService } from '../../../../src/common';
|
||||
import { QqbotPluginTaskService } from '../../../../src/modules/qqbot/plugin-platform/application/task';
|
||||
import {
|
||||
collectControllerRoutes,
|
||||
routeKey,
|
||||
} from '../../../helpers/controller-route.helper';
|
||||
|
||||
const createRepositoryMock = () => ({
|
||||
findAndCount: jest.fn(async () => [[], 0]),
|
||||
findOne: jest.fn(async () => null),
|
||||
save: jest.fn(async (value) => value),
|
||||
update: jest.fn(async () => ({ affected: 1 })),
|
||||
});
|
||||
|
||||
describe('QQBot plugin task API contract', () => {
|
||||
it('exposes task management routes under plugin-platform ownership', () => {
|
||||
expect(
|
||||
collectControllerRoutes([QqbotPluginPlatformTaskController]).map(
|
||||
routeKey,
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'GET /qqbot/plugin-platform/tasks/page',
|
||||
'GET /qqbot/plugin-platform/tasks/:id',
|
||||
'POST /qqbot/plugin-platform/tasks/:id/enable',
|
||||
'POST /qqbot/plugin-platform/tasks/:id/disable',
|
||||
'POST /qqbot/plugin-platform/tasks/:id/cron',
|
||||
'POST /qqbot/plugin-platform/tasks/:id/run',
|
||||
'GET /qqbot/plugin-platform/tasks/:id/runs',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies task page filters and safe pagination defaults', async () => {
|
||||
const taskRepository = createRepositoryMock();
|
||||
const runRepository = createRepositoryMock();
|
||||
const pluginRepository = createRepositoryMock();
|
||||
pluginRepository.findOne.mockResolvedValue({ id: 'plugin-1' });
|
||||
const service = new QqbotPluginTaskService(
|
||||
taskRepository as any,
|
||||
runRepository as any,
|
||||
pluginRepository as any,
|
||||
new ToolsService(),
|
||||
);
|
||||
|
||||
const page = await service.pageTasks({
|
||||
enabled: 'true',
|
||||
pageNo: 'bad',
|
||||
pageSize: 'also-bad',
|
||||
pluginKey: 'bangdream',
|
||||
status: 'scheduled',
|
||||
taskKey: 'bangdream.bestdori.sync-main-data',
|
||||
});
|
||||
|
||||
expect(pluginRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { pluginKey: 'bangdream' },
|
||||
});
|
||||
expect(taskRepository.findAndCount).toHaveBeenCalledWith({
|
||||
order: { createTime: 'DESC' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
where: {
|
||||
enabled: true,
|
||||
pluginId: 'plugin-1',
|
||||
runtimeStatus: 'scheduled',
|
||||
taskKey: 'bangdream.bestdori.sync-main-data',
|
||||
},
|
||||
});
|
||||
expect(page).toMatchObject({ pageNo: 1, pageSize: 10, total: 0 });
|
||||
});
|
||||
|
||||
it('applies task run status, trigger, and time-range filters', async () => {
|
||||
const taskRepository = createRepositoryMock();
|
||||
const runRepository = createRepositoryMock();
|
||||
const pluginRepository = createRepositoryMock();
|
||||
const service = new QqbotPluginTaskService(
|
||||
taskRepository as any,
|
||||
runRepository as any,
|
||||
pluginRepository as any,
|
||||
new ToolsService(),
|
||||
);
|
||||
|
||||
await service.pageTaskRuns('task-1', {
|
||||
endTime: '2026-06-17 23:59:59',
|
||||
pageNo: 2,
|
||||
pageSize: 20,
|
||||
startTime: '2026-06-17 00:00:00',
|
||||
status: 'success',
|
||||
triggerType: 'schedule',
|
||||
});
|
||||
|
||||
expect(runRepository.findAndCount).toHaveBeenCalledWith({
|
||||
order: { createTime: 'DESC' },
|
||||
skip: 20,
|
||||
take: 20,
|
||||
where: {
|
||||
createTime: expect.any(Object),
|
||||
status: 'success',
|
||||
taskId: 'task-1',
|
||||
triggerType: 'schedule',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
109
test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Normal file
109
test/modules/qqbot/plugin-platform/plugin-task-manifest.spec.ts
Normal file
@ -0,0 +1,109 @@
|
||||
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: [],
|
||||
legacyAliases: [],
|
||||
migrations: [],
|
||||
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: 'syncBestdoriMainDataAgain',
|
||||
});
|
||||
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 不允许每分钟执行',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects cron ranges that BullMQ cannot schedule', () => {
|
||||
expect(() => normalizeQqbotPluginTaskCron('99 99 99 99 99')).toThrow(
|
||||
'定时任务 cron 表达式不合法',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,49 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
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();
|
||||
const repoRoot = join(__dirname, '../../../..');
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
441
test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts
Normal file
441
test/modules/qqbot/plugin-platform/plugin-task-scheduler.spec.ts
Normal file
@ -0,0 +1,441 @@
|
||||
const createdQueues: any[] = [];
|
||||
const createdWorkers: any[] = [];
|
||||
|
||||
jest.mock('bullmq', () => ({
|
||||
Queue: class MockQueue {
|
||||
readonly schedulers = new Map<string, unknown>();
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public options: unknown,
|
||||
) {
|
||||
createdQueues.push(this);
|
||||
}
|
||||
|
||||
async add(name: string, data: unknown, opts?: unknown) {
|
||||
return { data, id: `${name}-job`, name, opts };
|
||||
}
|
||||
|
||||
async close() {}
|
||||
|
||||
async removeJobScheduler(id: string) {
|
||||
this.schedulers.delete(id);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async upsertJobScheduler(
|
||||
id: string,
|
||||
repeat: unknown,
|
||||
template: unknown,
|
||||
) {
|
||||
this.schedulers.set(id, { repeat, template });
|
||||
return { id };
|
||||
}
|
||||
|
||||
async waitUntilReady() {}
|
||||
},
|
||||
Worker: class MockWorker {
|
||||
constructor(
|
||||
public name: string,
|
||||
public processor: (job: unknown) => Promise<unknown> | unknown,
|
||||
public options: unknown,
|
||||
) {
|
||||
createdWorkers.push(this);
|
||||
}
|
||||
|
||||
on() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async close() {}
|
||||
|
||||
async waitUntilReady() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
QqbotPluginTaskSchedulerService,
|
||||
QqbotPluginTaskWorkerProcessor,
|
||||
} 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 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,
|
||||
);
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(taskRepository.update).toHaveBeenCalledWith(
|
||||
{ id: 'task-1' },
|
||||
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',
|
||||
installationId: 'install-1',
|
||||
pluginId: 'plugin-1',
|
||||
taskKey: 'bangdream.bestdori.sync-main-data',
|
||||
timeoutMs: 120000,
|
||||
};
|
||||
const taskRepository = createTaskRepository([task]);
|
||||
const runRepository = createRunRepository();
|
||||
const platformService = {
|
||||
executeTask: jest.fn(async () => ({
|
||||
replyText: 'secret text',
|
||||
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: {
|
||||
input: { force: true, payload: 'secret payload' },
|
||||
taskId: 'task-1',
|
||||
triggerType: 'manual',
|
||||
},
|
||||
id: 'job-1',
|
||||
});
|
||||
|
||||
expect(platformService.executeTask).toHaveBeenCalledWith({
|
||||
input: { force: true, payload: 'secret payload' },
|
||||
installationId: 'install-1',
|
||||
pluginId: 'plugin-1',
|
||||
taskHandlerName: 'syncBestdoriMainData',
|
||||
taskId: 'task-1',
|
||||
taskKey: 'bangdream.bestdori.sync-main-data',
|
||||
timeoutMs: 120000,
|
||||
triggerType: 'manual',
|
||||
});
|
||||
expect(runRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
safeSummary: { outputKeys: ['replyText', 'syncedKeys'] },
|
||||
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',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
runId: 'run-1',
|
||||
status: 'success',
|
||||
});
|
||||
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 {
|
||||
return {
|
||||
get: (key: string) =>
|
||||
({
|
||||
QQBOT_PLUGIN_QUEUE_REDIS_HOST: 'redis.local',
|
||||
QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX: 'kt:qqbot:plugin-task',
|
||||
})[key],
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
save: jest.fn(async (value) => value),
|
||||
update: jest.fn(async () => ({ affected: 1 })),
|
||||
};
|
||||
}
|
||||
|
||||
function createRunRepository() {
|
||||
let runningRun: any;
|
||||
return {
|
||||
findOne: jest.fn(async ({ where }: any) =>
|
||||
where.status === 'running' ? runningRun || null : null,
|
||||
),
|
||||
save: jest.fn(async (value: any) => {
|
||||
const next = { ...value, id: value.id || 'run-1' };
|
||||
if (next.status === 'running') {
|
||||
runningRun = next;
|
||||
} else {
|
||||
runningRun = undefined;
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -417,6 +417,35 @@ describe('QQBot plugin worker runtime', () => {
|
||||
expect(driver.disposed).toBe(true);
|
||||
});
|
||||
|
||||
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: { force: true, fullPayload: 'secret' },
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
it('isolates worker crashes as plugin runtime events without throwing raw errors', async () => {
|
||||
const { runtime } = createRuntime(
|
||||
new RecordingDriver(
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
BANGDREAM_BESTDORI_MAIN_DATA_KEYS,
|
||||
createBestdoriMainDataSyncTask,
|
||||
} from '../../../../../src/modules/qqbot/plugins/bangdream/src/application/tasks';
|
||||
import { BANGDREAM_TSUGU_ENV_KEYS } from '../../../../../src/modules/qqbot/plugins/bangdream/src/config/runtime-options';
|
||||
import { configureBangDreamRuntimeIo } from '../../../../../src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
|
||||
|
||||
describe('BangDream Bestdori main-data sync task', () => {
|
||||
let cacheRoot: string;
|
||||
let consoleInfoSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
cacheRoot = mkdtempSync(join(tmpdir(), 'bangdream-sync-'));
|
||||
consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleInfoSpy.mockRestore();
|
||||
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_ENV_KEYS.cacheRoot ? cacheRoot : undefined,
|
||||
readJsonFile: async (filePath) =>
|
||||
JSON.parse(readFileSync(filePath, 'utf8')),
|
||||
readExcelRows: async () => [],
|
||||
renameFile: async (from, to) => {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
renameSync(from, to);
|
||||
},
|
||||
requestJson: async <T = unknown>(url: string) => {
|
||||
requestedUrls.push(`${url}`);
|
||||
return { body: { ok: true, url } as T };
|
||||
},
|
||||
writeJsonFile: async (filePath, data) => {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(data)}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
expect(JSON.stringify(output)).not.toContain('/api/songs/all');
|
||||
});
|
||||
|
||||
it('keeps existing cache file when one key fails', async () => {
|
||||
const metaCachePath = join(cacheRoot, 'bestdori', 'meta.json');
|
||||
mkdirSync(dirname(metaCachePath), { recursive: true });
|
||||
writeFileSync(metaCachePath, '{"previous":true}\n');
|
||||
configureBangDreamRuntimeIo({
|
||||
getConfig: (key) =>
|
||||
key === BANGDREAM_TSUGU_ENV_KEYS.cacheRoot ? cacheRoot : undefined,
|
||||
readJsonFile: async (filePath) =>
|
||||
JSON.parse(readFileSync(filePath, 'utf8')),
|
||||
readExcelRows: async () => [],
|
||||
renameFile: async (from, to) => {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
renameSync(from, to);
|
||||
},
|
||||
requestJson: async <T = unknown>(url: string) => {
|
||||
if (`${url}`.includes('/api/songs/meta/')) {
|
||||
throw new Error('network failed');
|
||||
}
|
||||
return { body: { ok: true } as T };
|
||||
},
|
||||
writeJsonFile: async (filePath, data) => {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(data)}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const task = createBestdoriMainDataSyncTask();
|
||||
await expect(task.execute({ keys: ['songs', 'meta'] })).rejects.toThrow(
|
||||
'BangDream Bestdori 主数据同步失败',
|
||||
);
|
||||
|
||||
expect(readFileSync(metaCachePath, 'utf8')).toContain('"previous":true');
|
||||
expect(existsSync(join(cacheRoot, 'bestdori', 'songs.json'))).toBe(true);
|
||||
expect(BANGDREAM_BESTDORI_MAIN_DATA_KEYS).toEqual(
|
||||
expect.arrayContaining(['songs', 'meta', 'cards', 'skills', 'events']),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user