/** * 管理员控制器 * * 功能描述: * - 提供管理员登录认证接口 * - 提供用户管理相关接口(查询、重置密码) * - 提供系统日志查询和下载功能 * * 职责分离: * - 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 { 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 { 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(); } } } }