forked from datawhale/whale-town-end
feat:添加日志功能
This commit is contained in:
@@ -8,7 +8,11 @@ export function AdminLayout() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
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 (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
@@ -28,6 +32,11 @@ export function AdminLayout() {
|
|||||||
label: '用户管理',
|
label: '用户管理',
|
||||||
onClick: () => navigate('/users'),
|
onClick: () => navigate('/users'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'logs',
|
||||||
|
label: '运行日志',
|
||||||
|
onClick: () => navigate('/logs'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
label: '退出登录',
|
label: '退出登录',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import { AdminLayout } from './AdminLayout';
|
import { AdminLayout } from './AdminLayout';
|
||||||
import { LoginPage } from '../pages/LoginPage';
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
import { UsersPage } from '../pages/UsersPage';
|
import { UsersPage } from '../pages/UsersPage';
|
||||||
|
import { LogsPage } from '../pages/LogsPage';
|
||||||
import { isAuthed } from '../lib/adminAuth';
|
import { isAuthed } from '../lib/adminAuth';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -17,6 +18,7 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/users" replace />} />
|
<Route index element={<Navigate to="/users" replace />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
|
<Route path="logs" element={<LogsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
|
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|
||||||
@@ -44,6 +65,48 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return data as 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 = {
|
export const api = {
|
||||||
adminLogin: (identifier: string, password: string) =>
|
adminLogin: (identifier: string, password: string) =>
|
||||||
request<any>('/admin/auth/login', {
|
request<any>('/admin/auth/login', {
|
||||||
@@ -59,4 +122,9 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ new_password: newPassword }),
|
body: JSON.stringify({ new_password: newPassword }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getRuntimeLogs: (lines = 200) =>
|
||||||
|
request<any>(`/admin/logs/runtime?lines=${encodeURIComponent(lines)}`),
|
||||||
|
|
||||||
|
downloadLogsArchive: () => requestDownload('/admin/logs/archive'),
|
||||||
};
|
};
|
||||||
|
|||||||
106
client/src/pages/LogsPage.tsx
Normal file
106
client/src/pages/LogsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
- 管理员登录(role=9)
|
- 管理员登录(role=9)
|
||||||
- 用户列表管理
|
- 用户列表管理
|
||||||
- 用户密码重置
|
- 用户密码重置
|
||||||
|
- 运行日志查看(读取 logs/ 下最新日志)
|
||||||
|
|
||||||
> 说明:本项目用户系统原本的 `access_token` 为演示用 Base64 令牌。为了不影响现有用户端流程,管理员后台使用单独的签名 Token(HMAC-SHA256)做鉴权。
|
> 说明:本项目用户系统原本的 `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)
|
## 4. 前端后台(Ant Design)
|
||||||
@@ -136,6 +158,13 @@ pnpm -C client dev
|
|||||||
- 后端:`http://localhost:3000`
|
- 后端:`http://localhost:3000`
|
||||||
- Swagger:`http://localhost:3000/api-docs`
|
- Swagger:`http://localhost:3000/api-docs`
|
||||||
|
|
||||||
|
页面说明:
|
||||||
|
|
||||||
|
- 用户管理:`/users`
|
||||||
|
- 运行日志:`/logs`
|
||||||
|
|
||||||
|
在“运行日志”页面可点击“下载日志压缩包”获取整个 `logs/` 目录的打包文件。
|
||||||
|
|
||||||
### 4.4 前端配置
|
### 4.4 前端配置
|
||||||
|
|
||||||
- 复制 `client/.env.example` 为 `client/.env.local`
|
- 复制 `client/.env.example` 为 `client/.env.local`
|
||||||
@@ -154,3 +183,4 @@ pnpm -C client dev
|
|||||||
- 前端:
|
- 前端:
|
||||||
- `client/src/pages/LoginPage.tsx`:管理员登录页
|
- `client/src/pages/LoginPage.tsx`:管理员登录页
|
||||||
- `client/src/pages/UsersPage.tsx`:用户管理页(列表+重置密码)
|
- `client/src/pages/UsersPage.tsx`:用户管理页(列表+重置密码)
|
||||||
|
- `client/src/pages/LogsPage.tsx`:运行日志页
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "^11.2.3",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"@nestjs/websockets": "^10.4.20",
|
"@nestjs/websockets": "^10.4.20",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
@@ -6,22 +6,30 @@
|
|||||||
* - GET /admin/users 用户列表(需要管理员Token)
|
* - GET /admin/users 用户列表(需要管理员Token)
|
||||||
* - GET /admin/users/:id 用户详情(需要管理员Token)
|
* - GET /admin/users/:id 用户详情(需要管理员Token)
|
||||||
* - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token)
|
* - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token)
|
||||||
|
* - GET /admin/logs/runtime 获取运行日志尾部(需要管理员Token)
|
||||||
*
|
*
|
||||||
* @author jianuo
|
* @author jianuo
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-19
|
* @since 2025-12-19
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, UseGuards, ValidationPipe, UsePipes } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards, ValidationPipe, UsePipes, Res, Logger } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiProduces, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AdminGuard } from '../../core/guards/admin.guard';
|
import { AdminGuard } from '../../core/guards/admin.guard';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { AdminLoginDto, AdminResetPasswordDto } from '../../dto/admin.dto';
|
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')
|
@ApiTags('admin')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
|
private readonly logger = new Logger(AdminController.name);
|
||||||
|
|
||||||
constructor(private readonly adminService: AdminService) {}
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
|
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
|
||||||
@@ -72,4 +80,92 @@ export class AdminController {
|
|||||||
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
|
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
|
||||||
return await this.adminService.resetPassword(BigInt(id), dto.new_password);
|
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 { Module } from '@nestjs/common';
|
||||||
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
||||||
|
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminCoreModule],
|
imports: [AdminCoreModule, LoggerModule],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService],
|
providers: [AdminService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
|||||||
import { Users } from '../../core/db/users/users.entity';
|
import { Users } from '../../core/db/users/users.entity';
|
||||||
import { UsersService } from '../../core/db/users/users.service';
|
import { UsersService } from '../../core/db/users/users.service';
|
||||||
import { UsersMemoryService } from '../../core/db/users/users_memory.service';
|
import { UsersMemoryService } from '../../core/db/users/users_memory.service';
|
||||||
|
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||||
|
|
||||||
export interface AdminApiResponse<T = any> {
|
export interface AdminApiResponse<T = any> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -31,8 +32,13 @@ export class AdminService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly adminCoreService: AdminCoreService,
|
private readonly adminCoreService: AdminCoreService,
|
||||||
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
|
@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> {
|
async login(identifier: string, password: string): Promise<AdminApiResponse> {
|
||||||
try {
|
try {
|
||||||
const result = await this.adminCoreService.login({ identifier, password });
|
const result = await this.adminCoreService.login({ identifier, password });
|
||||||
@@ -83,6 +89,15 @@ export class AdminService {
|
|||||||
return { success: true, message: '密码重置成功' };
|
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) {
|
private formatUser(user: Users) {
|
||||||
return {
|
return {
|
||||||
id: user.id.toString(),
|
id: user.id.toString(),
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export class LogManagementService {
|
|||||||
this.maxSize = this.configService.get('LOG_MAX_SIZE', '10m');
|
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' })
|
@ApiProperty({ required: false, example: 'ADMIN_OPERATION_FAILED' })
|
||||||
error_code?: string;
|
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