feat: 站内信告警与QQBot离线同步
This commit is contained in:
parent
9b522e06ce
commit
d3c0affa33
12
API.md
12
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
|
||||
|
||||
|
||||
@ -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 回归。
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
21
src/admin/auth/admin-super.guard.ts
Normal file
21
src/admin/auth/admin-super.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
|
||||
19
src/common/notice/system-notice-publisher.ts
Normal file
19
src/common/notice/system-notice-publisher.ts
Normal 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>;
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
123
test/admin/notice/admin-notice.controller.spec.ts
Normal file
123
test/admin/notice/admin-notice.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
227
test/admin/notice/admin-notice.service.spec.ts
Normal file
227
test/admin/notice/admin-notice.service.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
54
test/sql/system-notice-sql.spec.ts
Normal file
54
test/sql/system-notice-sql.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user