feat:添加日志功能

This commit is contained in:
jianuo
2025-12-19 20:01:45 +08:00
parent 8166c95af4
commit a4a3a60db7
11 changed files with 429 additions and 5 deletions

View File

@@ -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 (
<Layout style={{ minHeight: '100vh' }}>
@@ -28,6 +32,11 @@ export function AdminLayout() {
label: '用户管理',
onClick: () => navigate('/users'),
},
{
key: 'logs',
label: '运行日志',
onClick: () => navigate('/logs'),
},
{
key: 'logout',
label: '退出登录',

View File

@@ -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() {
>
<Route index element={<Navigate to="/users" replace />} />
<Route path="users" element={<UsersPage />} />
<Route path="logs" element={<LogsPage />} />
</Route>
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
</Routes>

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
@@ -44,6 +65,48 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return data as T;
}
async function requestDownload(path: string, init?: RequestInit): Promise<{ blob: Blob; filename: string }>
{
const token = getToken();
const headers: Record<string, string> = {
...(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<any>('/admin/auth/login', {
@@ -59,4 +122,9 @@ export const api = {
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
}),
getRuntimeLogs: (lines = 200) =>
request<any>(`/admin/logs/runtime?lines=${encodeURIComponent(lines)}`),
downloadLogsArchive: () => requestDownload('/admin/logs/archive'),
};

View File

@@ -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<number>(200);
const [loading, setLoading] = useState(false);
const [downloadLoading, setDownloadLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [file, setFile] = useState<string>('');
const [updatedAt, setUpdatedAt] = useState<string>('');
const [logLines, setLogLines] = useState<string[]>([]);
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 (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{error ? <Alert type="error" message={error} /> : null}
<Card
title="运行日志"
extra={
<Space>
<span></span>
<InputNumber
min={1}
max={2000}
value={lines}
onChange={(v) => setLines(typeof v === 'number' ? v : 200)}
/>
<Button onClick={() => void load()} loading={loading}>
</Button>
<Button onClick={() => void downloadArchive()} loading={downloadLoading}>
</Button>
</Space>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<Typography.Text type="secondary">
{file ? `文件:${file}` : '文件:-'}
{updatedAt ? ` 更新时间:${updatedAt}` : ''}
</Typography.Text>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logText || '暂无日志'}</pre>
</Space>
</Card>
</Space>
);
}

View File

@@ -5,6 +5,7 @@
- 管理员登录role=9
- 用户列表管理
- 用户密码重置
- 运行日志查看(读取 logs/ 下最新日志)
> 说明:本项目用户系统原本的 `access_token` 为演示用 Base64 令牌。为了不影响现有用户端流程,管理员后台使用单独的签名 TokenHMAC-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`:运行日志页

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
/**
* 解析最大文件数配置
*

View File

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