feat: 站内信告警与QQBot离线同步

This commit is contained in:
sunlei 2026-06-11 11:16:16 +08:00
parent 9b522e06ce
commit d3c0affa33
26 changed files with 2015 additions and 235 deletions

12
API.md
View File

@ -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

View File

@ -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 回归。

View File

@ -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',

View File

@ -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;

View File

@ -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

View File

@ -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,
],

View File

@ -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<AdminRequest>();
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;
}
}

View File

@ -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) {

View File

@ -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<string, unknown>;
@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;
}

View File

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

View File

@ -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<string, number> = {
fatal: 4,
error: 3,
warn: 2,
info: 1,
};
type NormalizedSystemNoticeInput = {
content: string;
dedupeKey?: string;
eventType: string;
level: number;
metadata?: Record<string, unknown>;
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<AdminNotice>,
@ -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<Repository<AdminNotice>['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,

View File

@ -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 {}

View File

@ -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';

View File

@ -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<string, unknown>;
}) {
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();

View File

@ -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<string, unknown>;
notifyRoleCode?: string;
severity?: SystemNoticeSeverity;
source: string;
summary?: string;
title: string;
};
export interface SystemNoticePublisher {
publishSystemNotice(input: SystemNoticePublishInput): Promise<string | null>;
}

View File

@ -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)}...<truncated ${text.length - maxLength} chars>`;
}
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 (

View File

@ -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<QqbotNapcatContainer>,
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<string, unknown>,
) {
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: {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,227 @@
import { AdminNoticeService } from '@/admin/notice/admin-notice.service';
import { ToolsService } from '@/common';
function createRepositoryMock(overrides: Record<string, jest.Mock> = {}) {
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',
);
});
});

View File

@ -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<string, any> = {
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(),

View File

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

View File

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

View File

@ -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<string, string> = {
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<string, string> = {
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,
);
});
});

View File

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