From d3c0affa339e51d538c72715f65d8edf5ad68e4d Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 11 Jun 2026 11:16:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AB=99=E5=86=85=E4=BF=A1=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E4=B8=8EQQBot=E7=A6=BB=E7=BA=BF=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 12 +- README.md | 3 +- sql/fix-admin-menu-meta.sql | 4 +- sql/system-notice-menu.sql | 284 +++++++++++++++++- sql/vben-admin-init.sql | 42 ++- src/admin/auth/admin-auth-guard.module.ts | 3 + src/admin/auth/admin-super.guard.ts | 21 ++ src/admin/notice/admin-notice.controller.ts | 44 +-- src/admin/notice/admin-notice.dto.ts | 89 ++++-- src/admin/notice/admin-notice.entity.ts | 82 ++++- src/admin/notice/admin-notice.service.ts | 242 +++++++++++---- src/admin/notice/notice.module.ts | 13 +- src/common/index.ts | 1 + .../api-request-log.interceptor.ts | 60 ++++ src/common/notice/system-notice-publisher.ts | 19 ++ src/common/services/tool.service.ts | 50 +++ src/qqbot/account/qqbot-account.service.ts | 154 ++++++++-- src/qqbot/event/qqbot-event.service.ts | 37 ++- .../napcat/qqbot-napcat-container.service.ts | 211 +++++++++---- .../notice/admin-notice.controller.spec.ts | 123 ++++++++ .../admin/notice/admin-notice.service.spec.ts | 227 ++++++++++++++ .../api-request-log.interceptor.spec.ts | 59 ++++ .../account/qqbot-account.service.spec.ts | 258 ++++++++++++++++ test/qqbot/event/qqbot-event.service.spec.ts | 28 +- .../qqbot-napcat-container.service.spec.ts | 130 ++++++++ test/sql/system-notice-sql.spec.ts | 54 ++++ 26 files changed, 2015 insertions(+), 235 deletions(-) create mode 100644 src/admin/auth/admin-super.guard.ts create mode 100644 src/common/notice/system-notice-publisher.ts create mode 100644 test/admin/notice/admin-notice.controller.spec.ts create mode 100644 test/admin/notice/admin-notice.service.spec.ts create mode 100644 test/sql/system-notice-sql.spec.ts diff --git a/API.md b/API.md index 71b5921..77ecbdc 100644 --- a/API.md +++ b/API.md @@ -174,16 +174,18 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 ### 系统站内信 +站内信用于承接运行期事件,不再作为人工公告入口。后端在接口 5xx、QQBot OneBot 下线 notice、NapCat 容器日志检测到账户离线时自动生成或聚合一条通知,默认通知 `super` 角色;站内信接口在服务端也强制 `super` 角色访问。相同 `dedupeKey` 的事件通过 `active_dedupe_key` 唯一索引聚合,会累加 `occurrenceCount`,刷新 `lastSeenAt`,并把状态重新置为未处理。运行期通知会按表字段长度归一化 `title`、`dedupeKey`、`source`、`eventType` 和 `notifyRoleCode`,长 `dedupeKey` 会保留稳定 hash 后缀,避免长路径接口错误丢通知。 + | 方法 | 路径 | 说明 | | --- | --- | --- | -| `GET` | `/system/notice/list` | 站内信分页列表,支持 `keyword`、`level`、`status`、`isTop`、`notifyUsers`、`pageNo`、`pageSize` | +| `GET` | `/system/notice/list` | 日志级站内信分页列表,支持 `keyword`、`severity`、`source`、`eventType`、`status`、`isTop`、`notifyRoleCode`、`notifyUsers`、`pageNo`、`pageSize` | | `GET` | `/system/notice/detail/:id` | 查询站内信详情 | -| `POST` | `/system/notice/save` | 新增站内信 | -| `POST` | `/system/notice/update` | 更新站内信 | | `DELETE` | `/system/notice/:id` | 删除站内信(逻辑删除) | -| `POST` | `/system/notice/toggle` | 切换状态(`id`、`status`) | +| `POST` | `/system/notice/toggle` | 标记处理或重新打开(`id`、`status`,`1` 未处理,`0` 已处理) | | `POST` | `/system/notice/top` | 切换置顶(`id`、`isTop`) | +返回字段包含 `severity`、`source`、`eventType`、`dedupeKey`、`occurrenceCount`、`notifyRoleCode`、`metadata`、`firstSeenAt`、`lastSeenAt`。后端不暴露人工 `save/update` 入口。 + ## Blog 本地内容 `/blog/*` 是本地博客内容能力,供 `Vue/kt-blog-web` 和 Admin 博客管理使用。 @@ -314,7 +316,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。 -同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。 +同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。 ### Command / Rule / Permission diff --git a/README.md b/README.md index bed8bde..fa10798 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,9 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts - Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管理接口默认走 `JwtAuthGuard`;公开接口用 `@Public()`。 - WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。 - 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 +- 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问,Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。 - QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。 -- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,下线 notice 会写入账号 `lastError`,后续无错误的普通断连不能清空该原因。 +- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。 - BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。 - BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 diff --git a/sql/fix-admin-menu-meta.sql b/sql/fix-admin-menu-meta.sql index b33e814..db12944 100644 --- a/sql/fix-admin-menu-meta.sql +++ b/sql/fix-admin-menu-meta.sql @@ -22,8 +22,7 @@ SET `meta` = CASE `name` WHEN 'SystemDeptEdit' THEN '{"title":"common.edit"}' WHEN 'SystemDeptDelete' THEN '{"title":"common.delete"}' WHEN 'SystemNotice' THEN '{"icon":"mdi:bell-outline","title":"system.notice.title"}' - WHEN 'SystemNoticeCreate' THEN '{"title":"common.create"}' - WHEN 'SystemNoticeEdit' THEN '{"title":"common.edit"}' + WHEN 'SystemNoticeEdit' THEN '{"title":"system.notice.markHandled"}' WHEN 'SystemNoticeDelete' THEN '{"title":"common.delete"}' WHEN 'SystemLog' THEN '{"icon":"lucide:scroll-text","title":"system.log.title"}' WHEN 'Project' THEN '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}' @@ -50,7 +49,6 @@ WHERE `name` IN ( 'SystemDeptEdit', 'SystemDeptDelete', 'SystemNotice', - 'SystemNoticeCreate', 'SystemNoticeEdit', 'SystemNoticeDelete', 'SystemLog', diff --git a/sql/system-notice-menu.sql b/sql/system-notice-menu.sql index 027fa83..2de9494 100644 --- a/sql/system-notice-menu.sql +++ b/sql/system-notice-menu.sql @@ -1,5 +1,5 @@ --- 增量初始化系统站内信表与菜单权限。 --- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,补齐站内信能力。 +-- 增量初始化系统事件站内信表与菜单权限。 +-- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,补齐日志级事件通知能力。 SET NAMES utf8mb4; @@ -10,19 +10,275 @@ CREATE TABLE IF NOT EXISTS `admin_notice` ( `summary` text DEFAULT NULL, `level` int NOT NULL DEFAULT 1, `status` int NOT NULL DEFAULT 1, + `severity` varchar(16) NOT NULL DEFAULT 'info', + `source` varchar(64) NOT NULL DEFAULT 'system', + `event_type` varchar(120) NOT NULL DEFAULT 'system.event', + `dedupe_key` varchar(255) DEFAULT NULL, + `occurrence_count` int NOT NULL DEFAULT 1, + `notify_role_code` varchar(64) NOT NULL DEFAULT 'super', + `metadata` json DEFAULT NULL, `is_top` tinyint(1) NOT NULL DEFAULT 0, `notify_users` text DEFAULT NULL, `created_by` bigint DEFAULT NULL, `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `active_dedupe_key` varchar(255) GENERATED ALWAYS AS (CASE WHEN `is_deleted` = 0 AND `dedupe_key` IS NOT NULL THEN `dedupe_key` ELSE NULL END) VIRTUAL, `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), + `first_seen_at` datetime DEFAULT NULL, + `last_seen_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_notice_active_dedupe_key` (`active_dedupe_key`), KEY `idx_admin_notice_status` (`status`), + KEY `idx_admin_notice_severity` (`severity`), + KEY `idx_admin_notice_source_event` (`source`, `event_type`), + KEY `idx_admin_notice_dedupe_key` (`dedupe_key`), + KEY `idx_admin_notice_notify_role` (`notify_role_code`), KEY `idx_admin_notice_is_top` (`is_top`), KEY `idx_admin_notice_is_deleted` (`is_deleted`), - KEY `idx_admin_notice_create_time` (`create_time`) + KEY `idx_admin_notice_create_time` (`create_time`), + KEY `idx_admin_notice_last_seen` (`last_seen_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +SET @admin_notice_severity_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'severity' +); +SET @admin_notice_severity_sql := IF( + @admin_notice_severity_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `severity` varchar(16) NOT NULL DEFAULT ''info'' AFTER `status`', + 'SELECT 1' +); +PREPARE admin_notice_severity_stmt FROM @admin_notice_severity_sql; +EXECUTE admin_notice_severity_stmt; +DEALLOCATE PREPARE admin_notice_severity_stmt; + +SET @admin_notice_source_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'source' +); +SET @admin_notice_source_sql := IF( + @admin_notice_source_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `source` varchar(64) NOT NULL DEFAULT ''system'' AFTER `severity`', + 'SELECT 1' +); +PREPARE admin_notice_source_stmt FROM @admin_notice_source_sql; +EXECUTE admin_notice_source_stmt; +DEALLOCATE PREPARE admin_notice_source_stmt; + +SET @admin_notice_event_type_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'event_type' +); +SET @admin_notice_event_type_sql := IF( + @admin_notice_event_type_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `event_type` varchar(120) NOT NULL DEFAULT ''system.event'' AFTER `source`', + 'SELECT 1' +); +PREPARE admin_notice_event_type_stmt FROM @admin_notice_event_type_sql; +EXECUTE admin_notice_event_type_stmt; +DEALLOCATE PREPARE admin_notice_event_type_stmt; + +SET @admin_notice_dedupe_key_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'dedupe_key' +); +SET @admin_notice_dedupe_key_sql := IF( + @admin_notice_dedupe_key_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `dedupe_key` varchar(255) DEFAULT NULL AFTER `event_type`', + 'SELECT 1' +); +PREPARE admin_notice_dedupe_key_stmt FROM @admin_notice_dedupe_key_sql; +EXECUTE admin_notice_dedupe_key_stmt; +DEALLOCATE PREPARE admin_notice_dedupe_key_stmt; + +SET @admin_notice_active_dedupe_key_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'active_dedupe_key' +); +SET @admin_notice_active_dedupe_key_sql := IF( + @admin_notice_active_dedupe_key_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `active_dedupe_key` varchar(255) GENERATED ALWAYS AS (CASE WHEN `is_deleted` = 0 AND `dedupe_key` IS NOT NULL THEN `dedupe_key` ELSE NULL END) VIRTUAL AFTER `dedupe_key`', + 'SELECT 1' +); +PREPARE admin_notice_active_dedupe_key_stmt FROM @admin_notice_active_dedupe_key_sql; +EXECUTE admin_notice_active_dedupe_key_stmt; +DEALLOCATE PREPARE admin_notice_active_dedupe_key_stmt; + +SET @admin_notice_occurrence_count_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'occurrence_count' +); +SET @admin_notice_occurrence_count_sql := IF( + @admin_notice_occurrence_count_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `occurrence_count` int NOT NULL DEFAULT 1 AFTER `dedupe_key`', + 'SELECT 1' +); +PREPARE admin_notice_occurrence_count_stmt FROM @admin_notice_occurrence_count_sql; +EXECUTE admin_notice_occurrence_count_stmt; +DEALLOCATE PREPARE admin_notice_occurrence_count_stmt; + +SET @admin_notice_notify_role_code_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'notify_role_code' +); +SET @admin_notice_notify_role_code_sql := IF( + @admin_notice_notify_role_code_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `notify_role_code` varchar(64) NOT NULL DEFAULT ''super'' AFTER `occurrence_count`', + 'SELECT 1' +); +PREPARE admin_notice_notify_role_code_stmt FROM @admin_notice_notify_role_code_sql; +EXECUTE admin_notice_notify_role_code_stmt; +DEALLOCATE PREPARE admin_notice_notify_role_code_stmt; + +SET @admin_notice_metadata_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'metadata' +); +SET @admin_notice_metadata_sql := IF( + @admin_notice_metadata_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `metadata` json DEFAULT NULL AFTER `notify_role_code`', + 'SELECT 1' +); +PREPARE admin_notice_metadata_stmt FROM @admin_notice_metadata_sql; +EXECUTE admin_notice_metadata_stmt; +DEALLOCATE PREPARE admin_notice_metadata_stmt; + +SET @admin_notice_first_seen_at_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'first_seen_at' +); +SET @admin_notice_first_seen_at_sql := IF( + @admin_notice_first_seen_at_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `first_seen_at` datetime DEFAULT NULL AFTER `update_time`', + 'SELECT 1' +); +PREPARE admin_notice_first_seen_at_stmt FROM @admin_notice_first_seen_at_sql; +EXECUTE admin_notice_first_seen_at_stmt; +DEALLOCATE PREPARE admin_notice_first_seen_at_stmt; + +SET @admin_notice_last_seen_at_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND COLUMN_NAME = 'last_seen_at' +); +SET @admin_notice_last_seen_at_sql := IF( + @admin_notice_last_seen_at_exists = 0, + 'ALTER TABLE `admin_notice` ADD COLUMN `last_seen_at` datetime DEFAULT NULL AFTER `first_seen_at`', + 'SELECT 1' +); +PREPARE admin_notice_last_seen_at_stmt FROM @admin_notice_last_seen_at_sql; +EXECUTE admin_notice_last_seen_at_stmt; +DEALLOCATE PREPARE admin_notice_last_seen_at_stmt; + +SET @admin_notice_uk_active_dedupe_key_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND INDEX_NAME = 'uk_admin_notice_active_dedupe_key' +); +SET @admin_notice_uk_active_dedupe_key_sql := IF( + @admin_notice_uk_active_dedupe_key_exists = 0, + 'ALTER TABLE `admin_notice` ADD UNIQUE KEY `uk_admin_notice_active_dedupe_key` (`active_dedupe_key`)', + 'SELECT 1' +); +PREPARE admin_notice_uk_active_dedupe_key_stmt FROM @admin_notice_uk_active_dedupe_key_sql; +EXECUTE admin_notice_uk_active_dedupe_key_stmt; +DEALLOCATE PREPARE admin_notice_uk_active_dedupe_key_stmt; + +SET @admin_notice_idx_severity_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND INDEX_NAME = 'idx_admin_notice_severity' +); +SET @admin_notice_idx_severity_sql := IF( + @admin_notice_idx_severity_exists = 0, + 'ALTER TABLE `admin_notice` ADD INDEX `idx_admin_notice_severity` (`severity`)', + 'SELECT 1' +); +PREPARE admin_notice_idx_severity_stmt FROM @admin_notice_idx_severity_sql; +EXECUTE admin_notice_idx_severity_stmt; +DEALLOCATE PREPARE admin_notice_idx_severity_stmt; + +SET @admin_notice_idx_source_event_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND INDEX_NAME = 'idx_admin_notice_source_event' +); +SET @admin_notice_idx_source_event_sql := IF( + @admin_notice_idx_source_event_exists = 0, + 'ALTER TABLE `admin_notice` ADD INDEX `idx_admin_notice_source_event` (`source`, `event_type`)', + 'SELECT 1' +); +PREPARE admin_notice_idx_source_event_stmt FROM @admin_notice_idx_source_event_sql; +EXECUTE admin_notice_idx_source_event_stmt; +DEALLOCATE PREPARE admin_notice_idx_source_event_stmt; + +SET @admin_notice_idx_dedupe_key_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND INDEX_NAME = 'idx_admin_notice_dedupe_key' +); +SET @admin_notice_idx_dedupe_key_sql := IF( + @admin_notice_idx_dedupe_key_exists = 0, + 'ALTER TABLE `admin_notice` ADD INDEX `idx_admin_notice_dedupe_key` (`dedupe_key`)', + 'SELECT 1' +); +PREPARE admin_notice_idx_dedupe_key_stmt FROM @admin_notice_idx_dedupe_key_sql; +EXECUTE admin_notice_idx_dedupe_key_stmt; +DEALLOCATE PREPARE admin_notice_idx_dedupe_key_stmt; + +SET @admin_notice_idx_notify_role_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND INDEX_NAME = 'idx_admin_notice_notify_role' +); +SET @admin_notice_idx_notify_role_sql := IF( + @admin_notice_idx_notify_role_exists = 0, + 'ALTER TABLE `admin_notice` ADD INDEX `idx_admin_notice_notify_role` (`notify_role_code`)', + 'SELECT 1' +); +PREPARE admin_notice_idx_notify_role_stmt FROM @admin_notice_idx_notify_role_sql; +EXECUTE admin_notice_idx_notify_role_stmt; +DEALLOCATE PREPARE admin_notice_idx_notify_role_stmt; + +SET @admin_notice_idx_last_seen_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_notice' + AND INDEX_NAME = 'idx_admin_notice_last_seen' +); +SET @admin_notice_idx_last_seen_sql := IF( + @admin_notice_idx_last_seen_exists = 0, + 'ALTER TABLE `admin_notice` ADD INDEX `idx_admin_notice_last_seen` (`last_seen_at`)', + 'SELECT 1' +); +PREPARE admin_notice_idx_last_seen_stmt FROM @admin_notice_idx_last_seen_sql; +EXECUTE admin_notice_idx_last_seen_stmt; +DEALLOCATE PREPARE admin_notice_idx_last_seen_stmt; + INSERT INTO `admin_menu` ( `id`, `pid`, @@ -61,6 +317,14 @@ ON DUPLICATE KEY UPDATE `sort` = VALUES(`sort`), `is_deleted` = 0; +DELETE role_menu +FROM `admin_role_menu` role_menu +JOIN `admin_menu` menu ON menu.`id` = role_menu.`menu_id` +WHERE menu.`name` IN ('SystemNoticeCreate'); + +DELETE FROM `admin_menu` +WHERE `name` IN ('SystemNoticeCreate'); + INSERT INTO `admin_menu` ( `id`, `pid`, @@ -75,8 +339,7 @@ INSERT INTO `admin_menu` ( `sort` ) VALUES - (2041700000000120211, 2041700000000100206, 'SystemNoticeCreate', NULL, NULL, NULL, 'System:Notice:Create', 'button', '{"title":"common.create"}', 1, 0), - (2041700000000120212, 2041700000000100206, 'SystemNoticeEdit', NULL, NULL, NULL, 'System:Notice:Edit', 'button', '{"title":"common.edit"}', 1, 1), + (2041700000000120212, 2041700000000100206, 'SystemNoticeEdit', NULL, NULL, NULL, 'System:Notice:Edit', 'button', '{"title":"system.notice.markHandled"}', 1, 1), (2041700000000120213, 2041700000000100206, 'SystemNoticeDelete', NULL, NULL, NULL, 'System:Notice:Delete', 'button', '{"title":"common.delete"}', 1, 2) ON DUPLICATE KEY UPDATE `pid` = VALUES(`pid`), @@ -90,10 +353,17 @@ ON DUPLICATE KEY UPDATE `sort` = VALUES(`sort`), `is_deleted` = 0; +DELETE role_menu +FROM `admin_role_menu` role_menu +JOIN `admin_role` role ON role.`id` = role_menu.`role_id` +JOIN `admin_menu` menu ON menu.`id` = role_menu.`menu_id` +WHERE role.`role_code` <> 'super' + AND menu.`name` IN ('SystemNotice', 'SystemNoticeEdit', 'SystemNoticeDelete'); + INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) SELECT role.`id`, menu.`id` FROM `admin_role` role -JOIN `admin_menu` menu ON menu.`name` IN ('SystemNotice', 'SystemNoticeCreate', 'SystemNoticeEdit', 'SystemNoticeDelete') -WHERE role.`role_code` IN ('super', 'admin') +JOIN `admin_menu` menu ON menu.`name` IN ('SystemNotice', 'SystemNoticeEdit', 'SystemNoticeDelete') +WHERE role.`role_code` = 'super' AND role.`is_deleted` = 0 AND menu.`is_deleted` = 0; diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index 971f636..4a9e559 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -152,6 +152,42 @@ CREATE TABLE IF NOT EXISTS `admin_component` ( KEY `idx_admin_component_deleted` (`is_deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `admin_notice` ( + `id` bigint NOT NULL, + `title` varchar(255) NOT NULL, + `content` longtext NOT NULL, + `summary` text DEFAULT NULL, + `level` int NOT NULL DEFAULT 1, + `status` int NOT NULL DEFAULT 1, + `severity` varchar(16) NOT NULL DEFAULT 'info', + `source` varchar(64) NOT NULL DEFAULT 'system', + `event_type` varchar(120) NOT NULL DEFAULT 'system.event', + `dedupe_key` varchar(255) DEFAULT NULL, + `occurrence_count` int NOT NULL DEFAULT 1, + `notify_role_code` varchar(64) NOT NULL DEFAULT 'super', + `metadata` json DEFAULT NULL, + `is_top` tinyint(1) NOT NULL DEFAULT 0, + `notify_users` text DEFAULT NULL, + `created_by` bigint DEFAULT NULL, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `active_dedupe_key` varchar(255) GENERATED ALWAYS AS (CASE WHEN `is_deleted` = 0 AND `dedupe_key` IS NOT NULL THEN `dedupe_key` ELSE NULL END) VIRTUAL, + `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), + `first_seen_at` datetime DEFAULT NULL, + `last_seen_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_notice_active_dedupe_key` (`active_dedupe_key`), + KEY `idx_admin_notice_status` (`status`), + KEY `idx_admin_notice_severity` (`severity`), + KEY `idx_admin_notice_source_event` (`source`, `event_type`), + KEY `idx_admin_notice_dedupe_key` (`dedupe_key`), + KEY `idx_admin_notice_notify_role` (`notify_role_code`), + KEY `idx_admin_notice_is_top` (`is_top`), + KEY `idx_admin_notice_is_deleted` (`is_deleted`), + KEY `idx_admin_notice_create_time` (`create_time`), + KEY `idx_admin_notice_last_seen` (`last_seen_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `admin_user_role` ( `user_id` bigint NOT NULL, `role_id` bigint NOT NULL, @@ -201,6 +237,9 @@ VALUES (2041700000000120205, 2041700000000100203, 'SystemKtTableDemoEdit', NULL, NULL, NULL, 'System:KtTableDemo:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120206, 2041700000000100203, 'SystemKtTableDemoDelete', NULL, NULL, NULL, 'System:KtTableDemo:Delete', 'button', '{"title":"common.delete"}', 1, 0), (2041700000000100205, 2041700000000100002, 'SystemLog', '/system/logs', '/system/log/list', NULL, 'System:Log:List', 'menu', '{"icon":"lucide:scroll-text","title":"system.log.title"}', 1, 6), + (2041700000000100206, 2041700000000100002, 'SystemNotice', '/system/notice', '/system/notice/list', NULL, 'System:Notice:List', 'menu', '{"icon":"mdi:bell-outline","title":"system.notice.title"}', 1, 7), + (2041700000000120212, 2041700000000100206, 'SystemNoticeEdit', NULL, NULL, NULL, 'System:Notice:Edit', 'button', '{"title":"system.notice.markHandled"}', 1, 1), + (2041700000000120213, 2041700000000100206, 'SystemNoticeDelete', NULL, NULL, NULL, 'System:Notice:Delete', 'button', '{"title":"common.delete"}', 1, 2), (2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100), (2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0), (2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0), @@ -318,7 +357,8 @@ WHERE `is_deleted` = 0; INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) SELECT 2041700000000010002, `id` FROM `admin_menu` -WHERE `is_deleted` = 0; +WHERE `is_deleted` = 0 + AND `name` NOT IN ('SystemNotice', 'SystemNoticeEdit', 'SystemNoticeDelete'); INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) VALUES diff --git a/src/admin/auth/admin-auth-guard.module.ts b/src/admin/auth/admin-auth-guard.module.ts index d53d63b..4077a7b 100644 --- a/src/admin/auth/admin-auth-guard.module.ts +++ b/src/admin/auth/admin-auth-guard.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminUser } from '../user/admin-user.entity'; import { AdminAuthService } from './admin-auth.service'; import { AdminPasswordCryptoService } from './admin-password-crypto.service'; +import { AdminSuperGuard } from './admin-super.guard'; import { AdminTokenService } from './admin-token.service'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -12,12 +13,14 @@ import { JwtAuthGuard } from './jwt-auth.guard'; providers: [ AdminAuthService, AdminPasswordCryptoService, + AdminSuperGuard, AdminTokenService, JwtAuthGuard, ], exports: [ AdminAuthService, AdminPasswordCryptoService, + AdminSuperGuard, AdminTokenService, JwtAuthGuard, ], diff --git a/src/admin/auth/admin-super.guard.ts b/src/admin/auth/admin-super.guard.ts new file mode 100644 index 0000000..c0b1bbe --- /dev/null +++ b/src/admin/auth/admin-super.guard.ts @@ -0,0 +1,21 @@ +import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common'; +import { throwVbenError } from '@/common'; +import type { AdminRequest } from '../admin.types'; + +@Injectable() +export class AdminSuperGuard implements CanActivate { + canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const roles = request.adminUser?.roles || []; + const isSuper = roles.some( + (role) => + !role.isDeleted && role.status === 1 && role.roleCode === 'super', + ); + + if (!isSuper) { + throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN); + } + + return true; + } +} diff --git a/src/admin/notice/admin-notice.controller.ts b/src/admin/notice/admin-notice.controller.ts index 30061e6..2d3d51f 100644 --- a/src/admin/notice/admin-notice.controller.ts +++ b/src/admin/notice/admin-notice.controller.ts @@ -1,31 +1,14 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Query, - UseGuards, -} from '@nestjs/common'; +import { Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { - CurrentAdminUser, - vbenPage, - vbenSuccess, -} from '@/common'; +import { vbenPage, vbenSuccess } from '@/common'; +import { AdminSuperGuard } from '../auth/admin-super.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { AdminUser } from '../user/admin-user.entity'; -import { - AdminNoticeBodyDto, - AdminNoticeQueryDto, - AdminNoticeUpdateDto, -} from './admin-notice.dto'; +import { AdminNoticeQueryDto } from './admin-notice.dto'; import { AdminNoticeService } from './admin-notice.service'; @ApiTags('Admin - 站内信管理') @Controller('system/notice') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, AdminSuperGuard) export class AdminNoticeController { constructor(private readonly noticeService: AdminNoticeService) {} @@ -48,23 +31,6 @@ export class AdminNoticeController { return vbenSuccess(await this.noticeService.get(id)); } - @Post('save') - @ApiOperation({ summary: '新增站内信' }) - async save( - @Body() body: AdminNoticeBodyDto, - @CurrentAdminUser() currentUser: AdminUser, - ) { - return vbenSuccess( - await this.noticeService.create(body, currentUser?.id), - ); - } - - @Post('update') - @ApiOperation({ summary: '编辑站内信' }) - async update(@Body() body: AdminNoticeUpdateDto) { - return vbenSuccess(await this.noticeService.update(body)); - } - @Delete(':id') @ApiOperation({ summary: '删除站内信' }) async remove(@Param('id') id: string) { diff --git a/src/admin/notice/admin-notice.dto.ts b/src/admin/notice/admin-notice.dto.ts index 25cd2ff..9aec755 100644 --- a/src/admin/notice/admin-notice.dto.ts +++ b/src/admin/notice/admin-notice.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { FormatDateTime } from '@/common'; export class AdminNoticeDto { @@ -32,6 +32,39 @@ export class AdminNoticeDto { }) status: number; + @ApiProperty({ + example: 'error', + }) + severity: string; + + @ApiProperty({ + example: 'api', + }) + source: string; + + @ApiProperty({ + example: 'api.error', + }) + eventType: string; + + @ApiPropertyOptional({ + example: 'api:error:GET:/boom:500', + }) + dedupeKey?: string; + + @ApiProperty({ + example: 1, + }) + occurrenceCount: number; + + @ApiProperty({ + example: 'super', + }) + notifyRoleCode: string; + + @ApiPropertyOptional() + metadata?: Record; + @ApiProperty({ example: false, }) @@ -63,6 +96,18 @@ export class AdminNoticeDto { }) @FormatDateTime() updateTime?: Date; + + @ApiPropertyOptional({ + example: '2026-06-03 20:00:00', + }) + @FormatDateTime() + firstSeenAt?: Date; + + @ApiPropertyOptional({ + example: '2026-06-03 20:00:00', + }) + @FormatDateTime() + lastSeenAt?: Date; } export class AdminNoticeQueryDto { @@ -84,39 +129,21 @@ export class AdminNoticeQueryDto { @ApiPropertyOptional() status?: number | string; + @ApiPropertyOptional() + severity?: string; + + @ApiPropertyOptional() + source?: string; + + @ApiPropertyOptional() + eventType?: string; + + @ApiPropertyOptional() + notifyRoleCode?: string; + @ApiPropertyOptional() isTop?: boolean | number | string; @ApiPropertyOptional() notifyUsers?: string; } - -export class AdminNoticeBodyDto { - @ApiProperty({ example: '系统公告' }) - title: string; - - @ApiProperty({ example: '站内公告正文内容' }) - content: string; - - @ApiPropertyOptional({ example: '站内公告摘要' }) - summary?: string; - - @ApiPropertyOptional({ example: 1 }) - level?: number | string; - - @ApiPropertyOptional({ example: 1 }) - status?: number | string; - - @ApiPropertyOptional({ example: false }) - isTop?: boolean | number | string; - - @ApiPropertyOptional({ example: '100001,100002' }) - notifyUsers?: string; -} - -export class AdminNoticeUpdateDto extends PartialType(AdminNoticeBodyDto) { - @ApiProperty({ - example: '2041700000000300001', - }) - id: string; -} diff --git a/src/admin/notice/admin-notice.entity.ts b/src/admin/notice/admin-notice.entity.ts index 2e3fcee..d28cc3f 100644 --- a/src/admin/notice/admin-notice.entity.ts +++ b/src/admin/notice/admin-notice.entity.ts @@ -56,6 +56,70 @@ export class AdminNotice { }) status: number; + @ApiProperty({ + example: 'error', + }) + @Column({ + default: 'info', + length: 16, + }) + severity: string; + + @ApiProperty({ + example: 'api', + }) + @Column({ + default: 'system', + length: 64, + }) + source: string; + + @ApiProperty({ + example: 'api.error', + }) + @Column({ + default: 'system.event', + length: 120, + name: 'event_type', + }) + eventType: string; + + @ApiPropertyOptional({ + example: 'api:error:GET:/boom:500', + }) + @Column({ + length: 255, + name: 'dedupe_key', + nullable: true, + }) + dedupeKey?: string; + + @ApiProperty({ + example: 1, + }) + @Column({ + default: 1, + name: 'occurrence_count', + }) + occurrenceCount: number; + + @ApiProperty({ + example: 'super', + }) + @Column({ + default: 'super', + length: 64, + name: 'notify_role_code', + }) + notifyRoleCode: string; + + @ApiPropertyOptional() + @Column({ + nullable: true, + type: 'json', + }) + metadata?: Record; + @ApiProperty({ example: false, }) @@ -82,6 +146,7 @@ export class AdminNotice { @Column({ name: 'created_by', nullable: true, + type: 'bigint', }) createdBy?: string; @@ -106,9 +171,24 @@ export class AdminNotice { @FormatDateTime() updateTime: Date; + @Column({ + name: 'first_seen_at', + nullable: true, + type: 'datetime', + }) + @FormatDateTime() + firstSeenAt?: Date; + + @Column({ + name: 'last_seen_at', + nullable: true, + type: 'datetime', + }) + @FormatDateTime() + lastSeenAt?: Date; + @BeforeInsert() createId() { ensureSnowflakeId(this); } } - diff --git a/src/admin/notice/admin-notice.service.ts b/src/admin/notice/admin-notice.service.ts index e3a6212..f5778b7 100644 --- a/src/admin/notice/admin-notice.service.ts +++ b/src/admin/notice/admin-notice.service.ts @@ -2,18 +2,36 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Brackets, Repository } from 'typeorm'; import { + SystemNoticePublishInput, + SystemNoticePublisher, throwVbenError, ToolsService, } from '@/common'; import { AdminNotice } from './admin-notice.entity'; -import type { - AdminNoticeBodyDto, - AdminNoticeQueryDto, - AdminNoticeUpdateDto, -} from './admin-notice.dto'; +import type { AdminNoticeQueryDto } from './admin-notice.dto'; + +const SYSTEM_NOTICE_DEFAULT_ROLE_CODE = 'super'; +const NOTICE_SEVERITY_LEVEL_MAP: Record = { + fatal: 4, + error: 3, + warn: 2, + info: 1, +}; +type NormalizedSystemNoticeInput = { + content: string; + dedupeKey?: string; + eventType: string; + level: number; + metadata?: Record; + notifyRoleCode: string; + severity: string; + source: string; + summary: string; + title: string; +}; @Injectable() -export class AdminNoticeService { +export class AdminNoticeService implements SystemNoticePublisher { constructor( @InjectRepository(AdminNotice) private readonly noticeRepository: Repository, @@ -47,6 +65,10 @@ export class AdminNoticeService { } this.applyLikeFilter(builder, 'notifyUsers', query.notifyUsers); + this.applyExactTextFilter(builder, 'severity', query.severity); + this.applyExactTextFilter(builder, 'source', query.source); + this.applyExactTextFilter(builder, 'eventType', query.eventType); + this.applyExactTextFilter(builder, 'notifyRoleCode', query.notifyRoleCode); const level = this.normalizeLevel(query.level); if (Number.isFinite(level)) { @@ -65,6 +87,7 @@ export class AdminNoticeService { const [items, total] = await builder .orderBy('notice.isTop', 'DESC') + .addOrderBy('notice.lastSeenAt', 'DESC') .addOrderBy('notice.createTime', 'DESC') .skip((pageNo - 1) * pageSize) .take(pageSize) @@ -76,6 +99,52 @@ export class AdminNoticeService { }; } + async publishSystemNotice(input: SystemNoticePublishInput) { + const normalizedInput = this.normalizeSystemNoticeInput(input); + const now = new Date(); + + if (normalizedInput.dedupeKey) { + const existingNotice = await this.findActiveNoticeByDedupeKey( + normalizedInput.dedupeKey, + ); + + if (existingNotice) { + return this.aggregateSystemNotice( + existingNotice.id, + normalizedInput, + now, + ); + } + } + + const notice = this.noticeRepository.create({ + ...normalizedInput, + firstSeenAt: now, + isTop: false, + lastSeenAt: now, + occurrenceCount: 1, + status: 1, + }); + try { + const saved = await this.noticeRepository.save(notice); + return saved.id; + } catch (err) { + if (!normalizedInput.dedupeKey || !this.isDuplicateKeyError(err)) { + throw err; + } + + const existingNotice = await this.findActiveNoticeByDedupeKey( + normalizedInput.dedupeKey, + ); + if (!existingNotice) throw err; + return this.aggregateSystemNotice( + existingNotice.id, + normalizedInput, + now, + ); + } + } + async get(id: string) { const noticeId = this.toolsService.toTrimmedString(id); if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST); @@ -91,40 +160,6 @@ export class AdminNoticeService { return this.serialize(notice); } - async create(body: AdminNoticeBodyDto, createdBy?: string) { - const input = this.normalizeInput(body); - const notice = this.noticeRepository.create({ - ...input, - createdBy, - }); - - const saved = await this.noticeRepository.save(notice); - return saved.id; - } - - async update(body: AdminNoticeUpdateDto) { - const id = this.toolsService.toTrimmedString(body.id); - if (!id) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST); - - const notice = await this.noticeRepository.findOne({ - where: { - id, - isDeleted: false, - }, - }); - if (!notice) throwVbenError('站内信不存在', HttpStatus.BAD_REQUEST); - - const input = this.normalizeInput({ - ...body, - content: body.content || notice.content, - title: body.title || notice.title, - }); - await this.noticeRepository.save( - this.noticeRepository.merge(notice, input), - ); - return null; - } - async remove(id: string) { const noticeId = this.toolsService.toTrimmedString(id); if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST); @@ -212,6 +247,22 @@ export class AdminNoticeService { }); } + private applyExactTextFilter( + builder: ReturnType['createQueryBuilder']>, + field: keyof Pick< + AdminNotice, + 'eventType' | 'notifyRoleCode' | 'severity' | 'source' + >, + value?: string, + ) { + const normalizedValue = this.toolsService.toTrimmedString(value); + if (!normalizedValue) return; + + builder.andWhere(`notice.${field} = :${field}`, { + [field]: normalizedValue, + }); + } + private normalizeBoolean(value: boolean | number | string | undefined) { if (value === undefined || value === null) return undefined; if (value === true || value === 1 || `${value}` === '1') return true; @@ -231,39 +282,89 @@ export class AdminNoticeService { : NaN; } - private normalizeNotifyUsers(notifyUsers?: string) { - const normalized = this.toolsService.toTrimmedString(notifyUsers); - if (!normalized) return undefined; - - const userIds = normalized - .split(',') - .map((item) => this.toolsService.toTrimmedString(item)) - .filter((item) => !!item) - .filter((item, index, array) => array.indexOf(item) === index); - - return userIds.length ? userIds.join(',') : undefined; + private normalizeSeverity(severity?: string) { + const normalized = this.toolsService + .toTrimmedString(severity) + .toLowerCase(); + return NOTICE_SEVERITY_LEVEL_MAP[normalized] ? normalized : 'info'; } - private normalizeInput(body: AdminNoticeBodyDto) { - const title = this.toolsService.toTrimmedString(body.title); - const content = this.toolsService.toTrimmedString(body.content); - const summary = this.toolsService.toTrimmedString(body.summary) || undefined; - const level = this.normalizeLevel(body.level); - const status = this.normalizeStatus(body.status); - const isTop = this.normalizeBoolean(body.isTop); + private normalizeSystemNoticeInput(input: SystemNoticePublishInput) { + const title = this.toolsService.toColumnText(input.title, 255); + const content = this.toolsService.toStoredMessageText(input.content, 4000); + const source = this.toolsService.toColumnText( + this.toolsService.toTrimmedString(input.source) || 'system', + 64, + ); + const eventType = this.toolsService.toColumnText( + this.toolsService.toTrimmedString(input.eventType) || 'system.event', + 120, + ); + const severity = this.normalizeSeverity(input.severity); + const dedupeKey = this.toolsService.toStableColumnText( + input.dedupeKey, + 255, + ); - if (!title) throwVbenError('标题不能为空', HttpStatus.BAD_REQUEST); - if (!content) throwVbenError('内容不能为空', HttpStatus.BAD_REQUEST); + if (!title) throwVbenError('站内信标题不能为空', HttpStatus.BAD_REQUEST); + if (!content) throwVbenError('站内信内容不能为空', HttpStatus.BAD_REQUEST); return { content, - level: Number.isFinite(level) ? level : 1, - notifyUsers: this.normalizeNotifyUsers(body.notifyUsers), - isTop: isTop ?? false, - status: status === 0 || status === 1 ? status : 1, - summary, + dedupeKey: dedupeKey || undefined, + eventType, + level: NOTICE_SEVERITY_LEVEL_MAP[severity], + metadata: input.metadata, + notifyRoleCode: this.toolsService.toColumnText( + this.toolsService.toTrimmedString(input.notifyRoleCode) || + SYSTEM_NOTICE_DEFAULT_ROLE_CODE, + 64, + ), + severity, + source, + summary: this.toolsService.toStoredMessageText( + input.summary || content, + 200, + ), title, - }; + } satisfies NormalizedSystemNoticeInput; + } + + private async findActiveNoticeByDedupeKey(dedupeKey: string) { + return this.noticeRepository.findOne({ + where: { + dedupeKey, + isDeleted: false, + }, + }); + } + + private async aggregateSystemNotice( + id: string, + normalizedInput: NormalizedSystemNoticeInput, + lastSeenAt: Date, + ) { + await this.noticeRepository + .createQueryBuilder() + .update(AdminNotice) + .set({ + ...normalizedInput, + lastSeenAt, + occurrenceCount: () => 'occurrence_count + 1', + status: 1, + } as any) + .where('id = :id', { id }) + .execute(); + return id; + } + + private isDuplicateKeyError(err: unknown) { + const error = err as { code?: string; errno?: number; message?: string }; + return ( + error?.code === 'ER_DUP_ENTRY' || + error?.errno === 1062 || + `${error?.message || ''}`.includes('Duplicate entry') + ); } private serialize(notice: AdminNotice) { @@ -274,8 +375,17 @@ export class AdminNoticeService { id: notice.id, isDeleted: notice.isDeleted, isTop: notice.isTop, + dedupeKey: notice.dedupeKey, + eventType: notice.eventType, + firstSeenAt: notice.firstSeenAt, + lastSeenAt: notice.lastSeenAt, level: notice.level, + metadata: notice.metadata, notifyUsers: notice.notifyUsers, + notifyRoleCode: notice.notifyRoleCode, + occurrenceCount: notice.occurrenceCount, + severity: notice.severity, + source: notice.source, status: notice.status, summary: notice.summary, title: notice.title, diff --git a/src/admin/notice/notice.module.ts b/src/admin/notice/notice.module.ts index 75ff3cc..3362c28 100644 --- a/src/admin/notice/notice.module.ts +++ b/src/admin/notice/notice.module.ts @@ -1,13 +1,22 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { SYSTEM_NOTICE_PUBLISHER } from '@/common'; import { AdminNoticeController } from './admin-notice.controller'; import { AdminNotice } from './admin-notice.entity'; import { AdminNoticeService } from './admin-notice.service'; import { AdminAuthGuardModule } from '../auth/admin-auth-guard.module'; +@Global() @Module({ imports: [AdminAuthGuardModule, TypeOrmModule.forFeature([AdminNotice])], controllers: [AdminNoticeController], - providers: [AdminNoticeService], + providers: [ + AdminNoticeService, + { + provide: SYSTEM_NOTICE_PUBLISHER, + useExisting: AdminNoticeService, + }, + ], + exports: [AdminNoticeService, SYSTEM_NOTICE_PUBLISHER], }) export class NoticeModule {} diff --git a/src/common/index.ts b/src/common/index.ts index 2c8d6e2..5d1d799 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -8,6 +8,7 @@ export * from './interceptors/api-request-log.interceptor'; export * from './interceptors/save-body.interceptor'; export * from './logger/loki-log-publisher.service'; export * from './logger/pino-logger.config'; +export * from './notice/system-notice-publisher'; export * from './response/vben-response'; export * from './services/markdown.service'; export * from './services/tool.service'; diff --git a/src/common/interceptors/api-request-log.interceptor.ts b/src/common/interceptors/api-request-log.interceptor.ts index e4f54e1..56061c9 100644 --- a/src/common/interceptors/api-request-log.interceptor.ts +++ b/src/common/interceptors/api-request-log.interceptor.ts @@ -3,13 +3,19 @@ import { CallHandler, ExecutionContext, HttpException, + Inject, Injectable, NestInterceptor, + Optional, } from '@nestjs/common'; import type { Request, Response } from 'express'; import { PinoLogger } from 'nestjs-pino'; import { catchError, Observable, tap, throwError } from 'rxjs'; import { LokiLogPublisherService } from '../logger/loki-log-publisher.service'; +import { + SYSTEM_NOTICE_PUBLISHER, + SystemNoticePublisher, +} from '../notice/system-notice-publisher'; import { ToolsService } from '../services/tool.service'; type RequestWithId = Request & { @@ -22,6 +28,9 @@ export class ApiRequestLogInterceptor implements NestInterceptor { private readonly logger: PinoLogger, private readonly lokiLogPublisherService: LokiLogPublisherService, private readonly toolsService: ToolsService, + @Optional() + @Inject(SYSTEM_NOTICE_PUBLISHER) + private readonly systemNoticePublisher?: SystemNoticePublisher, ) { this.logger.setContext(ApiRequestLogInterceptor.name); } @@ -93,6 +102,10 @@ export class ApiRequestLogInterceptor implements NestInterceptor { message: 'HTTP request failed', payload, }); + this.publishSystemNotice({ + error: params.error, + payload, + }); this.logger.error( { ...payload, @@ -140,6 +153,43 @@ export class ApiRequestLogInterceptor implements NestInterceptor { .catch(() => undefined); } + private publishSystemNotice(params: { + error?: unknown; + payload: Record; + }) { + const method = this.toolsService.toTrimmedString(params.payload.method); + const path = this.toolsService.normalizeRequestPathValue( + params.payload.path, + ); + const statusCode = Number(params.payload.statusCode) || 500; + + if (!this.systemNoticePublisher || this.shouldSkipSystemNotice(path)) { + return; + } + + const errorMessage = this.toolsService.getErrorMessage( + params.error, + 'HTTP request failed', + ); + + void this.systemNoticePublisher + .publishSystemNotice({ + content: errorMessage, + dedupeKey: `api:error:${method}:${path}:${statusCode}`, + eventType: 'api.error', + metadata: { + ...params.payload, + errorMessage, + }, + notifyRoleCode: 'super', + severity: 'error', + source: 'api', + summary: `${statusCode} ${method} ${path}`, + title: `接口错误:${method} ${path}`, + }) + .catch(() => undefined); + } + private shouldSkipLokiPublish(path: unknown) { const normalizedPath = this.toolsService.normalizeRequestPathValue(path); return ( @@ -148,6 +198,16 @@ export class ApiRequestLogInterceptor implements NestInterceptor { ); } + private shouldSkipSystemNotice(path: unknown) { + const normalizedPath = this.toolsService.normalizeRequestPathValue(path); + return ( + normalizedPath === '/system/logs' || + normalizedPath.startsWith('/system/logs/') || + normalizedPath === '/system/notice' || + normalizedPath.startsWith('/system/notice/') + ); + } + private getStatusCode(error: unknown, response: Response) { if (error instanceof HttpException) { return error.getStatus(); diff --git a/src/common/notice/system-notice-publisher.ts b/src/common/notice/system-notice-publisher.ts new file mode 100644 index 0000000..6c4e9c6 --- /dev/null +++ b/src/common/notice/system-notice-publisher.ts @@ -0,0 +1,19 @@ +export const SYSTEM_NOTICE_PUBLISHER = Symbol('SYSTEM_NOTICE_PUBLISHER'); + +export type SystemNoticeSeverity = 'error' | 'fatal' | 'info' | 'warn'; + +export type SystemNoticePublishInput = { + content: string; + dedupeKey?: string; + eventType: string; + metadata?: Record; + notifyRoleCode?: string; + severity?: SystemNoticeSeverity; + source: string; + summary?: string; + title: string; +}; + +export interface SystemNoticePublisher { + publishSystemNotice(input: SystemNoticePublishInput): Promise; +} diff --git a/src/common/services/tool.service.ts b/src/common/services/tool.service.ts index 327ad03..bc844a0 100644 --- a/src/common/services/tool.service.ts +++ b/src/common/services/tool.service.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import * as svgCaptcha from 'svg-captcha'; import { normalizeVbenErrorText } from '../response/vben-response'; @@ -138,6 +139,24 @@ export class ToolsService { return `${text.slice(0, maxLength)}...`; } + toColumnText(value: unknown, maxLength: number) { + const text = this.toTrimmedString(value); + if (maxLength <= 0) return ''; + if (text.length <= maxLength) return text; + if (maxLength <= 3) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - 3)}...`; + } + + toStableColumnText(value: unknown, maxLength: number) { + const text = this.toTrimmedString(value); + if (maxLength <= 0) return ''; + if (text.length <= maxLength) return text; + + const suffix = `...#${createHash('sha1').update(text).digest('hex').slice(0, 12)}`; + if (maxLength <= suffix.length) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - suffix.length)}${suffix}`; + } + toStringId(value: number | string | undefined | null) { return value === undefined || value === null ? '' : `${value}`; } @@ -346,6 +365,8 @@ export class ToolsService { } isNapcatOfflineLoginMessage(message?: string) { + if (this.isNapcatOfflineFlagMessage(message)) return true; + return this.includesAny(message, [ 'KickedOffLine', 'Not Login', @@ -358,6 +379,35 @@ export class ToolsService { ]); } + isNapcatOnlineLoginMessage(message?: string) { + if (this.isNapcatOfflineFlagMessage(message)) return false; + if (this.isNapcatOnlineFlagMessage(message)) return true; + + return this.includesAny(message, [ + '账号状态变更为在线', + '扫码登录成功', + '登录成功', + 'Login Success', + ]); + } + + isNapcatOfflineFlagMessage(message?: string) { + return this.matchesNapcatOnlineFlag(message, false); + } + + isNapcatOnlineFlagMessage(message?: string) { + return this.matchesNapcatOnlineFlag(message, true); + } + + private matchesNapcatOnlineFlag(message: unknown, expected: boolean) { + const text = this.toTrimmedString(message); + if (!text) return false; + return new RegExp( + `["']?isOnline["']?\\s*[:=]\\s*${expected ? 'true' : 'false'}\\b`, + 'i', + ).test(text); + } + isNapcatExpiredQrcodeStatus(status: NapcatLoginStatusLike) { const message = status.loginError || ''; return ( diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index 6114201..31b0454 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -1,7 +1,12 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { throwVbenError, ToolsService } from '@/common'; +import { + SYSTEM_NOTICE_PUBLISHER, + SystemNoticePublisher, + throwVbenError, + ToolsService, +} from '@/common'; import { QqbotAccountAbility } from './qqbot-account-ability.entity'; import { QqbotAccount } from './qqbot-account.entity'; import type { @@ -22,6 +27,8 @@ import type { QqbotConnectionRole, } from '../qqbot.types'; +const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000; + @Injectable() export class QqbotAccountService { constructor( @@ -35,6 +42,9 @@ export class QqbotAccountService { private readonly napcatContainerRepository: Repository, private readonly napcatContainerService: QqbotNapcatContainerService, private readonly toolsService: ToolsService, + @Optional() + @Inject(SYSTEM_NOTICE_PUBLISHER) + private readonly systemNoticePublisher?: SystemNoticePublisher, ) {} async page(query: QqbotAccountQueryDto) { @@ -376,29 +386,131 @@ export class QqbotAccountService { } } - return accounts.map((account) => { - const binding = bindingMap.get(account.id); - if (!binding) { - return Object.assign(account, { napcat: null }); - } + return Promise.all( + accounts.map(async (account) => { + const binding = bindingMap.get(account.id); + if (!binding) { + return Object.assign(account, { napcat: null }); + } - const container = containerMap.get(binding.containerId); - return Object.assign(account, { - napcat: { - bindStatus: binding.bindStatus, - containerId: binding.containerId, - containerName: container?.name, - containerStatus: container?.status, - lastCheckedAt: container?.lastCheckedAt, - lastError: container?.lastError, - lastLoginAt: binding.lastLoginAt, - lastStartedAt: container?.lastStartedAt, - webuiPort: container?.webuiPort, - }, - }); + const container = containerMap.get(binding.containerId); + await this.syncNapcatOfflineState(account, container); + return Object.assign(account, { + napcat: { + bindStatus: binding.bindStatus, + containerId: binding.containerId, + containerName: container?.name, + containerStatus: container?.status, + lastCheckedAt: container?.lastCheckedAt, + lastError: container?.lastError, + lastLoginAt: binding.lastLoginAt, + lastStartedAt: container?.lastStartedAt, + webuiPort: container?.webuiPort, + }, + }); + }), + ); + } + + private async syncNapcatOfflineState( + account: QqbotAccount, + container?: QqbotNapcatContainer, + ) { + if ( + account.connectStatus !== 'online' || + !container || + container.status !== 'running' + ) { + return; + } + + if (this.isAccountStateNewerThanRuntimeCheck(account, container)) return; + + const cachedOfflineReason = this.getFreshCachedOfflineReason(container); + if (cachedOfflineReason) { + await this.applyNapcatOfflineState(account, container, cachedOfflineReason); + return; + } + if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return; + + const offlineReason = + await this.napcatContainerService.detectRuntimeOffline(container); + if (!offlineReason) return; + + await this.applyNapcatOfflineState(account, container, offlineReason); + } + + private async applyNapcatOfflineState( + account: QqbotAccount, + container: QqbotNapcatContainer, + offlineReason: string, + ) { + await this.markOffline(account.selfId, offlineReason); + account.connectStatus = 'offline'; + account.lastError = offlineReason; + this.publishOfflineNotice(account.selfId, offlineReason, { + containerId: container.id, + containerName: container.name, }); } + private getFreshCachedOfflineReason(container: QqbotNapcatContainer) { + if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null; + const reason = this.toolsService.toTrimmedString(container.lastError); + return this.toolsService.isNapcatOfflineLoginMessage(reason) ? reason : null; + } + + private isAccountStateNewerThanRuntimeCheck( + account: QqbotAccount, + container: QqbotNapcatContainer, + ) { + const checkedAt = this.toTime(container.lastCheckedAt); + if (!checkedAt) return false; + + return ( + this.toTime(account.lastConnectedAt) > checkedAt || + this.toTime(account.lastHeartbeatAt) > checkedAt + ); + } + + private isFreshRuntimeCheck(lastCheckedAt?: Date | null) { + if (!lastCheckedAt) return false; + const checkedAt = this.toTime(lastCheckedAt); + if (!Number.isFinite(checkedAt)) return false; + return Date.now() - checkedAt < NAPCAT_RUNTIME_CHECK_TTL_MS; + } + + private toTime(value?: Date | null) { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; + } + + private publishOfflineNotice( + selfId: string, + offlineReason: string, + metadata: Record, + ) { + if (!this.systemNoticePublisher) return; + + void this.systemNoticePublisher + .publishSystemNotice({ + content: offlineReason, + dedupeKey: `qqbot:offline:${selfId}`, + eventType: 'qqbot.account.offline', + metadata: { + ...metadata, + selfId, + }, + notifyRoleCode: 'super', + severity: 'error', + source: 'qqbot', + summary: offlineReason, + title: `QQBot 账号已下线:${selfId}`, + }) + .catch(() => undefined); + } + private async assertSelfIdAvailable(selfId: string, id?: string) { const exists = await this.accountRepository.findOne({ where: { diff --git a/src/qqbot/event/qqbot-event.service.ts b/src/qqbot/event/qqbot-event.service.ts index 8130886..891554b 100644 --- a/src/qqbot/event/qqbot-event.service.ts +++ b/src/qqbot/event/qqbot-event.service.ts @@ -1,5 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ToolsService } from '@/common'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import { + SYSTEM_NOTICE_PUBLISHER, + SystemNoticePublisher, + ToolsService, +} from '@/common'; import { QQBOT_MQTT_TOPICS } from '../qqbot.constants'; import { QqbotDedupeService } from '../dedupe/qqbot-dedupe.service'; import { QqbotMessageService } from '../message/qqbot-message.service'; @@ -25,6 +29,9 @@ export class QqbotEventService { private readonly ruleEngineService: QqbotRuleEngineService, private readonly toolsService: ToolsService, private readonly accountService: QqbotAccountService, + @Optional() + @Inject(SYSTEM_NOTICE_PUBLISHER) + private readonly systemNoticePublisher?: SystemNoticePublisher, ) {} async handleIncoming(payload: QqbotOneBotEvent) { @@ -65,5 +72,31 @@ export class QqbotEventService { const offlineReason = getOneBotOfflineReason(payload); if (!offlineReason) return; await this.accountService.markOffline(selfId, offlineReason); + this.publishOfflineNotice(selfId, offlineReason, payload); + } + + private publishOfflineNotice( + selfId: string, + offlineReason: string, + payload: QqbotOneBotEvent, + ) { + if (!this.systemNoticePublisher) return; + + void this.systemNoticePublisher + .publishSystemNotice({ + content: offlineReason, + dedupeKey: `qqbot:offline:${selfId}`, + eventType: 'qqbot.account.offline', + metadata: { + payload, + selfId, + }, + notifyRoleCode: 'super', + severity: 'error', + source: 'qqbot', + summary: offlineReason, + title: `QQBot 账号已下线:${selfId}`, + }) + .catch(() => undefined); } } diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 335c108..28b1ff1 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -10,6 +10,13 @@ import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; import type { QqbotNapcatRuntime } from '../qqbot.types'; +type NapcatLoginLogState = 'offline' | 'online' | 'unknown'; + +type NapcatLoginLogResult = { + offlineReason: string | null; + state: NapcatLoginLogState; +}; + @Injectable() export class QqbotNapcatContainerService { constructor( @@ -207,6 +214,41 @@ export class QqbotNapcatContainerService { return true; } + async detectRuntimeOffline(container: QqbotNapcatContainer) { + if (this.getManagedMode() !== 'ssh' || !container.name) return null; + + try { + const result = await this.runProcess( + 'ssh', + [...this.getSshArgs(), 'sh -s'], + this.buildRemoteRecentLogsScript(container), + undefined, + this.getRuntimeCheckTimeoutMs(), + ); + const loginState = this.extractLoginState(result.stdout); + await this.containerRepository.update( + { id: container.id }, + { + lastCheckedAt: new Date(), + ...(loginState.state === 'offline' + ? { lastError: loginState.offlineReason } + : {}), + ...(loginState.state === 'online' ? { lastError: null } : {}), + }, + ); + return loginState.offlineReason; + } catch (err) { + await this.containerRepository.update( + { id: container.id }, + { + lastCheckedAt: new Date(), + lastError: this.toolsService.getErrorMessage(err), + }, + ); + return null; + } + } + private async removeContainer(containerId: string) { const container = await this.containerRepository.findOne({ where: { @@ -335,6 +377,60 @@ echo "__KT_PROGRESS__:container-started:NapCat 容器已启动" `; } + private buildRemoteRecentLogsScript(container: QqbotNapcatContainer) { + const name = this.sh(container.name); + + return ` +set -eu +NAME=${name} +docker logs --tail 300 "$NAME" 2>&1 || true +`; + } + + private extractLoginState(logs: string): NapcatLoginLogResult { + const lines = logs + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .reverse(); + + const matchedLine = lines.find( + (line) => + this.toolsService.isNapcatOfflineLoginMessage(line) || + this.toolsService.isNapcatOnlineLoginMessage(line), + ); + if (!matchedLine) { + return { + offlineReason: null, + state: 'unknown', + }; + } + + if (this.toolsService.isNapcatOnlineLoginMessage(matchedLine)) { + return { + offlineReason: null, + state: 'online', + }; + } + + const message = matchedLine + .replace(/^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\[[^\]]+\]\s+/, '') + .replace(/^Mirror\s*\|\s*/, '') + .replace(/\[KickedOffLine]/gi, '') + .replace(/\[下线通知]/g, '') + .trim(); + const offlineReason = this.toolsService.isNapcatOfflineFlagMessage( + matchedLine, + ) + ? 'NapCat 账号状态变更为离线' + : message || 'NapCat 账号状态变更为离线'; + + return { + offlineReason, + state: 'offline', + }; + } + private async getPrimaryRuntime(accountId: string) { const binding = await this.bindingRepository.findOne({ order: { @@ -657,6 +753,13 @@ docker run -d \\ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000; } + private getRuntimeCheckTimeoutMs() { + const timeoutMs = Number( + this.getConfig('QQBOT_NAPCAT_RUNTIME_CHECK_TIMEOUT_MS', '5000'), + ); + return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 5000; + } + private sh(value: string) { return `'${`${value}`.replace(/'/g, `'\\''`)}'`; } @@ -666,60 +769,62 @@ docker run -d \\ args: string[], input: string, onStdoutLine?: (line: string) => void, + timeoutMs = this.getProcessTimeoutMs(), ) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - windowsHide: true, - }); - let settled = false; - let stdout = ''; - let stderr = ''; - let stdoutLineBuffer = ''; - const timeoutMs = this.getProcessTimeoutMs(); - const timer = setTimeout(() => { - if (settled) return; - settled = true; - child.kill('SIGTERM'); - reject(new Error(`${command} timeout after ${timeoutMs}ms`)); - }, timeoutMs); - const finish = (callback: () => void) => { - if (settled) return; - settled = true; - clearTimeout(timer); - callback(); - }; - child.stdout.on('data', (chunk) => { - const text = Buffer.from(chunk).toString('utf8'); - stdout += text; - if (onStdoutLine) { - const lines = `${stdoutLineBuffer}${text}`.split(/\r?\n/); - stdoutLineBuffer = lines.pop() || ''; - lines - .map((line) => line.trim()) - .filter(Boolean) - .forEach((line) => onStdoutLine(line)); - } - }); - child.stderr.on('data', (chunk) => { - stderr += Buffer.from(chunk).toString('utf8'); - }); - child.on('error', (err) => { - finish(() => reject(err)); - }); - child.on('close', (code) => { - finish(() => { - if (onStdoutLine && stdoutLineBuffer.trim()) { - onStdoutLine(stdoutLineBuffer.trim()); - } - if (code === 0) { - resolve(); - return; - } - reject(new Error((stderr || stdout || `${command} failed`).trim())); + return new Promise<{ stderr: string; stdout: string }>( + (resolve, reject) => { + const child = spawn(command, args, { + windowsHide: true, }); - }); - child.stdin.write(input); - child.stdin.end(); - }); + let settled = false; + let stdout = ''; + let stderr = ''; + let stdoutLineBuffer = ''; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGTERM'); + reject(new Error(`${command} timeout after ${timeoutMs}ms`)); + }, timeoutMs); + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + callback(); + }; + child.stdout.on('data', (chunk) => { + const text = Buffer.from(chunk).toString('utf8'); + stdout += text; + if (onStdoutLine) { + const lines = `${stdoutLineBuffer}${text}`.split(/\r?\n/); + stdoutLineBuffer = lines.pop() || ''; + lines + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => onStdoutLine(line)); + } + }); + child.stderr.on('data', (chunk) => { + stderr += Buffer.from(chunk).toString('utf8'); + }); + child.on('error', (err) => { + finish(() => reject(err)); + }); + child.on('close', (code) => { + finish(() => { + if (onStdoutLine && stdoutLineBuffer.trim()) { + onStdoutLine(stdoutLineBuffer.trim()); + } + if (code === 0) { + resolve({ stderr, stdout }); + return; + } + reject(new Error((stderr || stdout || `${command} failed`).trim())); + }); + }); + child.stdin.write(input); + child.stdin.end(); + }, + ); } } diff --git a/test/admin/notice/admin-notice.controller.spec.ts b/test/admin/notice/admin-notice.controller.spec.ts new file mode 100644 index 0000000..7b389ab --- /dev/null +++ b/test/admin/notice/admin-notice.controller.spec.ts @@ -0,0 +1,123 @@ +import type { INestApplication } from '@nestjs/common'; + +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AdminSuperGuard } from '../../../src/admin/auth/admin-super.guard'; +import { JwtAuthGuard } from '../../../src/admin/auth/jwt-auth.guard'; +import { AdminNoticeController } from '../../../src/admin/notice/admin-notice.controller'; +import { AdminNoticeService } from '../../../src/admin/notice/admin-notice.service'; + +describe('AdminNoticeController', () => { + let app: INestApplication; + let currentRoles: string[]; + const noticeService = { + page: jest.fn(), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [AdminNoticeController], + providers: [ + { + provide: AdminNoticeService, + useValue: noticeService, + }, + AdminSuperGuard, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: jest.fn((context) => { + context.switchToHttp().getRequest().adminUser = { + roles: currentRoles.map((roleCode) => ({ + isDeleted: false, + roleCode, + status: 1, + })), + }; + return true; + }), + }) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + beforeEach(() => { + currentRoles = ['super']; + jest.clearAllMocks(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('passes event notice filters through the HTTP list endpoint', async () => { + noticeService.page.mockResolvedValueOnce({ + items: [ + { + eventType: 'qqbot.account.offline', + id: 'notice-1', + occurrenceCount: 3, + severity: 'error', + source: 'qqbot', + status: 1, + title: 'QQBot 账号已下线:1914728559', + }, + ], + total: 1, + }); + + const response = await request(app.getHttpServer()) + .get('/system/notice/list') + .query({ + eventType: 'qqbot.account.offline', + severity: 'error', + source: 'qqbot', + status: 1, + }) + .expect(200); + + expect(noticeService.page).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'qqbot.account.offline', + severity: 'error', + source: 'qqbot', + status: '1', + }), + ); + expect(response.body).toEqual({ + code: 200, + data: { + items: [ + expect.objectContaining({ + eventType: 'qqbot.account.offline', + occurrenceCount: 3, + severity: 'error', + source: 'qqbot', + }), + ], + total: 1, + }, + msg: '操作成功', + }); + }); + + it('rejects event notice access for non-super admin users', async () => { + currentRoles = ['admin']; + + await request(app.getHttpServer()).get('/system/notice/list').expect(403); + expect(noticeService.page).not.toHaveBeenCalled(); + }); + + it('does not expose manual notice creation endpoint', async () => { + await request(app.getHttpServer()) + .post('/system/notice/save') + .send({ + content: 'manual', + title: 'manual', + }) + .expect(404); + }); +}); diff --git a/test/admin/notice/admin-notice.service.spec.ts b/test/admin/notice/admin-notice.service.spec.ts new file mode 100644 index 0000000..34fc53a --- /dev/null +++ b/test/admin/notice/admin-notice.service.spec.ts @@ -0,0 +1,227 @@ +import { AdminNoticeService } from '@/admin/notice/admin-notice.service'; +import { ToolsService } from '@/common'; + +function createRepositoryMock(overrides: Record = {}) { + const repository = { + createQueryBuilder: jest.fn(), + create: jest.fn((entity) => entity), + findOne: jest.fn().mockResolvedValue(null), + merge: jest.fn((target, input) => ({ ...target, ...input })), + save: jest.fn(async (entity) => ({ + id: entity.id || 'notice-1', + ...entity, + })), + update: jest.fn(), + ...overrides, + }; + + return repository; +} + +describe('AdminNoticeService system event notices', () => { + it('publishes API errors as unread super role notices', async () => { + const repository = createRepositoryMock(); + const service = new AdminNoticeService( + repository as any, + new ToolsService(), + ); + + const id = await (service as any).publishSystemNotice({ + content: 'Error: boom', + dedupeKey: 'api:error:GET:/boom:500', + eventType: 'api.error', + metadata: { + method: 'GET', + path: '/boom', + requestId: 'req-1', + statusCode: 500, + }, + severity: 'error', + source: 'api', + summary: '500 GET /boom', + title: '接口错误:GET /boom', + }); + + expect(id).toBe('notice-1'); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Error: boom', + dedupeKey: 'api:error:GET:/boom:500', + eventType: 'api.error', + level: 3, + metadata: expect.objectContaining({ + method: 'GET', + path: '/boom', + requestId: 'req-1', + statusCode: 500, + }), + notifyRoleCode: 'super', + occurrenceCount: 1, + severity: 'error', + source: 'api', + status: 1, + summary: '500 GET /boom', + title: '接口错误:GET /boom', + }), + ); + }); + + it('bounds long system notice title and dedupe key to database column sizes', async () => { + const repository = createRepositoryMock(); + const service = new AdminNoticeService( + repository as any, + new ToolsService(), + ); + const longPath = `/system/${'very-long-path-'.repeat(40)}`; + + await (service as any).publishSystemNotice({ + content: 'database unavailable', + dedupeKey: `api:error:POST:${longPath}:500`, + eventType: 'api.error', + severity: 'error', + source: 'api', + summary: `500 POST ${longPath}`, + title: `接口错误:POST ${longPath}`, + }); + + const notice = repository.create.mock.calls[0][0]; + expect(notice.dedupeKey).toHaveLength(255); + expect(notice.title.length).toBeLessThanOrEqual(255); + expect(notice.dedupeKey).toMatch(/#[a-f0-9]{12}$/); + }); + + it('aggregates repeated events by dedupe key instead of creating noise', async () => { + const existingNotice = { + content: 'old', + dedupeKey: 'qqbot:offline:1914728559', + id: 'notice-1', + isDeleted: false, + occurrenceCount: 2, + status: 0, + summary: 'old', + title: 'old', + }; + const repository = createRepositoryMock({ + findOne: jest.fn().mockResolvedValue(existingNotice), + }); + const updateBuilder = { + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + set: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + }; + repository.createQueryBuilder.mockReturnValue(updateBuilder); + const service = new AdminNoticeService( + repository as any, + new ToolsService(), + ); + + const id = await (service as any).publishSystemNotice({ + content: 'bot_offline/kick_offline:账号已在另一台终端登录', + dedupeKey: 'qqbot:offline:1914728559', + eventType: 'qqbot.account.offline', + metadata: { + selfId: '1914728559', + }, + severity: 'error', + source: 'qqbot', + summary: '账号已在另一台终端登录', + title: 'QQBot 账号已下线:1914728559', + }); + + expect(id).toBe('notice-1'); + expect(repository.create).not.toHaveBeenCalled(); + expect(updateBuilder.set).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'bot_offline/kick_offline:账号已在另一台终端登录', + metadata: expect.objectContaining({ + selfId: '1914728559', + }), + occurrenceCount: expect.any(Function), + severity: 'error', + status: 1, + summary: '账号已在另一台终端登录', + title: 'QQBot 账号已下线:1914728559', + }), + ); + expect(updateBuilder.where).toHaveBeenCalledWith('id = :id', { + id: 'notice-1', + }); + expect(updateBuilder.execute).toHaveBeenCalled(); + }); + + it('aggregates when a concurrent insert hits the dedupe unique key', async () => { + const duplicateError = Object.assign(new Error('Duplicate entry'), { + code: 'ER_DUP_ENTRY', + }); + const repository = createRepositoryMock({ + findOne: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + dedupeKey: 'api:error:GET:/boom:500', + id: 'notice-1', + isDeleted: false, + occurrenceCount: 1, + }), + save: jest.fn().mockRejectedValueOnce(duplicateError), + }); + const updateBuilder = { + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + set: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + }; + repository.createQueryBuilder.mockReturnValue(updateBuilder); + const service = new AdminNoticeService( + repository as any, + new ToolsService(), + ); + + const id = await (service as any).publishSystemNotice({ + content: 'Error: boom', + dedupeKey: 'api:error:GET:/boom:500', + eventType: 'api.error', + severity: 'error', + source: 'api', + summary: '500 GET /boom', + title: '接口错误:GET /boom', + }); + + expect(id).toBe('notice-1'); + expect(repository.findOne).toHaveBeenCalledTimes(2); + expect(updateBuilder.execute).toHaveBeenCalled(); + }); + + it('orders event notices by the latest occurrence time', async () => { + const builder = { + addOrderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + }; + const repository = createRepositoryMock({ + createQueryBuilder: jest.fn().mockReturnValue(builder), + }); + const service = new AdminNoticeService( + repository as any, + new ToolsService(), + ); + + await service.page({}); + + expect(builder.orderBy).toHaveBeenCalledWith('notice.isTop', 'DESC'); + expect(builder.addOrderBy).toHaveBeenNthCalledWith( + 1, + 'notice.lastSeenAt', + 'DESC', + ); + expect(builder.addOrderBy).toHaveBeenNthCalledWith( + 2, + 'notice.createTime', + 'DESC', + ); + }); +}); diff --git a/test/common/api-request-log.interceptor.spec.ts b/test/common/api-request-log.interceptor.spec.ts index 42e633c..43d9915 100644 --- a/test/common/api-request-log.interceptor.spec.ts +++ b/test/common/api-request-log.interceptor.spec.ts @@ -48,6 +48,12 @@ function createLokiLogPublisherMock() { }; } +function createSystemNoticePublisherMock() { + return { + publishSystemNotice: jest.fn().mockResolvedValue('notice-1'), + }; +} + describe('ApiRequestLogInterceptor', () => { it('writes structured HTTP request fields for successful controller calls', async () => { const logger = { @@ -195,6 +201,59 @@ describe('ApiRequestLogInterceptor', () => { ); }); + it('publishes a super notice when a controller call fails with 5xx', async () => { + const logger = { + error: jest.fn(), + info: jest.fn(), + setContext: jest.fn(), + warn: jest.fn(), + }; + const lokiLogPublisher = createLokiLogPublisherMock(); + const systemNoticePublisher = createSystemNoticePublisherMock(); + const interceptor = new (ApiRequestLogInterceptor as any)( + logger, + lokiLogPublisher, + new ToolsService(), + systemNoticePublisher, + ); + const request: Record = { + headers: { + 'x-request-id': 'req-error', + }, + method: 'POST', + originalUrl: '/system/user/save?trace=1', + }; + const response = createResponse(200); + const error = new Error('database unavailable'); + + await expect( + lastValueFrom( + interceptor.intercept(createHttpContext(request, response), { + handle: () => throwError(() => error), + }), + ), + ).rejects.toBe(error); + + expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'database unavailable', + dedupeKey: 'api:error:POST:/system/user/save:500', + eventType: 'api.error', + metadata: expect.objectContaining({ + method: 'POST', + path: '/system/user/save', + requestId: 'req-error', + statusCode: 500, + }), + notifyRoleCode: 'super', + severity: 'error', + source: 'api', + summary: '500 POST /system/user/save', + title: '接口错误:POST /system/user/save', + }), + ); + }); + it('captures a real local HTTP request in a Nest application', async () => { const logger = { error: jest.fn(), diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index a741adf..3d67738 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -1,3 +1,4 @@ +import { ToolsService } from '@/common'; import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; describe('QqbotAccountService', () => { @@ -23,4 +24,261 @@ describe('QqbotAccountService', () => { }, ); }); + + it('syncs NapCat runtime offline logs back to account list status', async () => { + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastError: null, + name: '主账号', + selfId: '1914728559', + }; + const binding = { + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-1', + isDeleted: false, + isPrimary: true, + lastLoginAt: new Date('2026-06-10T12:00:00.000Z'), + }; + const container = { + id: 'container-1', + isDeleted: false, + lastError: null, + name: 'kt-qqbot-napcat-1914728559', + status: 'running', + webuiPort: 6101, + }; + const createAccountBuilder = () => ({ + andWhere: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[account], 1]), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + }); + const createBindingBuilder = () => ({ + addOrderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([binding]), + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + }); + const createContainerBuilder = () => ({ + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([container]), + where: jest.fn().mockReturnThis(), + }); + const accountRepository = { + createQueryBuilder: jest.fn(createAccountBuilder), + update: jest.fn(), + }; + const napcatContainerService = { + detectRuntimeOffline: jest + .fn() + .mockResolvedValue('NapCat 账号状态变更为离线'), + }; + const systemNoticePublisher = { + publishSystemNotice: jest.fn().mockResolvedValue('notice-1'), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + { createQueryBuilder: jest.fn(createBindingBuilder) } as any, + { createQueryBuilder: jest.fn(createContainerBuilder) } as any, + napcatContainerService as any, + new ToolsService(), + systemNoticePublisher as any, + ); + + const page = await service.page({}); + + expect(accountRepository.update).toHaveBeenCalledWith( + { selfId: '1914728559' }, + { + connectStatus: 'offline', + lastError: 'NapCat 账号状态变更为离线', + }, + ); + expect(page.list[0]).toEqual( + expect.objectContaining({ + connectStatus: 'offline', + lastError: 'NapCat 账号状态变更为离线', + }), + ); + expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'NapCat 账号状态变更为离线', + dedupeKey: 'qqbot:offline:1914728559', + eventType: 'qqbot.account.offline', + notifyRoleCode: 'super', + severity: 'error', + source: 'qqbot', + title: 'QQBot 账号已下线:1914728559', + }), + ); + }); + + it('does not re-read NapCat logs when the container was checked recently', async () => { + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastError: null, + name: '主账号', + selfId: '1914728559', + }; + const binding = { + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-1', + isDeleted: false, + isPrimary: true, + lastLoginAt: new Date('2026-06-10T12:00:00.000Z'), + }; + const container = { + id: 'container-1', + isDeleted: false, + lastCheckedAt: new Date(), + lastError: null, + name: 'kt-qqbot-napcat-1914728559', + status: 'running', + webuiPort: 6101, + }; + const accountRepository = { + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[account], 1]), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const napcatContainerService = { + detectRuntimeOffline: jest.fn(), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + { + createQueryBuilder: jest.fn(() => ({ + addOrderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([binding]), + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + })), + } as any, + { + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([container]), + where: jest.fn().mockReturnThis(), + })), + } as any, + napcatContainerService as any, + new ToolsService(), + ); + + const page = await service.page({}); + + expect(napcatContainerService.detectRuntimeOffline).not.toHaveBeenCalled(); + expect(accountRepository.update).not.toHaveBeenCalled(); + expect(page.list[0]).toEqual( + expect.objectContaining({ + connectStatus: 'online', + napcat: expect.objectContaining({ + lastCheckedAt: container.lastCheckedAt, + }), + }), + ); + }); + + it('ignores cached NapCat offline reason after the account reconnects', async () => { + const checkedAt = new Date('2026-06-11T02:00:00.000Z'); + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastConnectedAt: new Date('2026-06-11T02:00:10.000Z'), + lastError: null, + name: '主账号', + selfId: '1914728559', + }; + const binding = { + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-1', + isDeleted: false, + isPrimary: true, + lastLoginAt: checkedAt, + }; + const container = { + id: 'container-1', + isDeleted: false, + lastCheckedAt: checkedAt, + lastError: '账号状态变更为离线', + name: 'kt-qqbot-napcat-1914728559', + status: 'running', + webuiPort: 6101, + }; + const accountRepository = { + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[account], 1]), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const napcatContainerService = { + detectRuntimeOffline: jest.fn(), + }; + const systemNoticePublisher = { + publishSystemNotice: jest.fn(), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + { + createQueryBuilder: jest.fn(() => ({ + addOrderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([binding]), + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + })), + } as any, + { + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([container]), + where: jest.fn().mockReturnThis(), + })), + } as any, + napcatContainerService as any, + new ToolsService(), + systemNoticePublisher as any, + ); + + const page = await service.page({}); + + expect(napcatContainerService.detectRuntimeOffline).not.toHaveBeenCalled(); + expect(accountRepository.update).not.toHaveBeenCalled(); + expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled(); + expect(page.list[0]).toEqual( + expect.objectContaining({ + connectStatus: 'online', + lastError: null, + }), + ); + }); }); diff --git a/test/qqbot/event/qqbot-event.service.spec.ts b/test/qqbot/event/qqbot-event.service.spec.ts index 439247a..c17fd38 100644 --- a/test/qqbot/event/qqbot-event.service.spec.ts +++ b/test/qqbot/event/qqbot-event.service.spec.ts @@ -9,7 +9,10 @@ describe('QqbotEventService', () => { const accountService = { markOffline: jest.fn(), }; - const service = new QqbotEventService( + const systemNoticePublisher = { + publishSystemNotice: jest.fn().mockResolvedValue('notice-1'), + }; + const service = new (QqbotEventService as any)( { publish: jest.fn() } as any, {} as any, {} as any, @@ -20,19 +23,38 @@ describe('QqbotEventService', () => { toStringId: (value: unknown) => `${value || ''}`, } as any, accountService as any, + systemNoticePublisher, ); - await service.handleIncoming({ + const payload = { message: '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。', notice_type: 'bot_offline', post_type: 'notice', self_id: 1914728559, sub_type: 'kick_offline', - }); + }; + + await service.handleIncoming(payload); expect(accountService.markOffline).toHaveBeenCalledWith( '1914728559', 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', ); + expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', + dedupeKey: 'qqbot:offline:1914728559', + eventType: 'qqbot.account.offline', + metadata: expect.objectContaining({ + payload, + selfId: '1914728559', + }), + notifyRoleCode: 'super', + severity: 'error', + source: 'qqbot', + summary: 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', + title: 'QQBot 账号已下线:1914728559', + }), + ); }); }); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index 0bc5f20..8974822 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -79,4 +79,134 @@ describe('QqbotNapcatContainerService', () => { }), ); }); + + it('uses the latest NapCat account status line as runtime offline truth', () => { + const service = new QqbotNapcatContainerService( + { get: jest.fn() } as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + + expect( + service.extractLoginState(` +06-11 08:39:19 [info] Mirror | 账号状态变更为离线 +06-11 08:41:19 [info] Mirror | 账号状态变更为在线 +`).offlineReason, + ).toBeNull(); + }); + + it('treats isOnline false as an offline runtime state', () => { + const service = new QqbotNapcatContainerService( + { get: jest.fn() } as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + + const result = service.extractLoginState(` +06-11 08:39:19 [info] Mirror | {"isOnline": false} +`); + + expect(result).toEqual({ + offlineReason: 'NapCat 账号状态变更为离线', + state: 'offline', + }); + }); + + it('clears stale offline error when the latest NapCat account status is online', async () => { + const containerRepository = { + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => { + const values: Record = { + QQBOT_NAPCAT_CONTAINER_MODE: 'ssh', + QQBOT_NAPCAT_SSH_TARGET: 'nas', + }; + return values[key] || ''; + }), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + service.runProcess = jest.fn().mockResolvedValue({ + stderr: '', + stdout: ` +06-11 08:39:19 [info] Mirror | 账号状态变更为离线 +06-11 08:41:19 [info] Mirror | 账号状态变更为在线 +`, + }); + + const reason = await service.detectRuntimeOffline({ + id: 'container-1', + name: 'kt-qqbot-napcat-1914728559', + }); + + expect(reason).toBeNull(); + expect(containerRepository.update).toHaveBeenCalledWith( + { id: 'container-1' }, + expect.objectContaining({ + lastCheckedAt: expect.any(Date), + lastError: null, + }), + ); + }); + + it('extracts the latest NapCat offline reason from container logs', () => { + const service = new QqbotNapcatContainerService( + { get: jest.fn() } as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + + expect( + service.extractLoginState(` +06-11 08:39:19 [info] Mirror | [KickedOffLine] [下线通知] 您的账号已在另一台终端登录 +06-11 08:40:49 [info] Mirror | 账号状态变更为离线 +`).offlineReason, + ).toBe('账号状态变更为离线'); + }); + + it('uses a short timeout for runtime offline log detection', async () => { + const containerRepository = { + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => { + const values: Record = { + QQBOT_NAPCAT_CONTAINER_MODE: 'ssh', + QQBOT_NAPCAT_RUNTIME_CHECK_TIMEOUT_MS: '5000', + QQBOT_NAPCAT_SSH_TARGET: 'nas', + }; + return values[key] || ''; + }), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + service.runProcess = jest.fn().mockResolvedValue({ + stderr: '', + stdout: '06-11 08:39:19 [info] Mirror | 账号状态变更为离线', + }); + + const reason = await service.detectRuntimeOffline({ + id: 'container-1', + name: 'kt-qqbot-napcat-1914728559', + }); + + expect(reason).toBe('账号状态变更为离线'); + expect(service.runProcess).toHaveBeenCalledWith( + 'ssh', + expect.any(Array), + expect.any(String), + undefined, + 5000, + ); + }); }); diff --git a/test/sql/system-notice-sql.spec.ts b/test/sql/system-notice-sql.spec.ts new file mode 100644 index 0000000..aa156be --- /dev/null +++ b/test/sql/system-notice-sql.spec.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const projectRoot = join(__dirname, '..', '..'); + +function readSql(relativePath: string) { + return readFileSync(join(projectRoot, relativePath), 'utf8'); +} + +describe('system notice SQL', () => { + it('keeps the notice table and menu in the full admin initializer', () => { + const sql = readSql('sql/vben-admin-init.sql'); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS `admin_notice`'); + expect(sql).toContain("'SystemNotice'"); + expect(sql).toContain("'System:Notice:List'"); + }); + + it('grants system notice permissions only to the super role', () => { + const sql = readSql('sql/system-notice-menu.sql'); + + expect(sql).toContain("role.`role_code` = 'super'"); + expect(sql).not.toContain("role.`role_code` IN ('super', 'admin')"); + }); + + it('does not keep the legacy manual create notice button in menu scripts', () => { + const initSql = readSql('sql/vben-admin-init.sql'); + const fixMenuSql = readSql('sql/fix-admin-menu-meta.sql'); + + expect(initSql).not.toContain('SystemNoticeCreate'); + expect(fixMenuSql).not.toContain('SystemNoticeCreate'); + }); + + it('uses existing locale keys for notice menu button titles', () => { + const noticeSql = readSql('sql/system-notice-menu.sql'); + const initSql = readSql('sql/vben-admin-init.sql'); + const fixMenuSql = readSql('sql/fix-admin-menu-meta.sql'); + + for (const sql of [noticeSql, initSql, fixMenuSql]) { + expect(sql).not.toContain('system.notice.handle'); + expect(sql).toContain('system.notice.markHandled'); + } + }); + + it('adds active dedupe uniqueness and runtime indexes for existing notice tables', () => { + const sql = readSql('sql/system-notice-menu.sql'); + + expect(sql).toContain('active_dedupe_key'); + expect(sql).toContain('uk_admin_notice_active_dedupe_key'); + expect(sql).toContain('idx_admin_notice_source_event'); + expect(sql).toContain('idx_admin_notice_last_seen'); + expect(sql).toContain('INFORMATION_SCHEMA.STATISTICS'); + }); +});