feat: 接入系统日志与统一时间格式化
This commit is contained in:
parent
054c6ce23e
commit
e6ce74e485
19
.env.example
19
.env.example
@ -23,6 +23,25 @@ ADMIN_COOKIE_SECURE=false
|
|||||||
SNOWFLAKE_WORKER_ID=1
|
SNOWFLAKE_WORKER_ID=1
|
||||||
SNOWFLAKE_DATACENTER_ID=1
|
SNOWFLAKE_DATACENTER_ID=1
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_APP_NAME=kt-template-online-api
|
||||||
|
LOG_PRETTY=true
|
||||||
|
LOKI_URL=
|
||||||
|
LOKI_QUERY_HOST=
|
||||||
|
LOKI_ENV=development
|
||||||
|
LOKI_TENANT_ID=
|
||||||
|
LOKI_USERNAME=
|
||||||
|
LOKI_PASSWORD=
|
||||||
|
LOKI_PUSH_ENDPOINT=/loki/api/v1/push
|
||||||
|
LOKI_QUERY_ENDPOINT=/loki/api/v1/query_range
|
||||||
|
LOKI_PUSH_TIMEOUT_MS=30000
|
||||||
|
LOKI_QUERY_TIMEOUT_MS=10000
|
||||||
|
LOKI_BATCH_INTERVAL_SECONDS=5
|
||||||
|
LOKI_BATCH_MAX_BUFFER_SIZE=10000
|
||||||
|
LOKI_QUERY_MAX_LIMIT=1000
|
||||||
|
LOKI_QUERY_SELECTOR=
|
||||||
|
LOKI_SILENCE_ERRORS=true
|
||||||
|
|
||||||
QQBOT_ENABLED=false
|
QQBOT_ENABLED=false
|
||||||
QQBOT_EVENT_BUS=mqtt
|
QQBOT_EVENT_BUS=mqtt
|
||||||
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
|
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
|
||||||
|
|||||||
29
API.md
29
API.md
@ -32,6 +32,7 @@
|
|||||||
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||||
| WordPress | WordPress 文章、标签、分类管理,复用客户端 WordPress 登录态访问 REST API |
|
| WordPress | WordPress 文章、标签、分类管理,复用客户端 WordPress 登录态访问 REST API |
|
||||||
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
|
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
|
||||||
|
| Logging | 基于 Pino 的结构化日志、可选 Loki 直推,以及 Admin 系统日志查询代理 |
|
||||||
|
|
||||||
## 通用规则
|
## 通用规则
|
||||||
|
|
||||||
@ -85,6 +86,29 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
|
|||||||
|
|
||||||
Admin 主登录不依赖 WordPress 可用性:本系统账号验证通过后会先写入 Admin token;WordPress 自动认证失败时登录仍返回成功,`wordpressAuth` 为 `null`、`wordpressAvailable=false`,并清理旧 WordPress cookie。随后 `/menu/all` 与 `/auth/codes` 会基于最近一次 WordPress 可用性状态过滤 `Blog*` 菜单和 `Blog:*` 按钮权限码,避免前端展示不可用的文章、分类、标签管理入口。
|
Admin 主登录不依赖 WordPress 可用性:本系统账号验证通过后会先写入 Admin token;WordPress 自动认证失败时登录仍返回成功,`wordpressAuth` 为 `null`、`wordpressAvailable=false`,并清理旧 WordPress cookie。随后 `/menu/all` 与 `/auth/codes` 会基于最近一次 WordPress 可用性状态过滤 `Blog*` 菜单和 `Blog:*` 按钮权限码,避免前端展示不可用的文章、分类、标签管理入口。
|
||||||
|
|
||||||
|
### 系统日志
|
||||||
|
|
||||||
|
后端使用 `nestjs-pino` 输出结构化 JSON 日志。生产环境默认写 stdout;配置 `LOKI_URL` 或 `LOKI_HOST` 后,会同时通过 `pino-loki` 批量推送到 Loki。Admin 不直接连接 Loki,统一通过后端 `/system/logs/*` 查询代理访问,避免 Loki 地址和凭据暴露到浏览器。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| ---------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `LOG_LEVEL` | 日志级别,默认生产 `info`、开发 `debug` |
|
||||||
|
| `LOG_APP_NAME` | Loki `app` 标签,默认 `kt-template-online-api` |
|
||||||
|
| `LOG_PRETTY` | 开发环境是否使用 `pino-pretty`,默认 `true` |
|
||||||
|
| `LOKI_URL` / `LOKI_HOST` | Loki HTTP 地址,例如 `http://loki:3100`;为空时仅输出 stdout |
|
||||||
|
| `LOKI_QUERY_HOST` | 查询专用 Loki 地址;为空时复用 `LOKI_HOST/LOKI_URL` |
|
||||||
|
| `LOKI_ENV` | Loki `env` 标签,默认跟随 `NODE_ENV` |
|
||||||
|
| `LOKI_TENANT_ID` | Loki 多租户 `X-Scope-OrgID`,没有多租户时留空 |
|
||||||
|
| `LOKI_USERNAME` / `LOKI_PASSWORD` | Loki Basic Auth,仅放真实 env,不提交到仓库 |
|
||||||
|
| `LOKI_PUSH_ENDPOINT` | Loki push 路径,默认 `/loki/api/v1/push` |
|
||||||
|
| `LOKI_QUERY_ENDPOINT` | Loki query_range 路径,默认 `/loki/api/v1/query_range` |
|
||||||
|
| `LOKI_QUERY_SELECTOR` | 查询默认 selector;为空时使用 `{app="<LOG_APP_NAME>",env="<LOKI_ENV>"}` |
|
||||||
|
| `LOKI_BATCH_INTERVAL_SECONDS` | pino-loki 批量发送间隔,默认 `5` |
|
||||||
|
| `LOKI_BATCH_MAX_BUFFER_SIZE` | Loki 不可用时最大内存缓冲条数,默认 `10000`,超过会丢弃旧日志避免 OOM |
|
||||||
|
| `LOKI_QUERY_MAX_LIMIT` | Admin 单次查询最大拉取条数,默认 `1000` |
|
||||||
|
|
||||||
### 数据库字典翻译
|
### 数据库字典翻译
|
||||||
|
|
||||||
组件数据维护在 `admin_component` 表中,字典数据维护在新的 `admin_dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射;旧 `/dict/*` 接口路径保持兼容,但仍需要登录态。
|
组件数据维护在 `admin_component` 表中,字典数据维护在新的 `admin_dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射;旧 `/dict/*` 接口路径保持兼容,但仍需要登录态。
|
||||||
@ -349,6 +373,10 @@ Query:
|
|||||||
| POST | `/system/dept` | 新增部门 |
|
| POST | `/system/dept` | 新增部门 |
|
||||||
| PUT | `/system/dept/:id` | 更新部门 |
|
| PUT | `/system/dept/:id` | 更新部门 |
|
||||||
| DELETE | `/system/dept/:id` | 删除部门 |
|
| DELETE | `/system/dept/:id` | 删除部门 |
|
||||||
|
| GET | `/system/logs` | 查询 Loki 系统日志 |
|
||||||
|
| GET | `/system/logs/summary` | 查询日志级别统计 |
|
||||||
|
| GET | `/system/logs/levels` | 查询日志级别选项 |
|
||||||
|
| GET | `/system/logs/status` | 查询 Loki 配置状态 |
|
||||||
| GET | `/timezone/getTimezoneOptions` | 获取时区选项 |
|
| GET | `/timezone/getTimezoneOptions` | 获取时区选项 |
|
||||||
| GET | `/timezone/getTimezone` | 获取当前用户时区 |
|
| GET | `/timezone/getTimezone` | 获取当前用户时区 |
|
||||||
| POST | `/timezone/setTimezone` | 设置当前用户时区 |
|
| POST | `/timezone/setTimezone` | 设置当前用户时区 |
|
||||||
@ -360,6 +388,7 @@ Query:
|
|||||||
初始化 SQL:
|
初始化 SQL:
|
||||||
|
|
||||||
- `sql/vben-admin-init.sql`:创建 `admin_*` 表并导入基础用户、角色、菜单、部门、字典数据,同时创建空的 `admin_component` 表。
|
- `sql/vben-admin-init.sql`:创建 `admin_*` 表并导入基础用户、角色、菜单、部门、字典数据,同时创建空的 `admin_component` 表。
|
||||||
|
- `sql/system-log-menu.sql`:为已有库增量补齐系统日志菜单及管理员角色授权。
|
||||||
- `sql/migrate-dict-to-admin-dict.sql`:将旧 `dict` 表数据迁移到 `admin_dict`。
|
- `sql/migrate-dict-to-admin-dict.sql`:将旧 `dict` 表数据迁移到 `admin_dict`。
|
||||||
- `sql/migrate-component-to-admin-component.sql`:将旧 `component` 表数据迁移到 `admin_component`,并把旧表改名为备份表。
|
- `sql/migrate-component-to-admin-component.sql`:将旧 `component` 表数据迁移到 `admin_component`,并把旧表改名为备份表。
|
||||||
- `sql/fix-admin-menu-meta.sql`:修复基础后台菜单 `meta` 被旧数据或错误保存覆盖为空的问题。
|
- `sql/fix-admin-menu-meta.sql`:修复基础后台菜单 `meta` 被旧数据或错误保存覆盖为空的问题。
|
||||||
|
|||||||
12
dockerfile
12
dockerfile
@ -4,6 +4,18 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV APP_PORT=48085
|
ENV APP_PORT=48085
|
||||||
|
ENV LOG_LEVEL=info
|
||||||
|
ENV LOG_APP_NAME=kt-template-online-api
|
||||||
|
ENV LOG_PRETTY=false
|
||||||
|
ENV LOKI_ENV=production
|
||||||
|
ENV LOKI_PUSH_ENDPOINT=/loki/api/v1/push
|
||||||
|
ENV LOKI_QUERY_ENDPOINT=/loki/api/v1/query_range
|
||||||
|
ENV LOKI_PUSH_TIMEOUT_MS=30000
|
||||||
|
ENV LOKI_QUERY_TIMEOUT_MS=10000
|
||||||
|
ENV LOKI_BATCH_INTERVAL_SECONDS=5
|
||||||
|
ENV LOKI_BATCH_MAX_BUFFER_SIZE=10000
|
||||||
|
ENV LOKI_QUERY_MAX_LIMIT=1000
|
||||||
|
ENV LOKI_SILENCE_ERRORS=true
|
||||||
ENV FFLOGS_BASE_URL=https://www.fflogs.com
|
ENV FFLOGS_BASE_URL=https://www.fflogs.com
|
||||||
ENV FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
|
ENV FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
|
||||||
ENV FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client
|
ENV FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client
|
||||||
|
|||||||
47
package.json
47
package.json
@ -27,50 +27,55 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kwitsukasa/knife4j-swagger-vue3": "0.1.2",
|
"@kwitsukasa/knife4j-swagger-vue3": "0.1.2",
|
||||||
"@nestjs/common": "^9.4.3",
|
"@nestjs/common": "^11.1.24",
|
||||||
"@nestjs/config": "^2.3.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^9.4.3",
|
"@nestjs/core": "^11.1.24",
|
||||||
"@nestjs/platform-express": "^9.4.3",
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
"@nestjs/typeorm": "^9.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"express": "4.18.2",
|
"express": "5.2.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
"nestjs-minio-client": "^2.2.0",
|
"nestjs-minio-client": "^2.2.0",
|
||||||
"reflect-metadata": "^0.1.14",
|
"nestjs-pino": "^4.6.1",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-http": "^11.0.0",
|
||||||
|
"pino-loki": "^3.0.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"svg-captcha": "^1.4.0",
|
"svg-captcha": "^1.4.0",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.5.0",
|
"@nestjs/cli": "^11.0.21",
|
||||||
"@nestjs/schematics": "^9.2.0",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^9.4.3",
|
"@nestjs/testing": "^11.1.24",
|
||||||
"@types/express": "^4.17.25",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "29.2.4",
|
"@types/jest": "29.2.4",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "22.19.19",
|
||||||
"@types/supertest": "^2.0.16",
|
"@types/supertest": "^2.0.16",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^8.60.1",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-prettier": "^8.10.2",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^4.2.5",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"eslint-plugin-typeorm": "0.0.19",
|
"eslint-plugin-typeorm": "0.0.19",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "29.3.1",
|
"jest": "29.3.1",
|
||||||
"prettier": "^2.8.8",
|
"pino-pretty": "^13.1.3",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^6.3.4",
|
"supertest": "^6.3.4",
|
||||||
"ts-jest": "29.0.3",
|
"ts-jest": "29.4.11",
|
||||||
"ts-loader": "^9.5.7",
|
"ts-loader": "^9.5.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "4.1.1",
|
"tsconfig-paths": "4.1.1",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
2611
pnpm-lock.yaml
2611
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@ SET `meta` = CASE `name`
|
|||||||
WHEN 'SystemDeptCreate' THEN '{"title":"common.create"}'
|
WHEN 'SystemDeptCreate' THEN '{"title":"common.create"}'
|
||||||
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 '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"}'
|
||||||
WHEN 'VbenDocument' THEN '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}'
|
WHEN 'VbenDocument' THEN '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}'
|
||||||
WHEN 'VbenGithub' THEN '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}'
|
WHEN 'VbenGithub' THEN '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}'
|
||||||
@ -44,6 +45,7 @@ WHERE `name` IN (
|
|||||||
'SystemDeptCreate',
|
'SystemDeptCreate',
|
||||||
'SystemDeptEdit',
|
'SystemDeptEdit',
|
||||||
'SystemDeptDelete',
|
'SystemDeptDelete',
|
||||||
|
'SystemLog',
|
||||||
'Project',
|
'Project',
|
||||||
'VbenDocument',
|
'VbenDocument',
|
||||||
'VbenGithub',
|
'VbenGithub',
|
||||||
|
|||||||
50
sql/system-log-menu.sql
Normal file
50
sql/system-log-menu.sql
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
-- 增量补齐系统日志菜单。
|
||||||
|
-- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,可单独执行本文件。
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO `admin_menu` (
|
||||||
|
`id`,
|
||||||
|
`pid`,
|
||||||
|
`name`,
|
||||||
|
`path`,
|
||||||
|
`component`,
|
||||||
|
`redirect`,
|
||||||
|
`auth_code`,
|
||||||
|
`type`,
|
||||||
|
`meta`,
|
||||||
|
`status`,
|
||||||
|
`sort`
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
2041700000000100205,
|
||||||
|
2041700000000100002,
|
||||||
|
'SystemLog',
|
||||||
|
'/system/logs',
|
||||||
|
'/system/log/list',
|
||||||
|
NULL,
|
||||||
|
'System:Log:List',
|
||||||
|
'menu',
|
||||||
|
'{"icon":"lucide:scroll-text","title":"system.log.title"}',
|
||||||
|
1,
|
||||||
|
6
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`pid` = VALUES(`pid`),
|
||||||
|
`path` = VALUES(`path`),
|
||||||
|
`component` = VALUES(`component`),
|
||||||
|
`redirect` = VALUES(`redirect`),
|
||||||
|
`auth_code` = VALUES(`auth_code`),
|
||||||
|
`type` = VALUES(`type`),
|
||||||
|
`meta` = VALUES(`meta`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`sort` = VALUES(`sort`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`)
|
||||||
|
SELECT role.`id`, menu.`id`
|
||||||
|
FROM `admin_role` role
|
||||||
|
JOIN `admin_menu` menu ON menu.`name` = 'SystemLog'
|
||||||
|
WHERE role.`role_code` IN ('super', 'admin')
|
||||||
|
AND role.`is_deleted` = 0
|
||||||
|
AND menu.`is_deleted` = 0;
|
||||||
@ -183,6 +183,7 @@ VALUES
|
|||||||
(2041700000000120204, 2041700000000100203, 'SystemKtTableDemoCreate', NULL, NULL, NULL, 'System:KtTableDemo:Create', 'button', '{"title":"common.create"}', 1, 0),
|
(2041700000000120204, 2041700000000100203, 'SystemKtTableDemoCreate', NULL, NULL, NULL, 'System:KtTableDemo:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||||
(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),
|
||||||
(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),
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import { AdminMenuService } from './menu/admin-menu.service';
|
|||||||
import { AdminRoleController } from './role/admin-role.controller';
|
import { AdminRoleController } from './role/admin-role.controller';
|
||||||
import { AdminRole } from './role/admin-role.entity';
|
import { AdminRole } from './role/admin-role.entity';
|
||||||
import { AdminRoleService } from './role/admin-role.service';
|
import { AdminRoleService } from './role/admin-role.service';
|
||||||
|
import { SystemLogController } from './system-log/system-log.controller';
|
||||||
|
import { SystemLogService } from './system-log/system-log.service';
|
||||||
import { AdminTimezoneController } from './timezone/admin-timezone.controller';
|
import { AdminTimezoneController } from './timezone/admin-timezone.controller';
|
||||||
import { AdminTimezoneService } from './timezone/admin-timezone.service';
|
import { AdminTimezoneService } from './timezone/admin-timezone.service';
|
||||||
import { AdminUserManageController } from './user/admin-user-manage.controller';
|
import { AdminUserManageController } from './user/admin-user-manage.controller';
|
||||||
@ -47,6 +49,7 @@ import { WordpressModule } from '@/wordpress/wordpress.module';
|
|||||||
AdminRoleController,
|
AdminRoleController,
|
||||||
AdminDeptController,
|
AdminDeptController,
|
||||||
ComponentController,
|
ComponentController,
|
||||||
|
SystemLogController,
|
||||||
AdminTimezoneController,
|
AdminTimezoneController,
|
||||||
AdminExampleController,
|
AdminExampleController,
|
||||||
],
|
],
|
||||||
@ -55,6 +58,7 @@ import { WordpressModule } from '@/wordpress/wordpress.module';
|
|||||||
AdminDeptService,
|
AdminDeptService,
|
||||||
AdminMenuService,
|
AdminMenuService,
|
||||||
AdminRoleService,
|
AdminRoleService,
|
||||||
|
SystemLogService,
|
||||||
AdminTimezoneService,
|
AdminTimezoneService,
|
||||||
AdminUserService,
|
AdminUserService,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -39,8 +39,8 @@ const componentExample = {
|
|||||||
componentTypeMsg: '折线图',
|
componentTypeMsg: '折线图',
|
||||||
image: '',
|
image: '',
|
||||||
template: '%7B%22version%22%3A%221.0%22%7D',
|
template: '%7B%22version%22%3A%221.0%22%7D',
|
||||||
createTime: '2026-05-13T02:30:00.000Z',
|
createTime: '2026-05-13 10:30:00',
|
||||||
updateTime: '2026-05-13T02:30:00.000Z',
|
updateTime: '2026-05-13 10:30:00',
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,12 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { DecodeDictKey, decodeDictKeys, ensureSnowflakeId } from '@/common';
|
import {
|
||||||
|
DecodeDictKey,
|
||||||
|
decodeDictKeys,
|
||||||
|
ensureSnowflakeId,
|
||||||
|
FormatDateTime,
|
||||||
|
} from '@/common';
|
||||||
|
|
||||||
@Entity('admin_component')
|
@Entity('admin_component')
|
||||||
export class Component {
|
export class Component {
|
||||||
@ -67,11 +72,13 @@ export class Component {
|
|||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
name: 'create_time',
|
name: 'create_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'update_time',
|
name: 'update_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
|
|
||||||
@Entity('admin_dept')
|
@Entity('admin_dept')
|
||||||
export class AdminDept {
|
export class AdminDept {
|
||||||
@ -43,11 +43,13 @@ export class AdminDept {
|
|||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
name: 'create_time',
|
name: 'create_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'update_time',
|
name: 'update_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
|
|
||||||
@Entity('admin_dict')
|
@Entity('admin_dict')
|
||||||
export class AdminDict {
|
export class AdminDict {
|
||||||
@ -68,11 +68,13 @@ export class AdminDict {
|
|||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
name: 'create_time',
|
name: 'create_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'update_time',
|
name: 'update_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { FormatDateTime } from '@/common';
|
||||||
|
|
||||||
export class DictDto {
|
export class DictDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@ -56,13 +57,15 @@ export class AdminDictDto {
|
|||||||
status?: number;
|
status?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '2026-06-03T12:00:00.000Z',
|
example: '2026-06-03 20:00:00',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
createTime?: Date;
|
createTime?: Date;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '2026-06-03T12:00:00.000Z',
|
example: '2026-06-03 20:00:00',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
updateTime?: Date;
|
updateTime?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { Public, vbenPage, vbenSuccess } from '@/common';
|
import { formatKtDateTime, Public, vbenPage, vbenSuccess } from '@/common';
|
||||||
import { MinioClientService } from '@/minio/minio.service';
|
import { MinioClientService } from '@/minio/minio.service';
|
||||||
import type { MinioUploadFile } from '@/minio/minio.types';
|
import type { MinioUploadFile } from '@/minio/minio.types';
|
||||||
import type { AdminDemoTableRow } from '../admin.types';
|
import type { AdminDemoTableRow } from '../admin.types';
|
||||||
@ -42,7 +42,9 @@ const DEMO_ROWS: AdminDemoTableRow[] = Array.from(
|
|||||||
productName: `KT Admin 模板能力 ${sequence}`,
|
productName: `KT Admin 模板能力 ${sequence}`,
|
||||||
quantity: 10 + sequence,
|
quantity: 10 + sequence,
|
||||||
rating: Number((3 + (sequence % 20) / 10).toFixed(1)),
|
rating: Number((3 + (sequence % 20) / 10).toFixed(1)),
|
||||||
releaseDate: new Date(2026, index % 12, (index % 28) + 1).toISOString(),
|
releaseDate: formatKtDateTime(
|
||||||
|
new Date(2026, index % 12, (index % 28) + 1),
|
||||||
|
),
|
||||||
status: statuses[index % statuses.length],
|
status: statuses[index % statuses.length],
|
||||||
tags: [
|
tags: [
|
||||||
'kt',
|
'kt',
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import { AdminRole } from '../role/admin-role.entity';
|
import { AdminRole } from '../role/admin-role.entity';
|
||||||
import type { AdminMenuMeta, AdminMenuType } from '../admin.types';
|
import type { AdminMenuMeta, AdminMenuType } from '../admin.types';
|
||||||
|
|
||||||
@ -86,11 +86,13 @@ export class AdminMenu {
|
|||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
name: 'create_time',
|
name: 'create_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'update_time',
|
name: 'update_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@ManyToMany(() => AdminRole, (role) => role.menus)
|
@ManyToMany(() => AdminRole, (role) => role.menus)
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import { AdminMenu } from '../menu/admin-menu.entity';
|
import { AdminMenu } from '../menu/admin-menu.entity';
|
||||||
import { AdminUser } from '../user/admin-user.entity';
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
|
||||||
@ -47,11 +47,13 @@ export class AdminRole {
|
|||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
name: 'create_time',
|
name: 'create_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'update_time',
|
name: 'update_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@ManyToMany(() => AdminMenu, (menu) => menu.roles)
|
@ManyToMany(() => AdminMenu, (menu) => menu.roles)
|
||||||
|
|||||||
74
src/admin/system-log/system-log.controller.ts
Normal file
74
src/admin/system-log/system-log.controller.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ApiArrayResponse,
|
||||||
|
ApiModelResponse,
|
||||||
|
ApiPageResponse,
|
||||||
|
vbenPage,
|
||||||
|
vbenSuccess,
|
||||||
|
} from '@/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
SystemLogDto,
|
||||||
|
SystemLogQueryDto,
|
||||||
|
SystemLogStatusDto,
|
||||||
|
SystemLogSummaryDto,
|
||||||
|
} from './system-log.dto';
|
||||||
|
import { SystemLogService } from './system-log.service';
|
||||||
|
|
||||||
|
@ApiTags('Admin - 系统日志')
|
||||||
|
@Controller('system/logs')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class SystemLogController {
|
||||||
|
constructor(private readonly systemLogService: SystemLogService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '查询系统日志' })
|
||||||
|
@ApiPageResponse(SystemLogDto, [
|
||||||
|
{
|
||||||
|
id: '1760000000000000000-0-0',
|
||||||
|
timestamp: '2026-06-04 08:00:00',
|
||||||
|
timestampNs: '1760000000000000000',
|
||||||
|
level: 'info',
|
||||||
|
context: 'RouterExplorer',
|
||||||
|
message: 'Mapped route',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/system/logs',
|
||||||
|
statusCode: 200,
|
||||||
|
durationMs: 12,
|
||||||
|
requestId: 'd9ac19e3-9e0d-4e19-b48e-6a6b4a31a2d2',
|
||||||
|
hostname: 'kt-template-online-api',
|
||||||
|
raw: '{"level":30,"msg":"Mapped route"}',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
async list(@Query() query: SystemLogQueryDto) {
|
||||||
|
const page = await this.systemLogService.page(query);
|
||||||
|
return vbenPage(page.items, page.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
@ApiOperation({ summary: '查询系统日志级别统计' })
|
||||||
|
@ApiArrayResponse(SystemLogSummaryDto, [{ level: 'error', count: 2 }])
|
||||||
|
async summary(@Query() query: SystemLogQueryDto) {
|
||||||
|
return vbenSuccess(await this.systemLogService.summary(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('levels')
|
||||||
|
@ApiOperation({ summary: '查询系统日志级别选项' })
|
||||||
|
async levels() {
|
||||||
|
return vbenSuccess(this.systemLogService.levels());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@ApiOperation({ summary: '查询系统日志配置状态' })
|
||||||
|
@ApiModelResponse(SystemLogStatusDto, {
|
||||||
|
app: 'kt-template-online-api',
|
||||||
|
configured: true,
|
||||||
|
env: 'production',
|
||||||
|
host: 'http://loki:3100',
|
||||||
|
selector: '{app="kt-template-online-api",env="production"}',
|
||||||
|
})
|
||||||
|
async status() {
|
||||||
|
return vbenSuccess(this.systemLogService.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/admin/system-log/system-log.dto.ts
Normal file
107
src/admin/system-log/system-log.dto.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { FormatDateTime } from '@/common';
|
||||||
|
|
||||||
|
export class SystemLogQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: '日志级别:debug/info/warning/error/critical' })
|
||||||
|
level?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '全文关键字' })
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Nest 日志上下文' })
|
||||||
|
context?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '请求路径关键字' })
|
||||||
|
path?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '请求 ID' })
|
||||||
|
requestId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '开始时间 ISO 字符串或时间戳' })
|
||||||
|
startTime?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '结束时间 ISO 字符串或时间戳' })
|
||||||
|
endTime?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '相对结束时间向前查询分钟数' })
|
||||||
|
rangeMinutes?: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '页码' })
|
||||||
|
page?: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '页码' })
|
||||||
|
pageNo?: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页条数' })
|
||||||
|
pageSize?: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Loki 单次查询最大拉取条数' })
|
||||||
|
limit?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemLogDto {
|
||||||
|
@ApiPropertyOptional({ description: '唯一行 ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '日志时间' })
|
||||||
|
@FormatDateTime()
|
||||||
|
timestamp: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Loki 纳秒时间戳' })
|
||||||
|
timestampNs: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '日志级别' })
|
||||||
|
level: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '日志上下文' })
|
||||||
|
context?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '消息' })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'HTTP 方法' })
|
||||||
|
method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '请求路径' })
|
||||||
|
path?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'HTTP 状态码' })
|
||||||
|
statusCode?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '耗时,毫秒' })
|
||||||
|
durationMs?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '请求 ID' })
|
||||||
|
requestId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '主机名' })
|
||||||
|
hostname?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '原始日志行' })
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemLogSummaryDto {
|
||||||
|
@ApiPropertyOptional({ description: '日志级别' })
|
||||||
|
level: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '数量' })
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemLogStatusDto {
|
||||||
|
@ApiPropertyOptional({ description: '是否已配置 Loki 查询地址' })
|
||||||
|
configured: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '默认 LogQL selector' })
|
||||||
|
selector: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '日志应用标签' })
|
||||||
|
app: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '日志环境标签' })
|
||||||
|
env: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Loki 地址脱敏展示' })
|
||||||
|
host?: string;
|
||||||
|
}
|
||||||
454
src/admin/system-log/system-log.service.ts
Normal file
454
src/admin/system-log/system-log.service.ts
Normal file
@ -0,0 +1,454 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
import { URL } from 'node:url';
|
||||||
|
import {
|
||||||
|
getAppName,
|
||||||
|
getLokiEnvironment,
|
||||||
|
formatDateTimeFields,
|
||||||
|
throwVbenError,
|
||||||
|
ToolsService,
|
||||||
|
} from '@/common';
|
||||||
|
import { SystemLogDto } from './system-log.dto';
|
||||||
|
import type {
|
||||||
|
SystemLogQueryDto,
|
||||||
|
SystemLogStatusDto,
|
||||||
|
SystemLogSummaryDto,
|
||||||
|
} from './system-log.dto';
|
||||||
|
|
||||||
|
type LokiStreamResult = {
|
||||||
|
stream?: Record<string, string>;
|
||||||
|
values?: Array<[string, string, Record<string, string>?]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LokiQueryRangeResponse = {
|
||||||
|
data?: {
|
||||||
|
result?: LokiStreamResult[];
|
||||||
|
resultType?: string;
|
||||||
|
};
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LEVELS = ['debug', 'info', 'warning', 'error', 'critical'];
|
||||||
|
const PINO_LEVEL_MAP: Record<string, string> = {
|
||||||
|
'10': 'debug',
|
||||||
|
'20': 'debug',
|
||||||
|
'30': 'info',
|
||||||
|
'40': 'warning',
|
||||||
|
'50': 'error',
|
||||||
|
'60': 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SystemLogService {
|
||||||
|
private readonly appName: string;
|
||||||
|
private readonly environment: string;
|
||||||
|
private readonly host: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly toolsService: ToolsService,
|
||||||
|
) {
|
||||||
|
this.appName = getAppName(configService);
|
||||||
|
this.environment = getLokiEnvironment(configService);
|
||||||
|
this.host = this.normalizeUrl(
|
||||||
|
this.getConfig('LOKI_QUERY_HOST') ||
|
||||||
|
this.getConfig('LOKI_HOST') ||
|
||||||
|
this.getConfig('LOKI_URL'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): SystemLogStatusDto {
|
||||||
|
return {
|
||||||
|
app: this.appName,
|
||||||
|
configured: !!this.host,
|
||||||
|
env: this.environment,
|
||||||
|
host: this.maskHost(this.host),
|
||||||
|
selector: this.getBaseSelector(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
levels() {
|
||||||
|
return DEFAULT_LEVELS.map((level) => ({
|
||||||
|
label: level,
|
||||||
|
value: level,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async page(query: SystemLogQueryDto = {}) {
|
||||||
|
if (!this.host) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageNo, pageSize } = this.toolsService.getPageParams(
|
||||||
|
query,
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
const requestLimit = Math.min(
|
||||||
|
this.toolsService.toPositiveNumber(query.limit, pageNo * pageSize),
|
||||||
|
this.getNumberConfig('LOKI_QUERY_MAX_LIMIT', 1000),
|
||||||
|
);
|
||||||
|
const logs = await this.queryLogs(query, Math.max(requestLimit, pageSize));
|
||||||
|
const filteredLogs = logs.filter((item) => this.matchesQuery(item, query));
|
||||||
|
const startIndex = (pageNo - 1) * pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: filteredLogs.slice(startIndex, startIndex + pageSize),
|
||||||
|
total: filteredLogs.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async summary(
|
||||||
|
query: SystemLogQueryDto = {},
|
||||||
|
): Promise<SystemLogSummaryDto[]> {
|
||||||
|
if (!this.host) {
|
||||||
|
return DEFAULT_LEVELS.map((level) => ({ count: 0, level }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await this.queryLogs(
|
||||||
|
{
|
||||||
|
...query,
|
||||||
|
level: undefined,
|
||||||
|
},
|
||||||
|
this.toolsService.toPositiveNumber(query.limit, 1000),
|
||||||
|
);
|
||||||
|
const filteredLogs = logs.filter((item) => this.matchesQuery(item, query));
|
||||||
|
const countMap = new Map(DEFAULT_LEVELS.map((level) => [level, 0]));
|
||||||
|
filteredLogs.forEach((item) => {
|
||||||
|
const level = this.normalizeLevel(item.level) || 'info';
|
||||||
|
countMap.set(level, (countMap.get(level) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return DEFAULT_LEVELS.map((level) => ({
|
||||||
|
count: countMap.get(level) || 0,
|
||||||
|
level,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queryLogs(query: SystemLogQueryDto, limit: number) {
|
||||||
|
const url = new URL(
|
||||||
|
this.getConfig('LOKI_QUERY_ENDPOINT', '/loki/api/v1/query_range'),
|
||||||
|
this.host,
|
||||||
|
);
|
||||||
|
const { start, end } = this.getTimeRange(query);
|
||||||
|
url.searchParams.set('query', this.buildLogQL(query));
|
||||||
|
url.searchParams.set('start', this.toNanoseconds(start));
|
||||||
|
url.searchParams.set('end', this.toNanoseconds(end));
|
||||||
|
url.searchParams.set('limit', `${limit}`);
|
||||||
|
url.searchParams.set('direction', 'backward');
|
||||||
|
|
||||||
|
let response: LokiQueryRangeResponse;
|
||||||
|
try {
|
||||||
|
response = await this.requestJson<LokiQueryRangeResponse>(url);
|
||||||
|
} catch (error) {
|
||||||
|
throwVbenError(
|
||||||
|
this.toolsService.getErrorMessage(error, 'Loki 查询失败'),
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status && response.status !== 'success') {
|
||||||
|
throwVbenError('Loki 查询失败', HttpStatus.BAD_GATEWAY, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.flattenLogs(response.data?.result || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildLogQL(query: SystemLogQueryDto) {
|
||||||
|
const selector = this.withLevelSelector(
|
||||||
|
this.getBaseSelector(),
|
||||||
|
this.normalizeLevel(query.level),
|
||||||
|
);
|
||||||
|
const lineFilters = [
|
||||||
|
query.keyword,
|
||||||
|
query.context,
|
||||||
|
query.path,
|
||||||
|
query.requestId,
|
||||||
|
]
|
||||||
|
.map((value) => this.toolsService.toTrimmedString(value))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => `|= "${this.escapeLogqlString(value)}"`);
|
||||||
|
|
||||||
|
return [selector, ...lineFilters].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private withLevelSelector(selector: string, level?: string) {
|
||||||
|
if (!level || selector.includes('level=')) return selector;
|
||||||
|
return selector.replace(/}\s*$/, `,level="${this.escapeLabelValue(level)}"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBaseSelector() {
|
||||||
|
const selector = this.getConfig('LOKI_QUERY_SELECTOR');
|
||||||
|
if (selector) return selector;
|
||||||
|
|
||||||
|
return `{app="${this.escapeLabelValue(
|
||||||
|
this.appName,
|
||||||
|
)}",env="${this.escapeLabelValue(this.environment)}"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private flattenLogs(streams: LokiStreamResult[]): SystemLogDto[] {
|
||||||
|
return streams
|
||||||
|
.flatMap((stream, streamIndex) =>
|
||||||
|
(stream.values || []).map(([timestampNs, line, metadata], rowIndex) =>
|
||||||
|
this.serializeLog({
|
||||||
|
line,
|
||||||
|
metadata,
|
||||||
|
rowIndex,
|
||||||
|
stream: stream.stream || {},
|
||||||
|
streamIndex,
|
||||||
|
timestampNs,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.sort((prev, next) =>
|
||||||
|
this.compareTimestamp(next.timestampNs, prev.timestampNs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeLog(params: {
|
||||||
|
line: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
rowIndex: number;
|
||||||
|
stream: Record<string, string>;
|
||||||
|
streamIndex: number;
|
||||||
|
timestampNs: string;
|
||||||
|
}): SystemLogDto {
|
||||||
|
const parsed = this.parseLogLine(params.line);
|
||||||
|
const req = this.asRecord(parsed.req);
|
||||||
|
const res = this.asRecord(parsed.res);
|
||||||
|
const meta = this.asRecord(parsed.meta);
|
||||||
|
const level =
|
||||||
|
this.normalizeLevel(params.stream.level) ||
|
||||||
|
this.normalizeLevel(parsed.level) ||
|
||||||
|
'info';
|
||||||
|
const requestId =
|
||||||
|
this.pickText(params.metadata?.requestId, meta.requestId, req.id) ||
|
||||||
|
undefined;
|
||||||
|
const path = this.pickText(parsed.path, req.url, req.originalUrl) || undefined;
|
||||||
|
|
||||||
|
return formatDateTimeFields(
|
||||||
|
Object.assign(new SystemLogDto(), {
|
||||||
|
context:
|
||||||
|
this.pickText(parsed.context, params.stream.context) || undefined,
|
||||||
|
durationMs: this.toOptionalNumber(
|
||||||
|
parsed.durationMs,
|
||||||
|
parsed.responseTime,
|
||||||
|
),
|
||||||
|
hostname: params.stream.hostname,
|
||||||
|
id: `${params.timestampNs}-${params.streamIndex}-${params.rowIndex}`,
|
||||||
|
level,
|
||||||
|
message:
|
||||||
|
this.pickText(parsed.msg, parsed.message, parsed.err?.message) ||
|
||||||
|
params.line,
|
||||||
|
method: this.pickText(parsed.method, req.method) || undefined,
|
||||||
|
path,
|
||||||
|
raw: params.line,
|
||||||
|
requestId,
|
||||||
|
statusCode: this.toOptionalNumber(parsed.statusCode, res.statusCode),
|
||||||
|
timestamp: this.timestampNsToDate(params.timestampNs),
|
||||||
|
timestampNs: params.timestampNs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesQuery(item: SystemLogDto, query: SystemLogQueryDto) {
|
||||||
|
const level = this.normalizeLevel(query.level);
|
||||||
|
if (level && item.level !== level) return false;
|
||||||
|
if (
|
||||||
|
!this.includes(item.raw, query.keyword) &&
|
||||||
|
!this.includes(item.message, query.keyword)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.includes(item.context, query.context)) return false;
|
||||||
|
if (!this.includes(item.path, query.path)) return false;
|
||||||
|
if (!this.includes(item.requestId, query.requestId)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private includes(value: unknown, keyword: unknown) {
|
||||||
|
const normalizedKeyword = this.toolsService.toTrimmedString(keyword);
|
||||||
|
if (!normalizedKeyword) return true;
|
||||||
|
return this.toolsService.includesText(value, normalizedKeyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTimeRange(query: SystemLogQueryDto) {
|
||||||
|
const end = this.toDate(query.endTime) || new Date();
|
||||||
|
const start =
|
||||||
|
this.toDate(query.startTime) ||
|
||||||
|
new Date(
|
||||||
|
end.getTime() -
|
||||||
|
this.toolsService.toPositiveNumber(query.rangeMinutes, 60) *
|
||||||
|
60 *
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
return { end, start };
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestJson<T>(url: URL) {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const client = url.protocol === 'http:' ? http : https;
|
||||||
|
const request = client.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'kt-template-online-api/admin-system-log',
|
||||||
|
...this.getHeaders(),
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
timeout: this.getNumberConfig('LOKI_QUERY_TIMEOUT_MS', 10000),
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let body = '';
|
||||||
|
response.setEncoding('utf8');
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', () => {
|
||||||
|
if ((response.statusCode || 500) >= 400) {
|
||||||
|
reject(new Error(`Loki 查询失败:${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body) as T);
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Loki 返回不是合法 JSON'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy(new Error('Loki 查询超时'));
|
||||||
|
});
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaders() {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const tenantId = this.getConfig('LOKI_TENANT_ID');
|
||||||
|
const username = this.getConfig('LOKI_USERNAME');
|
||||||
|
const password = this.getConfig('LOKI_PASSWORD');
|
||||||
|
|
||||||
|
if (tenantId) headers['X-Scope-OrgID'] = tenantId;
|
||||||
|
if (username && password) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(
|
||||||
|
`${username}:${password}`,
|
||||||
|
).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLogLine(line: string): Record<string, any> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private asRecord(value: unknown): Record<string, any> {
|
||||||
|
return value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickText(...values: unknown[]) {
|
||||||
|
return this.toolsService.pickFirstText(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLevel(value: unknown) {
|
||||||
|
const text = this.toolsService.toTrimmedString(value).toLowerCase();
|
||||||
|
if (!text) return '';
|
||||||
|
if (PINO_LEVEL_MAP[text]) return PINO_LEVEL_MAP[text];
|
||||||
|
if (text === 'warn') return 'warning';
|
||||||
|
return DEFAULT_LEVELS.includes(text) ? text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDate(value: unknown) {
|
||||||
|
const text = this.toolsService.toTrimmedString(value);
|
||||||
|
if (!text) return null;
|
||||||
|
const timestamp = /^\d+$/.test(text) ? this.normalizeTimestamp(text) : Date.parse(text);
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toNanoseconds(date: Date) {
|
||||||
|
return `${BigInt(date.getTime()) * 1000000n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private timestampNsToDate(value: string) {
|
||||||
|
try {
|
||||||
|
return new Date(Number(BigInt(value) / 1000000n));
|
||||||
|
} catch {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareTimestamp(left: string, right: string) {
|
||||||
|
try {
|
||||||
|
const diff = BigInt(left) - BigInt(right);
|
||||||
|
return diff > 0n ? 1 : diff < 0n ? -1 : 0;
|
||||||
|
} catch {
|
||||||
|
return left.localeCompare(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOptionalNumber(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
const nextValue = Number(value);
|
||||||
|
if (Number.isFinite(nextValue)) return nextValue;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeLogqlString(value: string) {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeLabelValue(value: string) {
|
||||||
|
return this.escapeLogqlString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfig(key: string, fallback = '') {
|
||||||
|
const value = this.configService.get<string>(key);
|
||||||
|
return this.toolsService.toTrimmedString(value || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNumberConfig(key: string, fallback: number) {
|
||||||
|
const value = Number(this.configService.get<string>(key));
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeUrl(value: string) {
|
||||||
|
return value.replace(/\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskHost(host: string) {
|
||||||
|
if (!host) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(host);
|
||||||
|
if (url.username || url.password) {
|
||||||
|
url.username = '***';
|
||||||
|
url.password = '***';
|
||||||
|
}
|
||||||
|
return url.toString().replace(/\/+$/g, '');
|
||||||
|
} catch {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTimestamp(value: string) {
|
||||||
|
if (value.length === 10) return Number(value) * 1000;
|
||||||
|
if (value.length === 16) return Math.floor(Number(value) / 1000);
|
||||||
|
if (value.length >= 19) return Number(BigInt(value) / 1000000n);
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import { AdminDept } from '../dept/admin-dept.entity';
|
import { AdminDept } from '../dept/admin-dept.entity';
|
||||||
import { AdminRole } from '../role/admin-role.entity';
|
import { AdminRole } from '../role/admin-role.entity';
|
||||||
|
|
||||||
@ -66,11 +66,13 @@ export class AdminUser {
|
|||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
name: 'create_time',
|
name: 'create_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({
|
@UpdateDateColumn({
|
||||||
name: 'update_time',
|
name: 'update_time',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@ManyToMany(() => AdminRole, (role) => role.users, {
|
@ManyToMany(() => AdminRole, (role) => role.users, {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MinioModule } from 'nestjs-minio-client';
|
import { MinioModule } from 'nestjs-minio-client';
|
||||||
@ -9,6 +10,7 @@ import { MinioClientModule } from './minio/minio.module';
|
|||||||
import {
|
import {
|
||||||
ApiExceptionFilter,
|
ApiExceptionFilter,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
createPinoLoggerParams,
|
||||||
SaveBodyInterceptor,
|
SaveBodyInterceptor,
|
||||||
} from './common';
|
} from './common';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
@ -21,6 +23,12 @@ import { QqbotModule } from './qqbot/qqbot.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||||
}),
|
}),
|
||||||
|
LoggerModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) =>
|
||||||
|
createPinoLoggerParams(configService),
|
||||||
|
}),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => {
|
useFactory: async (configService: ConfigService) => {
|
||||||
|
|||||||
103
src/common/decorators/format-date-time.decorator.ts
Normal file
103
src/common/decorators/format-date-time.decorator.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { AfterLoad } from 'typeorm';
|
||||||
|
|
||||||
|
export const KT_DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||||
|
|
||||||
|
const DATE_TIME_FORMAT_RULES = Symbol('DATE_TIME_FORMAT_RULES');
|
||||||
|
const DATE_TIME_FORMAT_HOOK_REGISTERED = Symbol(
|
||||||
|
'DATE_TIME_FORMAT_HOOK_REGISTERED',
|
||||||
|
);
|
||||||
|
const DATE_TIME_FORMAT_HOOK = '__ktFormatDateTimeFields';
|
||||||
|
const ISO_DATE_TIME_PATTERN =
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})?$/;
|
||||||
|
const DATE_TIME_TEXT_PATTERN =
|
||||||
|
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/;
|
||||||
|
|
||||||
|
type DateTimeFormatRule = {
|
||||||
|
sourceKey: string;
|
||||||
|
targetKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DateTimeFormatTarget = Record<PropertyKey, any> & {
|
||||||
|
[DATE_TIME_FORMAT_HOOK_REGISTERED]?: boolean;
|
||||||
|
[DATE_TIME_FORMAT_RULES]?: DateTimeFormatRule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const padDateUnit = (value: number) => `${value}`.padStart(2, '0');
|
||||||
|
|
||||||
|
export const formatKtDateTime = (value: Date | number | string): string => {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${date.getFullYear()}-${padDateUnit(date.getMonth() + 1)}-${padDateUnit(
|
||||||
|
date.getDate(),
|
||||||
|
)}`,
|
||||||
|
`${padDateUnit(date.getHours())}:${padDateUnit(
|
||||||
|
date.getMinutes(),
|
||||||
|
)}:${padDateUnit(date.getSeconds())}`,
|
||||||
|
].join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTimeFieldValue = (value: unknown) => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return formatKtDateTime(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return formatKtDateTime(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && isDateTimeText(value)) {
|
||||||
|
const formatted = formatKtDateTime(value);
|
||||||
|
return formatted || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateTimeText = (value: string) =>
|
||||||
|
ISO_DATE_TIME_PATTERN.test(value) || DATE_TIME_TEXT_PATTERN.test(value);
|
||||||
|
|
||||||
|
const getDateTimeFormatRules = (target: object): DateTimeFormatRule[] => {
|
||||||
|
const prototype = Object.getPrototypeOf(target);
|
||||||
|
|
||||||
|
return prototype?.[DATE_TIME_FORMAT_RULES] || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureDateTimeFormatHook = (target: DateTimeFormatTarget) => {
|
||||||
|
if (target[DATE_TIME_FORMAT_HOOK_REGISTERED]) return;
|
||||||
|
|
||||||
|
Object.defineProperty(target, DATE_TIME_FORMAT_HOOK, {
|
||||||
|
configurable: true,
|
||||||
|
value: function (this: object) {
|
||||||
|
formatDateTimeFields(this);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
AfterLoad()(target, DATE_TIME_FORMAT_HOOK);
|
||||||
|
target[DATE_TIME_FORMAT_HOOK_REGISTERED] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormatDateTime = (): PropertyDecorator => {
|
||||||
|
return (target, propertyKey) => {
|
||||||
|
const fieldKey = propertyKey.toString();
|
||||||
|
const rules = target[DATE_TIME_FORMAT_RULES] || [];
|
||||||
|
|
||||||
|
target[DATE_TIME_FORMAT_RULES] = [
|
||||||
|
...rules.filter(({ targetKey }) => targetKey !== fieldKey),
|
||||||
|
{
|
||||||
|
sourceKey: fieldKey,
|
||||||
|
targetKey: fieldKey,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ensureDateTimeFormatHook(target as DateTimeFormatTarget);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDateTimeFields<T extends object>(target: T): T {
|
||||||
|
getDateTimeFormatRules(target).forEach(({ sourceKey, targetKey }) => {
|
||||||
|
target[targetKey] = formatDateTimeFieldValue(target[sourceKey]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@ -5,26 +5,64 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { normalizeVbenErrorText } from '../response/vben-response';
|
import { normalizeVbenErrorText } from '../response/vben-response';
|
||||||
import type { ExceptionBody, KtErrorResponse } from '../types';
|
import type { ExceptionBody, KtErrorResponse } from '../types';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class ApiExceptionFilter implements ExceptionFilter {
|
export class ApiExceptionFilter implements ExceptionFilter {
|
||||||
|
constructor(private readonly logger: PinoLogger) {
|
||||||
|
this.logger.setContext(ApiExceptionFilter.name);
|
||||||
|
}
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const status = this.getStatus(exception);
|
const status = this.getStatus(exception);
|
||||||
const body = this.getBody(exception);
|
const body = this.getBody(exception);
|
||||||
const msg = this.getMessage(status, body, exception);
|
const msg = this.getMessage(status, body, exception);
|
||||||
|
const err = this.getErr(status, body, exception, msg);
|
||||||
|
|
||||||
|
this.logException({
|
||||||
|
err,
|
||||||
|
exception,
|
||||||
|
msg,
|
||||||
|
request,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
code: status,
|
code: status,
|
||||||
msg,
|
msg,
|
||||||
err: this.getErr(status, body, exception, msg),
|
err,
|
||||||
} satisfies KtErrorResponse);
|
} satisfies KtErrorResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private logException(params: {
|
||||||
|
err: string;
|
||||||
|
exception: unknown;
|
||||||
|
msg: string;
|
||||||
|
request: Request;
|
||||||
|
status: number;
|
||||||
|
}) {
|
||||||
|
const payload = {
|
||||||
|
err: this.getLogError(params.exception, params.err),
|
||||||
|
method: params.request.method,
|
||||||
|
path: params.request.originalUrl || params.request.url,
|
||||||
|
requestId: `${(params.request as any).id || ''}`,
|
||||||
|
statusCode: params.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.status >= 500) {
|
||||||
|
this.logger.error(payload, params.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(payload, params.msg);
|
||||||
|
}
|
||||||
|
|
||||||
private getStatus(exception: unknown) {
|
private getStatus(exception: unknown) {
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
return exception.getStatus();
|
return exception.getStatus();
|
||||||
@ -78,4 +116,12 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
|||||||
private stringifyMessage(message: unknown) {
|
private stringifyMessage(message: unknown) {
|
||||||
return normalizeVbenErrorText(message);
|
return normalizeVbenErrorText(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLogError(exception: unknown, fallback: string) {
|
||||||
|
if (exception instanceof Error) return exception;
|
||||||
|
return {
|
||||||
|
message: fallback,
|
||||||
|
raw: normalizeVbenErrorText(exception, fallback),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
export * from './common.module';
|
export * from './common.module';
|
||||||
export * from './decorators/current-admin-user.decorator';
|
export * from './decorators/current-admin-user.decorator';
|
||||||
export * from './decorators/decode-dict.decorator';
|
export * from './decorators/decode-dict.decorator';
|
||||||
|
export * from './decorators/format-date-time.decorator';
|
||||||
export * from './decorators/public.decorator';
|
export * from './decorators/public.decorator';
|
||||||
export * from './filters/api-exception.filter';
|
export * from './filters/api-exception.filter';
|
||||||
export * from './interceptors/save-body.interceptor';
|
export * from './interceptors/save-body.interceptor';
|
||||||
|
export * from './logger/pino-logger.config';
|
||||||
export * from './response/vben-response';
|
export * from './response/vben-response';
|
||||||
export * from './services/tool.service';
|
export * from './services/tool.service';
|
||||||
export * from './snowflake/snowflake-id';
|
export * from './snowflake/snowflake-id';
|
||||||
|
|||||||
219
src/common/logger/pino-logger.config.ts
Normal file
219
src/common/logger/pino-logger.config.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { Params } from 'nestjs-pino';
|
||||||
|
import type { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
const DEFAULT_APP_NAME = 'kt-template-online-api';
|
||||||
|
const REDACT_PATHS = [
|
||||||
|
'req.headers.authorization',
|
||||||
|
'req.headers.cookie',
|
||||||
|
'req.headers["x-admin-token"]',
|
||||||
|
'req.headers["x-token"]',
|
||||||
|
'body.accessToken',
|
||||||
|
'body.adminToken',
|
||||||
|
'body.authorization',
|
||||||
|
'body.cookie',
|
||||||
|
'body.password',
|
||||||
|
'body.refreshToken',
|
||||||
|
'body.secret',
|
||||||
|
'body.token',
|
||||||
|
'*.clientSecret',
|
||||||
|
'*.password',
|
||||||
|
'*.secret',
|
||||||
|
'*.token',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createPinoLoggerParams(
|
||||||
|
configService: ConfigService,
|
||||||
|
): Params {
|
||||||
|
const nodeEnv = getString(configService, 'NODE_ENV', 'development');
|
||||||
|
const appName = getAppName(configService);
|
||||||
|
const lokiHost = normalizeUrl(
|
||||||
|
getString(configService, 'LOKI_HOST') || getString(configService, 'LOKI_URL'),
|
||||||
|
);
|
||||||
|
const logLevel = getString(
|
||||||
|
configService,
|
||||||
|
'LOG_LEVEL',
|
||||||
|
nodeEnv === 'production' ? 'info' : 'debug',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pinoHttp: {
|
||||||
|
autoLogging: {
|
||||||
|
ignore: (req: Request) =>
|
||||||
|
['/favicon.ico'].includes(req.url) || req.url.startsWith('/api-json'),
|
||||||
|
},
|
||||||
|
base: {
|
||||||
|
app: appName,
|
||||||
|
env: nodeEnv,
|
||||||
|
},
|
||||||
|
customAttributeKeys: {
|
||||||
|
responseTime: 'durationMs',
|
||||||
|
},
|
||||||
|
customProps: (req: Request) => ({
|
||||||
|
meta: {
|
||||||
|
requestId: getRequestId(req),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
genReqId: (req: Request, res: Response) => {
|
||||||
|
const requestId =
|
||||||
|
getHeader(req, 'x-request-id') ||
|
||||||
|
getHeader(req, 'x-correlation-id') ||
|
||||||
|
randomUUID();
|
||||||
|
res.setHeader('x-request-id', requestId);
|
||||||
|
return requestId;
|
||||||
|
},
|
||||||
|
level: logLevel,
|
||||||
|
redact: {
|
||||||
|
censor: '[Redacted]',
|
||||||
|
paths: REDACT_PATHS,
|
||||||
|
},
|
||||||
|
transport: createTransport(configService, {
|
||||||
|
appName,
|
||||||
|
logLevel,
|
||||||
|
lokiHost,
|
||||||
|
nodeEnv,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppName(configService: ConfigService) {
|
||||||
|
return getString(configService, 'LOG_APP_NAME', DEFAULT_APP_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLokiEnvironment(configService: ConfigService) {
|
||||||
|
return getString(configService, 'LOKI_ENV', getString(configService, 'NODE_ENV', 'development'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransport(
|
||||||
|
configService: ConfigService,
|
||||||
|
options: {
|
||||||
|
appName: string;
|
||||||
|
logLevel: string;
|
||||||
|
lokiHost: string;
|
||||||
|
nodeEnv: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (options.lokiHost) {
|
||||||
|
return {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
level: options.logLevel,
|
||||||
|
options: {
|
||||||
|
destination: 1,
|
||||||
|
},
|
||||||
|
target: 'pino/file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: options.logLevel,
|
||||||
|
options: {
|
||||||
|
batching: {
|
||||||
|
interval: getNumber(
|
||||||
|
configService,
|
||||||
|
'LOKI_BATCH_INTERVAL_SECONDS',
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
maxBufferSize: getNumber(
|
||||||
|
configService,
|
||||||
|
'LOKI_BATCH_MAX_BUFFER_SIZE',
|
||||||
|
10000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
basicAuth: getBasicAuth(configService),
|
||||||
|
endpoint: getString(
|
||||||
|
configService,
|
||||||
|
'LOKI_PUSH_ENDPOINT',
|
||||||
|
'/loki/api/v1/push',
|
||||||
|
),
|
||||||
|
headers: getLokiHeaders(configService),
|
||||||
|
host: options.lokiHost,
|
||||||
|
labels: {
|
||||||
|
app: options.appName,
|
||||||
|
env: getLokiEnvironment(configService),
|
||||||
|
service: 'api',
|
||||||
|
},
|
||||||
|
propsToLabels: ['context'],
|
||||||
|
silenceErrors: getBoolean(configService, 'LOKI_SILENCE_ERRORS', true),
|
||||||
|
timeout: getNumber(configService, 'LOKI_PUSH_TIMEOUT_MS', 30000),
|
||||||
|
},
|
||||||
|
target: 'pino-loki',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.nodeEnv !== 'production' &&
|
||||||
|
getBoolean(configService, 'LOG_PRETTY', true)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
singleLine: false,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
},
|
||||||
|
target: 'pino-pretty',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLokiHeaders(configService: ConfigService) {
|
||||||
|
const tenantId = getString(configService, 'LOKI_TENANT_ID');
|
||||||
|
return tenantId
|
||||||
|
? {
|
||||||
|
'X-Scope-OrgID': tenantId,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBasicAuth(configService: ConfigService) {
|
||||||
|
const username = getString(configService, 'LOKI_USERNAME');
|
||||||
|
const password = getString(configService, 'LOKI_PASSWORD');
|
||||||
|
return username && password ? { password, username } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeader(req: Request, name: string) {
|
||||||
|
const value = req.headers[name.toLowerCase()];
|
||||||
|
return Array.isArray(value) ? value[0] : `${value || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestId(req: Request) {
|
||||||
|
return `${(req as any).id || getHeader(req, 'x-request-id') || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getString(
|
||||||
|
configService: ConfigService,
|
||||||
|
key: string,
|
||||||
|
fallback = '',
|
||||||
|
) {
|
||||||
|
const value = configService.get<string>(key);
|
||||||
|
const normalized = `${value ?? ''}`.trim();
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumber(
|
||||||
|
configService: ConfigService,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
) {
|
||||||
|
const value = Number(configService.get<string>(key));
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoolean(
|
||||||
|
configService: ConfigService,
|
||||||
|
key: string,
|
||||||
|
fallback: boolean,
|
||||||
|
) {
|
||||||
|
const value = configService.get<string>(key);
|
||||||
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
return ['1', 'true', 'yes'].includes(`${value}`.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value: string) {
|
||||||
|
return value.replace(/\/+$/g, '');
|
||||||
|
}
|
||||||
@ -758,8 +758,8 @@ function adminDictExample() {
|
|||||||
childrenCode: 'CHART',
|
childrenCode: 'CHART',
|
||||||
sort: 1,
|
sort: 1,
|
||||||
status: 1,
|
status: 1,
|
||||||
createTime: '2026-06-03T12:00:00.000Z',
|
createTime: '2026-06-03 20:00:00',
|
||||||
updateTime: '2026-06-03T12:00:00.000Z',
|
updateTime: '2026-06-03 20:00:00',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -819,13 +819,13 @@ function qqbotAccountExample() {
|
|||||||
connectionMode: 'reverse-ws',
|
connectionMode: 'reverse-ws',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
id: '1000000000000000001',
|
id: '1000000000000000001',
|
||||||
lastHeartbeatAt: '2026-06-02T12:00:00.000Z',
|
lastHeartbeatAt: '2026-06-02 20:00:00',
|
||||||
name: '主账号',
|
name: '主账号',
|
||||||
napcat: {
|
napcat: {
|
||||||
bindStatus: 'bound',
|
bindStatus: 'bound',
|
||||||
containerName: 'kt-qqbot-napcat-1914728559',
|
containerName: 'kt-qqbot-napcat-1914728559',
|
||||||
containerStatus: 'running',
|
containerStatus: 'running',
|
||||||
lastLoginAt: '2026-06-02T11:55:00.000Z',
|
lastLoginAt: '2026-06-02 19:55:00',
|
||||||
webuiPort: 6100,
|
webuiPort: 6100,
|
||||||
},
|
},
|
||||||
selfId: '1914728559',
|
selfId: '1914728559',
|
||||||
@ -911,7 +911,7 @@ function qqbotScanExample() {
|
|||||||
sessionId: 'KT_SCAN_20260602120000',
|
sessionId: 'KT_SCAN_20260602120000',
|
||||||
qrcode: 'data:image/png;base64,MOCK_QRCODE',
|
qrcode: 'data:image/png;base64,MOCK_QRCODE',
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
expireAt: '2026-06-02T12:05:00.000Z',
|
expireAt: '2026-06-02 20:05:00',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -964,7 +964,7 @@ function minioObjectExample() {
|
|||||||
name: 'uploads/demo.png',
|
name: 'uploads/demo.png',
|
||||||
size: 2048,
|
size: 2048,
|
||||||
etag: '9b2cf535f27731c974343645a3985328',
|
etag: '9b2cf535f27731c974343645a3985328',
|
||||||
lastModified: '2026-06-02T12:00:00.000Z',
|
lastModified: '2026-06-02 20:00:00',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { Logger } from 'nestjs-pino';
|
||||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||||
import { urlencoded, json } from 'express';
|
import { urlencoded, json } from 'express';
|
||||||
import { knife4jSetup } from '@kwitsukasa/knife4j-swagger-vue3';
|
import { knife4jSetup } from '@kwitsukasa/knife4j-swagger-vue3';
|
||||||
@ -47,7 +48,8 @@ const swaggerGroups: SwaggerDocumentGroup[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
|
app.useLogger(app.get(Logger));
|
||||||
app.use(json({ limit: '50mb' }));
|
app.use(json({ limit: '50mb' }));
|
||||||
app.use(urlencoded({ extended: true, limit: '50mb' }));
|
app.use(urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
ApiArrayResponse,
|
ApiArrayResponse,
|
||||||
ApiModelResponse,
|
ApiModelResponse,
|
||||||
ApiSuccessResponse,
|
ApiSuccessResponse,
|
||||||
|
formatDateTimeFields,
|
||||||
ToolsService,
|
ToolsService,
|
||||||
} from '@/common';
|
} from '@/common';
|
||||||
import {
|
import {
|
||||||
@ -142,7 +143,7 @@ export class MinioClientController {
|
|||||||
name: 'uploads/demo.png',
|
name: 'uploads/demo.png',
|
||||||
size: 2048,
|
size: 2048,
|
||||||
etag: '9b2cf535f27731c974343645a3985328',
|
etag: '9b2cf535f27731c974343645a3985328',
|
||||||
lastModified: '2026-05-13T02:30:00.000Z',
|
lastModified: '2026-05-13 10:30:00',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
async list(
|
async list(
|
||||||
@ -151,11 +152,15 @@ export class MinioClientController {
|
|||||||
@Query('prefix') prefix?: string,
|
@Query('prefix') prefix?: string,
|
||||||
@Query('recursive') recursive?: string,
|
@Query('recursive') recursive?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.minioClientService.listObjects({
|
const result = (
|
||||||
bucketName,
|
(await this.minioClientService.listObjects({
|
||||||
prefix,
|
bucketName,
|
||||||
recursive: recursive !== 'false',
|
prefix,
|
||||||
});
|
recursive: recursive !== 'false',
|
||||||
|
})) as Record<string, unknown>[]
|
||||||
|
).map((item) =>
|
||||||
|
formatDateTimeFields(Object.assign(new MinioObjectDto(), item)),
|
||||||
|
);
|
||||||
|
|
||||||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { FormatDateTime } from '@/common';
|
||||||
|
|
||||||
export class MinioBucketStatusDto {
|
export class MinioBucketStatusDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@ -61,7 +62,8 @@ export class MinioObjectDto {
|
|||||||
etag: string;
|
etag: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '2026-05-13T02:30:00.000Z',
|
example: '2026-05-13 10:30:00',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastModified: string;
|
lastModified: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotAccountAbilityType } from '../qqbot.types';
|
import type { QqbotAccountAbilityType } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_account_ability')
|
@Entity('qqbot_account_ability')
|
||||||
@ -35,9 +35,11 @@ export class QqbotAccountAbility {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type {
|
import type {
|
||||||
QqbotConnectionMode,
|
QqbotConnectionMode,
|
||||||
QqbotConnectionRole,
|
QqbotConnectionRole,
|
||||||
@ -51,6 +51,7 @@ export class QqbotAccount {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastConnectedAt: Date | null;
|
lastConnectedAt: Date | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
@ -59,6 +60,7 @@ export class QqbotAccount {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastHeartbeatAt: Date | null;
|
lastHeartbeatAt: Date | null;
|
||||||
|
|
||||||
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
|
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
|
||||||
@ -71,9 +73,11 @@ export class QqbotAccount {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotMessageType } from '../qqbot.types';
|
import type { QqbotMessageType } from '../qqbot.types';
|
||||||
import type { QqbotCommandLogStatus } from '../qqbot.types';
|
import type { QqbotCommandLogStatus } from '../qqbot.types';
|
||||||
|
|
||||||
@ -60,9 +60,11 @@ export class QqbotCommandLog {
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotCommandParserType, QqbotRuleTargetType } from '../qqbot.types';
|
import type { QqbotCommandParserType, QqbotRuleTargetType } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_command')
|
@Entity('qqbot_command')
|
||||||
@ -62,6 +62,7 @@ export class QqbotCommand {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastHitAt: Date | null;
|
lastHitAt: Date | null;
|
||||||
|
|
||||||
@Column({ default: '', length: 255 })
|
@Column({ default: '', length: 255 })
|
||||||
@ -71,9 +72,11 @@ export class QqbotCommand {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
|
|
||||||
@Entity('qqbot_config')
|
@Entity('qqbot_config')
|
||||||
@Index('uk_qqbot_config_key', ['configKey'], { unique: true })
|
@Index('uk_qqbot_config_key', ['configKey'], { unique: true })
|
||||||
@ -25,9 +25,11 @@ export class QqbotConfig {
|
|||||||
remark: string;
|
remark: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpAdapterHost, ModuleRef } from '@nestjs/core';
|
import { HttpAdapterHost, ModuleRef } from '@nestjs/core';
|
||||||
import WebSocket = require('ws');
|
import WebSocket from 'ws';
|
||||||
import { ToolsService } from '@/common';
|
import { ToolsService } from '@/common';
|
||||||
import { QQBOT_MQTT_TOPICS, QQBOT_REVERSE_WS_PATH } from '../qqbot.constants';
|
import { QQBOT_MQTT_TOPICS, QQBOT_REVERSE_WS_PATH } from '../qqbot.constants';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
|
|
||||||
@Entity('qqbot_dedupe')
|
@Entity('qqbot_dedupe')
|
||||||
export class QqbotDedupe {
|
export class QqbotDedupe {
|
||||||
@ -22,12 +22,15 @@ export class QqbotDedupe {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
expireAt: Date | null;
|
expireAt: Date | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotMessageType } from '../qqbot.types';
|
import type { QqbotMessageType } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_conversation')
|
@Entity('qqbot_conversation')
|
||||||
@ -45,6 +45,7 @@ export class QqbotConversation {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastMessageTime: Date | null;
|
lastMessageTime: Date | null;
|
||||||
|
|
||||||
@Column({ default: 0, name: 'message_count' })
|
@Column({ default: 0, name: 'message_count' })
|
||||||
@ -54,9 +55,11 @@ export class QqbotConversation {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotMessageDirection, QqbotMessageType } from '../qqbot.types';
|
import type { QqbotMessageDirection, QqbotMessageType } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_message')
|
@Entity('qqbot_message')
|
||||||
@ -58,12 +58,15 @@ export class QqbotMessage {
|
|||||||
rawEvent: Record<string, any>;
|
rawEvent: Record<string, any>;
|
||||||
|
|
||||||
@Column({ name: 'event_time', type: 'datetime' })
|
@Column({ name: 'event_time', type: 'datetime' })
|
||||||
|
@FormatDateTime()
|
||||||
eventTime: Date;
|
eventTime: Date;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotAccountNapcatBindStatus } from '../qqbot.types';
|
import type { QqbotAccountNapcatBindStatus } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_account_napcat')
|
@Entity('qqbot_account_napcat')
|
||||||
@ -35,6 +35,7 @@ export class QqbotAccountNapcat {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastLoginAt: Date | null;
|
lastLoginAt: Date | null;
|
||||||
|
|
||||||
@Column({ default: '', length: 255 })
|
@Column({ default: '', length: 255 })
|
||||||
@ -44,9 +45,11 @@ export class QqbotAccountNapcat {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotNapcatContainerStatus } from '../qqbot.types';
|
import type { QqbotNapcatContainerStatus } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_napcat_container')
|
@Entity('qqbot_napcat_container')
|
||||||
@ -53,6 +53,7 @@ export class QqbotNapcatContainer {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastStartedAt: Date | null;
|
lastStartedAt: Date | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
@ -61,6 +62,7 @@ export class QqbotNapcatContainer {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastCheckedAt: Date | null;
|
lastCheckedAt: Date | null;
|
||||||
|
|
||||||
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
|
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
|
||||||
@ -73,9 +75,11 @@ export class QqbotNapcatContainer {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotPermissionTargetType } from '../qqbot.types';
|
import type { QqbotPermissionTargetType } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_allowlist')
|
@Entity('qqbot_allowlist')
|
||||||
@ -39,9 +39,11 @@ export class QqbotAllowlist {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotPermissionTargetType } from '../qqbot.types';
|
import type { QqbotPermissionTargetType } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_blocklist')
|
@Entity('qqbot_blocklist')
|
||||||
@ -39,9 +39,11 @@ export class QqbotBlocklist {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { throwVbenError } from '@/common';
|
import { formatKtDateTime, throwVbenError } from '@/common';
|
||||||
import { QqbotAccountService } from '../account/qqbot-account.service';
|
import { QqbotAccountService } from '../account/qqbot-account.service';
|
||||||
import { QqbotRepeaterPluginService } from '../plugins/repeater/qqbot-repeater.plugin';
|
import { QqbotRepeaterPluginService } from '../plugins/repeater/qqbot-repeater.plugin';
|
||||||
import type {
|
import type {
|
||||||
@ -52,7 +52,7 @@ export class QqbotEventPluginRegistryService {
|
|||||||
|
|
||||||
async health(pluginKey?: string): Promise<QqbotPluginHealth[]> {
|
async health(pluginKey?: string): Promise<QqbotPluginHealth[]> {
|
||||||
return this.getDefinitions(pluginKey).map((definition) => ({
|
return this.getDefinitions(pluginKey).map((definition) => ({
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: formatKtDateTime(new Date()),
|
||||||
message: definition.remark || '事件插件由账号配置绑定后触发',
|
message: definition.remark || '事件插件由账号配置绑定后触发',
|
||||||
name: definition.name,
|
name: definition.name,
|
||||||
pluginKey: definition.key,
|
pluginKey: definition.key,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { throwVbenError } from '@/common';
|
import { formatKtDateTime, throwVbenError } from '@/common';
|
||||||
import { QqbotFf14MarketPluginService } from '../plugins/ff14Market/qqbot-ff14-market.plugin';
|
import { QqbotFf14MarketPluginService } from '../plugins/ff14Market/qqbot-ff14-market.plugin';
|
||||||
import { QqbotFflogsPluginService } from '../plugins/fflogs/qqbot-fflogs.plugin';
|
import { QqbotFflogsPluginService } from '../plugins/fflogs/qqbot-fflogs.plugin';
|
||||||
import type {
|
import type {
|
||||||
@ -63,7 +63,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
|||||||
plugins.map(async (plugin) => {
|
plugins.map(async (plugin) => {
|
||||||
if (!plugin.healthCheck) {
|
if (!plugin.healthCheck) {
|
||||||
return {
|
return {
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: formatKtDateTime(new Date()),
|
||||||
message: '插件未提供健康检查',
|
message: '插件未提供健康检查',
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
pluginKey: plugin.key,
|
pluginKey: plugin.key,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
|
import { formatKtDateTime } from '@/common';
|
||||||
import { DictService } from '../../../admin/dict/dict.service';
|
import { DictService } from '../../../admin/dict/dict.service';
|
||||||
import {
|
import {
|
||||||
buildQqbotFf14MarketCatalog,
|
buildQqbotFf14MarketCatalog,
|
||||||
@ -110,7 +111,7 @@ export class QqbotFf14ClientService {
|
|||||||
listings,
|
listings,
|
||||||
);
|
);
|
||||||
const updatedAt = data.lastUploadTime
|
const updatedAt = data.lastUploadTime
|
||||||
? new Date(data.lastUploadTime).toISOString()
|
? formatKtDateTime(data.lastUploadTime)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ToolsService } from '@/common';
|
import { formatKtDateTime, ToolsService } from '@/common';
|
||||||
import type { QqbotIntegrationPlugin } from '../../qqbot.types';
|
import type { QqbotIntegrationPlugin } from '../../qqbot.types';
|
||||||
import { QqbotFf14ClientService } from './qqbot-ff14-client.service';
|
import { QqbotFf14ClientService } from './qqbot-ff14-client.service';
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ export class QqbotFf14MarketPluginService {
|
|||||||
description:
|
description:
|
||||||
'对接 XIVAPI v2 与 Universalis,提供 FF14 物品解析和市场价格查询能力。',
|
'对接 XIVAPI v2 与 Universalis,提供 FF14 物品解析和市场价格查询能力。',
|
||||||
healthCheck: async () => {
|
healthCheck: async () => {
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = formatKtDateTime(new Date());
|
||||||
try {
|
try {
|
||||||
await this.ff14ClientService.resolveItem({
|
await this.ff14ClientService.resolveItem({
|
||||||
itemId: 2,
|
itemId: 2,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ToolsService } from '@/common';
|
import { formatKtDateTime, ToolsService } from '@/common';
|
||||||
import type { QqbotIntegrationPlugin } from '../../qqbot.types';
|
import type { QqbotIntegrationPlugin } from '../../qqbot.types';
|
||||||
import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
|
import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ export class QqbotFflogsPluginService {
|
|||||||
description:
|
description:
|
||||||
'对接 FFLogs v2 GraphQL,提供 FF14 角色公开排名和指定高难最近记录查询能力。',
|
'对接 FFLogs v2 GraphQL,提供 FF14 角色公开排名和指定高难最近记录查询能力。',
|
||||||
healthCheck: async () => {
|
healthCheck: async () => {
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = formatKtDateTime(new Date());
|
||||||
try {
|
try {
|
||||||
await this.fflogsClientService.checkHealth();
|
await this.fflogsClientService.checkHealth();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotRuleMatchType, QqbotRuleTargetType } from '../qqbot.types';
|
import type { QqbotRuleMatchType, QqbotRuleTargetType } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_rule')
|
@Entity('qqbot_rule')
|
||||||
@ -44,6 +44,7 @@ export class QqbotRule {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
})
|
})
|
||||||
|
@FormatDateTime()
|
||||||
lastHitAt: Date | null;
|
lastHitAt: Date | null;
|
||||||
|
|
||||||
@Column({ default: '', length: 255 })
|
@Column({ default: '', length: 255 })
|
||||||
@ -53,9 +54,11 @@ export class QqbotRule {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||||
import type { QqbotMessageType, QqbotSendStatus } from '../qqbot.types';
|
import type { QqbotMessageType, QqbotSendStatus } from '../qqbot.types';
|
||||||
|
|
||||||
@Entity('qqbot_send_log')
|
@Entity('qqbot_send_log')
|
||||||
@ -50,9 +50,11 @@ export class QqbotSendLog {
|
|||||||
response: Record<string, any>;
|
response: Record<string, any>;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'create_time' })
|
@CreateDateColumn({ name: 'create_time' })
|
||||||
|
@FormatDateTime()
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'update_time' })
|
@UpdateDateColumn({ name: 'update_time' })
|
||||||
|
@FormatDateTime()
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
|
|||||||
73
test/admin/system-log/system-log.controller.spec.ts
Normal file
73
test/admin/system-log/system-log.controller.spec.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { JwtAuthGuard } from '../../../src/admin/auth/jwt-auth.guard';
|
||||||
|
import { SystemLogController } from '../../../src/admin/system-log/system-log.controller';
|
||||||
|
import { SystemLogService } from '../../../src/admin/system-log/system-log.service';
|
||||||
|
import { ToolsService } from '../../../src/common';
|
||||||
|
|
||||||
|
describe('SystemLogController', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [SystemLogController],
|
||||||
|
providers: [
|
||||||
|
SystemLogService,
|
||||||
|
ToolsService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(JwtAuthGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: jest.fn(() => true),
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Loki status with the real system log service fallback', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/system/logs/status')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
app: 'kt-template-online-api',
|
||||||
|
configured: false,
|
||||||
|
env: 'development',
|
||||||
|
selector: '{app="kt-template-online-api",env="development"}',
|
||||||
|
},
|
||||||
|
msg: '操作成功',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty page when Loki is not configured', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/system/logs')
|
||||||
|
.query({ pageNo: 1, pageSize: 20 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
msg: '操作成功',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { HttpException, HttpStatus, INestApplication } from '@nestjs/common';
|
import { HttpException, HttpStatus, INestApplication } from '@nestjs/common';
|
||||||
import { APP_FILTER, APP_INTERCEPTOR, Reflector } from '@nestjs/core';
|
import { APP_FILTER, APP_INTERCEPTOR, Reflector } from '@nestjs/core';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import request = require('supertest');
|
import * as request from 'supertest';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { AppController } from '../src/app.controller';
|
import { AppController } from '../src/app.controller';
|
||||||
import { AppService } from '../src/app.service';
|
import { AppService } from '../src/app.service';
|
||||||
@ -11,6 +11,8 @@ import { ComponentController } from '../src/admin/component/component.controller
|
|||||||
import { ComponentService } from '../src/admin/component/component.service';
|
import { ComponentService } from '../src/admin/component/component.service';
|
||||||
import { DictController } from '../src/admin/dict/dict.controller';
|
import { DictController } from '../src/admin/dict/dict.controller';
|
||||||
import { DictService } from '../src/admin/dict/dict.service';
|
import { DictService } from '../src/admin/dict/dict.service';
|
||||||
|
import { SystemLogController } from '../src/admin/system-log/system-log.controller';
|
||||||
|
import { SystemLogService } from '../src/admin/system-log/system-log.service';
|
||||||
import {
|
import {
|
||||||
ApiExceptionFilter,
|
ApiExceptionFilter,
|
||||||
SaveBodyInterceptor,
|
SaveBodyInterceptor,
|
||||||
@ -23,6 +25,7 @@ import { WordpressAuthController } from '../src/wordpress/wordpress-auth.control
|
|||||||
import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller';
|
import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller';
|
||||||
import { WordpressService } from '../src/wordpress/wordpress.service';
|
import { WordpressService } from '../src/wordpress/wordpress.service';
|
||||||
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
|
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import {
|
import {
|
||||||
collectControllerRoutes,
|
collectControllerRoutes,
|
||||||
routeKey,
|
routeKey,
|
||||||
@ -38,8 +41,8 @@ const component = {
|
|||||||
componentTypeMsg: '折线图',
|
componentTypeMsg: '折线图',
|
||||||
image: '',
|
image: '',
|
||||||
template: '{}',
|
template: '{}',
|
||||||
createTime: '2026-05-13T02:30:00.000Z',
|
createTime: '2026-05-13 10:30:00',
|
||||||
updateTime: '2026-05-13T02:30:00.000Z',
|
updateTime: '2026-05-13 10:30:00',
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -92,6 +95,22 @@ const dictGroupItem = {
|
|||||||
value: 'COMPONENT_TYPE',
|
value: 'COMPONENT_TYPE',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const systemLogItem = {
|
||||||
|
context: 'ApiExceptionFilter',
|
||||||
|
durationMs: 12,
|
||||||
|
hostname: 'kt-template-online-api',
|
||||||
|
id: '1760000000000000000-0-0',
|
||||||
|
level: 'error',
|
||||||
|
message: 'Loki smoke log',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/system/logs',
|
||||||
|
raw: '{"level":50,"msg":"Loki smoke log"}',
|
||||||
|
requestId: 'request-id',
|
||||||
|
statusCode: 500,
|
||||||
|
timestamp: '2026-06-04 08:00:00',
|
||||||
|
timestampNs: '1760000000000000000',
|
||||||
|
};
|
||||||
|
|
||||||
const uploadResult = {
|
const uploadResult = {
|
||||||
bucketName: 'kt-template-online',
|
bucketName: 'kt-template-online',
|
||||||
objectName: 'uploads/demo.txt',
|
objectName: 'uploads/demo.txt',
|
||||||
@ -105,7 +124,7 @@ const objectStat = {
|
|||||||
name: 'uploads/demo.txt',
|
name: 'uploads/demo.txt',
|
||||||
size: 4,
|
size: 4,
|
||||||
etag: 'etag',
|
etag: 'etag',
|
||||||
lastModified: '2026-05-13T02:30:00.000Z',
|
lastModified: '2026-05-13 10:30:00',
|
||||||
};
|
};
|
||||||
|
|
||||||
const wordpressAuthContext = {
|
const wordpressAuthContext = {
|
||||||
@ -177,6 +196,19 @@ const dictServiceMock = {
|
|||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const systemLogServiceMock = {
|
||||||
|
levels: jest.fn(),
|
||||||
|
page: jest.fn(),
|
||||||
|
status: jest.fn(),
|
||||||
|
summary: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinoLoggerMock = {
|
||||||
|
error: jest.fn(),
|
||||||
|
setContext: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const minioServiceMock = {
|
const minioServiceMock = {
|
||||||
checkConnection: jest.fn(),
|
checkConnection: jest.fn(),
|
||||||
ensureBucket: jest.fn(),
|
ensureBucket: jest.fn(),
|
||||||
@ -214,6 +246,7 @@ const controllerClasses = [
|
|||||||
AppController,
|
AppController,
|
||||||
ComponentController,
|
ComponentController,
|
||||||
DictController,
|
DictController,
|
||||||
|
SystemLogController,
|
||||||
MinioClientController,
|
MinioClientController,
|
||||||
WordpressAuthController,
|
WordpressAuthController,
|
||||||
WordpressArticleController,
|
WordpressArticleController,
|
||||||
@ -565,6 +598,92 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'GET /system/logs': async (server) => {
|
||||||
|
systemLogServiceMock.page.mockResolvedValue({
|
||||||
|
items: [systemLogItem],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(server)
|
||||||
|
.get('/system/logs')
|
||||||
|
.query({ level: 'error', pageNo: 1, pageSize: 20 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(systemLogServiceMock.page).toHaveBeenCalledWith({
|
||||||
|
level: 'error',
|
||||||
|
pageNo: '1',
|
||||||
|
pageSize: '20',
|
||||||
|
});
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
code: 200,
|
||||||
|
msg: '操作成功',
|
||||||
|
data: {
|
||||||
|
items: [systemLogItem],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /system/logs/levels': async (server) => {
|
||||||
|
systemLogServiceMock.levels.mockReturnValue([
|
||||||
|
{ label: 'error', value: 'error' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(server).get('/system/logs/levels').expect(200);
|
||||||
|
|
||||||
|
expect(systemLogServiceMock.levels).toHaveBeenCalledWith();
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
code: 200,
|
||||||
|
msg: '操作成功',
|
||||||
|
data: [{ label: 'error', value: 'error' }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /system/logs/status': async (server) => {
|
||||||
|
systemLogServiceMock.status.mockReturnValue({
|
||||||
|
app: 'kt-template-online-api',
|
||||||
|
configured: true,
|
||||||
|
env: 'test',
|
||||||
|
host: 'http://loki:3100',
|
||||||
|
selector: '{app="kt-template-online-api",env="test"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(server).get('/system/logs/status').expect(200);
|
||||||
|
|
||||||
|
expect(systemLogServiceMock.status).toHaveBeenCalledWith();
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
code: 200,
|
||||||
|
msg: '操作成功',
|
||||||
|
data: {
|
||||||
|
app: 'kt-template-online-api',
|
||||||
|
configured: true,
|
||||||
|
env: 'test',
|
||||||
|
host: 'http://loki:3100',
|
||||||
|
selector: '{app="kt-template-online-api",env="test"}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /system/logs/summary': async (server) => {
|
||||||
|
systemLogServiceMock.summary.mockResolvedValue([
|
||||||
|
{ count: 1, level: 'error' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(server)
|
||||||
|
.get('/system/logs/summary')
|
||||||
|
.query({ rangeMinutes: 30 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(systemLogServiceMock.summary).toHaveBeenCalledWith({
|
||||||
|
rangeMinutes: '30',
|
||||||
|
});
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
code: 200,
|
||||||
|
msg: '操作成功',
|
||||||
|
data: [{ count: 1, level: 'error' }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
'GET /minio/check': async (server) => {
|
'GET /minio/check': async (server) => {
|
||||||
minioServiceMock.checkConnection.mockResolvedValue({
|
minioServiceMock.checkConnection.mockResolvedValue({
|
||||||
bucketName: 'demo-bucket',
|
bucketName: 'demo-bucket',
|
||||||
@ -1209,6 +1328,10 @@ describe('KT Template Online API (e2e)', () => {
|
|||||||
provide: DictService,
|
provide: DictService,
|
||||||
useValue: dictServiceMock,
|
useValue: dictServiceMock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: SystemLogService,
|
||||||
|
useValue: systemLogServiceMock,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: MinioClientService,
|
provide: MinioClientService,
|
||||||
useValue: minioServiceMock,
|
useValue: minioServiceMock,
|
||||||
@ -1217,6 +1340,10 @@ describe('KT Template Online API (e2e)', () => {
|
|||||||
provide: WordpressService,
|
provide: WordpressService,
|
||||||
useValue: wordpressServiceMock,
|
useValue: wordpressServiceMock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PinoLogger,
|
||||||
|
useValue: pinoLoggerMock,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: SaveBodyInterceptor,
|
useClass: SaveBodyInterceptor,
|
||||||
|
|||||||
50
test/common/format-date-time.decorator.spec.ts
Normal file
50
test/common/format-date-time.decorator.spec.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
FormatDateTime,
|
||||||
|
formatDateTimeFields,
|
||||||
|
formatKtDateTime,
|
||||||
|
vbenSuccess,
|
||||||
|
} from '../../src/common';
|
||||||
|
|
||||||
|
class DateTimeFormatFixture {
|
||||||
|
@FormatDateTime()
|
||||||
|
createTime!: Date;
|
||||||
|
|
||||||
|
@FormatDateTime()
|
||||||
|
updateTime!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FormatDateTime', () => {
|
||||||
|
it('formats decorated fields as YYYY-MM-DD HH:mm:ss', () => {
|
||||||
|
const data = new DateTimeFormatFixture();
|
||||||
|
data.createTime = new Date(2026, 4, 13, 10, 30, 0);
|
||||||
|
data.updateTime = '2026-05-13T10:31:00';
|
||||||
|
|
||||||
|
expect(formatDateTimeFields(data)).toEqual({
|
||||||
|
createTime: '2026-05-13 10:30:00',
|
||||||
|
updateTime: '2026-05-13 10:31:00',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not recursively format response payloads', () => {
|
||||||
|
const createTime = new Date(2026, 4, 13, 10, 30, 0);
|
||||||
|
const response = vbenSuccess({
|
||||||
|
createTime,
|
||||||
|
nested: {
|
||||||
|
updateTime: '2026-05-13T10:31:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.data).toEqual({
|
||||||
|
createTime,
|
||||||
|
nested: {
|
||||||
|
updateTime: '2026-05-13T10:31:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the configured formatter', () => {
|
||||||
|
expect(formatKtDateTime(new Date(2026, 4, 13, 10, 30, 0))).toBe(
|
||||||
|
'2026-05-13 10:30:00',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,11 +1,16 @@
|
|||||||
jest.mock('@/common', () => ({
|
jest.mock('@/common', () => {
|
||||||
ToolsService: jest.requireActual('@/common').ToolsService,
|
const actualCommon = jest.requireActual('@/common');
|
||||||
ensureSnowflakeId: jest.fn(),
|
return {
|
||||||
setDictDecodeCache: jest.fn(),
|
FormatDateTime: actualCommon.FormatDateTime,
|
||||||
throwVbenError: (message: string) => {
|
ToolsService: actualCommon.ToolsService,
|
||||||
throw new Error(message);
|
ensureSnowflakeId: jest.fn(),
|
||||||
},
|
formatKtDateTime: actualCommon.formatKtDateTime,
|
||||||
}));
|
setDictDecodeCache: jest.fn(),
|
||||||
|
throwVbenError: (message: string) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ToolsService } from '@/common';
|
import { ToolsService } from '@/common';
|
||||||
|
|||||||
@ -2,6 +2,20 @@ jest.mock(
|
|||||||
'@/common',
|
'@/common',
|
||||||
() => ({
|
() => ({
|
||||||
ensureSnowflakeId: jest.fn(),
|
ensureSnowflakeId: jest.fn(),
|
||||||
|
FormatDateTime: () => () => undefined,
|
||||||
|
formatKtDateTime: (value: Date | number | string) => {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
return [
|
||||||
|
`${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}-${`${date.getDate()}`.padStart(2, '0')}`,
|
||||||
|
`${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}:${`${date.getSeconds()}`.padStart(2, '0')}`,
|
||||||
|
].join(' ');
|
||||||
|
},
|
||||||
setDictDecodeCache: jest.fn(),
|
setDictDecodeCache: jest.fn(),
|
||||||
}),
|
}),
|
||||||
{ virtual: true },
|
{ virtual: true },
|
||||||
|
|||||||
@ -2,6 +2,20 @@ jest.mock(
|
|||||||
'@/common',
|
'@/common',
|
||||||
() => ({
|
() => ({
|
||||||
ensureSnowflakeId: jest.fn(),
|
ensureSnowflakeId: jest.fn(),
|
||||||
|
FormatDateTime: () => () => undefined,
|
||||||
|
formatKtDateTime: (value: Date | number | string) => {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
return [
|
||||||
|
`${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}-${`${date.getDate()}`.padStart(2, '0')}`,
|
||||||
|
`${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}:${`${date.getSeconds()}`.padStart(2, '0')}`,
|
||||||
|
].join(' ');
|
||||||
|
},
|
||||||
setDictDecodeCache: jest.fn(),
|
setDictDecodeCache: jest.fn(),
|
||||||
}),
|
}),
|
||||||
{ virtual: true },
|
{ virtual: true },
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import request = require('supertest');
|
import type request from 'supertest';
|
||||||
|
|
||||||
export type ControllerRoute = {
|
export type ControllerRoute = {
|
||||||
controllerName: string;
|
controllerName: string;
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user