Files
whale-town-end/src/business/admin/admin.controller.ts
moyin 5f662ef091 feat: 完善管理员系统和用户管理模块
- 更新管理员控制器和数据库管理功能
- 完善管理员操作日志系统
- 添加全面的属性测试覆盖
- 优化用户管理和用户档案服务
- 更新代码检查规范文档

功能改进:
- 增强管理员权限验证
- 完善操作日志记录
- 优化数据库管理接口
- 提升系统安全性和可维护性
2026-01-09 17:05:08 +08:00

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 管理员控制器
*
* 功能描述:
* - 提供管理员登录认证接口
* - 提供用户管理相关接口(查询、重置密码)
* - 提供系统日志查询和下载功能
*
* 职责分离:
* - 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();
}
}
}
}