- 更新管理员控制器和数据库管理功能 - 完善管理员操作日志系统 - 添加全面的属性测试覆盖 - 优化用户管理和用户档案服务 - 更新代码检查规范文档 功能改进: - 增强管理员权限验证 - 完善操作日志记录 - 优化数据库管理接口 - 提升系统安全性和可维护性
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
/**
|
||
* 管理员控制器
|
||
*
|
||
* 功能描述:
|
||
* - 提供管理员登录认证接口
|
||
* - 提供用户管理相关接口(查询、重置密码)
|
||
* - 提供系统日志查询和下载功能
|
||
*
|
||
* 职责分离:
|
||
* - HTTP请求处理和参数验证
|
||
* - 业务逻辑委托给AdminService处理
|
||
* - 权限控制通过AdminGuard实现
|
||
*
|
||
* API端点:
|
||
* - POST /admin/auth/login 管理员登录
|
||
* - GET /admin/users 用户列表(需要管理员Token)
|
||
* - GET /admin/users/:id 用户详情(需要管理员Token)
|
||
* - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token)
|
||
* - GET /admin/logs/runtime 获取运行日志尾部(需要管理员Token)
|
||
*
|
||
* 最近修改:
|
||
* - 2026-01-09: 代码质量优化 - 将同步文件系统操作改为异步操作,避免阻塞事件循环 (修改者: moyin)
|
||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||
* - 2026-01-08: 注释规范优化 - 补充方法注释,添加@param、@returns、@throws和@example (修改者: moyin)
|
||
*
|
||
* @author moyin
|
||
* @version 1.0.4
|
||
* @since 2025-12-19
|
||
* @lastModified 2026-01-09
|
||
*/
|
||
|
||
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 './admin.guard';
|
||
import { AdminService } from './admin.service';
|
||
import { AdminLoginDto, AdminResetPasswordDto } from './admin_login.dto';
|
||
import {
|
||
AdminLoginResponseDto,
|
||
AdminUsersResponseDto,
|
||
AdminCommonResponseDto,
|
||
AdminUserResponseDto,
|
||
AdminRuntimeLogsResponseDto
|
||
} from './admin_response.dto';
|
||
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
|
||
import { getCurrentTimestamp } from './admin_utils';
|
||
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) {}
|
||
|
||
/**
|
||
* 管理员登录
|
||
*
|
||
* 功能描述:
|
||
* 验证管理员身份并生成JWT Token,仅允许role=9的账户登录后台
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证登录标识符和密码
|
||
* 2. 检查用户角色是否为管理员(role=9)
|
||
* 3. 生成JWT Token
|
||
* 4. 返回登录结果和Token
|
||
*
|
||
* @param dto 登录请求数据
|
||
* @returns 登录结果,包含Token和管理员信息
|
||
*
|
||
* @throws UnauthorizedException 当登录失败时
|
||
* @throws ForbiddenException 当权限不足或账户被禁用时
|
||
* @throws TooManyRequestsException 当登录尝试过于频繁时
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* const result = await adminController.login({
|
||
* identifier: 'admin',
|
||
* password: 'Admin123456'
|
||
* });
|
||
* ```
|
||
*/
|
||
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
|
||
@ApiBody({ type: AdminLoginDto })
|
||
@ApiResponse({ status: 200, description: '登录成功', type: AdminLoginResponseDto })
|
||
@ApiResponse({ status: 401, description: '登录失败' })
|
||
@ApiResponse({ status: 403, description: '权限不足或账户被禁用' })
|
||
@ApiResponse({ status: 429, description: '登录尝试过于频繁' })
|
||
@Throttle(ThrottlePresets.LOGIN)
|
||
@Post('auth/login')
|
||
@HttpCode(HttpStatus.OK)
|
||
@UsePipes(new ValidationPipe({ transform: true }))
|
||
async login(@Body() dto: AdminLoginDto) {
|
||
return await this.adminService.login(dto.identifier, dto.password);
|
||
}
|
||
|
||
/**
|
||
* 获取用户列表
|
||
*
|
||
* 功能描述:
|
||
* 分页获取系统中的用户列表,支持限制数量和偏移量参数
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 解析查询参数(limit和offset)
|
||
* 2. 调用用户服务获取用户列表
|
||
* 3. 格式化用户数据
|
||
* 4. 返回分页结果
|
||
*
|
||
* @param limit 返回数量,默认100,可选参数
|
||
* @param offset 偏移量,默认0,可选参数
|
||
* @returns 用户列表和分页信息
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* // 获取前20个用户
|
||
* const result = await adminController.listUsers('20', '0');
|
||
* ```
|
||
*/
|
||
@ApiBearerAuth('JWT-auth')
|
||
@ApiOperation({ summary: '获取用户列表', description: '后台用户管理:分页获取用户列表' })
|
||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认100)' })
|
||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)' })
|
||
@ApiResponse({ status: 200, description: '获取成功', type: AdminUsersResponseDto })
|
||
@UseGuards(AdminGuard)
|
||
@Get('users')
|
||
async listUsers(
|
||
@Query('limit') limit?: string,
|
||
@Query('offset') offset?: string,
|
||
) {
|
||
const parsedLimit = limit ? Number(limit) : 100;
|
||
const parsedOffset = offset ? Number(offset) : 0;
|
||
return await this.adminService.listUsers(parsedLimit, parsedOffset);
|
||
}
|
||
|
||
/**
|
||
* 获取用户详情
|
||
*
|
||
* 功能描述:
|
||
* 根据用户ID获取指定用户的详细信息
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证用户ID格式
|
||
* 2. 查询用户详细信息
|
||
* 3. 格式化用户数据
|
||
* 4. 返回用户详情
|
||
*
|
||
* @param id 用户ID字符串
|
||
* @returns 用户详细信息
|
||
*
|
||
* @throws NotFoundException 当用户不存在时
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* const result = await adminController.getUser('123');
|
||
* ```
|
||
*/
|
||
@ApiBearerAuth('JWT-auth')
|
||
@ApiOperation({ summary: '获取用户详情' })
|
||
@ApiParam({ name: 'id', description: '用户ID' })
|
||
@ApiResponse({ status: 200, description: '获取成功', type: AdminUserResponseDto })
|
||
@UseGuards(AdminGuard)
|
||
@Get('users/:id')
|
||
async getUser(@Param('id') id: string) {
|
||
return await this.adminService.getUser(BigInt(id));
|
||
}
|
||
|
||
/**
|
||
* 重置用户密码
|
||
*
|
||
* 功能描述:
|
||
* 管理员直接为指定用户设置新密码,新密码需满足密码强度规则
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证用户ID和新密码格式
|
||
* 2. 检查用户是否存在
|
||
* 3. 验证密码强度规则
|
||
* 4. 更新用户密码
|
||
* 5. 记录操作日志
|
||
*
|
||
* @param id 用户ID字符串
|
||
* @param dto 密码重置请求数据
|
||
* @returns 重置结果
|
||
*
|
||
* @throws NotFoundException 当用户不存在时
|
||
* @throws BadRequestException 当密码不符合强度规则时
|
||
* @throws TooManyRequestsException 当操作过于频繁时
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* const result = await adminController.resetPassword('123', {
|
||
* newPassword: 'NewPass1234'
|
||
* });
|
||
* ```
|
||
*/
|
||
@ApiBearerAuth('JWT-auth')
|
||
@ApiOperation({ summary: '重置用户密码', description: '管理员直接为用户设置新密码(需满足密码强度规则)' })
|
||
@ApiParam({ name: 'id', description: '用户ID' })
|
||
@ApiBody({ type: AdminResetPasswordDto })
|
||
@ApiResponse({ status: 200, description: '重置成功', type: AdminCommonResponseDto })
|
||
@ApiResponse({ status: 429, description: '操作过于频繁' })
|
||
@UseGuards(AdminGuard)
|
||
@Throttle(ThrottlePresets.ADMIN_OPERATION)
|
||
@Post('users/:id/reset-password')
|
||
@HttpCode(HttpStatus.OK)
|
||
@UsePipes(new ValidationPipe({ transform: true }))
|
||
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
|
||
return await this.adminService.resetPassword(BigInt(id), dto.newPassword);
|
||
}
|
||
|
||
@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();
|
||
|
||
// 验证日志目录
|
||
const dirValidation = await this.validateLogDirectory(logDir, res);
|
||
if (!dirValidation.isValid) {
|
||
return;
|
||
}
|
||
|
||
// 设置响应头
|
||
this.setArchiveResponseHeaders(res);
|
||
|
||
// 创建并处理tar进程
|
||
await this.createAndHandleTarProcess(logDir, res);
|
||
}
|
||
|
||
/**
|
||
* 验证日志目录是否存在且可用
|
||
*
|
||
* @param logDir 日志目录路径
|
||
* @param res 响应对象
|
||
* @returns 验证结果
|
||
*/
|
||
private async validateLogDirectory(logDir: string, res: Response): Promise<{ isValid: boolean }> {
|
||
try {
|
||
const stats = await fs.promises.stat(logDir);
|
||
if (!stats.isDirectory()) {
|
||
res.status(404).json({ success: false, message: '日志目录不可用' });
|
||
return { isValid: false };
|
||
}
|
||
return { isValid: true };
|
||
} catch (error) {
|
||
res.status(404).json({ success: false, message: '日志目录不存在' });
|
||
return { isValid: false };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置文件下载的响应头
|
||
*
|
||
* @param res 响应对象
|
||
*/
|
||
private setArchiveResponseHeaders(res: Response): void {
|
||
const ts = getCurrentTimestamp().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');
|
||
}
|
||
|
||
/**
|
||
* 创建并处理tar进程
|
||
*
|
||
* @param logDir 日志目录路径
|
||
* @param res 响应对象
|
||
*/
|
||
private async createAndHandleTarProcess(logDir: string, res: Response): Promise<void> {
|
||
const parentDir = path.dirname(logDir);
|
||
const baseName = path.basename(logDir);
|
||
|
||
const tar = spawn('tar', ['-czf', '-', '-C', parentDir, baseName], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
|
||
// 处理tar进程的stderr输出
|
||
tar.stderr.on('data', (chunk: Buffer) => {
|
||
const msg = chunk.toString('utf8').trim();
|
||
if (msg) {
|
||
this.logger.warn(`tar stderr: ${msg}`);
|
||
}
|
||
});
|
||
|
||
// 处理tar进程错误
|
||
tar.on('error', (err: any) => {
|
||
this.handleTarProcessError(err, res);
|
||
});
|
||
|
||
// 处理数据流和进程退出
|
||
await this.handleTarStreams(tar, res);
|
||
}
|
||
|
||
/**
|
||
* 处理tar进程错误
|
||
*
|
||
* @param err 错误对象
|
||
* @param res 响应对象
|
||
*/
|
||
private handleTarProcessError(err: any, res: Response): void {
|
||
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();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理tar进程的数据流和退出
|
||
*
|
||
* @param tar tar进程
|
||
* @param res 响应对象
|
||
*/
|
||
private async handleTarStreams(tar: any, res: Response): Promise<void> {
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
}
|