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_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_EVENT_BUS=mqtt
|
||||
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
|
||||
|
||||
29
API.md
29
API.md
@ -32,6 +32,7 @@
|
||||
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||
| WordPress | WordPress 文章、标签、分类管理,复用客户端 WordPress 登录态访问 REST API |
|
||||
| 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:*` 按钮权限码,避免前端展示不可用的文章、分类、标签管理入口。
|
||||
|
||||
### 系统日志
|
||||
|
||||
后端使用 `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/*` 接口路径保持兼容,但仍需要登录态。
|
||||
@ -349,6 +373,10 @@ Query:
|
||||
| POST | `/system/dept` | 新增部门 |
|
||||
| PUT | `/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/getTimezone` | 获取当前用户时区 |
|
||||
| POST | `/timezone/setTimezone` | 设置当前用户时区 |
|
||||
@ -360,6 +388,7 @@ Query:
|
||||
初始化 SQL:
|
||||
|
||||
- `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-component-to-admin-component.sql`:将旧 `component` 表数据迁移到 `admin_component`,并把旧表改名为备份表。
|
||||
- `sql/fix-admin-menu-meta.sql`:修复基础后台菜单 `meta` 被旧数据或错误保存覆盖为空的问题。
|
||||
|
||||
12
dockerfile
12
dockerfile
@ -4,6 +4,18 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
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_WEB_BASE_URL=https://cn.fflogs.com
|
||||
ENV FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client
|
||||
|
||||
47
package.json
47
package.json
@ -27,50 +27,55 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kwitsukasa/knife4j-swagger-vue3": "0.1.2",
|
||||
"@nestjs/common": "^9.4.3",
|
||||
"@nestjs/config": "^2.3.4",
|
||||
"@nestjs/core": "^9.4.3",
|
||||
"@nestjs/platform-express": "^9.4.3",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/typeorm": "^9.0.1",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@nestjs/swagger": "^11.4.4",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"express": "4.18.2",
|
||||
"express": "5.2.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mqtt": "^5.15.1",
|
||||
"mysql2": "^3.22.3",
|
||||
"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",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.5.0",
|
||||
"@nestjs/schematics": "^9.2.0",
|
||||
"@nestjs/testing": "^9.4.3",
|
||||
"@types/express": "^4.17.25",
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "29.2.4",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/node": "22.19.19",
|
||||
"@types/supertest": "^2.0.16",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^8.10.2",
|
||||
"eslint-plugin-prettier": "^4.2.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-typeorm": "0.0.19",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "29.3.1",
|
||||
"prettier": "^2.8.8",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "29.0.3",
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-jest": "29.4.11",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "^4.9.5"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"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 'SystemDeptEdit' THEN '{"title":"common.edit"}'
|
||||
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 '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"}'
|
||||
@ -44,6 +45,7 @@ WHERE `name` IN (
|
||||
'SystemDeptCreate',
|
||||
'SystemDeptEdit',
|
||||
'SystemDeptDelete',
|
||||
'SystemLog',
|
||||
'Project',
|
||||
'VbenDocument',
|
||||
'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),
|
||||
(2041700000000120205, 2041700000000100203, 'SystemKtTableDemoEdit', NULL, NULL, NULL, 'System:KtTableDemo:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
(2041700000000120206, 2041700000000100203, 'SystemKtTableDemoDelete', NULL, NULL, NULL, 'System:KtTableDemo:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||
(2041700000000100205, 2041700000000100002, 'SystemLog', '/system/logs', '/system/log/list', NULL, 'System:Log:List', 'menu', '{"icon":"lucide:scroll-text","title":"system.log.title"}', 1, 6),
|
||||
(2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100),
|
||||
(2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0),
|
||||
(2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
|
||||
@ -16,6 +16,8 @@ import { AdminMenuService } from './menu/admin-menu.service';
|
||||
import { AdminRoleController } from './role/admin-role.controller';
|
||||
import { AdminRole } from './role/admin-role.entity';
|
||||
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 { AdminTimezoneService } from './timezone/admin-timezone.service';
|
||||
import { AdminUserManageController } from './user/admin-user-manage.controller';
|
||||
@ -47,6 +49,7 @@ import { WordpressModule } from '@/wordpress/wordpress.module';
|
||||
AdminRoleController,
|
||||
AdminDeptController,
|
||||
ComponentController,
|
||||
SystemLogController,
|
||||
AdminTimezoneController,
|
||||
AdminExampleController,
|
||||
],
|
||||
@ -55,6 +58,7 @@ import { WordpressModule } from '@/wordpress/wordpress.module';
|
||||
AdminDeptService,
|
||||
AdminMenuService,
|
||||
AdminRoleService,
|
||||
SystemLogService,
|
||||
AdminTimezoneService,
|
||||
AdminUserService,
|
||||
],
|
||||
|
||||
@ -39,8 +39,8 @@ const componentExample = {
|
||||
componentTypeMsg: '折线图',
|
||||
image: '',
|
||||
template: '%7B%22version%22%3A%221.0%22%7D',
|
||||
createTime: '2026-05-13T02:30:00.000Z',
|
||||
updateTime: '2026-05-13T02:30:00.000Z',
|
||||
createTime: '2026-05-13 10:30:00',
|
||||
updateTime: '2026-05-13 10:30:00',
|
||||
is_deleted: false,
|
||||
};
|
||||
|
||||
|
||||
@ -8,7 +8,12 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { DecodeDictKey, decodeDictKeys, ensureSnowflakeId } from '@/common';
|
||||
import {
|
||||
DecodeDictKey,
|
||||
decodeDictKeys,
|
||||
ensureSnowflakeId,
|
||||
FormatDateTime,
|
||||
} from '@/common';
|
||||
|
||||
@Entity('admin_component')
|
||||
export class Component {
|
||||
@ -67,11 +72,13 @@ export class Component {
|
||||
@CreateDateColumn({
|
||||
name: 'create_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
name: 'update_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@Column({
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
|
||||
@Entity('admin_dept')
|
||||
export class AdminDept {
|
||||
@ -43,11 +43,13 @@ export class AdminDept {
|
||||
@CreateDateColumn({
|
||||
name: 'create_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
name: 'update_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
|
||||
@Entity('admin_dict')
|
||||
export class AdminDict {
|
||||
@ -68,11 +68,13 @@ export class AdminDict {
|
||||
@CreateDateColumn({
|
||||
name: 'create_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
name: 'update_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { FormatDateTime } from '@/common';
|
||||
|
||||
export class DictDto {
|
||||
@ApiProperty({
|
||||
@ -56,13 +57,15 @@ export class AdminDictDto {
|
||||
status?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2026-06-03T12:00:00.000Z',
|
||||
example: '2026-06-03 20:00:00',
|
||||
})
|
||||
@FormatDateTime()
|
||||
createTime?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2026-06-03T12:00:00.000Z',
|
||||
example: '2026-06-03 20:00:00',
|
||||
})
|
||||
@FormatDateTime()
|
||||
updateTime?: Date;
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
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 type { MinioUploadFile } from '@/minio/minio.types';
|
||||
import type { AdminDemoTableRow } from '../admin.types';
|
||||
@ -42,7 +42,9 @@ const DEMO_ROWS: AdminDemoTableRow[] = Array.from(
|
||||
productName: `KT Admin 模板能力 ${sequence}`,
|
||||
quantity: 10 + sequence,
|
||||
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],
|
||||
tags: [
|
||||
'kt',
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import { AdminRole } from '../role/admin-role.entity';
|
||||
import type { AdminMenuMeta, AdminMenuType } from '../admin.types';
|
||||
|
||||
@ -86,11 +86,13 @@ export class AdminMenu {
|
||||
@CreateDateColumn({
|
||||
name: 'create_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
name: 'update_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@ManyToMany(() => AdminRole, (role) => role.menus)
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import { AdminMenu } from '../menu/admin-menu.entity';
|
||||
import { AdminUser } from '../user/admin-user.entity';
|
||||
|
||||
@ -47,11 +47,13 @@ export class AdminRole {
|
||||
@CreateDateColumn({
|
||||
name: 'create_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
name: 'update_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@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,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import { AdminDept } from '../dept/admin-dept.entity';
|
||||
import { AdminRole } from '../role/admin-role.entity';
|
||||
|
||||
@ -66,11 +66,13 @@ export class AdminUser {
|
||||
@CreateDateColumn({
|
||||
name: 'create_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
name: 'update_time',
|
||||
})
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@ManyToMany(() => AdminRole, (role) => role.users, {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { AppService } from './app.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MinioModule } from 'nestjs-minio-client';
|
||||
@ -9,6 +10,7 @@ import { MinioClientModule } from './minio/minio.module';
|
||||
import {
|
||||
ApiExceptionFilter,
|
||||
CommonModule,
|
||||
createPinoLoggerParams,
|
||||
SaveBodyInterceptor,
|
||||
} from './common';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
@ -21,6 +23,12 @@ import { QqbotModule } from './qqbot/qqbot.module';
|
||||
isGlobal: true,
|
||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||
}),
|
||||
LoggerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) =>
|
||||
createPinoLoggerParams(configService),
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
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,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { normalizeVbenErrorText } from '../response/vben-response';
|
||||
import type { ExceptionBody, KtErrorResponse } from '../types';
|
||||
|
||||
@Catch()
|
||||
export class ApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: PinoLogger) {
|
||||
this.logger.setContext(ApiExceptionFilter.name);
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const status = this.getStatus(exception);
|
||||
const body = this.getBody(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({
|
||||
code: status,
|
||||
msg,
|
||||
err: this.getErr(status, body, exception, msg),
|
||||
err,
|
||||
} 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) {
|
||||
if (exception instanceof HttpException) {
|
||||
return exception.getStatus();
|
||||
@ -78,4 +116,12 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
||||
private stringifyMessage(message: unknown) {
|
||||
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 './decorators/current-admin-user.decorator';
|
||||
export * from './decorators/decode-dict.decorator';
|
||||
export * from './decorators/format-date-time.decorator';
|
||||
export * from './decorators/public.decorator';
|
||||
export * from './filters/api-exception.filter';
|
||||
export * from './interceptors/save-body.interceptor';
|
||||
export * from './logger/pino-logger.config';
|
||||
export * from './response/vben-response';
|
||||
export * from './services/tool.service';
|
||||
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',
|
||||
sort: 1,
|
||||
status: 1,
|
||||
createTime: '2026-06-03T12:00:00.000Z',
|
||||
updateTime: '2026-06-03T12:00:00.000Z',
|
||||
createTime: '2026-06-03 20:00:00',
|
||||
updateTime: '2026-06-03 20:00:00',
|
||||
};
|
||||
}
|
||||
|
||||
@ -819,13 +819,13 @@ function qqbotAccountExample() {
|
||||
connectionMode: 'reverse-ws',
|
||||
enabled: true,
|
||||
id: '1000000000000000001',
|
||||
lastHeartbeatAt: '2026-06-02T12:00:00.000Z',
|
||||
lastHeartbeatAt: '2026-06-02 20:00:00',
|
||||
name: '主账号',
|
||||
napcat: {
|
||||
bindStatus: 'bound',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
containerStatus: 'running',
|
||||
lastLoginAt: '2026-06-02T11:55:00.000Z',
|
||||
lastLoginAt: '2026-06-02 19:55:00',
|
||||
webuiPort: 6100,
|
||||
},
|
||||
selfId: '1914728559',
|
||||
@ -911,7 +911,7 @@ function qqbotScanExample() {
|
||||
sessionId: 'KT_SCAN_20260602120000',
|
||||
qrcode: 'data:image/png;base64,MOCK_QRCODE',
|
||||
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',
|
||||
size: 2048,
|
||||
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 { AppModule } from './app.module';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||
import { urlencoded, json } from 'express';
|
||||
import { knife4jSetup } from '@kwitsukasa/knife4j-swagger-vue3';
|
||||
@ -47,7 +48,8 @@ const swaggerGroups: SwaggerDocumentGroup[] = [
|
||||
];
|
||||
|
||||
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(urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
ApiArrayResponse,
|
||||
ApiModelResponse,
|
||||
ApiSuccessResponse,
|
||||
formatDateTimeFields,
|
||||
ToolsService,
|
||||
} from '@/common';
|
||||
import {
|
||||
@ -142,7 +143,7 @@ export class MinioClientController {
|
||||
name: 'uploads/demo.png',
|
||||
size: 2048,
|
||||
etag: '9b2cf535f27731c974343645a3985328',
|
||||
lastModified: '2026-05-13T02:30:00.000Z',
|
||||
lastModified: '2026-05-13 10:30:00',
|
||||
},
|
||||
])
|
||||
async list(
|
||||
@ -151,11 +152,15 @@ export class MinioClientController {
|
||||
@Query('prefix') prefix?: string,
|
||||
@Query('recursive') recursive?: string,
|
||||
) {
|
||||
const result = await this.minioClientService.listObjects({
|
||||
const result = (
|
||||
(await this.minioClientService.listObjects({
|
||||
bucketName,
|
||||
prefix,
|
||||
recursive: recursive !== 'false',
|
||||
});
|
||||
})) as Record<string, unknown>[]
|
||||
).map((item) =>
|
||||
formatDateTimeFields(Object.assign(new MinioObjectDto(), item)),
|
||||
);
|
||||
|
||||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { FormatDateTime } from '@/common';
|
||||
|
||||
export class MinioBucketStatusDto {
|
||||
@ApiProperty({
|
||||
@ -61,7 +62,8 @@ export class MinioObjectDto {
|
||||
etag: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-05-13T02:30:00.000Z',
|
||||
example: '2026-05-13 10:30:00',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotAccountAbilityType } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_account_ability')
|
||||
@ -35,9 +35,11 @@ export class QqbotAccountAbility {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type {
|
||||
QqbotConnectionMode,
|
||||
QqbotConnectionRole,
|
||||
@ -51,6 +51,7 @@ export class QqbotAccount {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastConnectedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
@ -59,6 +60,7 @@ export class QqbotAccount {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastHeartbeatAt: Date | null;
|
||||
|
||||
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
|
||||
@ -71,9 +73,11 @@ export class QqbotAccount {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotMessageType } from '../qqbot.types';
|
||||
import type { QqbotCommandLogStatus } from '../qqbot.types';
|
||||
|
||||
@ -60,9 +60,11 @@ export class QqbotCommandLog {
|
||||
errorMessage: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotCommandParserType, QqbotRuleTargetType } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_command')
|
||||
@ -62,6 +62,7 @@ export class QqbotCommand {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastHitAt: Date | null;
|
||||
|
||||
@Column({ default: '', length: 255 })
|
||||
@ -71,9 +72,11 @@ export class QqbotCommand {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
|
||||
@Entity('qqbot_config')
|
||||
@Index('uk_qqbot_config_key', ['configKey'], { unique: true })
|
||||
@ -25,9 +25,11 @@ export class QqbotConfig {
|
||||
remark: string;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpAdapterHost, ModuleRef } from '@nestjs/core';
|
||||
import WebSocket = require('ws');
|
||||
import WebSocket from 'ws';
|
||||
import { ToolsService } from '@/common';
|
||||
import { QQBOT_MQTT_TOPICS, QQBOT_REVERSE_WS_PATH } from '../qqbot.constants';
|
||||
import type {
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
|
||||
@Entity('qqbot_dedupe')
|
||||
export class QqbotDedupe {
|
||||
@ -22,12 +22,15 @@ export class QqbotDedupe {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
expireAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotMessageType } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_conversation')
|
||||
@ -45,6 +45,7 @@ export class QqbotConversation {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastMessageTime: Date | null;
|
||||
|
||||
@Column({ default: 0, name: 'message_count' })
|
||||
@ -54,9 +55,11 @@ export class QqbotConversation {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotMessageDirection, QqbotMessageType } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_message')
|
||||
@ -58,12 +58,15 @@ export class QqbotMessage {
|
||||
rawEvent: Record<string, any>;
|
||||
|
||||
@Column({ name: 'event_time', type: 'datetime' })
|
||||
@FormatDateTime()
|
||||
eventTime: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotAccountNapcatBindStatus } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_account_napcat')
|
||||
@ -35,6 +35,7 @@ export class QqbotAccountNapcat {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastLoginAt: Date | null;
|
||||
|
||||
@Column({ default: '', length: 255 })
|
||||
@ -44,9 +45,11 @@ export class QqbotAccountNapcat {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotNapcatContainerStatus } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_napcat_container')
|
||||
@ -53,6 +53,7 @@ export class QqbotNapcatContainer {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastStartedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
@ -61,6 +62,7 @@ export class QqbotNapcatContainer {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastCheckedAt: Date | null;
|
||||
|
||||
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
|
||||
@ -73,9 +75,11 @@ export class QqbotNapcatContainer {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotPermissionTargetType } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_allowlist')
|
||||
@ -39,9 +39,11 @@ export class QqbotAllowlist {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotPermissionTargetType } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_blocklist')
|
||||
@ -39,9 +39,11 @@ export class QqbotBlocklist {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { throwVbenError } from '@/common';
|
||||
import { formatKtDateTime, throwVbenError } from '@/common';
|
||||
import { QqbotAccountService } from '../account/qqbot-account.service';
|
||||
import { QqbotRepeaterPluginService } from '../plugins/repeater/qqbot-repeater.plugin';
|
||||
import type {
|
||||
@ -52,7 +52,7 @@ export class QqbotEventPluginRegistryService {
|
||||
|
||||
async health(pluginKey?: string): Promise<QqbotPluginHealth[]> {
|
||||
return this.getDefinitions(pluginKey).map((definition) => ({
|
||||
checkedAt: new Date().toISOString(),
|
||||
checkedAt: formatKtDateTime(new Date()),
|
||||
message: definition.remark || '事件插件由账号配置绑定后触发',
|
||||
name: definition.name,
|
||||
pluginKey: definition.key,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { QqbotFflogsPluginService } from '../plugins/fflogs/qqbot-fflogs.plugin';
|
||||
import type {
|
||||
@ -63,7 +63,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
||||
plugins.map(async (plugin) => {
|
||||
if (!plugin.healthCheck) {
|
||||
return {
|
||||
checkedAt: new Date().toISOString(),
|
||||
checkedAt: formatKtDateTime(new Date()),
|
||||
message: '插件未提供健康检查',
|
||||
name: plugin.name,
|
||||
pluginKey: plugin.key,
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { formatKtDateTime } from '@/common';
|
||||
import { DictService } from '../../../admin/dict/dict.service';
|
||||
import {
|
||||
buildQqbotFf14MarketCatalog,
|
||||
@ -110,7 +111,7 @@ export class QqbotFf14ClientService {
|
||||
listings,
|
||||
);
|
||||
const updatedAt = data.lastUploadTime
|
||||
? new Date(data.lastUploadTime).toISOString()
|
||||
? formatKtDateTime(data.lastUploadTime)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ToolsService } from '@/common';
|
||||
import { formatKtDateTime, ToolsService } from '@/common';
|
||||
import type { QqbotIntegrationPlugin } from '../../qqbot.types';
|
||||
import { QqbotFf14ClientService } from './qqbot-ff14-client.service';
|
||||
|
||||
@ -15,7 +15,7 @@ export class QqbotFf14MarketPluginService {
|
||||
description:
|
||||
'对接 XIVAPI v2 与 Universalis,提供 FF14 物品解析和市场价格查询能力。',
|
||||
healthCheck: async () => {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const checkedAt = formatKtDateTime(new Date());
|
||||
try {
|
||||
await this.ff14ClientService.resolveItem({
|
||||
itemId: 2,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ToolsService } from '@/common';
|
||||
import { formatKtDateTime, ToolsService } from '@/common';
|
||||
import type { QqbotIntegrationPlugin } from '../../qqbot.types';
|
||||
import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
|
||||
|
||||
@ -15,7 +15,7 @@ export class QqbotFflogsPluginService {
|
||||
description:
|
||||
'对接 FFLogs v2 GraphQL,提供 FF14 角色公开排名和指定高难最近记录查询能力。',
|
||||
healthCheck: async () => {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const checkedAt = formatKtDateTime(new Date());
|
||||
try {
|
||||
await this.fflogsClientService.checkHealth();
|
||||
return {
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotRuleMatchType, QqbotRuleTargetType } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_rule')
|
||||
@ -44,6 +44,7 @@ export class QqbotRule {
|
||||
nullable: true,
|
||||
type: 'datetime',
|
||||
})
|
||||
@FormatDateTime()
|
||||
lastHitAt: Date | null;
|
||||
|
||||
@Column({ default: '', length: 255 })
|
||||
@ -53,9 +54,11 @@ export class QqbotRule {
|
||||
isDeleted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
||||
import type { QqbotMessageType, QqbotSendStatus } from '../qqbot.types';
|
||||
|
||||
@Entity('qqbot_send_log')
|
||||
@ -50,9 +50,11 @@ export class QqbotSendLog {
|
||||
response: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'create_time' })
|
||||
@FormatDateTime()
|
||||
createTime: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'update_time' })
|
||||
@FormatDateTime()
|
||||
updateTime: Date;
|
||||
|
||||
@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 { APP_FILTER, APP_INTERCEPTOR, Reflector } from '@nestjs/core';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request = require('supertest');
|
||||
import * as request from 'supertest';
|
||||
import { Readable } from 'stream';
|
||||
import { AppController } from '../src/app.controller';
|
||||
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 { DictController } from '../src/admin/dict/dict.controller';
|
||||
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 {
|
||||
ApiExceptionFilter,
|
||||
SaveBodyInterceptor,
|
||||
@ -23,6 +25,7 @@ import { WordpressAuthController } from '../src/wordpress/wordpress-auth.control
|
||||
import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller';
|
||||
import { WordpressService } from '../src/wordpress/wordpress.service';
|
||||
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import {
|
||||
collectControllerRoutes,
|
||||
routeKey,
|
||||
@ -38,8 +41,8 @@ const component = {
|
||||
componentTypeMsg: '折线图',
|
||||
image: '',
|
||||
template: '{}',
|
||||
createTime: '2026-05-13T02:30:00.000Z',
|
||||
updateTime: '2026-05-13T02:30:00.000Z',
|
||||
createTime: '2026-05-13 10:30:00',
|
||||
updateTime: '2026-05-13 10:30:00',
|
||||
is_deleted: false,
|
||||
};
|
||||
|
||||
@ -92,6 +95,22 @@ const dictGroupItem = {
|
||||
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 = {
|
||||
bucketName: 'kt-template-online',
|
||||
objectName: 'uploads/demo.txt',
|
||||
@ -105,7 +124,7 @@ const objectStat = {
|
||||
name: 'uploads/demo.txt',
|
||||
size: 4,
|
||||
etag: 'etag',
|
||||
lastModified: '2026-05-13T02:30:00.000Z',
|
||||
lastModified: '2026-05-13 10:30:00',
|
||||
};
|
||||
|
||||
const wordpressAuthContext = {
|
||||
@ -177,6 +196,19 @@ const dictServiceMock = {
|
||||
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 = {
|
||||
checkConnection: jest.fn(),
|
||||
ensureBucket: jest.fn(),
|
||||
@ -214,6 +246,7 @@ const controllerClasses = [
|
||||
AppController,
|
||||
ComponentController,
|
||||
DictController,
|
||||
SystemLogController,
|
||||
MinioClientController,
|
||||
WordpressAuthController,
|
||||
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) => {
|
||||
minioServiceMock.checkConnection.mockResolvedValue({
|
||||
bucketName: 'demo-bucket',
|
||||
@ -1209,6 +1328,10 @@ describe('KT Template Online API (e2e)', () => {
|
||||
provide: DictService,
|
||||
useValue: dictServiceMock,
|
||||
},
|
||||
{
|
||||
provide: SystemLogService,
|
||||
useValue: systemLogServiceMock,
|
||||
},
|
||||
{
|
||||
provide: MinioClientService,
|
||||
useValue: minioServiceMock,
|
||||
@ -1217,6 +1340,10 @@ describe('KT Template Online API (e2e)', () => {
|
||||
provide: WordpressService,
|
||||
useValue: wordpressServiceMock,
|
||||
},
|
||||
{
|
||||
provide: PinoLogger,
|
||||
useValue: pinoLoggerMock,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
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', () => ({
|
||||
ToolsService: jest.requireActual('@/common').ToolsService,
|
||||
jest.mock('@/common', () => {
|
||||
const actualCommon = jest.requireActual('@/common');
|
||||
return {
|
||||
FormatDateTime: actualCommon.FormatDateTime,
|
||||
ToolsService: actualCommon.ToolsService,
|
||||
ensureSnowflakeId: jest.fn(),
|
||||
formatKtDateTime: actualCommon.formatKtDateTime,
|
||||
setDictDecodeCache: jest.fn(),
|
||||
throwVbenError: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ToolsService } from '@/common';
|
||||
|
||||
@ -2,6 +2,20 @@ jest.mock(
|
||||
'@/common',
|
||||
() => ({
|
||||
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(),
|
||||
}),
|
||||
{ virtual: true },
|
||||
|
||||
@ -2,6 +2,20 @@ jest.mock(
|
||||
'@/common',
|
||||
() => ({
|
||||
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(),
|
||||
}),
|
||||
{ virtual: true },
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import request = require('supertest');
|
||||
import type request from 'supertest';
|
||||
|
||||
export type ControllerRoute = {
|
||||
controllerName: string;
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user