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` | 查询站内信详情 |
|
| `GET` | `/system/notice/detail/:id` | 查询站内信详情 |
|
||||||
| `POST` | `/system/notice/save` | 新增站内信 |
|
|
||||||
| `POST` | `/system/notice/update` | 更新站内信 |
|
|
||||||
| `DELETE` | `/system/notice/:id` | 删除站内信(逻辑删除) |
|
| `DELETE` | `/system/notice/:id` | 删除站内信(逻辑删除) |
|
||||||
| `POST` | `/system/notice/toggle` | 切换状态(`id`、`status`) |
|
| `POST` | `/system/notice/toggle` | 标记处理或重新打开(`id`、`status`,`1` 未处理,`0` 已处理) |
|
||||||
| `POST` | `/system/notice/top` | 切换置顶(`id`、`isTop`) |
|
| `POST` | `/system/notice/top` | 切换置顶(`id`、`isTop`) |
|
||||||
|
|
||||||
|
返回字段包含 `severity`、`source`、`eventType`、`dedupeKey`、`occurrenceCount`、`notifyRoleCode`、`metadata`、`firstSeenAt`、`lastSeenAt`。后端不暴露人工 `save/update` 入口。
|
||||||
|
|
||||||
## Blog 本地内容
|
## Blog 本地内容
|
||||||
|
|
||||||
`/blog/*` 是本地博客内容能力,供 `Vue/kt-blog-web` 和 Admin 博客管理使用。
|
`/blog/*` 是本地博客内容能力,供 `Vue/kt-blog-web` 和 Admin 博客管理使用。
|
||||||
@ -314,7 +316,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
|||||||
|
|
||||||
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。
|
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。
|
||||||
|
|
||||||
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。
|
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。
|
||||||
|
|
||||||
### Command / Rule / Permission
|
### 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()`。
|
- Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管理接口默认走 `JwtAuthGuard`;公开接口用 `@Public()`。
|
||||||
- WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。
|
- WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。
|
||||||
- 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
|
- 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
|
||||||
|
- 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问,Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。
|
||||||
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。
|
- 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 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
||||||
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
|
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
|
||||||
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
||||||
|
|||||||
@ -22,8 +22,7 @@ SET `meta` = CASE `name`
|
|||||||
WHEN 'SystemDeptEdit' THEN '{"title":"common.edit"}'
|
WHEN 'SystemDeptEdit' THEN '{"title":"common.edit"}'
|
||||||
WHEN 'SystemDeptDelete' THEN '{"title":"common.delete"}'
|
WHEN 'SystemDeptDelete' THEN '{"title":"common.delete"}'
|
||||||
WHEN 'SystemNotice' THEN '{"icon":"mdi:bell-outline","title":"system.notice.title"}'
|
WHEN 'SystemNotice' THEN '{"icon":"mdi:bell-outline","title":"system.notice.title"}'
|
||||||
WHEN 'SystemNoticeCreate' THEN '{"title":"common.create"}'
|
WHEN 'SystemNoticeEdit' THEN '{"title":"system.notice.markHandled"}'
|
||||||
WHEN 'SystemNoticeEdit' THEN '{"title":"common.edit"}'
|
|
||||||
WHEN 'SystemNoticeDelete' THEN '{"title":"common.delete"}'
|
WHEN 'SystemNoticeDelete' THEN '{"title":"common.delete"}'
|
||||||
WHEN 'SystemLog' THEN '{"icon":"lucide:scroll-text","title":"system.log.title"}'
|
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"}'
|
WHEN 'Project' THEN '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}'
|
||||||
@ -50,7 +49,6 @@ WHERE `name` IN (
|
|||||||
'SystemDeptEdit',
|
'SystemDeptEdit',
|
||||||
'SystemDeptDelete',
|
'SystemDeptDelete',
|
||||||
'SystemNotice',
|
'SystemNotice',
|
||||||
'SystemNoticeCreate',
|
|
||||||
'SystemNoticeEdit',
|
'SystemNoticeEdit',
|
||||||
'SystemNoticeDelete',
|
'SystemNoticeDelete',
|
||||||
'SystemLog',
|
'SystemLog',
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
-- 增量初始化系统站内信表与菜单权限。
|
-- 增量初始化系统事件站内信表与菜单权限。
|
||||||
-- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,补齐站内信能力。
|
-- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,补齐日志级事件通知能力。
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
@ -10,19 +10,275 @@ CREATE TABLE IF NOT EXISTS `admin_notice` (
|
|||||||
`summary` text DEFAULT NULL,
|
`summary` text DEFAULT NULL,
|
||||||
`level` int NOT NULL DEFAULT 1,
|
`level` int NOT NULL DEFAULT 1,
|
||||||
`status` 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,
|
`is_top` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
`notify_users` text DEFAULT NULL,
|
`notify_users` text DEFAULT NULL,
|
||||||
`created_by` bigint DEFAULT NULL,
|
`created_by` bigint DEFAULT NULL,
|
||||||
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
`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),
|
`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),
|
`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`),
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_admin_notice_active_dedupe_key` (`active_dedupe_key`),
|
||||||
KEY `idx_admin_notice_status` (`status`),
|
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_top` (`is_top`),
|
||||||
KEY `idx_admin_notice_is_deleted` (`is_deleted`),
|
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;
|
) 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` (
|
INSERT INTO `admin_menu` (
|
||||||
`id`,
|
`id`,
|
||||||
`pid`,
|
`pid`,
|
||||||
@ -61,6 +317,14 @@ ON DUPLICATE KEY UPDATE
|
|||||||
`sort` = VALUES(`sort`),
|
`sort` = VALUES(`sort`),
|
||||||
`is_deleted` = 0;
|
`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` (
|
INSERT INTO `admin_menu` (
|
||||||
`id`,
|
`id`,
|
||||||
`pid`,
|
`pid`,
|
||||||
@ -75,8 +339,7 @@ INSERT INTO `admin_menu` (
|
|||||||
`sort`
|
`sort`
|
||||||
)
|
)
|
||||||
VALUES
|
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":"system.notice.markHandled"}', 1, 1),
|
||||||
(2041700000000120212, 2041700000000100206, 'SystemNoticeEdit', NULL, NULL, NULL, 'System:Notice:Edit', 'button', '{"title":"common.edit"}', 1, 1),
|
|
||||||
(2041700000000120213, 2041700000000100206, 'SystemNoticeDelete', NULL, NULL, NULL, 'System:Notice:Delete', 'button', '{"title":"common.delete"}', 1, 2)
|
(2041700000000120213, 2041700000000100206, 'SystemNoticeDelete', NULL, NULL, NULL, 'System:Notice:Delete', 'button', '{"title":"common.delete"}', 1, 2)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
`pid` = VALUES(`pid`),
|
`pid` = VALUES(`pid`),
|
||||||
@ -90,10 +353,17 @@ ON DUPLICATE KEY UPDATE
|
|||||||
`sort` = VALUES(`sort`),
|
`sort` = VALUES(`sort`),
|
||||||
`is_deleted` = 0;
|
`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`)
|
INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`)
|
||||||
SELECT role.`id`, menu.`id`
|
SELECT role.`id`, menu.`id`
|
||||||
FROM `admin_role` role
|
FROM `admin_role` role
|
||||||
JOIN `admin_menu` menu ON menu.`name` IN ('SystemNotice', 'SystemNoticeCreate', 'SystemNoticeEdit', 'SystemNoticeDelete')
|
JOIN `admin_menu` menu ON menu.`name` IN ('SystemNotice', 'SystemNoticeEdit', 'SystemNoticeDelete')
|
||||||
WHERE role.`role_code` IN ('super', 'admin')
|
WHERE role.`role_code` = 'super'
|
||||||
AND role.`is_deleted` = 0
|
AND role.`is_deleted` = 0
|
||||||
AND menu.`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`)
|
KEY `idx_admin_component_deleted` (`is_deleted`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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` (
|
CREATE TABLE IF NOT EXISTS `admin_user_role` (
|
||||||
`user_id` bigint NOT NULL,
|
`user_id` bigint NOT NULL,
|
||||||
`role_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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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`)
|
INSERT INTO `admin_role_menu` (`role_id`, `menu_id`)
|
||||||
SELECT 2041700000000010002, `id`
|
SELECT 2041700000000010002, `id`
|
||||||
FROM `admin_menu`
|
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`)
|
INSERT INTO `admin_role_menu` (`role_id`, `menu_id`)
|
||||||
VALUES
|
VALUES
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { AdminUser } from '../user/admin-user.entity';
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
import { AdminAuthService } from './admin-auth.service';
|
import { AdminAuthService } from './admin-auth.service';
|
||||||
import { AdminPasswordCryptoService } from './admin-password-crypto.service';
|
import { AdminPasswordCryptoService } from './admin-password-crypto.service';
|
||||||
|
import { AdminSuperGuard } from './admin-super.guard';
|
||||||
import { AdminTokenService } from './admin-token.service';
|
import { AdminTokenService } from './admin-token.service';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
@ -12,12 +13,14 @@ import { JwtAuthGuard } from './jwt-auth.guard';
|
|||||||
providers: [
|
providers: [
|
||||||
AdminAuthService,
|
AdminAuthService,
|
||||||
AdminPasswordCryptoService,
|
AdminPasswordCryptoService,
|
||||||
|
AdminSuperGuard,
|
||||||
AdminTokenService,
|
AdminTokenService,
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AdminAuthService,
|
AdminAuthService,
|
||||||
AdminPasswordCryptoService,
|
AdminPasswordCryptoService,
|
||||||
|
AdminSuperGuard,
|
||||||
AdminTokenService,
|
AdminTokenService,
|
||||||
JwtAuthGuard,
|
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 {
|
import { Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
import {
|
import { vbenPage, vbenSuccess } from '@/common';
|
||||||
CurrentAdminUser,
|
import { AdminSuperGuard } from '../auth/admin-super.guard';
|
||||||
vbenPage,
|
|
||||||
vbenSuccess,
|
|
||||||
} from '@/common';
|
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { AdminUser } from '../user/admin-user.entity';
|
import { AdminNoticeQueryDto } from './admin-notice.dto';
|
||||||
import {
|
|
||||||
AdminNoticeBodyDto,
|
|
||||||
AdminNoticeQueryDto,
|
|
||||||
AdminNoticeUpdateDto,
|
|
||||||
} from './admin-notice.dto';
|
|
||||||
import { AdminNoticeService } from './admin-notice.service';
|
import { AdminNoticeService } from './admin-notice.service';
|
||||||
|
|
||||||
@ApiTags('Admin - 站内信管理')
|
@ApiTags('Admin - 站内信管理')
|
||||||
@Controller('system/notice')
|
@Controller('system/notice')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, AdminSuperGuard)
|
||||||
export class AdminNoticeController {
|
export class AdminNoticeController {
|
||||||
constructor(private readonly noticeService: AdminNoticeService) {}
|
constructor(private readonly noticeService: AdminNoticeService) {}
|
||||||
|
|
||||||
@ -48,23 +31,6 @@ export class AdminNoticeController {
|
|||||||
return vbenSuccess(await this.noticeService.get(id));
|
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')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: '删除站内信' })
|
@ApiOperation({ summary: '删除站内信' })
|
||||||
async remove(@Param('id') id: string) {
|
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';
|
import { FormatDateTime } from '@/common';
|
||||||
|
|
||||||
export class AdminNoticeDto {
|
export class AdminNoticeDto {
|
||||||
@ -32,6 +32,39 @@ export class AdminNoticeDto {
|
|||||||
})
|
})
|
||||||
status: number;
|
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({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
@ -63,6 +96,18 @@ export class AdminNoticeDto {
|
|||||||
})
|
})
|
||||||
@FormatDateTime()
|
@FormatDateTime()
|
||||||
updateTime?: Date;
|
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 {
|
export class AdminNoticeQueryDto {
|
||||||
@ -84,39 +129,21 @@ export class AdminNoticeQueryDto {
|
|||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
status?: number | string;
|
status?: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
severity?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
source?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
eventType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
notifyRoleCode?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
isTop?: boolean | number | string;
|
isTop?: boolean | number | string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
notifyUsers?: string;
|
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;
|
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({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
@ -82,6 +146,7 @@ export class AdminNotice {
|
|||||||
@Column({
|
@Column({
|
||||||
name: 'created_by',
|
name: 'created_by',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
type: 'bigint',
|
||||||
})
|
})
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
|
||||||
@ -106,9 +171,24 @@ export class AdminNotice {
|
|||||||
@FormatDateTime()
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
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()
|
@BeforeInsert()
|
||||||
createId() {
|
createId() {
|
||||||
ensureSnowflakeId(this);
|
ensureSnowflakeId(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,18 +2,36 @@ import { HttpStatus, Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Brackets, Repository } from 'typeorm';
|
import { Brackets, Repository } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
|
SystemNoticePublishInput,
|
||||||
|
SystemNoticePublisher,
|
||||||
throwVbenError,
|
throwVbenError,
|
||||||
ToolsService,
|
ToolsService,
|
||||||
} from '@/common';
|
} from '@/common';
|
||||||
import { AdminNotice } from './admin-notice.entity';
|
import { AdminNotice } from './admin-notice.entity';
|
||||||
import type {
|
import type { AdminNoticeQueryDto } from './admin-notice.dto';
|
||||||
AdminNoticeBodyDto,
|
|
||||||
AdminNoticeQueryDto,
|
const SYSTEM_NOTICE_DEFAULT_ROLE_CODE = 'super';
|
||||||
AdminNoticeUpdateDto,
|
const NOTICE_SEVERITY_LEVEL_MAP: Record<string, number> = {
|
||||||
} from './admin-notice.dto';
|
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()
|
@Injectable()
|
||||||
export class AdminNoticeService {
|
export class AdminNoticeService implements SystemNoticePublisher {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AdminNotice)
|
@InjectRepository(AdminNotice)
|
||||||
private readonly noticeRepository: Repository<AdminNotice>,
|
private readonly noticeRepository: Repository<AdminNotice>,
|
||||||
@ -47,6 +65,10 @@ export class AdminNoticeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.applyLikeFilter(builder, 'notifyUsers', query.notifyUsers);
|
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);
|
const level = this.normalizeLevel(query.level);
|
||||||
if (Number.isFinite(level)) {
|
if (Number.isFinite(level)) {
|
||||||
@ -65,6 +87,7 @@ export class AdminNoticeService {
|
|||||||
|
|
||||||
const [items, total] = await builder
|
const [items, total] = await builder
|
||||||
.orderBy('notice.isTop', 'DESC')
|
.orderBy('notice.isTop', 'DESC')
|
||||||
|
.addOrderBy('notice.lastSeenAt', 'DESC')
|
||||||
.addOrderBy('notice.createTime', 'DESC')
|
.addOrderBy('notice.createTime', 'DESC')
|
||||||
.skip((pageNo - 1) * pageSize)
|
.skip((pageNo - 1) * pageSize)
|
||||||
.take(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) {
|
async get(id: string) {
|
||||||
const noticeId = this.toolsService.toTrimmedString(id);
|
const noticeId = this.toolsService.toTrimmedString(id);
|
||||||
if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
|
if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
|
||||||
@ -91,40 +160,6 @@ export class AdminNoticeService {
|
|||||||
return this.serialize(notice);
|
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) {
|
async remove(id: string) {
|
||||||
const noticeId = this.toolsService.toTrimmedString(id);
|
const noticeId = this.toolsService.toTrimmedString(id);
|
||||||
if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
|
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) {
|
private normalizeBoolean(value: boolean | number | string | undefined) {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined;
|
||||||
if (value === true || value === 1 || `${value}` === '1') return true;
|
if (value === true || value === 1 || `${value}` === '1') return true;
|
||||||
@ -231,39 +282,89 @@ export class AdminNoticeService {
|
|||||||
: NaN;
|
: NaN;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeNotifyUsers(notifyUsers?: string) {
|
private normalizeSeverity(severity?: string) {
|
||||||
const normalized = this.toolsService.toTrimmedString(notifyUsers);
|
const normalized = this.toolsService
|
||||||
if (!normalized) return undefined;
|
.toTrimmedString(severity)
|
||||||
|
.toLowerCase();
|
||||||
const userIds = normalized
|
return NOTICE_SEVERITY_LEVEL_MAP[normalized] ? normalized : 'info';
|
||||||
.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 normalizeInput(body: AdminNoticeBodyDto) {
|
private normalizeSystemNoticeInput(input: SystemNoticePublishInput) {
|
||||||
const title = this.toolsService.toTrimmedString(body.title);
|
const title = this.toolsService.toColumnText(input.title, 255);
|
||||||
const content = this.toolsService.toTrimmedString(body.content);
|
const content = this.toolsService.toStoredMessageText(input.content, 4000);
|
||||||
const summary = this.toolsService.toTrimmedString(body.summary) || undefined;
|
const source = this.toolsService.toColumnText(
|
||||||
const level = this.normalizeLevel(body.level);
|
this.toolsService.toTrimmedString(input.source) || 'system',
|
||||||
const status = this.normalizeStatus(body.status);
|
64,
|
||||||
const isTop = this.normalizeBoolean(body.isTop);
|
);
|
||||||
|
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 (!title) throwVbenError('站内信标题不能为空', HttpStatus.BAD_REQUEST);
|
||||||
if (!content) throwVbenError('内容不能为空', HttpStatus.BAD_REQUEST);
|
if (!content) throwVbenError('站内信内容不能为空', HttpStatus.BAD_REQUEST);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content,
|
content,
|
||||||
level: Number.isFinite(level) ? level : 1,
|
dedupeKey: dedupeKey || undefined,
|
||||||
notifyUsers: this.normalizeNotifyUsers(body.notifyUsers),
|
eventType,
|
||||||
isTop: isTop ?? false,
|
level: NOTICE_SEVERITY_LEVEL_MAP[severity],
|
||||||
status: status === 0 || status === 1 ? status : 1,
|
metadata: input.metadata,
|
||||||
summary,
|
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,
|
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) {
|
private serialize(notice: AdminNotice) {
|
||||||
@ -274,8 +375,17 @@ export class AdminNoticeService {
|
|||||||
id: notice.id,
|
id: notice.id,
|
||||||
isDeleted: notice.isDeleted,
|
isDeleted: notice.isDeleted,
|
||||||
isTop: notice.isTop,
|
isTop: notice.isTop,
|
||||||
|
dedupeKey: notice.dedupeKey,
|
||||||
|
eventType: notice.eventType,
|
||||||
|
firstSeenAt: notice.firstSeenAt,
|
||||||
|
lastSeenAt: notice.lastSeenAt,
|
||||||
level: notice.level,
|
level: notice.level,
|
||||||
|
metadata: notice.metadata,
|
||||||
notifyUsers: notice.notifyUsers,
|
notifyUsers: notice.notifyUsers,
|
||||||
|
notifyRoleCode: notice.notifyRoleCode,
|
||||||
|
occurrenceCount: notice.occurrenceCount,
|
||||||
|
severity: notice.severity,
|
||||||
|
source: notice.source,
|
||||||
status: notice.status,
|
status: notice.status,
|
||||||
summary: notice.summary,
|
summary: notice.summary,
|
||||||
title: notice.title,
|
title: notice.title,
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { SYSTEM_NOTICE_PUBLISHER } from '@/common';
|
||||||
import { AdminNoticeController } from './admin-notice.controller';
|
import { AdminNoticeController } from './admin-notice.controller';
|
||||||
import { AdminNotice } from './admin-notice.entity';
|
import { AdminNotice } from './admin-notice.entity';
|
||||||
import { AdminNoticeService } from './admin-notice.service';
|
import { AdminNoticeService } from './admin-notice.service';
|
||||||
import { AdminAuthGuardModule } from '../auth/admin-auth-guard.module';
|
import { AdminAuthGuardModule } from '../auth/admin-auth-guard.module';
|
||||||
|
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminAuthGuardModule, TypeOrmModule.forFeature([AdminNotice])],
|
imports: [AdminAuthGuardModule, TypeOrmModule.forFeature([AdminNotice])],
|
||||||
controllers: [AdminNoticeController],
|
controllers: [AdminNoticeController],
|
||||||
providers: [AdminNoticeService],
|
providers: [
|
||||||
|
AdminNoticeService,
|
||||||
|
{
|
||||||
|
provide: SYSTEM_NOTICE_PUBLISHER,
|
||||||
|
useExisting: AdminNoticeService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [AdminNoticeService, SYSTEM_NOTICE_PUBLISHER],
|
||||||
})
|
})
|
||||||
export class NoticeModule {}
|
export class NoticeModule {}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export * from './interceptors/api-request-log.interceptor';
|
|||||||
export * from './interceptors/save-body.interceptor';
|
export * from './interceptors/save-body.interceptor';
|
||||||
export * from './logger/loki-log-publisher.service';
|
export * from './logger/loki-log-publisher.service';
|
||||||
export * from './logger/pino-logger.config';
|
export * from './logger/pino-logger.config';
|
||||||
|
export * from './notice/system-notice-publisher';
|
||||||
export * from './response/vben-response';
|
export * from './response/vben-response';
|
||||||
export * from './services/markdown.service';
|
export * from './services/markdown.service';
|
||||||
export * from './services/tool.service';
|
export * from './services/tool.service';
|
||||||
|
|||||||
@ -3,13 +3,19 @@ import {
|
|||||||
CallHandler,
|
CallHandler,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { catchError, Observable, tap, throwError } from 'rxjs';
|
import { catchError, Observable, tap, throwError } from 'rxjs';
|
||||||
import { LokiLogPublisherService } from '../logger/loki-log-publisher.service';
|
import { LokiLogPublisherService } from '../logger/loki-log-publisher.service';
|
||||||
|
import {
|
||||||
|
SYSTEM_NOTICE_PUBLISHER,
|
||||||
|
SystemNoticePublisher,
|
||||||
|
} from '../notice/system-notice-publisher';
|
||||||
import { ToolsService } from '../services/tool.service';
|
import { ToolsService } from '../services/tool.service';
|
||||||
|
|
||||||
type RequestWithId = Request & {
|
type RequestWithId = Request & {
|
||||||
@ -22,6 +28,9 @@ export class ApiRequestLogInterceptor implements NestInterceptor {
|
|||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
private readonly lokiLogPublisherService: LokiLogPublisherService,
|
private readonly lokiLogPublisherService: LokiLogPublisherService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(SYSTEM_NOTICE_PUBLISHER)
|
||||||
|
private readonly systemNoticePublisher?: SystemNoticePublisher,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(ApiRequestLogInterceptor.name);
|
this.logger.setContext(ApiRequestLogInterceptor.name);
|
||||||
}
|
}
|
||||||
@ -93,6 +102,10 @@ export class ApiRequestLogInterceptor implements NestInterceptor {
|
|||||||
message: 'HTTP request failed',
|
message: 'HTTP request failed',
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
this.publishSystemNotice({
|
||||||
|
error: params.error,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
{
|
{
|
||||||
...payload,
|
...payload,
|
||||||
@ -140,6 +153,43 @@ export class ApiRequestLogInterceptor implements NestInterceptor {
|
|||||||
.catch(() => undefined);
|
.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) {
|
private shouldSkipLokiPublish(path: unknown) {
|
||||||
const normalizedPath = this.toolsService.normalizeRequestPathValue(path);
|
const normalizedPath = this.toolsService.normalizeRequestPathValue(path);
|
||||||
return (
|
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) {
|
private getStatusCode(error: unknown, response: Response) {
|
||||||
if (error instanceof HttpException) {
|
if (error instanceof HttpException) {
|
||||||
return error.getStatus();
|
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 { Injectable } from '@nestjs/common';
|
||||||
import * as svgCaptcha from 'svg-captcha';
|
import * as svgCaptcha from 'svg-captcha';
|
||||||
import { normalizeVbenErrorText } from '../response/vben-response';
|
import { normalizeVbenErrorText } from '../response/vben-response';
|
||||||
@ -138,6 +139,24 @@ export class ToolsService {
|
|||||||
return `${text.slice(0, maxLength)}...<truncated ${text.length - maxLength} chars>`;
|
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) {
|
toStringId(value: number | string | undefined | null) {
|
||||||
return value === undefined || value === null ? '' : `${value}`;
|
return value === undefined || value === null ? '' : `${value}`;
|
||||||
}
|
}
|
||||||
@ -346,6 +365,8 @@ export class ToolsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isNapcatOfflineLoginMessage(message?: string) {
|
isNapcatOfflineLoginMessage(message?: string) {
|
||||||
|
if (this.isNapcatOfflineFlagMessage(message)) return true;
|
||||||
|
|
||||||
return this.includesAny(message, [
|
return this.includesAny(message, [
|
||||||
'KickedOffLine',
|
'KickedOffLine',
|
||||||
'Not Login',
|
'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) {
|
isNapcatExpiredQrcodeStatus(status: NapcatLoginStatusLike) {
|
||||||
const message = status.loginError || '';
|
const message = status.loginError || '';
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from '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 { QqbotAccountAbility } from './qqbot-account-ability.entity';
|
||||||
import { QqbotAccount } from './qqbot-account.entity';
|
import { QqbotAccount } from './qqbot-account.entity';
|
||||||
import type {
|
import type {
|
||||||
@ -22,6 +27,8 @@ import type {
|
|||||||
QqbotConnectionRole,
|
QqbotConnectionRole,
|
||||||
} from '../qqbot.types';
|
} from '../qqbot.types';
|
||||||
|
|
||||||
|
const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotAccountService {
|
export class QqbotAccountService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -35,6 +42,9 @@ export class QqbotAccountService {
|
|||||||
private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>,
|
private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>,
|
||||||
private readonly napcatContainerService: QqbotNapcatContainerService,
|
private readonly napcatContainerService: QqbotNapcatContainerService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(SYSTEM_NOTICE_PUBLISHER)
|
||||||
|
private readonly systemNoticePublisher?: SystemNoticePublisher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async page(query: QqbotAccountQueryDto) {
|
async page(query: QqbotAccountQueryDto) {
|
||||||
@ -376,13 +386,15 @@ export class QqbotAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts.map((account) => {
|
return Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
const binding = bindingMap.get(account.id);
|
const binding = bindingMap.get(account.id);
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return Object.assign(account, { napcat: null });
|
return Object.assign(account, { napcat: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = containerMap.get(binding.containerId);
|
const container = containerMap.get(binding.containerId);
|
||||||
|
await this.syncNapcatOfflineState(account, container);
|
||||||
return Object.assign(account, {
|
return Object.assign(account, {
|
||||||
napcat: {
|
napcat: {
|
||||||
bindStatus: binding.bindStatus,
|
bindStatus: binding.bindStatus,
|
||||||
@ -396,9 +408,109 @@ export class QqbotAccountService {
|
|||||||
webuiPort: container?.webuiPort,
|
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) {
|
private async assertSelfIdAvailable(selfId: string, id?: string) {
|
||||||
const exists = await this.accountRepository.findOne({
|
const exists = await this.accountRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import { ToolsService } from '@/common';
|
import {
|
||||||
|
SYSTEM_NOTICE_PUBLISHER,
|
||||||
|
SystemNoticePublisher,
|
||||||
|
ToolsService,
|
||||||
|
} from '@/common';
|
||||||
import { QQBOT_MQTT_TOPICS } from '../qqbot.constants';
|
import { QQBOT_MQTT_TOPICS } from '../qqbot.constants';
|
||||||
import { QqbotDedupeService } from '../dedupe/qqbot-dedupe.service';
|
import { QqbotDedupeService } from '../dedupe/qqbot-dedupe.service';
|
||||||
import { QqbotMessageService } from '../message/qqbot-message.service';
|
import { QqbotMessageService } from '../message/qqbot-message.service';
|
||||||
@ -25,6 +29,9 @@ export class QqbotEventService {
|
|||||||
private readonly ruleEngineService: QqbotRuleEngineService,
|
private readonly ruleEngineService: QqbotRuleEngineService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
private readonly accountService: QqbotAccountService,
|
private readonly accountService: QqbotAccountService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(SYSTEM_NOTICE_PUBLISHER)
|
||||||
|
private readonly systemNoticePublisher?: SystemNoticePublisher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleIncoming(payload: QqbotOneBotEvent) {
|
async handleIncoming(payload: QqbotOneBotEvent) {
|
||||||
@ -65,5 +72,31 @@ export class QqbotEventService {
|
|||||||
const offlineReason = getOneBotOfflineReason(payload);
|
const offlineReason = getOneBotOfflineReason(payload);
|
||||||
if (!offlineReason) return;
|
if (!offlineReason) return;
|
||||||
await this.accountService.markOffline(selfId, offlineReason);
|
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 { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
|
||||||
import type { QqbotNapcatRuntime } from '../qqbot.types';
|
import type { QqbotNapcatRuntime } from '../qqbot.types';
|
||||||
|
|
||||||
|
type NapcatLoginLogState = 'offline' | 'online' | 'unknown';
|
||||||
|
|
||||||
|
type NapcatLoginLogResult = {
|
||||||
|
offlineReason: string | null;
|
||||||
|
state: NapcatLoginLogState;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotNapcatContainerService {
|
export class QqbotNapcatContainerService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -207,6 +214,41 @@ export class QqbotNapcatContainerService {
|
|||||||
return true;
|
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) {
|
private async removeContainer(containerId: string) {
|
||||||
const container = await this.containerRepository.findOne({
|
const container = await this.containerRepository.findOne({
|
||||||
where: {
|
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) {
|
private async getPrimaryRuntime(accountId: string) {
|
||||||
const binding = await this.bindingRepository.findOne({
|
const binding = await this.bindingRepository.findOne({
|
||||||
order: {
|
order: {
|
||||||
@ -657,6 +753,13 @@ docker run -d \\
|
|||||||
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000;
|
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) {
|
private sh(value: string) {
|
||||||
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
|
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
@ -666,8 +769,10 @@ docker run -d \\
|
|||||||
args: string[],
|
args: string[],
|
||||||
input: string,
|
input: string,
|
||||||
onStdoutLine?: (line: string) => void,
|
onStdoutLine?: (line: string) => void,
|
||||||
|
timeoutMs = this.getProcessTimeoutMs(),
|
||||||
) {
|
) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<{ stderr: string; stdout: string }>(
|
||||||
|
(resolve, reject) => {
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
@ -675,7 +780,6 @@ docker run -d \\
|
|||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
let stdoutLineBuffer = '';
|
let stdoutLineBuffer = '';
|
||||||
const timeoutMs = this.getProcessTimeoutMs();
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
@ -712,7 +816,7 @@ docker run -d \\
|
|||||||
onStdoutLine(stdoutLineBuffer.trim());
|
onStdoutLine(stdoutLineBuffer.trim());
|
||||||
}
|
}
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve({ stderr, stdout });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reject(new Error((stderr || stdout || `${command} failed`).trim()));
|
reject(new Error((stderr || stdout || `${command} failed`).trim()));
|
||||||
@ -720,6 +824,7 @@ docker run -d \\
|
|||||||
});
|
});
|
||||||
child.stdin.write(input);
|
child.stdin.write(input);
|
||||||
child.stdin.end();
|
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', () => {
|
describe('ApiRequestLogInterceptor', () => {
|
||||||
it('writes structured HTTP request fields for successful controller calls', async () => {
|
it('writes structured HTTP request fields for successful controller calls', async () => {
|
||||||
const logger = {
|
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 () => {
|
it('captures a real local HTTP request in a Nest application', async () => {
|
||||||
const logger = {
|
const logger = {
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { ToolsService } from '@/common';
|
||||||
import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service';
|
import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service';
|
||||||
|
|
||||||
describe('QqbotAccountService', () => {
|
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 = {
|
const accountService = {
|
||||||
markOffline: jest.fn(),
|
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,
|
{ publish: jest.fn() } as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
@ -20,19 +23,38 @@ describe('QqbotEventService', () => {
|
|||||||
toStringId: (value: unknown) => `${value || ''}`,
|
toStringId: (value: unknown) => `${value || ''}`,
|
||||||
} as any,
|
} as any,
|
||||||
accountService as any,
|
accountService as any,
|
||||||
|
systemNoticePublisher,
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.handleIncoming({
|
const payload = {
|
||||||
message: '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。',
|
message: '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。',
|
||||||
notice_type: 'bot_offline',
|
notice_type: 'bot_offline',
|
||||||
post_type: 'notice',
|
post_type: 'notice',
|
||||||
self_id: 1914728559,
|
self_id: 1914728559,
|
||||||
sub_type: 'kick_offline',
|
sub_type: 'kick_offline',
|
||||||
});
|
};
|
||||||
|
|
||||||
|
await service.handleIncoming(payload);
|
||||||
|
|
||||||
expect(accountService.markOffline).toHaveBeenCalledWith(
|
expect(accountService.markOffline).toHaveBeenCalledWith(
|
||||||
'1914728559',
|
'1914728559',
|
||||||
'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。',
|
'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