From a4a3a60db70b1b242e18e06b42eb3452da43d281 Mon Sep 17 00:00:00 2001 From: jianuo <32106500027@e.gzhu.edu.cn> Date: Fri, 19 Dec 2025 20:01:45 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=B7=BB=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/AdminLayout.tsx | 11 +- client/src/app/App.tsx | 2 + client/src/lib/api.ts | 68 +++++++++++ client/src/pages/LogsPage.tsx | 106 ++++++++++++++++++ docs/systems/admin-dashboard/README.md | 30 +++++ package.json | 2 + src/business/admin/admin.controller.ts | 102 ++++++++++++++++- src/business/admin/admin.module.ts | 3 +- src/business/admin/admin.service.ts | 15 +++ .../utils/logger/log_management.service.ts | 70 ++++++++++++ src/dto/admin_response.dto.ts | 25 +++++ 11 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 client/src/pages/LogsPage.tsx diff --git a/client/src/app/AdminLayout.tsx b/client/src/app/AdminLayout.tsx index 0ce9802..dd3a2dd 100644 --- a/client/src/app/AdminLayout.tsx +++ b/client/src/app/AdminLayout.tsx @@ -8,7 +8,11 @@ export function AdminLayout() { const navigate = useNavigate(); const location = useLocation(); - const selectedKey = location.pathname.startsWith('/users') ? 'users' : 'users'; + const selectedKey = location.pathname.startsWith('/logs') + ? 'logs' + : location.pathname.startsWith('/users') + ? 'users' + : 'users'; return ( @@ -28,6 +32,11 @@ export function AdminLayout() { label: '用户管理', onClick: () => navigate('/users'), }, + { + key: 'logs', + label: '运行日志', + onClick: () => navigate('/logs'), + }, { key: 'logout', label: '退出登录', diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index b7f8f6f..b748055 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AdminLayout } from './AdminLayout'; import { LoginPage } from '../pages/LoginPage'; import { UsersPage } from '../pages/UsersPage'; +import { LogsPage } from '../pages/LogsPage'; import { isAuthed } from '../lib/adminAuth'; export function App() { @@ -17,6 +18,7 @@ export function App() { > } /> } /> + } /> } /> diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 720a65a..98669ec 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -11,6 +11,27 @@ export class ApiError extends Error { } } +function parseFilenameFromContentDisposition(contentDisposition: string | null): string | null { + if (!contentDisposition) return null; + + // Prefer RFC 5987 filename*=UTF-8''... + const filenameStarMatch = contentDisposition.match(/filename\*=(?:UTF-8''|utf-8'')([^;]+)/); + if (filenameStarMatch?.[1]) { + try { + return decodeURIComponent(filenameStarMatch[1].trim().replace(/^"|"$/g, '')); + } catch { + return filenameStarMatch[1].trim().replace(/^"|"$/g, ''); + } + } + + const filenameMatch = contentDisposition.match(/filename=([^;]+)/); + if (filenameMatch?.[1]) { + return filenameMatch[1].trim().replace(/^"|"$/g, ''); + } + + return null; +} + async function request(path: string, init?: RequestInit): Promise { const token = getToken(); @@ -44,6 +65,48 @@ async function request(path: string, init?: RequestInit): Promise { return data as T; } +async function requestDownload(path: string, init?: RequestInit): Promise<{ blob: Blob; filename: string }> +{ + const token = getToken(); + + const headers: Record = { + ...(init?.headers as any), + }; + + // Do NOT force Content-Type for downloads (GET binary) + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers, + credentials: 'include', + }); + + if (res.status === 401) { + clearAuth(); + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + // Try to extract message from JSON-ish body + let message = `请求失败: ${res.status}`; + try { + const maybeJson = JSON.parse(text || '{}'); + message = maybeJson?.message || message; + } catch { + // ignore + } + throw new ApiError(message, res.status); + } + + const filename = + parseFilenameFromContentDisposition(res.headers.get('content-disposition')) || 'logs.tar.gz'; + const blob = await res.blob(); + return { blob, filename }; +} + export const api = { adminLogin: (identifier: string, password: string) => request('/admin/auth/login', { @@ -59,4 +122,9 @@ export const api = { method: 'POST', body: JSON.stringify({ new_password: newPassword }), }), + + getRuntimeLogs: (lines = 200) => + request(`/admin/logs/runtime?lines=${encodeURIComponent(lines)}`), + + downloadLogsArchive: () => requestDownload('/admin/logs/archive'), }; diff --git a/client/src/pages/LogsPage.tsx b/client/src/pages/LogsPage.tsx new file mode 100644 index 0000000..84e29b4 --- /dev/null +++ b/client/src/pages/LogsPage.tsx @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Card, InputNumber, Space, Typography } from 'antd'; +import { api, ApiError } from '../lib/api'; + +export function LogsPage() { + const [lines, setLines] = useState(200); + const [loading, setLoading] = useState(false); + const [downloadLoading, setDownloadLoading] = useState(false); + const [error, setError] = useState(null); + const [file, setFile] = useState(''); + const [updatedAt, setUpdatedAt] = useState(''); + const [logLines, setLogLines] = useState([]); + + const logText = useMemo(() => logLines.join('\n'), [logLines]); + + const load = async () => { + setLoading(true); + setError(null); + try { + const res = await api.getRuntimeLogs(lines); + if (!res?.success) { + setError(res?.message || '运行日志获取失败'); + return; + } + setFile(res?.data?.file || ''); + setUpdatedAt(res?.data?.updated_at || ''); + setLogLines(Array.isArray(res?.data?.lines) ? res.data.lines : []); + } catch (e) { + if (e instanceof ApiError) { + setError(e.message); + } else { + setError(e instanceof Error ? e.message : '运行日志获取失败'); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const downloadArchive = async () => { + setDownloadLoading(true); + setError(null); + try { + const { blob, filename } = await api.downloadLogsArchive(); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement('a'); + a.href = url; + a.download = filename || 'logs.tar.gz'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } finally { + URL.revokeObjectURL(url); + } + } catch (e) { + if (e instanceof ApiError) { + setError(e.message); + } else { + setError(e instanceof Error ? e.message : '日志下载失败'); + } + } finally { + setDownloadLoading(false); + } + }; + + return ( + + {error ? : null} + + + 行数 + setLines(typeof v === 'number' ? v : 200)} + /> + + + + } + > + + + {file ? `文件:${file}` : '文件:-'} + {updatedAt ? ` 更新时间:${updatedAt}` : ''} + + +
{logText || '暂无日志'}
+
+ + + ); +} diff --git a/docs/systems/admin-dashboard/README.md b/docs/systems/admin-dashboard/README.md index 2206a0a..9a7d47e 100644 --- a/docs/systems/admin-dashboard/README.md +++ b/docs/systems/admin-dashboard/README.md @@ -5,6 +5,7 @@ - 管理员登录(role=9) - 用户列表管理 - 用户密码重置 +- 运行日志查看(读取 logs/ 下最新日志) > 说明:本项目用户系统原本的 `access_token` 为演示用 Base64 令牌。为了不影响现有用户端流程,管理员后台使用单独的签名 Token(HMAC-SHA256)做鉴权。 @@ -104,6 +105,27 @@ } ``` +### 3.5 运行日志(tail) + +- `GET /admin/logs/runtime?lines=200` +- 需要管理员 Token + +说明: + +- 开发环境默认读取 `logs/dev.log` +- 生产环境默认读取 `logs/app.log` +- `lines` 默认 200,最大 2000 + +### 3.6 下载全部运行日志(archive) + +- `GET /admin/logs/archive` +- 需要管理员 Token + +说明: + +- 返回一个 `tar.gz` 文件(浏览器会触发下载) +- 内容为整个 `logs/` 目录(例如开发环境的 `dev.log`,生产环境的 `app.log/access.log/error.log` 等) + --- ## 4. 前端后台(Ant Design) @@ -136,6 +158,13 @@ pnpm -C client dev - 后端:`http://localhost:3000` - Swagger:`http://localhost:3000/api-docs` +页面说明: + +- 用户管理:`/users` +- 运行日志:`/logs` + +在“运行日志”页面可点击“下载日志压缩包”获取整个 `logs/` 目录的打包文件。 + ### 4.4 前端配置 - 复制 `client/.env.example` 为 `client/.env.local` @@ -154,3 +183,4 @@ pnpm -C client dev - 前端: - `client/src/pages/LoginPage.tsx`:管理员登录页 - `client/src/pages/UsersPage.tsx`:用户管理页(列表+重置密码) + - `client/src/pages/LogsPage.tsx`:运行日志页 diff --git a/package.json b/package.json index ac9307f..e29ee1c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "@nestjs/swagger": "^11.2.3", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^10.4.20", + "@types/archiver": "^7.0.0", "@types/bcrypt": "^6.0.0", + "archiver": "^7.0.1", "axios": "^1.13.2", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", diff --git a/src/business/admin/admin.controller.ts b/src/business/admin/admin.controller.ts index fb0bcc3..5db7353 100644 --- a/src/business/admin/admin.controller.ts +++ b/src/business/admin/admin.controller.ts @@ -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((resolve, reject) => { + pipeline(tar.stdout, res, (err) => (err ? reject(err) : resolve())); + }); + + const exitPromise = new Promise((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(); + } + } + } } diff --git a/src/business/admin/admin.module.ts b/src/business/admin/admin.module.ts index 3090700..5927865 100644 --- a/src/business/admin/admin.module.ts +++ b/src/business/admin/admin.module.ts @@ -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], }) diff --git a/src/business/admin/admin.service.ts b/src/business/admin/admin.service.ts index 80b2503..ecd94e7 100644 --- a/src/business/admin/admin.service.ts +++ b/src/business/admin/admin.service.ts @@ -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 { 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 { 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> { + const result = await this.logManagementService.getRuntimeLogTail({ lines }); + return { + success: true, + data: result, + message: '运行日志获取成功', + }; + } + private formatUser(user: Users) { return { id: user.id.toString(), diff --git a/src/core/utils/logger/log_management.service.ts b/src/core/utils/logger/log_management.service.ts index f3de155..2f06c4c 100644 --- a/src/core/utils/logger/log_management.service.ts +++ b/src/core/utils/logger/log_management.service.ts @@ -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); + } + } + /** * 解析最大文件数配置 * diff --git a/src/dto/admin_response.dto.ts b/src/dto/admin_response.dto.ts index 07f7dc0..faf2c0f 100644 --- a/src/dto/admin_response.dto.ts +++ b/src/dto/admin_response.dto.ts @@ -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; +}