feat: 接入系统日志与统一时间格式化

This commit is contained in:
sunlei 2026-06-04 13:13:13 +08:00
parent 054c6ce23e
commit e6ce74e485
58 changed files with 3012 additions and 1260 deletions

View File

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

@ -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 tokenWordPress 自动认证失败时登录仍返回成功,`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` 被旧数据或错误保存覆盖为空的问题。

View File

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

View File

@ -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": [

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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;
}

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

View File

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

View File

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

View 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;
}

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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({
bucketName,
prefix,
recursive: recursive !== 'false',
});
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));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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: '操作成功',
});
});
});

View File

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

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

View File

@ -1,11 +1,16 @@
jest.mock('@/common', () => ({
ToolsService: jest.requireActual('@/common').ToolsService,
ensureSnowflakeId: jest.fn(),
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);
},
}));
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';

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import request = require('supertest');
import type request from 'supertest';
export type ControllerRoute = {
controllerName: string;

View File

@ -1,7 +1,6 @@
{
"compilerOptions": {
"module": "commonjs",
"ignoreDeprecations": "6.0",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,