feat:添加日志功能
This commit is contained in:
@@ -6,22 +6,30 @@
|
||||
* - GET /admin/users 用户列表(需要管理员Token)
|
||||
* - GET /admin/users/:id 用户详情(需要管理员Token)
|
||||
* - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token)
|
||||
* - GET /admin/logs/runtime 获取运行日志尾部(需要管理员Token)
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, UseGuards, ValidationPipe, UsePipes } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards, ValidationPipe, UsePipes, Res, Logger } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiProduces, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from '../../core/guards/admin.guard';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminLoginDto, AdminResetPasswordDto } from '../../dto/admin.dto';
|
||||
import { AdminLoginResponseDto, AdminUsersResponseDto, AdminCommonResponseDto, AdminUserResponseDto } from '../../dto/admin_response.dto';
|
||||
import { AdminLoginResponseDto, AdminUsersResponseDto, AdminCommonResponseDto, AdminUserResponseDto, AdminRuntimeLogsResponseDto } from '../../dto/admin_response.dto';
|
||||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { pipeline } from 'stream';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
private readonly logger = new Logger(AdminController.name);
|
||||
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
|
||||
@@ -72,4 +80,92 @@ export class AdminController {
|
||||
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
|
||||
return await this.adminService.resetPassword(BigInt(id), dto.new_password);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '获取运行日志尾部', description: '从 logs/ 目录读取最近的日志行(默认200行)' })
|
||||
@ApiQuery({ name: 'lines', required: false, description: '返回行数(默认200,最大2000)' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AdminRuntimeLogsResponseDto })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('logs/runtime')
|
||||
async getRuntimeLogs(@Query('lines') lines?: string) {
|
||||
const parsedLines = lines ? Number(lines) : undefined;
|
||||
return await this.adminService.getRuntimeLogs(parsedLines);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '下载全部运行日志', description: '将 logs/ 目录打包为 tar.gz 并下载(需要管理员Token)' })
|
||||
@ApiProduces('application/gzip')
|
||||
@ApiResponse({ status: 200, description: '打包下载成功(tar.gz 二进制流)' })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('logs/archive')
|
||||
async downloadLogsArchive(@Res() res: Response) {
|
||||
const logDir = this.adminService.getLogDirAbsolutePath();
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
res.status(404).json({ success: false, message: '日志目录不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(logDir);
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(404).json({ success: false, message: '日志目录不可用' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(logDir);
|
||||
const baseName = path.basename(logDir);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `logs-${ts}.tar.gz`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
const tar = spawn('tar', ['-czf', '-', '-C', parentDir, baseName], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
tar.stderr.on('data', (chunk: Buffer) => {
|
||||
const msg = chunk.toString('utf8').trim();
|
||||
if (msg) {
|
||||
this.logger.warn(`tar stderr: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
tar.on('error', (err: any) => {
|
||||
this.logger.error('打包日志失败(tar 进程启动失败)', err?.stack || String(err));
|
||||
if (!res.headersSent) {
|
||||
const msg = err?.code === 'ENOENT' ? '服务器缺少 tar 命令,无法打包日志' : '日志打包失败';
|
||||
res.status(500).json({ success: false, message: msg });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
const pipelinePromise = new Promise<void>((resolve, reject) => {
|
||||
pipeline(tar.stdout, res, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
|
||||
const exitPromise = new Promise<void>((resolve, reject) => {
|
||||
tar.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`tar exited with code ${code ?? 'unknown'}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await pipelinePromise;
|
||||
await exitPromise;
|
||||
} catch (err) {
|
||||
this.logger.error('打包日志失败(tar 执行或输出失败)', err instanceof Error ? err.stack : String(err));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, message: '日志打包失败' });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AdminCoreModule],
|
||||
imports: [AdminCoreModule, LoggerModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { UsersService } from '../../core/db/users/users.service';
|
||||
import { UsersMemoryService } from '../../core/db/users/users_memory.service';
|
||||
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||
|
||||
export interface AdminApiResponse<T = any> {
|
||||
success: boolean;
|
||||
@@ -31,8 +32,13 @@ export class AdminService {
|
||||
constructor(
|
||||
private readonly adminCoreService: AdminCoreService,
|
||||
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
|
||||
private readonly logManagementService: LogManagementService,
|
||||
) {}
|
||||
|
||||
getLogDirAbsolutePath(): string {
|
||||
return this.logManagementService.getLogDirAbsolutePath();
|
||||
}
|
||||
|
||||
async login(identifier: string, password: string): Promise<AdminApiResponse> {
|
||||
try {
|
||||
const result = await this.adminCoreService.login({ identifier, password });
|
||||
@@ -83,6 +89,15 @@ export class AdminService {
|
||||
return { success: true, message: '密码重置成功' };
|
||||
}
|
||||
|
||||
async getRuntimeLogs(lines?: number): Promise<AdminApiResponse<{ file: string; updated_at: string; lines: string[] }>> {
|
||||
const result = await this.logManagementService.getRuntimeLogTail({ lines });
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: '运行日志获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
private formatUser(user: Users) {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
|
||||
@@ -61,6 +61,15 @@ export class LogManagementService {
|
||||
this.maxSize = this.configService.get('LOG_MAX_SIZE', '10m');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志目录的绝对路径
|
||||
*
|
||||
* 说明:用于后台打包下载 logs/ 整目录。
|
||||
*/
|
||||
getLogDirAbsolutePath(): string {
|
||||
return path.resolve(this.logDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期清理过期日志文件
|
||||
*
|
||||
@@ -307,6 +316,67 @@ export class LogManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行日志尾部(用于后台查看)
|
||||
*
|
||||
* 说明:
|
||||
* - 开发环境默认读取 dev.log
|
||||
* - 生产环境默认读取 app.log(可选 access/error)
|
||||
* - 通过读取文件尾部一定字节数实现“近似 tail”,避免大文件全量读取
|
||||
*/
|
||||
async getRuntimeLogTail(options?: {
|
||||
type?: 'app' | 'access' | 'error' | 'dev';
|
||||
lines?: number;
|
||||
}): Promise<{
|
||||
file: string;
|
||||
updated_at: string;
|
||||
lines: string[];
|
||||
}> {
|
||||
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||
const requestedLines = Math.max(1, Math.min(Number(options?.lines ?? 200), 2000));
|
||||
const requestedType = options?.type;
|
||||
|
||||
const allowedFiles = isProduction
|
||||
? {
|
||||
app: 'app.log',
|
||||
access: 'access.log',
|
||||
error: 'error.log',
|
||||
}
|
||||
: {
|
||||
dev: 'dev.log',
|
||||
};
|
||||
|
||||
const defaultType = isProduction ? 'app' : 'dev';
|
||||
const typeKey = (requestedType && requestedType in allowedFiles ? requestedType : defaultType) as keyof typeof allowedFiles;
|
||||
const fileName = allowedFiles[typeKey];
|
||||
const filePath = path.join(this.logDir, fileName);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { file: fileName, updated_at: new Date().toISOString(), lines: [] };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const maxBytes = 256 * 1024; // 256KB 足够覆盖常见的数百行日志
|
||||
const readBytes = Math.min(stats.size, maxBytes);
|
||||
const startPos = Math.max(0, stats.size - readBytes);
|
||||
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(readBytes);
|
||||
fs.readSync(fd, buffer, 0, readBytes, startPos);
|
||||
const text = buffer.toString('utf8');
|
||||
const allLines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
||||
const tailLines = allLines.slice(-requestedLines);
|
||||
return {
|
||||
file: fileName,
|
||||
updated_at: stats.mtime.toISOString(),
|
||||
lines: tailLines,
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析最大文件数配置
|
||||
*
|
||||
|
||||
@@ -137,3 +137,28 @@ export class AdminCommonResponseDto {
|
||||
@ApiProperty({ required: false, example: 'ADMIN_OPERATION_FAILED' })
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
class AdminRuntimeLogsDataDto {
|
||||
@ApiProperty({ example: 'dev.log' })
|
||||
file: string;
|
||||
|
||||
@ApiProperty({ description: '日志文件最后更新时间(ISO)', example: '2025-12-19T19:10:15.000Z' })
|
||||
updated_at: string;
|
||||
|
||||
@ApiProperty({ type: [String], description: '日志行(按时间顺序,越靠后越新)' })
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export class AdminRuntimeLogsResponseDto {
|
||||
@ApiProperty({ example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ type: AdminRuntimeLogsDataDto, required: false })
|
||||
data?: AdminRuntimeLogsDataDto;
|
||||
|
||||
@ApiProperty({ example: '运行日志获取成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'ADMIN_OPERATION_FAILED' })
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user