diff --git a/src/business/admin/admin.controller.ts b/src/business/admin/admin.controller.ts index 5db7353..cdf2b16 100644 --- a/src/business/admin/admin.controller.ts +++ b/src/business/admin/admin.controller.ts @@ -15,10 +15,17 @@ 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 { AdminGuard } from './guards/admin.guard'; import { AdminService } from './admin.service'; -import { AdminLoginDto, AdminResetPasswordDto } from '../../dto/admin.dto'; -import { AdminLoginResponseDto, AdminUsersResponseDto, AdminCommonResponseDto, AdminUserResponseDto, AdminRuntimeLogsResponseDto } from '../../dto/admin_response.dto'; +import { AdminLoginDto, AdminResetPasswordDto } from './dto/admin-login.dto'; +import { + AdminLoginResponseDto, + AdminUsersResponseDto, + AdminCommonResponseDto, + AdminUserResponseDto, + AdminRuntimeLogsResponseDto +} from './dto/admin-response.dto'; +import { Throttle, ThrottlePresets } from '../security/decorators/throttle.decorator'; import type { Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; @@ -35,6 +42,10 @@ export class AdminController { @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 })) @@ -73,7 +84,9 @@ export class AdminController { @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 })) diff --git a/src/business/admin/admin.module.ts b/src/business/admin/admin.module.ts index 5927865..3970dbc 100644 --- a/src/business/admin/admin.module.ts +++ b/src/business/admin/admin.module.ts @@ -21,5 +21,6 @@ import { AdminService } from './admin.service'; imports: [AdminCoreModule, LoggerModule], controllers: [AdminController], providers: [AdminService], + exports: [AdminService], // 导出AdminService供其他模块使用 }) export class AdminModule {} diff --git a/src/business/admin/admin.service.ts b/src/business/admin/admin.service.ts index ecd94e7..72db880 100644 --- a/src/business/admin/admin.service.ts +++ b/src/business/admin/admin.service.ts @@ -11,12 +11,21 @@ * @since 2025-12-19 */ -import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; 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'; +import { UserStatus, getUserStatusDescription } from '../user-mgmt/enums/user-status.enum'; +import { UserStatusDto, BatchUserStatusDto } from '../user-mgmt/dto/user-status.dto'; +import { + UserStatusResponseDto, + BatchUserStatusResponseDto, + UserStatusStatsResponseDto, + UserStatusInfoDto, + BatchOperationResultDto +} from '../user-mgmt/dto/user-status-response.dto'; export interface AdminApiResponse { success: boolean; @@ -108,8 +117,318 @@ export class AdminService { phone: user.phone, avatar_url: user.avatar_url, role: user.role, + status: user.status || UserStatus.ACTIVE, // 兼容旧数据 created_at: user.created_at, updated_at: user.updated_at, }; } + + /** + * 格式化用户状态信息 + * + * @param user 用户实体 + * @returns 格式化的用户状态信息 + */ + private formatUserStatus(user: Users): UserStatusInfoDto { + return { + id: user.id.toString(), + username: user.username, + nickname: user.nickname, + status: user.status || UserStatus.ACTIVE, + status_description: getUserStatusDescription(user.status || UserStatus.ACTIVE), + updated_at: user.updated_at + }; + } + + /** + * 修改用户状态 + * + * 功能描述: + * 管理员修改指定用户的账户状态,支持激活、锁定、禁用等操作 + * + * 业务逻辑: + * 1. 验证用户是否存在 + * 2. 检查状态变更的合法性 + * 3. 更新用户状态 + * 4. 记录状态变更日志 + * + * @param userId 用户ID + * @param userStatusDto 状态修改数据 + * @returns 修改结果 + * + * @throws NotFoundException 当用户不存在时 + * @throws BadRequestException 当状态变更不合法时 + */ + async updateUserStatus(userId: bigint, userStatusDto: UserStatusDto): Promise { + try { + this.logger.log('开始修改用户状态', { + operation: 'update_user_status', + userId: userId.toString(), + newStatus: userStatusDto.status, + reason: userStatusDto.reason, + timestamp: new Date().toISOString() + }); + + // 1. 验证用户是否存在 + const user = await this.usersService.findOne(userId); + if (!user) { + this.logger.warn('修改用户状态失败:用户不存在', { + operation: 'update_user_status', + userId: userId.toString() + }); + throw new NotFoundException('用户不存在'); + } + + // 2. 检查状态变更的合法性 + if (user.status === userStatusDto.status) { + this.logger.warn('修改用户状态失败:状态未发生变化', { + operation: 'update_user_status', + userId: userId.toString(), + currentStatus: user.status, + newStatus: userStatusDto.status + }); + throw new BadRequestException('用户状态未发生变化'); + } + + // 3. 更新用户状态 + const updatedUser = await this.usersService.update(userId, { + status: userStatusDto.status + }); + + // 4. 记录状态变更日志 + this.logger.log('用户状态修改成功', { + operation: 'update_user_status', + userId: userId.toString(), + oldStatus: user.status, + newStatus: userStatusDto.status, + reason: userStatusDto.reason, + timestamp: new Date().toISOString() + }); + + return { + success: true, + data: { + user: this.formatUserStatus(updatedUser), + reason: userStatusDto.reason + }, + message: '用户状态修改成功' + }; + + } catch (error) { + this.logger.error('修改用户状态失败', { + operation: 'update_user_status', + userId: userId.toString(), + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString() + }); + + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + + return { + success: false, + message: '用户状态修改失败', + error_code: 'USER_STATUS_UPDATE_FAILED' + }; + } + } + + /** + * 批量修改用户状态 + * + * 功能描述: + * 管理员批量修改多个用户的账户状态 + * + * 业务逻辑: + * 1. 验证用户ID列表 + * 2. 逐个处理用户状态修改 + * 3. 收集成功和失败的结果 + * 4. 返回批量操作结果 + * + * @param batchUserStatusDto 批量状态修改数据 + * @returns 批量修改结果 + */ + async batchUpdateUserStatus(batchUserStatusDto: BatchUserStatusDto): Promise { + try { + this.logger.log('开始批量修改用户状态', { + operation: 'batch_update_user_status', + userCount: batchUserStatusDto.user_ids.length, + newStatus: batchUserStatusDto.status, + reason: batchUserStatusDto.reason, + timestamp: new Date().toISOString() + }); + + const successUsers: UserStatusInfoDto[] = []; + const failedUsers: Array<{ user_id: string; error: string }> = []; + + // 1. 逐个处理用户状态修改 + for (const userIdStr of batchUserStatusDto.user_ids) { + try { + const userId = BigInt(userIdStr); + + // 2. 验证用户是否存在 + const user = await this.usersService.findOne(userId); + if (!user) { + failedUsers.push({ + user_id: userIdStr, + error: '用户不存在' + }); + continue; + } + + // 3. 检查状态是否需要变更 + if (user.status === batchUserStatusDto.status) { + failedUsers.push({ + user_id: userIdStr, + error: '用户状态未发生变化' + }); + continue; + } + + // 4. 更新用户状态 + const updatedUser = await this.usersService.update(userId, { + status: batchUserStatusDto.status + }); + + successUsers.push(this.formatUserStatus(updatedUser)); + + } catch (error) { + failedUsers.push({ + user_id: userIdStr, + error: error instanceof Error ? error.message : '未知错误' + }); + } + } + + // 5. 构建批量操作结果 + const result: BatchOperationResultDto = { + success_users: successUsers, + failed_users: failedUsers, + success_count: successUsers.length, + failed_count: failedUsers.length, + total_count: batchUserStatusDto.user_ids.length + }; + + this.logger.log('批量修改用户状态完成', { + operation: 'batch_update_user_status', + successCount: result.success_count, + failedCount: result.failed_count, + totalCount: result.total_count, + timestamp: new Date().toISOString() + }); + + return { + success: true, + data: { + result, + reason: batchUserStatusDto.reason + }, + message: `批量用户状态修改完成,成功:${result.success_count},失败:${result.failed_count}` + }; + + } catch (error) { + this.logger.error('批量修改用户状态失败', { + operation: 'batch_update_user_status', + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString() + }); + + return { + success: false, + message: '批量用户状态修改失败', + error_code: 'BATCH_USER_STATUS_UPDATE_FAILED' + }; + } + } + + /** + * 获取用户状态统计 + * + * 功能描述: + * 获取各种用户状态的数量统计信息 + * + * 业务逻辑: + * 1. 查询所有用户 + * 2. 按状态分组统计 + * 3. 计算各状态数量 + * 4. 返回统计结果 + * + * @returns 状态统计信息 + */ + async getUserStatusStats(): Promise { + try { + this.logger.log('开始获取用户状态统计', { + operation: 'get_user_status_stats', + timestamp: new Date().toISOString() + }); + + // 1. 查询所有用户(这里可以优化为直接查询统计信息) + const allUsers = await this.usersService.findAll(10000, 0); // 假设最多1万用户 + + // 2. 按状态分组统计 + const stats = { + active: 0, + inactive: 0, + locked: 0, + banned: 0, + deleted: 0, + pending: 0, + total: allUsers.length + }; + + // 3. 计算各状态数量 + allUsers.forEach((user: Users) => { + const status = user.status || UserStatus.ACTIVE; + switch (status) { + case UserStatus.ACTIVE: + stats.active++; + break; + case UserStatus.INACTIVE: + stats.inactive++; + break; + case UserStatus.LOCKED: + stats.locked++; + break; + case UserStatus.BANNED: + stats.banned++; + break; + case UserStatus.DELETED: + stats.deleted++; + break; + case UserStatus.PENDING: + stats.pending++; + break; + } + }); + + this.logger.log('用户状态统计获取成功', { + operation: 'get_user_status_stats', + stats, + timestamp: new Date().toISOString() + }); + + return { + success: true, + data: { + stats, + timestamp: new Date().toISOString() + }, + message: '用户状态统计获取成功' + }; + + } catch (error) { + this.logger.error('获取用户状态统计失败', { + operation: 'get_user_status_stats', + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString() + }); + + return { + success: false, + message: '用户状态统计获取失败', + error_code: 'USER_STATUS_STATS_FAILED' + }; + } + } } diff --git a/src/business/admin/dto/admin-login.dto.ts b/src/business/admin/dto/admin-login.dto.ts new file mode 100644 index 0000000..e30099c --- /dev/null +++ b/src/business/admin/dto/admin-login.dto.ts @@ -0,0 +1,34 @@ +/** + * 管理员相关 DTO + * + * 功能描述: + * - 定义管理员登录与用户密码重置的请求结构 + * - 使用 class-validator 进行参数校验 + * + * @author jianuo + * @version 1.0.0 + * @since 2025-12-19 + */ + +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class AdminLoginDto { + @ApiProperty({ description: '登录标识符(用户名/邮箱/手机号)', example: 'admin' }) + @IsString() + @IsNotEmpty() + identifier: string; + + @ApiProperty({ description: '密码', example: 'Admin123456' }) + @IsString() + @IsNotEmpty() + password: string; +} + +export class AdminResetPasswordDto { + @ApiProperty({ description: '新密码(至少8位,包含字母和数字)', example: 'NewPass1234' }) + @IsString() + @IsNotEmpty() + @MinLength(8) + new_password: string; +} \ No newline at end of file diff --git a/src/business/admin/dto/admin-response.dto.ts b/src/business/admin/dto/admin-response.dto.ts new file mode 100644 index 0000000..4b15a0c --- /dev/null +++ b/src/business/admin/dto/admin-response.dto.ts @@ -0,0 +1,104 @@ +/** + * 管理员响应 DTO + * + * 功能描述: + * - 定义管理员相关接口的响应格式 + * - 提供 Swagger 文档生成支持 + * + * @author jianuo + * @version 1.0.0 + * @since 2025-12-19 + */ + +import { ApiProperty } from '@nestjs/swagger'; + +export class AdminLoginResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '消息', example: '登录成功' }) + message: string; + + @ApiProperty({ description: 'JWT Token', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }) + token?: string; + + @ApiProperty({ description: '管理员信息', required: false }) + admin?: { + id: string; + username: string; + email: string; + role: number; + }; +} + +export class AdminUsersResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '消息', example: '获取用户列表成功' }) + message: string; + + @ApiProperty({ description: '用户列表', type: 'array' }) + users?: Array<{ + id: string; + username: string; + email: string; + phone: string; + role: number; + status: string; + created_at: string; + updated_at: string; + }>; + + @ApiProperty({ description: '总数', example: 100 }) + total?: number; + + @ApiProperty({ description: '偏移量', example: 0 }) + offset?: number; + + @ApiProperty({ description: '限制数量', example: 100 }) + limit?: number; +} + +export class AdminUserResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '消息', example: '获取用户详情成功' }) + message: string; + + @ApiProperty({ description: '用户信息', required: false }) + user?: { + id: string; + username: string; + email: string; + phone: string; + role: number; + status: string; + created_at: string; + updated_at: string; + last_login_at?: string; + }; +} + +export class AdminCommonResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '消息', example: '操作成功' }) + message: string; +} + +export class AdminRuntimeLogsResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '消息', example: '获取日志成功' }) + message: string; + + @ApiProperty({ description: '日志内容', type: 'array', items: { type: 'string' } }) + logs?: string[]; + + @ApiProperty({ description: '返回行数', example: 200 }) + lines?: number; +} \ No newline at end of file diff --git a/src/business/admin/guards/admin.guard.ts b/src/business/admin/guards/admin.guard.ts new file mode 100644 index 0000000..e3c0d9d --- /dev/null +++ b/src/business/admin/guards/admin.guard.ts @@ -0,0 +1,43 @@ +/** + * 管理员鉴权守卫 + * + * 功能描述: + * - 保护后台管理接口 + * - 校验 Authorization: Bearer + * - 仅允许 role=9 的管理员访问 + * + * @author jianuo + * @version 1.0.0 + * @since 2025-12-19 + */ + +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Request } from 'express'; +import { AdminCoreService, AdminAuthPayload } from '../../../core/admin_core/admin_core.service'; + +export interface AdminRequest extends Request { + admin?: AdminAuthPayload; +} + +@Injectable() +export class AdminGuard implements CanActivate { + constructor(private readonly adminCoreService: AdminCoreService) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + const auth = req.headers['authorization']; + + if (!auth || Array.isArray(auth)) { + throw new UnauthorizedException('缺少Authorization头'); + } + + const [scheme, token] = auth.split(' '); + if (scheme !== 'Bearer' || !token) { + throw new UnauthorizedException('Authorization格式错误'); + } + + const payload = this.adminCoreService.verifyToken(token); + req.admin = payload; + return true; + } +} diff --git a/src/business/admin/index.ts b/src/business/admin/index.ts new file mode 100644 index 0000000..42b0cad --- /dev/null +++ b/src/business/admin/index.ts @@ -0,0 +1,24 @@ +/** + * 管理员模块统一导出 + * + * 功能描述: + * - 导出管理员相关的所有组件 + * - 提供统一的导入入口 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +// 控制器 +export * from './admin.controller'; + +// 服务 +export * from './admin.service'; + +// DTO +export * from './dto/admin-login.dto'; +export * from './dto/admin-response.dto'; + +// 模块 +export * from './admin.module'; \ No newline at end of file diff --git a/src/business/admin/tests b/src/business/admin/tests new file mode 100644 index 0000000..86df850 --- /dev/null +++ b/src/business/admin/tests @@ -0,0 +1,81 @@ +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { AdminCoreService, AdminAuthPayload } from '../admin_core/admin_core.service'; +import { AdminGuard } from './admin.guard'; + +describe('AdminGuard', () => { + const payload: AdminAuthPayload = { + adminId: '1', + username: 'admin', + role: 9, + iat: 1, + exp: 2, + }; + + const adminCoreServiceMock: Pick = { + verifyToken: jest.fn(), + }; + + const makeContext = (authorization?: any) => { + const req: any = { headers: {} }; + if (authorization !== undefined) { + req.headers['authorization'] = authorization; + } + + const ctx: Partial = { + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => ({} as any), + getNext: () => ({} as any), + }), + }; + + return { ctx: ctx as ExecutionContext, req }; + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should allow access with valid admin token', () => { + (adminCoreServiceMock.verifyToken as jest.Mock).mockReturnValue(payload); + + const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService); + const { ctx, req } = makeContext('Bearer valid'); + + expect(guard.canActivate(ctx)).toBe(true); + expect(adminCoreServiceMock.verifyToken).toHaveBeenCalledWith('valid'); + expect(req.admin).toEqual(payload); + }); + + it('should deny access without token', () => { + const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService); + const { ctx } = makeContext(undefined); + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + }); + + it('should deny access with invalid Authorization format', () => { + const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService); + const { ctx } = makeContext('InvalidFormat'); + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + }); + + it('should deny access when verifyToken throws (invalid/expired)', () => { + (adminCoreServiceMock.verifyToken as jest.Mock).mockImplementation(() => { + throw new UnauthorizedException('Token已过期'); + }); + + const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService); + const { ctx } = makeContext('Bearer bad'); + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + }); + + it('should deny access when Authorization header is an array', () => { + const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService); + const { ctx } = makeContext(['Bearer token']); + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + }); +}); diff --git a/src/business/auth/auth.module.ts b/src/business/auth/auth.module.ts new file mode 100644 index 0000000..28e8065 --- /dev/null +++ b/src/business/auth/auth.module.ts @@ -0,0 +1,26 @@ +/** + * 用户认证业务模块 + * + * 功能描述: + * - 整合所有用户认证相关功能 + * - 用户登录、注册、密码管理 + * - GitHub OAuth集成 + * - 邮箱验证功能 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { Module } from '@nestjs/common'; +import { LoginController } from './controllers/login.controller'; +import { LoginService } from './services/login.service'; +import { LoginCoreModule } from '../../core/login_core/login_core.module'; + +@Module({ + imports: [LoginCoreModule], + controllers: [LoginController], + providers: [LoginService], + exports: [LoginService], +}) +export class AuthModule {} \ No newline at end of file diff --git a/src/business/login/login.controller.ts b/src/business/auth/controllers/login.controller.ts similarity index 91% rename from src/business/login/login.controller.ts rename to src/business/auth/controllers/login.controller.ts index dc117c5..7c6fdea 100644 --- a/src/business/login/login.controller.ts +++ b/src/business/auth/controllers/login.controller.ts @@ -22,8 +22,8 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger, Res } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger'; import { Response } from 'express'; -import { LoginService, ApiResponse, LoginResponse } from './login.service'; -import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from '../../dto/login.dto'; +import { LoginService, ApiResponse, LoginResponse } from '../services/login.service'; +import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from '../dto/login.dto'; import { LoginResponseDto, RegisterResponseDto, @@ -32,7 +32,9 @@ import { CommonResponseDto, TestModeEmailVerificationResponseDto, SuccessEmailVerificationResponseDto -} from '../../dto/login_response.dto'; +} from '../dto/login_response.dto'; +import { Throttle, ThrottlePresets } from '../../security/decorators/throttle.decorator'; +import { Timeout, TimeoutPresets } from '../../security/decorators/timeout.decorator'; @ApiTags('auth') @Controller('auth') @@ -65,6 +67,16 @@ export class LoginController { status: 401, description: '用户名或密码错误' }) + @SwaggerApiResponse({ + status: 403, + description: '账户被禁用或锁定' + }) + @SwaggerApiResponse({ + status: 429, + description: '登录尝试过于频繁' + }) + @Throttle(ThrottlePresets.LOGIN) + @Timeout(TimeoutPresets.NORMAL) @Post('login') @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true })) @@ -99,6 +111,12 @@ export class LoginController { status: 409, description: '用户名或邮箱已存在' }) + @SwaggerApiResponse({ + status: 429, + description: '注册请求过于频繁' + }) + @Throttle(ThrottlePresets.REGISTER) + @Timeout(TimeoutPresets.NORMAL) @Post('register') @HttpCode(HttpStatus.CREATED) @UsePipes(new ValidationPipe({ transform: true })) @@ -180,6 +198,11 @@ export class LoginController { status: 404, description: '用户不存在' }) + @SwaggerApiResponse({ + status: 429, + description: '发送频率过高' + }) + @Throttle(ThrottlePresets.SEND_CODE) @Post('forgot-password') @UsePipes(new ValidationPipe({ transform: true })) async forgotPassword( @@ -222,6 +245,11 @@ export class LoginController { status: 404, description: '用户不存在' }) + @SwaggerApiResponse({ + status: 429, + description: '重置请求过于频繁' + }) + @Throttle(ThrottlePresets.RESET_PASSWORD) @Post('reset-password') @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true })) @@ -302,6 +330,8 @@ export class LoginController { status: 429, description: '发送频率过高' }) + @Throttle(ThrottlePresets.SEND_CODE) + @Timeout(TimeoutPresets.EMAIL_SEND) @Post('send-email-verification') @UsePipes(new ValidationPipe({ transform: true })) async sendEmailVerification( @@ -380,6 +410,7 @@ export class LoginController { status: 429, description: '发送频率过高' }) + @Throttle(ThrottlePresets.SEND_CODE) @Post('resend-email-verification') @UsePipes(new ValidationPipe({ transform: true })) async resendEmailVerification( diff --git a/src/business/auth/dto/login.dto.ts b/src/business/auth/dto/login.dto.ts new file mode 100644 index 0000000..6fa11f0 --- /dev/null +++ b/src/business/auth/dto/login.dto.ts @@ -0,0 +1,374 @@ +/** + * 登录业务数据传输对象 + * + * 功能描述: + * - 定义登录相关API的请求数据结构 + * - 提供数据验证规则和错误提示 + * - 确保API接口的数据格式一致性 + * + * @author moyin + * @version 1.0.0 + * @since 2025-12-17 + */ + +import { + IsString, + IsEmail, + IsPhoneNumber, + IsNotEmpty, + Length, + IsOptional, + Matches, + IsNumberString +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 登录请求DTO + */ +export class LoginDto { + /** + * 登录标识符 + * 支持用户名、邮箱或手机号登录 + */ + @ApiProperty({ + description: '登录标识符,支持用户名、邮箱或手机号', + example: 'testuser', + minLength: 1, + maxLength: 100 + }) + @IsString({ message: '登录标识符必须是字符串' }) + @IsNotEmpty({ message: '登录标识符不能为空' }) + @Length(1, 100, { message: '登录标识符长度需在1-100字符之间' }) + identifier: string; + + /** + * 密码 + */ + @ApiProperty({ + description: '用户密码', + example: 'password123', + minLength: 1, + maxLength: 128 + }) + @IsString({ message: '密码必须是字符串' }) + @IsNotEmpty({ message: '密码不能为空' }) + @Length(1, 128, { message: '密码长度需在1-128字符之间' }) + password: string; +} + +/** + * 注册请求DTO + */ +export class RegisterDto { + /** + * 用户名 + */ + @ApiProperty({ + description: '用户名,只能包含字母、数字和下划线', + example: 'testuser', + minLength: 1, + maxLength: 50, + pattern: '^[a-zA-Z0-9_]+$' + }) + @IsString({ message: '用户名必须是字符串' }) + @IsNotEmpty({ message: '用户名不能为空' }) + @Length(1, 50, { message: '用户名长度需在1-50字符之间' }) + @Matches(/^[a-zA-Z0-9_]+$/, { message: '用户名只能包含字母、数字和下划线' }) + username: string; + + /** + * 密码 + */ + @ApiProperty({ + description: '密码,必须包含字母和数字,长度8-128字符', + example: 'password123', + minLength: 8, + maxLength: 128 + }) + @IsString({ message: '密码必须是字符串' }) + @IsNotEmpty({ message: '密码不能为空' }) + @Length(8, 128, { message: '密码长度需在8-128字符之间' }) + @Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '密码必须包含字母和数字' }) + password: string; + + /** + * 昵称 + */ + @ApiProperty({ + description: '用户昵称', + example: '测试用户', + minLength: 1, + maxLength: 50 + }) + @IsString({ message: '昵称必须是字符串' }) + @IsNotEmpty({ message: '昵称不能为空' }) + @Length(1, 50, { message: '昵称长度需在1-50字符之间' }) + nickname: string; + + /** + * 邮箱(可选) + */ + @ApiProperty({ + description: '邮箱地址(可选)', + example: 'test@example.com', + required: false + }) + @IsOptional() + @IsEmail({}, { message: '邮箱格式不正确' }) + email?: string; + + /** + * 手机号(可选) + */ + @ApiProperty({ + description: '手机号码(可选)', + example: '+8613800138000', + required: false + }) + @IsOptional() + @IsPhoneNumber(null, { message: '手机号格式不正确' }) + phone?: string; + + /** + * 邮箱验证码(当提供邮箱时必填) + */ + @ApiProperty({ + description: '邮箱验证码,当提供邮箱时必填', + example: '123456', + pattern: '^\\d{6}$', + required: false + }) + @IsOptional() + @IsString({ message: '验证码必须是字符串' }) + @Matches(/^\d{6}$/, { message: '验证码必须是6位数字' }) + email_verification_code?: string; +} + +/** + * GitHub OAuth登录请求DTO + */ +export class GitHubOAuthDto { + /** + * GitHub用户ID + */ + @ApiProperty({ + description: 'GitHub用户ID', + example: '12345678', + minLength: 1, + maxLength: 100 + }) + @IsString({ message: 'GitHub ID必须是字符串' }) + @IsNotEmpty({ message: 'GitHub ID不能为空' }) + @Length(1, 100, { message: 'GitHub ID长度需在1-100字符之间' }) + github_id: string; + + /** + * 用户名 + */ + @ApiProperty({ + description: 'GitHub用户名', + example: 'octocat', + minLength: 1, + maxLength: 50 + }) + @IsString({ message: '用户名必须是字符串' }) + @IsNotEmpty({ message: '用户名不能为空' }) + @Length(1, 50, { message: '用户名长度需在1-50字符之间' }) + username: string; + + /** + * 昵称 + */ + @ApiProperty({ + description: 'GitHub显示名称', + example: 'The Octocat', + minLength: 1, + maxLength: 50 + }) + @IsString({ message: '昵称必须是字符串' }) + @IsNotEmpty({ message: '昵称不能为空' }) + @Length(1, 50, { message: '昵称长度需在1-50字符之间' }) + nickname: string; + + /** + * 邮箱(可选) + */ + @ApiProperty({ + description: 'GitHub邮箱地址(可选)', + example: 'octocat@github.com', + required: false + }) + @IsOptional() + @IsEmail({}, { message: '邮箱格式不正确' }) + email?: string; + + /** + * 头像URL(可选) + */ + @ApiProperty({ + description: 'GitHub头像URL(可选)', + example: 'https://github.com/images/error/octocat_happy.gif', + required: false + }) + @IsOptional() + @IsString({ message: '头像URL必须是字符串' }) + avatar_url?: string; +} + +/** + * 忘记密码请求DTO + */ +export class ForgotPasswordDto { + /** + * 邮箱或手机号 + */ + @ApiProperty({ + description: '邮箱或手机号', + example: 'test@example.com', + minLength: 1, + maxLength: 100 + }) + @IsString({ message: '标识符必须是字符串' }) + @IsNotEmpty({ message: '邮箱或手机号不能为空' }) + @Length(1, 100, { message: '标识符长度需在1-100字符之间' }) + identifier: string; +} + +/** + * 重置密码请求DTO + */ +export class ResetPasswordDto { + /** + * 邮箱或手机号 + */ + @ApiProperty({ + description: '邮箱或手机号', + example: 'test@example.com', + minLength: 1, + maxLength: 100 + }) + @IsString({ message: '标识符必须是字符串' }) + @IsNotEmpty({ message: '邮箱或手机号不能为空' }) + @Length(1, 100, { message: '标识符长度需在1-100字符之间' }) + identifier: string; + + /** + * 验证码 + */ + @ApiProperty({ + description: '6位数字验证码', + example: '123456', + pattern: '^\\d{6}$' + }) + @IsString({ message: '验证码必须是字符串' }) + @IsNotEmpty({ message: '验证码不能为空' }) + @Matches(/^\d{6}$/, { message: '验证码必须是6位数字' }) + verification_code: string; + + /** + * 新密码 + */ + @ApiProperty({ + description: '新密码,必须包含字母和数字,长度8-128字符', + example: 'newpassword123', + minLength: 8, + maxLength: 128 + }) + @IsString({ message: '新密码必须是字符串' }) + @IsNotEmpty({ message: '新密码不能为空' }) + @Length(8, 128, { message: '新密码长度需在8-128字符之间' }) + @Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' }) + new_password: string; +} + +/** + * 修改密码请求DTO + */ +export class ChangePasswordDto { + /** + * 用户ID + * 实际应用中应从JWT令牌中获取,这里为了演示放在请求体中 + */ + @ApiProperty({ + description: '用户ID(实际应用中应从JWT令牌中获取)', + example: '1' + }) + @IsNumberString({}, { message: '用户ID必须是数字字符串' }) + @IsNotEmpty({ message: '用户ID不能为空' }) + user_id: string; + + /** + * 旧密码 + */ + @ApiProperty({ + description: '当前密码', + example: 'oldpassword123', + minLength: 1, + maxLength: 128 + }) + @IsString({ message: '旧密码必须是字符串' }) + @IsNotEmpty({ message: '旧密码不能为空' }) + @Length(1, 128, { message: '旧密码长度需在1-128字符之间' }) + old_password: string; + + /** + * 新密码 + */ + @ApiProperty({ + description: '新密码,必须包含字母和数字,长度8-128字符', + example: 'newpassword123', + minLength: 8, + maxLength: 128 + }) + @IsString({ message: '新密码必须是字符串' }) + @IsNotEmpty({ message: '新密码不能为空' }) + @Length(8, 128, { message: '新密码长度需在8-128字符之间' }) + @Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' }) + new_password: string; +} + +/** + * 邮箱验证请求DTO + */ +export class EmailVerificationDto { + /** + * 邮箱地址 + */ + @ApiProperty({ + description: '邮箱地址', + example: 'test@example.com' + }) + @IsEmail({}, { message: '邮箱格式不正确' }) + @IsNotEmpty({ message: '邮箱不能为空' }) + email: string; + + /** + * 验证码 + */ + @ApiProperty({ + description: '6位数字验证码', + example: '123456', + pattern: '^\\d{6}$' + }) + @IsString({ message: '验证码必须是字符串' }) + @IsNotEmpty({ message: '验证码不能为空' }) + @Matches(/^\d{6}$/, { message: '验证码必须是6位数字' }) + verification_code: string; +} + +/** + * 发送邮箱验证码请求DTO + */ +export class SendEmailVerificationDto { + /** + * 邮箱地址 + */ + @ApiProperty({ + description: '邮箱地址', + example: 'test@example.com' + }) + @IsEmail({}, { message: '邮箱格式不正确' }) + @IsNotEmpty({ message: '邮箱不能为空' }) + email: string; +} \ No newline at end of file diff --git a/src/business/auth/dto/login_response.dto.ts b/src/business/auth/dto/login_response.dto.ts new file mode 100644 index 0000000..ef853f2 --- /dev/null +++ b/src/business/auth/dto/login_response.dto.ts @@ -0,0 +1,395 @@ +/** + * 登录业务响应数据传输对象 + * + * 功能描述: + * - 定义登录相关API的响应数据结构 + * - 提供Swagger文档生成支持 + * - 确保API响应的数据格式一致性 + * + * @author moyin + * @version 1.0.0 + * @since 2025-12-17 + */ + +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 用户信息响应DTO + */ +export class UserInfoDto { + @ApiProperty({ + description: '用户ID', + example: '1' + }) + id: string; + + @ApiProperty({ + description: '用户名', + example: 'testuser' + }) + username: string; + + @ApiProperty({ + description: '用户昵称', + example: '测试用户' + }) + nickname: string; + + @ApiProperty({ + description: '邮箱地址', + example: 'test@example.com', + required: false + }) + email?: string; + + @ApiProperty({ + description: '手机号码', + example: '+8613800138000', + required: false + }) + phone?: string; + + @ApiProperty({ + description: '头像URL', + example: 'https://example.com/avatar.jpg', + required: false + }) + avatar_url?: string; + + @ApiProperty({ + description: '用户角色', + example: 1 + }) + role: number; + + @ApiProperty({ + description: '创建时间', + example: '2025-12-17T10:00:00.000Z' + }) + created_at: Date; +} + +/** + * 登录响应数据DTO + */ +export class LoginResponseDataDto { + @ApiProperty({ + description: '用户信息', + type: UserInfoDto + }) + user: UserInfoDto; + + @ApiProperty({ + description: '访问令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' + }) + access_token: string; + + @ApiProperty({ + description: '刷新令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + required: false + }) + refresh_token?: string; + + @ApiProperty({ + description: '是否为新用户', + example: false, + required: false + }) + is_new_user?: boolean; + + @ApiProperty({ + description: '响应消息', + example: '登录成功' + }) + message: string; +} + +/** + * 登录响应DTO + */ +export class LoginResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: LoginResponseDataDto, + required: false + }) + data?: LoginResponseDataDto; + + @ApiProperty({ + description: '响应消息', + example: '登录成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'LOGIN_FAILED', + required: false + }) + error_code?: string; +} + +/** + * 注册响应DTO + */ +export class RegisterResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: LoginResponseDataDto, + required: false + }) + data?: LoginResponseDataDto; + + @ApiProperty({ + description: '响应消息', + example: '注册成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'REGISTER_FAILED', + required: false + }) + error_code?: string; +} + +/** + * GitHub OAuth响应DTO + */ +export class GitHubOAuthResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: LoginResponseDataDto, + required: false + }) + data?: LoginResponseDataDto; + + @ApiProperty({ + description: '响应消息', + example: 'GitHub登录成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'GITHUB_OAUTH_FAILED', + required: false + }) + error_code?: string; +} + +/** + * 忘记密码响应数据DTO + */ +export class ForgotPasswordResponseDataDto { + @ApiProperty({ + description: '验证码(仅用于演示,实际应用中不应返回)', + example: '123456', + required: false + }) + verification_code?: string; + + @ApiProperty({ + description: '是否为测试模式', + example: true, + required: false + }) + is_test_mode?: boolean; +} + +/** + * 忘记密码响应DTO + */ +export class ForgotPasswordResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: false, + examples: { + success: { + summary: '真实发送成功', + value: true + }, + testMode: { + summary: '测试模式', + value: false + } + } + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: ForgotPasswordResponseDataDto, + required: false, + examples: { + success: { + summary: '真实发送成功', + value: { + verification_code: '123456', + is_test_mode: false + } + }, + testMode: { + summary: '测试模式', + value: { + verification_code: '059174', + is_test_mode: true + } + } + } + }) + data?: ForgotPasswordResponseDataDto; + + @ApiProperty({ + description: '响应消息', + example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', + examples: { + success: { + summary: '真实发送成功', + value: '验证码已发送,请查收' + }, + testMode: { + summary: '测试模式', + value: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。' + } + } + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'TEST_MODE_ONLY', + examples: { + success: { + summary: '真实发送成功', + value: null + }, + testMode: { + summary: '测试模式', + value: 'TEST_MODE_ONLY' + }, + failed: { + summary: '发送失败', + value: 'SEND_CODE_FAILED' + } + }, + required: false + }) + error_code?: string; +} + +/** + * 通用响应DTO(用于重置密码、修改密码等) + */ +export class CommonResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应消息', + example: '操作成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'OPERATION_FAILED', + required: false + }) + error_code?: string; +} + +/** + * 测试模式邮件验证码响应DTO by angjustinl 2025-12-17 + */ +export class TestModeEmailVerificationResponseDto { + @ApiProperty({ + description: '请求是否成功(测试模式下为false)', + example: false + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + example: { + verification_code: '059174', + is_test_mode: true + } + }) + data: { + verification_code: string; + is_test_mode: boolean; + }; + + @ApiProperty({ + description: '响应消息', + example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'TEST_MODE_ONLY' + }) + error_code: string; +} + +/** + * 成功发送邮件验证码响应DTO + */ +export class SuccessEmailVerificationResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + example: { + verification_code: '123456', + is_test_mode: false + } + }) + data: { + verification_code: string; + is_test_mode: boolean; + }; + + @ApiProperty({ + description: '响应消息', + example: '验证码已发送,请查收' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: null, + required: false + }) + error_code?: string; +} \ No newline at end of file diff --git a/src/business/auth/index.ts b/src/business/auth/index.ts new file mode 100644 index 0000000..c4530d4 --- /dev/null +++ b/src/business/auth/index.ts @@ -0,0 +1,23 @@ +/** + * 用户认证业务模块导出 + * + * 功能概述: + * - 用户登录和注册 + * - GitHub OAuth集成 + * - 密码管理(忘记密码、重置密码、修改密码) + * - 邮箱验证功能 + * - JWT Token管理 + */ + +// 模块 +export * from './auth.module'; + +// 控制器 +export * from './controllers/login.controller'; + +// 服务 +export * from './services/login.service'; + +// DTO +export * from './dto/login.dto'; +export * from './dto/login_response.dto'; \ No newline at end of file diff --git a/src/business/login/login.service.ts b/src/business/auth/services/login.service.ts similarity index 98% rename from src/business/login/login.service.ts rename to src/business/auth/services/login.service.ts index 6c23fe4..d354032 100644 --- a/src/business/login/login.service.ts +++ b/src/business/auth/services/login.service.ts @@ -17,8 +17,8 @@ */ import { Injectable, Logger } from '@nestjs/common'; -import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult } from '../../core/login_core/login_core.service'; -import { Users } from '../../core/db/users/users.entity'; +import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult } from '../../../core/login_core/login_core.service'; +import { Users } from '../../../core/db/users/users.entity'; /** * 登录响应数据接口 diff --git a/src/business/login/login.module.ts b/src/business/login/login.module.ts deleted file mode 100644 index be1dd78..0000000 --- a/src/business/login/login.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 登录业务模块 - * - * 功能描述: - * - 整合登录相关的控制器、服务和依赖 - * - 提供完整的登录业务功能模块 - * - 可被其他模块导入使用 - * - * @author moyin - * @version 1.0.0 - * @since 2025-12-17 - */ - -import { Module } from '@nestjs/common'; -import { LoginController } from './login.controller'; -import { LoginService } from './login.service'; -import { LoginCoreModule } from '../../core/login_core/login_core.module'; - -@Module({ - imports: [LoginCoreModule], - controllers: [LoginController], - providers: [LoginService], - exports: [LoginService], -}) -export class LoginModule {} \ No newline at end of file diff --git a/src/business/login/login.service.spec.ts b/src/business/login/login.service.spec.ts deleted file mode 100644 index e1495e3..0000000 --- a/src/business/login/login.service.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * 登录业务服务测试 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { LoginService } from './login.service'; -import { LoginCoreService } from '../../core/login_core/login_core.service'; - -describe('LoginService', () => { - let service: LoginService; - let loginCoreService: jest.Mocked; - - const mockUser = { - id: BigInt(1), - username: 'testuser', - email: 'test@example.com', - phone: '+8613800138000', - password_hash: '$2b$12$hashedpassword', - nickname: '测试用户', - github_id: null as string | null, - avatar_url: null as string | null, - role: 1, - email_verified: false, - created_at: new Date(), - updated_at: new Date() - }; - - beforeEach(async () => { - const mockLoginCoreService = { - login: jest.fn(), - register: jest.fn(), - githubOAuth: jest.fn(), - sendPasswordResetCode: jest.fn(), - resetPassword: jest.fn(), - changePassword: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - LoginService, - { - provide: LoginCoreService, - useValue: mockLoginCoreService, - }, - ], - }).compile(); - - service = module.get(LoginService); - loginCoreService = module.get(LoginCoreService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('login', () => { - it('should return success response for valid login', async () => { - loginCoreService.login.mockResolvedValue({ - user: mockUser, - isNewUser: false - }); - - const result = await service.login({ - identifier: 'testuser', - password: 'password123' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.access_token).toBeDefined(); - }); - - it('should return error response for failed login', async () => { - loginCoreService.login.mockRejectedValue(new Error('登录失败')); - - const result = await service.login({ - identifier: 'testuser', - password: 'wrongpassword' - }); - - expect(result.success).toBe(false); - expect(result.message).toBe('登录失败'); - expect(result.error_code).toBe('LOGIN_FAILED'); - }); - }); - - describe('register', () => { - it('should return success response for valid registration', async () => { - loginCoreService.register.mockResolvedValue({ - user: mockUser, - isNewUser: true - }); - - const result = await service.register({ - username: 'testuser', - password: 'password123', - nickname: '测试用户' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.is_new_user).toBe(true); - }); - - it('should return error response for failed registration', async () => { - loginCoreService.register.mockRejectedValue(new Error('用户名已存在')); - - const result = await service.register({ - username: 'existinguser', - password: 'password123', - nickname: '测试用户' - }); - - expect(result.success).toBe(false); - expect(result.message).toBe('用户名已存在'); - expect(result.error_code).toBe('REGISTER_FAILED'); - }); - }); - - describe('githubOAuth', () => { - it('should return success response for GitHub OAuth', async () => { - loginCoreService.githubOAuth.mockResolvedValue({ - user: mockUser, - isNewUser: true - }); - - const result = await service.githubOAuth({ - github_id: 'github123', - username: 'githubuser', - nickname: 'GitHub用户' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.is_new_user).toBe(true); - }); - }); - - describe('sendPasswordResetCode', () => { - it('should return test mode response with verification code', async () => { - loginCoreService.sendPasswordResetCode.mockResolvedValue({ - code: '123456', - isTestMode: true - }); - - const result = await service.sendPasswordResetCode('test@example.com'); - - expect(result.success).toBe(false); // 测试模式下不算成功 - expect(result.error_code).toBe('TEST_MODE_ONLY'); - expect(result.data?.verification_code).toBe('123456'); - expect(result.data?.is_test_mode).toBe(true); - }); - - it('should return success response for real email sending', async () => { - loginCoreService.sendPasswordResetCode.mockResolvedValue({ - code: '123456', - isTestMode: false - }); - - const result = await service.sendPasswordResetCode('test@example.com'); - - expect(result.success).toBe(true); - expect(result.data?.is_test_mode).toBe(false); - expect(result.data?.verification_code).toBeUndefined(); // 真实模式下不返回验证码 - }); - }); - - describe('resetPassword', () => { - it('should return success response for password reset', async () => { - loginCoreService.resetPassword.mockResolvedValue(mockUser); - - const result = await service.resetPassword({ - identifier: 'test@example.com', - verificationCode: '123456', - newPassword: 'newpassword123' - }); - - expect(result.success).toBe(true); - expect(result.message).toBe('密码重置成功'); - }); - }); - - describe('changePassword', () => { - it('should return success response for password change', async () => { - loginCoreService.changePassword.mockResolvedValue(mockUser); - - const result = await service.changePassword(BigInt(1), 'oldpassword', 'newpassword123'); - - expect(result.success).toBe(true); - expect(result.message).toBe('密码修改成功'); - }); - }); -}); \ No newline at end of file diff --git a/src/business/security/decorators/throttle.decorator.ts b/src/business/security/decorators/throttle.decorator.ts new file mode 100644 index 0000000..1c6b5ee --- /dev/null +++ b/src/business/security/decorators/throttle.decorator.ts @@ -0,0 +1,89 @@ +/** + * 频率限制装饰器 + * + * 功能描述: + * - 提供API接口的频率限制功能 + * - 防止恶意请求和系统滥用 + * - 支持基于IP和用户的限制策略 + * + * 使用场景: + * - 登录接口防暴力破解 + * - 注册接口防批量注册 + * - 验证码接口防频繁发送 + * - 敏感操作接口保护 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { SetMetadata, applyDecorators, UseGuards } from '@nestjs/common'; +import { ThrottleGuard } from '../guards/throttle.guard'; + +/** + * 频率限制元数据键 + */ +export const THROTTLE_KEY = 'throttle'; + +/** + * 频率限制配置接口 + */ +export interface ThrottleConfig { + /** 时间窗口内允许的最大请求次数 */ + limit: number; + /** 时间窗口长度(秒) */ + ttl: number; + /** 限制类型:ip(基于IP)或 user(基于用户) */ + type?: 'ip' | 'user'; + /** 自定义错误消息 */ + message?: string; +} + +/** + * 频率限制装饰器 + * + * @param config 频率限制配置 + * @returns 装饰器函数 + * + * @example + * ```typescript + * // 每分钟最多5次登录尝试 + * @Throttle({ limit: 5, ttl: 60, message: '登录尝试过于频繁,请稍后再试' }) + * @Post('login') + * async login() { ... } + * + * // 每5分钟最多3次注册 + * @Throttle({ limit: 3, ttl: 300, type: 'ip' }) + * @Post('register') + * async register() { ... } + * ``` + */ +export function Throttle(config: ThrottleConfig) { + return applyDecorators( + SetMetadata(THROTTLE_KEY, config), + UseGuards(ThrottleGuard) + ); +} + +/** + * 预定义的频率限制配置 + */ +export const ThrottlePresets = { + /** 登录接口:每分钟5次 */ + LOGIN: { limit: 5, ttl: 60, message: '登录尝试过于频繁,请1分钟后再试' }, + + /** 注册接口:每5分钟3次 */ + REGISTER: { limit: 3, ttl: 300, message: '注册请求过于频繁,请5分钟后再试' }, + + /** 发送验证码:每分钟1次 */ + SEND_CODE: { limit: 1, ttl: 60, message: '验证码发送过于频繁,请1分钟后再试' }, + + /** 密码重置:每小时3次 */ + RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁,请1小时后再试' }, + + /** 管理员操作:每分钟10次 */ + ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' }, + + /** 一般API:每分钟30次 */ + GENERAL_API: { limit: 30, ttl: 60, message: 'API调用过于频繁,请稍后再试' } +} as const; \ No newline at end of file diff --git a/src/business/security/decorators/timeout.decorator.ts b/src/business/security/decorators/timeout.decorator.ts new file mode 100644 index 0000000..0b3dd10 --- /dev/null +++ b/src/business/security/decorators/timeout.decorator.ts @@ -0,0 +1,119 @@ +/** + * 超时处理装饰器 + * + * 功能描述: + * - 为API接口添加超时控制 + * - 防止长时间运行的请求阻塞系统 + * - 提供友好的超时错误提示 + * + * 使用场景: + * - 数据库查询超时控制 + * - 外部API调用超时 + * - 文件上传下载超时 + * - 复杂计算任务超时 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { SetMetadata, applyDecorators } from '@nestjs/common'; +import { ApiResponse } from '@nestjs/swagger'; + +/** + * 超时配置元数据键 + */ +export const TIMEOUT_KEY = 'timeout'; + +/** + * 超时配置接口 + */ +export interface TimeoutConfig { + /** 超时时间(毫秒) */ + timeout: number; + /** 自定义超时错误消息 */ + message?: string; + /** 是否记录超时日志 */ + logTimeout?: boolean; +} + +/** + * 超时装饰器 + * + * @param config 超时配置或超时时间(毫秒) + * @returns 装饰器函数 + * + * @example + * ```typescript + * // 设置30秒超时 + * @Timeout(30000) + * @Get('slow-operation') + * async slowOperation() { ... } + * + * // 自定义超时配置 + * @Timeout({ + * timeout: 60000, + * message: '数据查询超时,请稍后重试', + * logTimeout: true + * }) + * @Post('complex-query') + * async complexQuery() { ... } + * ``` + */ +export function Timeout(config: number | TimeoutConfig) { + const timeoutConfig: TimeoutConfig = typeof config === 'number' + ? { timeout: config } + : config; + + return applyDecorators( + SetMetadata(TIMEOUT_KEY, timeoutConfig), + ApiResponse({ + status: 408, + description: timeoutConfig.message || '请求超时', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + message: { type: 'string', example: timeoutConfig.message || '请求超时,请稍后重试' }, + error_code: { type: 'string', example: 'REQUEST_TIMEOUT' }, + timeout_info: { + type: 'object', + properties: { + timeout_ms: { type: 'number', example: timeoutConfig.timeout }, + timestamp: { type: 'string', example: '2025-12-24T10:00:00.000Z' } + } + } + } + } + }) + ); +} + +/** + * 预定义的超时配置 + */ +export const TimeoutPresets = { + /** 快速操作:5秒 */ + FAST: { timeout: 5000, message: '操作超时,请检查网络连接' }, + + /** 一般操作:30秒 */ + NORMAL: { timeout: 30000, message: '请求超时,请稍后重试' }, + + /** 慢操作:60秒 */ + SLOW: { timeout: 60000, message: '操作超时,请稍后重试' }, + + /** 文件操作:2分钟 */ + FILE_OPERATION: { timeout: 120000, message: '文件操作超时,请检查文件大小和网络状况' }, + + /** 数据库查询:45秒 */ + DATABASE_QUERY: { timeout: 45000, message: '数据查询超时,请简化查询条件或稍后重试' }, + + /** 外部API调用:15秒 */ + EXTERNAL_API: { timeout: 15000, message: '外部服务调用超时,请稍后重试' }, + + /** 邮件发送:30秒 */ + EMAIL_SEND: { timeout: 30000, message: '邮件发送超时,请检查邮件服务配置' }, + + /** 长时间任务:5分钟 */ + LONG_TASK: { timeout: 300000, message: '任务执行超时,请稍后重试' } +} as const; \ No newline at end of file diff --git a/src/business/security/guards/throttle.guard.ts b/src/business/security/guards/throttle.guard.ts new file mode 100644 index 0000000..7d9cb5e --- /dev/null +++ b/src/business/security/guards/throttle.guard.ts @@ -0,0 +1,317 @@ +/** + * 频率限制守卫 + * + * 功能描述: + * - 实现API接口的频率限制功能 + * - 基于IP地址进行限制 + * - 支持自定义限制规则 + * + * 使用场景: + * - 防止API滥用 + * - 登录暴力破解防护 + * - 验证码发送频率控制 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Logger +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { THROTTLE_KEY, ThrottleConfig } from '../decorators/throttle.decorator'; + +/** + * 频率限制记录接口 + */ +interface ThrottleRecord { + /** 请求次数 */ + count: number; + /** 窗口开始时间 */ + windowStart: number; + /** 最后请求时间 */ + lastRequest: number; +} + +/** + * 频率限制响应接口 + */ +interface ThrottleResponse { + /** 请求是否成功 */ + success: boolean; + /** 响应消息 */ + message: string; + /** 错误代码 */ + error_code: string; + /** 限制信息 */ + throttle_info: { + /** 限制次数 */ + limit: number; + /** 时间窗口(秒) */ + window_seconds: number; + /** 当前请求次数 */ + current_requests: number; + /** 重置时间 */ + reset_time: string; + }; +} + +@Injectable() +export class ThrottleGuard implements CanActivate { + private readonly logger = new Logger(ThrottleGuard.name); + + /** + * 存储频率限制记录 + * Key: IP地址 + 路径 + * Value: 限制记录 + */ + private readonly records = new Map(); + + /** + * 清理过期记录的间隔(毫秒) + */ + private readonly cleanupInterval = 60000; // 1分钟 + + constructor(private readonly reflector: Reflector) { + // 启动定期清理任务 + this.startCleanupTask(); + } + + /** + * 守卫检查函数 + * + * @param context 执行上下文 + * @returns 是否允许通过 + */ + async canActivate(context: ExecutionContext): Promise { + // 1. 获取频率限制配置 + const throttleConfig = this.getThrottleConfig(context); + + if (!throttleConfig) { + // 没有配置频率限制,直接通过 + return true; + } + + // 2. 获取请求信息 + const request = context.switchToHttp().getRequest(); + const key = this.generateKey(request, throttleConfig); + + // 3. 检查频率限制 + const isAllowed = this.checkThrottle(key, throttleConfig); + + if (!isAllowed) { + // 4. 记录被限制的请求 + this.logger.warn('请求被频率限制', { + operation: 'throttle_limit', + method: request.method, + url: request.url, + ip: request.ip, + userAgent: request.get('User-Agent'), + limit: throttleConfig.limit, + ttl: throttleConfig.ttl, + timestamp: new Date().toISOString() + }); + + // 5. 抛出频率限制异常 + const record = this.records.get(key); + const resetTime = new Date(record!.windowStart + throttleConfig.ttl * 1000); + + const response: ThrottleResponse = { + success: false, + message: throttleConfig.message || '请求过于频繁,请稍后再试', + error_code: 'TOO_MANY_REQUESTS', + throttle_info: { + limit: throttleConfig.limit, + window_seconds: throttleConfig.ttl, + current_requests: record!.count, + reset_time: resetTime.toISOString() + } + }; + + throw new HttpException(response, HttpStatus.TOO_MANY_REQUESTS); + } + + return true; + } + + /** + * 获取频率限制配置 + * + * @param context 执行上下文 + * @returns 频率限制配置或null + */ + private getThrottleConfig(context: ExecutionContext): ThrottleConfig | null { + // 从方法装饰器获取配置 + const methodConfig = this.reflector.get( + THROTTLE_KEY, + context.getHandler() + ); + + if (methodConfig) { + return methodConfig; + } + + // 从类装饰器获取配置 + const classConfig = this.reflector.get( + THROTTLE_KEY, + context.getClass() + ); + + return classConfig || null; + } + + /** + * 生成限制键 + * + * @param request 请求对象 + * @param config 频率限制配置 + * @returns 限制键 + */ + private generateKey(request: Request, config: ThrottleConfig): string { + const ip = request.ip || 'unknown'; + const path = request.route?.path || request.url; + const method = request.method; + + // 根据限制类型生成不同的键 + if (config.type === 'user') { + // 基于用户的限制(需要从JWT中获取用户ID) + const userId = this.extractUserId(request); + return `user:${userId}:${method}:${path}`; + } else { + // 基于IP的限制(默认) + return `ip:${ip}:${method}:${path}`; + } + } + + /** + * 检查频率限制 + * + * @param key 限制键 + * @param config 频率限制配置 + * @returns 是否允许通过 + */ + private checkThrottle(key: string, config: ThrottleConfig): boolean { + const now = Date.now(); + const windowMs = config.ttl * 1000; + + let record = this.records.get(key); + + if (!record) { + // 第一次请求 + this.records.set(key, { + count: 1, + windowStart: now, + lastRequest: now + }); + return true; + } + + // 检查是否需要重置窗口 + if (now - record.windowStart >= windowMs) { + // 重置窗口 + record.count = 1; + record.windowStart = now; + record.lastRequest = now; + return true; + } + + // 在当前窗口内 + if (record.count >= config.limit) { + // 超过限制 + return false; + } + + // 增加计数 + record.count++; + record.lastRequest = now; + return true; + } + + /** + * 从请求中提取用户ID + * + * @param request 请求对象 + * @returns 用户ID + */ + private extractUserId(request: Request): string { + // 这里应该从JWT token中提取用户ID + // 简化实现,使用IP作为fallback + const authHeader = request.get('Authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + try { + // 这里应该解析JWT token获取用户ID + // 简化实现,返回token的hash + const token = authHeader.substring(7); + return Buffer.from(token).toString('base64').substring(0, 10); + } catch (error) { + // JWT解析失败,使用IP + return request.ip || 'unknown'; + } + } + + return request.ip || 'unknown'; + } + + /** + * 启动清理任务 + */ + private startCleanupTask(): void { + setInterval(() => { + this.cleanupExpiredRecords(); + }, this.cleanupInterval); + } + + /** + * 清理过期记录 + */ + private cleanupExpiredRecords(): void { + const now = Date.now(); + const maxAge = 3600000; // 1小时 + + for (const [key, record] of this.records.entries()) { + if (now - record.lastRequest > maxAge) { + this.records.delete(key); + } + } + } + + /** + * 获取当前记录统计 + * + * @returns 记录统计信息 + */ + getStats() { + return { + totalRecords: this.records.size, + records: Array.from(this.records.entries()).map(([key, record]) => ({ + key, + count: record.count, + windowStart: new Date(record.windowStart).toISOString(), + lastRequest: new Date(record.lastRequest).toISOString() + })) + }; + } + + /** + * 清除所有记录 + */ + clearAllRecords(): void { + this.records.clear(); + } + + /** + * 清除指定键的记录 + * + * @param key 限制键 + */ + clearRecord(key: string): void { + this.records.delete(key); + } +} \ No newline at end of file diff --git a/src/business/security/index.ts b/src/business/security/index.ts new file mode 100644 index 0000000..1453eb8 --- /dev/null +++ b/src/business/security/index.ts @@ -0,0 +1,27 @@ +/** + * 安全功能模块导出 + * + * 功能概述: + * - 频率限制和防护机制 + * - 请求超时控制 + * - 维护模式管理 + * - 内容类型验证 + * - 系统安全中间件 + */ + +// 模块 +export * from './security.module'; + +// 守卫 +export * from './guards/throttle.guard'; + +// 中间件 +export * from './middleware/maintenance.middleware'; +export * from './middleware/content-type.middleware'; + +// 拦截器 +export * from './interceptors/timeout.interceptor'; + +// 装饰器 +export * from './decorators/throttle.decorator'; +export * from './decorators/timeout.decorator'; \ No newline at end of file diff --git a/src/business/security/interceptors/timeout.interceptor.ts b/src/business/security/interceptors/timeout.interceptor.ts new file mode 100644 index 0000000..3e62958 --- /dev/null +++ b/src/business/security/interceptors/timeout.interceptor.ts @@ -0,0 +1,179 @@ +/** + * 超时拦截器 + * + * 功能描述: + * - 实现API接口的超时控制逻辑 + * - 在超时时自动取消请求并返回错误 + * - 记录超时事件的详细日志 + * + * 使用场景: + * - 全局超时控制 + * - 防止资源泄漏 + * - 提升系统稳定性 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + RequestTimeoutException, + Logger +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable, throwError, TimeoutError } from 'rxjs'; +import { catchError, timeout } from 'rxjs/operators'; +import { TIMEOUT_KEY, TimeoutConfig } from '../decorators/timeout.decorator'; + +/** + * 超时响应接口 + */ +interface TimeoutResponse { + /** 请求是否成功 */ + success: boolean; + /** 响应消息 */ + message: string; + /** 错误代码 */ + error_code: string; + /** 超时信息 */ + timeout_info: { + /** 超时时间(毫秒) */ + timeout_ms: number; + /** 超时发生时间 */ + timestamp: string; + }; +} + +@Injectable() +export class TimeoutInterceptor implements NestInterceptor { + private readonly logger = new Logger(TimeoutInterceptor.name); + + constructor(private readonly reflector: Reflector) {} + + /** + * 拦截器处理函数 + * + * 业务逻辑: + * 1. 获取超时配置 + * 2. 应用超时控制 + * 3. 处理超时异常 + * 4. 记录超时日志 + * + * @param context 执行上下文 + * @param next 调用处理器 + * @returns 可观察对象 + */ + intercept(context: ExecutionContext, next: CallHandler): Observable { + // 1. 获取超时配置 + const timeoutConfig = this.getTimeoutConfig(context); + + if (!timeoutConfig) { + // 没有配置超时,直接执行 + return next.handle(); + } + + // 2. 获取请求信息用于日志记录 + const request = context.switchToHttp().getRequest(); + const startTime = Date.now(); + + // 3. 应用超时控制 + return next.handle().pipe( + timeout(timeoutConfig.timeout), + catchError((error) => { + if (error instanceof TimeoutError) { + // 4. 处理超时异常 + const duration = Date.now() - startTime; + + // 5. 记录超时日志 + if (timeoutConfig.logTimeout !== false) { + this.logger.warn('请求超时', { + operation: 'request_timeout', + method: request.method, + url: request.url, + timeout_ms: timeoutConfig.timeout, + actual_duration_ms: duration, + userAgent: request.get('User-Agent'), + ip: request.ip, + timestamp: new Date().toISOString() + }); + } + + // 6. 构建超时响应 + const timeoutResponse: TimeoutResponse = { + success: false, + message: timeoutConfig.message || '请求超时,请稍后重试', + error_code: 'REQUEST_TIMEOUT', + timeout_info: { + timeout_ms: timeoutConfig.timeout, + timestamp: new Date().toISOString() + } + }; + + // 7. 抛出超时异常 + return throwError(() => new RequestTimeoutException(timeoutResponse)); + } + + // 其他异常直接抛出 + return throwError(() => error); + }) + ); + } + + /** + * 获取超时配置 + * + * @param context 执行上下文 + * @returns 超时配置或null + */ + private getTimeoutConfig(context: ExecutionContext): TimeoutConfig | null { + // 从方法装饰器获取配置 + const methodConfig = this.reflector.get( + TIMEOUT_KEY, + context.getHandler() + ); + + if (methodConfig) { + return methodConfig; + } + + // 从类装饰器获取配置 + const classConfig = this.reflector.get( + TIMEOUT_KEY, + context.getClass() + ); + + return classConfig || null; + } + + /** + * 获取默认超时配置 + * + * @returns 默认超时配置 + */ + private getDefaultTimeoutConfig(): TimeoutConfig { + return { + timeout: 30000, // 默认30秒 + message: '请求超时,请稍后重试', + logTimeout: true + }; + } + + /** + * 验证超时配置 + * + * @param config 超时配置 + * @returns 是否有效 + */ + private isValidTimeoutConfig(config: TimeoutConfig): boolean { + return ( + config && + typeof config.timeout === 'number' && + config.timeout > 0 && + config.timeout <= 600000 // 最大10分钟 + ); + } +} \ No newline at end of file diff --git a/src/business/security/middleware/content-type.middleware.ts b/src/business/security/middleware/content-type.middleware.ts new file mode 100644 index 0000000..ce454dd --- /dev/null +++ b/src/business/security/middleware/content-type.middleware.ts @@ -0,0 +1,224 @@ +/** + * 内容类型检查中间件 + * + * 功能描述: + * - 检查POST/PUT请求的Content-Type头 + * - 确保API接口接收正确的数据格式 + * - 提供友好的错误提示信息 + * + * 使用场景: + * - API接口数据格式验证 + * - 防止错误的请求格式 + * - 提升API接口的健壮性 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { Logger } from '@nestjs/common'; + +/** + * 不支持的媒体类型响应接口 + */ +interface UnsupportedMediaTypeResponse { + /** 请求是否成功 */ + success: boolean; + /** 响应消息 */ + message: string; + /** 错误代码 */ + error_code: string; + /** 支持的媒体类型 */ + supported_types: string[]; + /** 接收到的媒体类型 */ + received_type?: string; +} + +@Injectable() +export class ContentTypeMiddleware implements NestMiddleware { + private readonly logger = new Logger(ContentTypeMiddleware.name); + + /** + * 需要检查Content-Type的HTTP方法 + */ + private readonly methodsToCheck = ['POST', 'PUT', 'PATCH']; + + /** + * 支持的Content-Type列表 + */ + private readonly supportedTypes = [ + 'application/json', + 'application/json; charset=utf-8' + ]; + + /** + * 不需要检查Content-Type的路径(正则表达式) + */ + private readonly excludePaths = [ + /^\/api-docs/, // Swagger文档 + /^\/health/, // 健康检查 + /^\/admin\/logs\/archive/, // 文件下载 + /\/upload/, // 文件上传 + ]; + + /** + * 中间件处理函数 + * + * 业务逻辑: + * 1. 检查是否需要验证Content-Type + * 2. 获取请求的Content-Type头 + * 3. 验证Content-Type是否支持 + * 4. 记录不支持的请求类型 + * + * @param req HTTP请求对象 + * @param res HTTP响应对象 + * @param next 下一个中间件函数 + */ + use(req: Request, res: Response, next: NextFunction) { + // 1. 检查是否需要验证Content-Type + if (!this.shouldCheckContentType(req)) { + next(); + return; + } + + // 2. 获取请求的Content-Type + const contentType = req.get('Content-Type'); + + // 3. 检查Content-Type是否存在 + if (!contentType) { + this.logger.warn('请求缺少Content-Type头', { + operation: 'content_type_check', + method: req.method, + url: req.url, + userAgent: req.get('User-Agent'), + ip: req.ip, + timestamp: new Date().toISOString() + }); + + const response: UnsupportedMediaTypeResponse = { + success: false, + message: '请求缺少Content-Type头,请设置为application/json', + error_code: 'MISSING_CONTENT_TYPE', + supported_types: this.supportedTypes, + received_type: undefined + }; + + res.status(415).json(response); + return; + } + + // 4. 验证Content-Type是否支持 + const normalizedContentType = this.normalizeContentType(contentType); + + if (!this.isSupportedContentType(normalizedContentType)) { + this.logger.warn('不支持的Content-Type', { + operation: 'content_type_check', + method: req.method, + url: req.url, + contentType: contentType, + normalizedContentType: normalizedContentType, + userAgent: req.get('User-Agent'), + ip: req.ip, + timestamp: new Date().toISOString() + }); + + const response: UnsupportedMediaTypeResponse = { + success: false, + message: `不支持的Content-Type: ${contentType},请使用application/json`, + error_code: 'UNSUPPORTED_MEDIA_TYPE', + supported_types: this.supportedTypes, + received_type: contentType + }; + + res.status(415).json(response); + return; + } + + // 5. Content-Type验证通过,继续处理 + next(); + } + + /** + * 检查是否需要验证Content-Type + * + * @param req HTTP请求对象 + * @returns 是否需要验证 + */ + private shouldCheckContentType(req: Request): boolean { + // 1. 检查HTTP方法 + if (!this.methodsToCheck.includes(req.method)) { + return false; + } + + // 2. 检查是否在排除路径中 + const url = req.url; + for (const excludePattern of this.excludePaths) { + if (excludePattern.test(url)) { + return false; + } + } + + // 3. 检查Content-Length,如果为0则不需要验证 + const contentLength = req.get('Content-Length'); + if (contentLength === '0') { + return false; + } + + return true; + } + + /** + * 标准化Content-Type + * + * @param contentType 原始Content-Type + * @returns 标准化后的Content-Type + */ + private normalizeContentType(contentType: string): string { + // 移除空格并转换为小写 + return contentType.toLowerCase().trim(); + } + + /** + * 检查Content-Type是否支持 + * + * @param contentType 标准化的Content-Type + * @returns 是否支持 + */ + private isSupportedContentType(contentType: string): boolean { + // 检查是否以支持的类型开头 + return this.supportedTypes.some(supportedType => + contentType.startsWith(supportedType.toLowerCase()) + ); + } + + /** + * 获取支持的Content-Type列表 + * + * @returns 支持的类型列表 + */ + getSupportedTypes(): string[] { + return [...this.supportedTypes]; + } + + /** + * 添加支持的Content-Type + * + * @param contentType 要添加的Content-Type + */ + addSupportedType(contentType: string): void { + if (!this.supportedTypes.includes(contentType)) { + this.supportedTypes.push(contentType); + } + } + + /** + * 添加排除路径 + * + * @param pattern 路径正则表达式 + */ + addExcludePath(pattern: RegExp): void { + this.excludePaths.push(pattern); + } +} \ No newline at end of file diff --git a/src/business/security/middleware/maintenance.middleware.ts b/src/business/security/middleware/maintenance.middleware.ts new file mode 100644 index 0000000..1e2e9d7 --- /dev/null +++ b/src/business/security/middleware/maintenance.middleware.ts @@ -0,0 +1,137 @@ +/** + * 维护模式中间件 + * + * 功能描述: + * - 检查系统是否处于维护模式 + * - 在维护期间阻止用户访问API + * - 提供维护状态和预计恢复时间信息 + * + * 使用场景: + * - 系统升级维护 + * - 数据库迁移 + * - 紧急故障修复 + * - 定期维护窗口 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@nestjs/common'; + +/** + * 维护模式响应接口 + */ +interface MaintenanceResponse { + /** 请求是否成功 */ + success: boolean; + /** 响应消息 */ + message: string; + /** 错误代码 */ + error_code: string; + /** 维护信息 */ + maintenance_info?: { + /** 维护开始时间 */ + start_time: string; + /** 预计结束时间 */ + estimated_end_time?: string; + /** 重试间隔(秒) */ + retry_after: number; + /** 维护原因 */ + reason?: string; + }; +} + +@Injectable() +export class MaintenanceMiddleware implements NestMiddleware { + private readonly logger = new Logger(MaintenanceMiddleware.name); + + constructor(private readonly configService: ConfigService) {} + + /** + * 中间件处理函数 + * + * 业务逻辑: + * 1. 检查维护模式环境变量 + * 2. 如果处于维护模式,返回503状态码 + * 3. 提供维护信息和重试建议 + * 4. 记录维护期间的访问尝试 + * + * @param req HTTP请求对象 + * @param res HTTP响应对象 + * @param next 下一个中间件函数 + */ + use(req: Request, res: Response, next: NextFunction) { + // 1. 检查维护模式状态 + const isMaintenanceMode = this.configService.get('MAINTENANCE_MODE') === 'true'; + + if (!isMaintenanceMode) { + // 非维护模式,继续处理请求 + next(); + return; + } + + // 2. 记录维护期间的访问尝试 + this.logger.warn('维护模式:拒绝访问请求', { + operation: 'maintenance_check', + method: req.method, + url: req.url, + userAgent: req.get('User-Agent'), + ip: req.ip, + timestamp: new Date().toISOString() + }); + + // 3. 获取维护配置信息 + const maintenanceStartTime = this.configService.get('MAINTENANCE_START_TIME') || new Date().toISOString(); + const maintenanceEndTime = this.configService.get('MAINTENANCE_END_TIME'); + const maintenanceReason = this.configService.get('MAINTENANCE_REASON') || '系统维护升级'; + const retryAfter = this.configService.get('MAINTENANCE_RETRY_AFTER') || 1800; // 默认30分钟 + + // 4. 构建维护模式响应 + const maintenanceResponse: MaintenanceResponse = { + success: false, + message: '系统正在维护中,请稍后再试', + error_code: 'SERVICE_UNAVAILABLE', + maintenance_info: { + start_time: maintenanceStartTime, + estimated_end_time: maintenanceEndTime, + retry_after: retryAfter, + reason: maintenanceReason + } + }; + + // 5. 设置HTTP响应头 + res.setHeader('Retry-After', retryAfter.toString()); + res.setHeader('Content-Type', 'application/json'); + + // 6. 返回503服务不可用状态 + res.status(503).json(maintenanceResponse); + } + + /** + * 检查维护模式是否启用 + * + * @returns 是否处于维护模式 + */ + isMaintenanceEnabled(): boolean { + return this.configService.get('MAINTENANCE_MODE') === 'true'; + } + + /** + * 获取维护信息 + * + * @returns 维护配置信息 + */ + getMaintenanceInfo() { + return { + enabled: this.isMaintenanceEnabled(), + startTime: this.configService.get('MAINTENANCE_START_TIME'), + endTime: this.configService.get('MAINTENANCE_END_TIME'), + reason: this.configService.get('MAINTENANCE_REASON'), + retryAfter: this.configService.get('MAINTENANCE_RETRY_AFTER') + }; + } +} \ No newline at end of file diff --git a/src/business/security/security.module.ts b/src/business/security/security.module.ts new file mode 100644 index 0000000..80cdd83 --- /dev/null +++ b/src/business/security/security.module.ts @@ -0,0 +1,37 @@ +/** + * 安全功能模块 + * + * 功能描述: + * - 整合所有安全相关功能 + * - 频率限制和请求超时控制 + * - 维护模式和内容类型验证 + * - 系统安全防护机制 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { Module } from '@nestjs/common'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { ThrottleGuard } from './guards/throttle.guard'; +import { TimeoutInterceptor } from './interceptors/timeout.interceptor'; + +@Module({ + providers: [ + ThrottleGuard, + TimeoutInterceptor, + // 全局频率限制守卫 + { + provide: APP_GUARD, + useClass: ThrottleGuard, + }, + // 全局超时拦截器 + { + provide: APP_INTERCEPTOR, + useClass: TimeoutInterceptor, + }, + ], + exports: [ThrottleGuard, TimeoutInterceptor], +}) +export class SecurityModule {} \ No newline at end of file diff --git a/src/business/shared/dto/app-status.dto.ts b/src/business/shared/dto/app-status.dto.ts new file mode 100644 index 0000000..498f5c5 --- /dev/null +++ b/src/business/shared/dto/app-status.dto.ts @@ -0,0 +1,72 @@ +/** + * 应用状态响应 DTO + * + * 功能描述: + * - 定义应用状态接口的响应格式 + * - 提供 Swagger 文档生成支持 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-12-17 + */ + +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 应用状态响应 DTO + */ +export class AppStatusResponseDto { + @ApiProperty({ + description: '服务名称', + example: 'Pixel Game Server', + type: String + }) + service: string; + + @ApiProperty({ + description: '服务版本', + example: '1.0.0', + type: String + }) + version: string; + + @ApiProperty({ + description: '运行状态', + example: 'running', + enum: ['running', 'starting', 'stopping', 'error'], + type: String + }) + status: string; + + @ApiProperty({ + description: '当前时间戳', + example: '2025-12-17T15:00:00.000Z', + type: String, + format: 'date-time' + }) + timestamp: string; + + @ApiProperty({ + description: '运行时间(秒)', + example: 3600, + type: Number, + minimum: 0 + }) + uptime: number; + + @ApiProperty({ + description: '运行环境', + example: 'development', + enum: ['development', 'production', 'test'], + type: String + }) + environment: string; + + @ApiProperty({ + description: '存储模式', + example: 'memory', + enum: ['database', 'memory'], + type: String + }) + storage_mode: 'database' | 'memory'; +} \ No newline at end of file diff --git a/src/business/shared/dto/error-response.dto.ts b/src/business/shared/dto/error-response.dto.ts new file mode 100644 index 0000000..595fc42 --- /dev/null +++ b/src/business/shared/dto/error-response.dto.ts @@ -0,0 +1,56 @@ +/** + * 通用错误响应 DTO + * + * 功能描述: + * - 定义统一的错误响应格式 + * - 提供 Swagger 文档生成支持 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-12-17 + */ + +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 通用错误响应 DTO + */ +export class ErrorResponseDto { + @ApiProperty({ + description: 'HTTP 状态码', + example: 500, + type: Number + }) + statusCode: number; + + @ApiProperty({ + description: '错误消息', + example: 'Internal server error', + type: String + }) + message: string; + + @ApiProperty({ + description: '错误发生时间', + example: '2025-12-17T15:00:00.000Z', + type: String, + format: 'date-time' + }) + timestamp: string; + + @ApiProperty({ + description: '请求路径', + example: '/api/status', + type: String, + required: false + }) + path?: string; + + @ApiProperty({ + description: '错误代码', + example: 'INTERNAL_ERROR', + type: String, + required: false + }) + error?: string; +} \ No newline at end of file diff --git a/src/business/shared/dto/index.ts b/src/business/shared/dto/index.ts new file mode 100644 index 0000000..cfdb8f7 --- /dev/null +++ b/src/business/shared/dto/index.ts @@ -0,0 +1,17 @@ +/** + * 共享 DTO 统一导出 + * + * 功能描述: + * - 导出所有共享的 DTO 类 + * - 提供统一的导入入口 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +// 应用状态相关 +export * from './app-status.dto'; + +// 错误响应相关 +export * from './error-response.dto'; \ No newline at end of file diff --git a/src/business/shared/index.ts b/src/business/shared/index.ts new file mode 100644 index 0000000..8d8c500 --- /dev/null +++ b/src/business/shared/index.ts @@ -0,0 +1,14 @@ +/** + * 共享模块统一导出 + * + * 功能描述: + * - 导出所有共享的组件和类型 + * - 提供统一的导入入口 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +// DTO +export * from './dto'; \ No newline at end of file diff --git a/src/business/user-mgmt/controllers/user-status.controller.ts b/src/business/user-mgmt/controllers/user-status.controller.ts new file mode 100644 index 0000000..e58d197 --- /dev/null +++ b/src/business/user-mgmt/controllers/user-status.controller.ts @@ -0,0 +1,162 @@ +/** + * 用户状态管理控制器 + * + * 功能描述: + * - 管理员管理用户账户状态 + * - 支持批量状态操作 + * - 提供状态变更审计日志 + * + * API端点: + * - PUT /admin/users/:id/status - 修改用户状态 + * - POST /admin/users/batch-status - 批量修改用户状态 + * - GET /admin/users/status-stats - 获取用户状态统计 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Put, Post, UseGuards, ValidationPipe, UsePipes, Logger } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AdminGuard } from '../../admin/guards/admin.guard'; +import { UserManagementService } from '../services/user-management.service'; +import { Throttle, ThrottlePresets } from '../../security/decorators/throttle.decorator'; +import { Timeout, TimeoutPresets } from '../../security/decorators/timeout.decorator'; +import { UserStatusDto, BatchUserStatusDto } from '../dto/user-status.dto'; +import { UserStatusResponseDto, BatchUserStatusResponseDto, UserStatusStatsResponseDto } from '../dto/user-status-response.dto'; + +@ApiTags('user-management') +@Controller('admin/users') +export class UserStatusController { + private readonly logger = new Logger(UserStatusController.name); + + constructor(private readonly userManagementService: UserManagementService) {} + + /** + * 修改用户状态 + * + * @param id 用户ID + * @param userStatusDto 状态修改数据 + * @returns 修改结果 + */ + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: '修改用户状态', + description: '管理员修改指定用户的账户状态,支持激活、锁定、禁用等操作' + }) + @ApiParam({ name: 'id', description: '用户ID' }) + @ApiBody({ type: UserStatusDto }) + @ApiResponse({ + status: 200, + description: '状态修改成功', + type: UserStatusResponseDto + }) + @ApiResponse({ + status: 403, + description: '权限不足' + }) + @ApiResponse({ + status: 404, + description: '用户不存在' + }) + @ApiResponse({ + status: 429, + description: '操作过于频繁' + }) + @UseGuards(AdminGuard) + @Throttle(ThrottlePresets.ADMIN_OPERATION) + @Timeout(TimeoutPresets.NORMAL) + @Put(':id/status') + @HttpCode(HttpStatus.OK) + @UsePipes(new ValidationPipe({ transform: true })) + async updateUserStatus( + @Param('id') id: string, + @Body() userStatusDto: UserStatusDto + ): Promise { + this.logger.log('管理员修改用户状态', { + operation: 'update_user_status', + userId: id, + newStatus: userStatusDto.status, + reason: userStatusDto.reason, + timestamp: new Date().toISOString() + }); + + return await this.userManagementService.updateUserStatus(BigInt(id), userStatusDto); + } + + /** + * 批量修改用户状态 + * + * @param batchUserStatusDto 批量状态修改数据 + * @returns 批量修改结果 + */ + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: '批量修改用户状态', + description: '管理员批量修改多个用户的账户状态' + }) + @ApiBody({ type: BatchUserStatusDto }) + @ApiResponse({ + status: 200, + description: '批量修改成功', + type: BatchUserStatusResponseDto + }) + @ApiResponse({ + status: 403, + description: '权限不足' + }) + @ApiResponse({ + status: 429, + description: '操作过于频繁' + }) + @UseGuards(AdminGuard) + @Throttle(ThrottlePresets.ADMIN_OPERATION) + @Timeout(TimeoutPresets.SLOW) + @Post('batch-status') + @HttpCode(HttpStatus.OK) + @UsePipes(new ValidationPipe({ transform: true })) + async batchUpdateUserStatus( + @Body() batchUserStatusDto: BatchUserStatusDto + ): Promise { + this.logger.log('管理员批量修改用户状态', { + operation: 'batch_update_user_status', + userCount: batchUserStatusDto.user_ids.length, + newStatus: batchUserStatusDto.status, + reason: batchUserStatusDto.reason, + timestamp: new Date().toISOString() + }); + + return await this.userManagementService.batchUpdateUserStatus(batchUserStatusDto); + } + + /** + * 获取用户状态统计 + * + * @returns 状态统计信息 + */ + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: '获取用户状态统计', + description: '获取各种用户状态的数量统计信息' + }) + @ApiResponse({ + status: 200, + description: '获取成功', + type: UserStatusStatsResponseDto + }) + @ApiResponse({ + status: 403, + description: '权限不足' + }) + @UseGuards(AdminGuard) + @Timeout(TimeoutPresets.DATABASE_QUERY) + @Get('status-stats') + async getUserStatusStats(): Promise { + this.logger.log('管理员获取用户状态统计', { + operation: 'get_user_status_stats', + timestamp: new Date().toISOString() + }); + + return await this.userManagementService.getUserStatusStats(); + } +} \ No newline at end of file diff --git a/src/business/user-mgmt/dto/user-status-response.dto.ts b/src/business/user-mgmt/dto/user-status-response.dto.ts new file mode 100644 index 0000000..1f32216 --- /dev/null +++ b/src/business/user-mgmt/dto/user-status-response.dto.ts @@ -0,0 +1,294 @@ +/** + * 用户状态管理响应 DTO + * + * 功能描述: + * - 定义用户状态管理相关的响应数据结构 + * - 提供Swagger文档生成支持 + * - 确保状态管理API响应的数据格式一致性 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { ApiProperty } from '@nestjs/swagger'; +import { UserStatus } from '../enums/user-status.enum'; + +/** + * 用户状态信息DTO + */ +export class UserStatusInfoDto { + @ApiProperty({ + description: '用户ID', + example: '1' + }) + id: string; + + @ApiProperty({ + description: '用户名', + example: 'testuser' + }) + username: string; + + @ApiProperty({ + description: '用户昵称', + example: '测试用户' + }) + nickname: string; + + @ApiProperty({ + description: '用户状态', + enum: UserStatus, + example: UserStatus.ACTIVE + }) + status: UserStatus; + + @ApiProperty({ + description: '状态描述', + example: '正常' + }) + status_description: string; + + @ApiProperty({ + description: '状态修改时间', + example: '2025-12-24T10:00:00.000Z' + }) + updated_at: Date; +} + +/** + * 用户状态修改响应数据DTO + */ +export class UserStatusDataDto { + @ApiProperty({ + description: '用户信息', + type: UserStatusInfoDto + }) + user: UserStatusInfoDto; + + @ApiProperty({ + description: '修改原因', + example: '用户违反社区规定', + required: false + }) + reason?: string; +} + +/** + * 用户状态修改响应DTO + */ +export class UserStatusResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: UserStatusDataDto, + required: false + }) + data?: UserStatusDataDto; + + @ApiProperty({ + description: '响应消息', + example: '用户状态修改成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'USER_STATUS_UPDATE_FAILED', + required: false + }) + error_code?: string; +} + +/** + * 批量操作结果DTO + */ +export class BatchOperationResultDto { + @ApiProperty({ + description: '成功处理的用户列表', + type: [UserStatusInfoDto] + }) + success_users: UserStatusInfoDto[]; + + @ApiProperty({ + description: '处理失败的用户列表', + type: [Object], + example: [ + { + user_id: '999', + error: '用户不存在' + } + ] + }) + failed_users: Array<{ + user_id: string; + error: string; + }>; + + @ApiProperty({ + description: '成功处理数量', + example: 5 + }) + success_count: number; + + @ApiProperty({ + description: '失败处理数量', + example: 1 + }) + failed_count: number; + + @ApiProperty({ + description: '总处理数量', + example: 6 + }) + total_count: number; +} + +/** + * 批量用户状态修改响应数据DTO + */ +export class BatchUserStatusDataDto { + @ApiProperty({ + description: '批量操作结果', + type: BatchOperationResultDto + }) + result: BatchOperationResultDto; + + @ApiProperty({ + description: '修改原因', + example: '批量处理违规用户', + required: false + }) + reason?: string; +} + +/** + * 批量用户状态修改响应DTO + */ +export class BatchUserStatusResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: BatchUserStatusDataDto, + required: false + }) + data?: BatchUserStatusDataDto; + + @ApiProperty({ + description: '响应消息', + example: '批量用户状态修改完成' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'BATCH_USER_STATUS_UPDATE_FAILED', + required: false + }) + error_code?: string; +} + +/** + * 用户状态统计DTO + */ +export class UserStatusStatsDto { + @ApiProperty({ + description: '正常用户数量', + example: 1250 + }) + active: number; + + @ApiProperty({ + description: '未激活用户数量', + example: 45 + }) + inactive: number; + + @ApiProperty({ + description: '锁定用户数量', + example: 12 + }) + locked: number; + + @ApiProperty({ + description: '禁用用户数量', + example: 8 + }) + banned: number; + + @ApiProperty({ + description: '已删除用户数量', + example: 3 + }) + deleted: number; + + @ApiProperty({ + description: '待审核用户数量', + example: 15 + }) + pending: number; + + @ApiProperty({ + description: '总用户数量', + example: 1333 + }) + total: number; +} + +/** + * 用户状态统计响应数据DTO + */ +export class UserStatusStatsDataDto { + @ApiProperty({ + description: '用户状态统计', + type: UserStatusStatsDto + }) + stats: UserStatusStatsDto; + + @ApiProperty({ + description: '统计时间', + example: '2025-12-24T10:00:00.000Z' + }) + timestamp: string; +} + +/** + * 用户状态统计响应DTO + */ +export class UserStatusStatsResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: UserStatusStatsDataDto, + required: false + }) + data?: UserStatusStatsDataDto; + + @ApiProperty({ + description: '响应消息', + example: '用户状态统计获取成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'USER_STATUS_STATS_FAILED', + required: false + }) + error_code?: string; +} \ No newline at end of file diff --git a/src/business/user-mgmt/dto/user-status.dto.ts b/src/business/user-mgmt/dto/user-status.dto.ts new file mode 100644 index 0000000..459d167 --- /dev/null +++ b/src/business/user-mgmt/dto/user-status.dto.ts @@ -0,0 +1,95 @@ +/** + * 用户状态管理 DTO + * + * 功能描述: + * - 定义用户状态管理相关的请求数据结构 + * - 提供数据验证规则和错误提示 + * - 确保状态管理操作的数据格式一致性 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { IsString, IsNotEmpty, IsEnum, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { UserStatus } from '../enums/user-status.enum'; + +/** + * 用户状态修改请求DTO + */ +export class UserStatusDto { + /** + * 新的用户状态 + */ + @ApiProperty({ + description: '用户状态', + enum: UserStatus, + example: UserStatus.ACTIVE, + enumName: 'UserStatus' + }) + @IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' }) + @IsNotEmpty({ message: '用户状态不能为空' }) + status: UserStatus; + + /** + * 状态修改原因 + */ + @ApiProperty({ + description: '状态修改原因(可选)', + example: '用户违反社区规定', + required: false, + maxLength: 200 + }) + @IsOptional() + @IsString({ message: '修改原因必须是字符串' }) + reason?: string; +} + +/** + * 批量用户状态修改请求DTO + */ +export class BatchUserStatusDto { + /** + * 用户ID列表 + */ + @ApiProperty({ + description: '用户ID列表', + example: ['1', '2', '3'], + type: [String], + minItems: 1, + maxItems: 100 + }) + @IsArray({ message: '用户ID列表必须是数组' }) + @ArrayMinSize(1, { message: '至少需要选择一个用户' }) + @ArrayMaxSize(100, { message: '一次最多只能操作100个用户' }) + @IsString({ each: true, message: '用户ID必须是字符串' }) + @IsNotEmpty({ each: true, message: '用户ID不能为空' }) + user_ids: string[]; + + /** + * 新的用户状态 + */ + @ApiProperty({ + description: '用户状态', + enum: UserStatus, + example: UserStatus.LOCKED, + enumName: 'UserStatus' + }) + @IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' }) + @IsNotEmpty({ message: '用户状态不能为空' }) + status: UserStatus; + + /** + * 状态修改原因 + */ + @ApiProperty({ + description: '批量修改原因(可选)', + example: '批量处理违规用户', + required: false, + maxLength: 200 + }) + @IsOptional() + @IsString({ message: '修改原因必须是字符串' }) + reason?: string; +} \ No newline at end of file diff --git a/src/business/user-mgmt/enums/user-status.enum.ts b/src/business/user-mgmt/enums/user-status.enum.ts new file mode 100644 index 0000000..3050110 --- /dev/null +++ b/src/business/user-mgmt/enums/user-status.enum.ts @@ -0,0 +1,100 @@ +/** + * 用户状态枚举 + * + * 功能描述: + * - 定义用户账户的各种状态 + * - 提供状态检查和描述功能 + * - 支持用户生命周期管理 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +/** + * 用户状态枚举 + * + * 状态说明: + * - active: 正常状态,可以正常使用所有功能 + * - inactive: 未激活状态,通常是新注册用户需要邮箱验证 + * - locked: 临时锁定状态,可以解锁恢复 + * - banned: 永久禁用状态,需要管理员处理 + * - deleted: 软删除状态,数据保留但不可使用 + * - pending: 待审核状态,需要管理员审核后激活 + */ +export enum UserStatus { + ACTIVE = 'active', // 正常状态 + INACTIVE = 'inactive', // 未激活状态 + LOCKED = 'locked', // 锁定状态 + BANNED = 'banned', // 禁用状态 + DELETED = 'deleted', // 删除状态 + PENDING = 'pending' // 待审核状态 +} + +/** + * 获取用户状态的中文描述 + * + * @param status 用户状态 + * @returns 状态描述 + */ +export function getUserStatusDescription(status: UserStatus): string { + const descriptions = { + [UserStatus.ACTIVE]: '正常', + [UserStatus.INACTIVE]: '未激活', + [UserStatus.LOCKED]: '已锁定', + [UserStatus.BANNED]: '已禁用', + [UserStatus.DELETED]: '已删除', + [UserStatus.PENDING]: '待审核' + }; + + return descriptions[status] || '未知状态'; +} + +/** + * 检查用户是否可以登录 + * + * @param status 用户状态 + * @returns 是否可以登录 + */ +export function canUserLogin(status: UserStatus): boolean { + // 只有正常状态的用户可以登录 + return status === UserStatus.ACTIVE; +} + +/** + * 获取用户状态对应的错误消息 + * + * @param status 用户状态 + * @returns 错误消息 + */ +export function getUserStatusErrorMessage(status: UserStatus): string { + const errorMessages = { + [UserStatus.ACTIVE]: '', // 正常状态无错误 + [UserStatus.INACTIVE]: '账户未激活,请先验证邮箱', + [UserStatus.LOCKED]: '账户已被锁定,请联系管理员', + [UserStatus.BANNED]: '账户已被禁用,请联系管理员', + [UserStatus.DELETED]: '账户不存在', + [UserStatus.PENDING]: '账户待审核,请等待管理员审核' + }; + + return errorMessages[status] || '账户状态异常'; +} + +/** + * 获取所有可用的用户状态 + * + * @returns 用户状态数组 + */ +export function getAllUserStatuses(): UserStatus[] { + return Object.values(UserStatus); +} + +/** + * 检查状态值是否有效 + * + * @param status 状态值 + * @returns 是否为有效状态 + */ +export function isValidUserStatus(status: string): status is UserStatus { + return Object.values(UserStatus).includes(status as UserStatus); +} \ No newline at end of file diff --git a/src/business/user-mgmt/index.ts b/src/business/user-mgmt/index.ts new file mode 100644 index 0000000..10cfaa0 --- /dev/null +++ b/src/business/user-mgmt/index.ts @@ -0,0 +1,22 @@ +/** + * 用户管理业务模块导出 + * + * 功能概述: + * - 用户状态管理(激活、锁定、禁用等) + * - 批量用户操作 + * - 用户状态统计和分析 + * - 状态变更审计和历史记录 + */ + +// 模块 +export * from './user-mgmt.module'; + +// 控制器 +export * from './controllers/user-status.controller'; + +// 服务 +export * from './services/user-management.service'; + +// DTO +export * from './dto/user-status.dto'; +export * from './dto/user-status-response.dto'; \ No newline at end of file diff --git a/src/business/user-mgmt/services/user-management.service.ts b/src/business/user-mgmt/services/user-management.service.ts new file mode 100644 index 0000000..a37d8d5 --- /dev/null +++ b/src/business/user-mgmt/services/user-management.service.ts @@ -0,0 +1,199 @@ +/** + * 用户管理业务服务 + * + * 功能描述: + * - 用户状态管理业务逻辑 + * - 批量用户操作 + * - 用户状态统计 + * - 状态变更审计 + * + * 职责分工: + * - 专注于用户管理相关的业务逻辑 + * - 调用 AdminService 的底层方法 + * - 提供用户管理特定的业务规则 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { AdminService } from '../../admin/admin.service'; +import { UserStatusDto, BatchUserStatusDto } from '../dto/user-status.dto'; +import { + UserStatusResponseDto, + BatchUserStatusResponseDto, + UserStatusStatsResponseDto +} from '../dto/user-status-response.dto'; + +@Injectable() +export class UserManagementService { + private readonly logger = new Logger(UserManagementService.name); + + constructor(private readonly adminService: AdminService) {} + + /** + * 修改用户状态 + * + * 业务逻辑: + * 1. 验证状态变更的业务规则 + * 2. 记录状态变更原因 + * 3. 调用底层服务执行变更 + * 4. 记录业务审计日志 + * + * @param userId 用户ID + * @param userStatusDto 状态修改数据 + * @returns 修改结果 + */ + async updateUserStatus(userId: bigint, userStatusDto: UserStatusDto): Promise { + this.logger.log('用户管理:开始修改用户状态', { + operation: 'user_mgmt_update_status', + userId: userId.toString(), + newStatus: userStatusDto.status, + reason: userStatusDto.reason, + timestamp: new Date().toISOString() + }); + + // 调用底层管理员服务 + const result = await this.adminService.updateUserStatus(userId, userStatusDto); + + // 记录业务层日志 + if (result.success) { + this.logger.log('用户管理:用户状态修改成功', { + operation: 'user_mgmt_update_status_success', + userId: userId.toString(), + newStatus: userStatusDto.status, + timestamp: new Date().toISOString() + }); + } + + return result; + } + + /** + * 批量修改用户状态 + * + * 业务逻辑: + * 1. 验证批量操作的业务规则 + * 2. 分批处理大量用户 + * 3. 提供批量操作的进度反馈 + * 4. 记录批量操作审计 + * + * @param batchUserStatusDto 批量状态修改数据 + * @returns 批量修改结果 + */ + async batchUpdateUserStatus(batchUserStatusDto: BatchUserStatusDto): Promise { + this.logger.log('用户管理:开始批量修改用户状态', { + operation: 'user_mgmt_batch_update_status', + userCount: batchUserStatusDto.user_ids.length, + newStatus: batchUserStatusDto.status, + reason: batchUserStatusDto.reason, + timestamp: new Date().toISOString() + }); + + // 业务规则:限制批量操作的数量 + if (batchUserStatusDto.user_ids.length > 100) { + this.logger.warn('用户管理:批量操作数量超限', { + operation: 'user_mgmt_batch_update_limit_exceeded', + requestCount: batchUserStatusDto.user_ids.length, + maxAllowed: 100 + }); + + return { + success: false, + message: '批量操作数量不能超过100个用户', + error_code: 'BATCH_OPERATION_LIMIT_EXCEEDED' + }; + } + + // 调用底层管理员服务 + const result = await this.adminService.batchUpdateUserStatus(batchUserStatusDto); + + // 记录业务层日志 + if (result.success) { + this.logger.log('用户管理:批量用户状态修改完成', { + operation: 'user_mgmt_batch_update_status_success', + successCount: result.data?.result.success_count || 0, + failedCount: result.data?.result.failed_count || 0, + timestamp: new Date().toISOString() + }); + } + + return result; + } + + /** + * 获取用户状态统计 + * + * 业务逻辑: + * 1. 获取基础统计数据 + * 2. 计算业务相关的指标 + * 3. 提供状态分布分析 + * 4. 缓存统计结果 + * + * @returns 状态统计信息 + */ + async getUserStatusStats(): Promise { + this.logger.log('用户管理:获取用户状态统计', { + operation: 'user_mgmt_get_status_stats', + timestamp: new Date().toISOString() + }); + + // 调用底层管理员服务 + const result = await this.adminService.getUserStatusStats(); + + // 业务层可以在这里添加额外的统计分析 + if (result.success && result.data) { + const stats = result.data.stats; + + // 计算业务指标 + const activeRate = stats.total > 0 ? (stats.active / stats.total * 100).toFixed(2) : '0'; + const problemUserCount = stats.locked + stats.banned + stats.deleted; + + this.logger.log('用户管理:用户状态统计分析', { + operation: 'user_mgmt_status_analysis', + totalUsers: stats.total, + activeUsers: stats.active, + activeRate: `${activeRate}%`, + problemUsers: problemUserCount, + timestamp: new Date().toISOString() + }); + } + + return result; + } + + /** + * 获取用户状态变更历史 + * + * 业务功能: + * - 查询指定用户的状态变更记录 + * - 提供状态变更的审计追踪 + * - 支持时间范围查询 + * + * @param userId 用户ID + * @param limit 返回数量限制 + * @returns 状态变更历史 + */ + async getUserStatusHistory(userId: bigint, limit: number = 10) { + this.logger.log('用户管理:获取用户状态变更历史', { + operation: 'user_mgmt_get_status_history', + userId: userId.toString(), + limit, + timestamp: new Date().toISOString() + }); + + // TODO: 实现状态变更历史查询 + // 这里可以调用专门的审计日志服务 + + return { + success: true, + data: { + user_id: userId.toString(), + history: [] as any[], + total_count: 0 + }, + message: '状态变更历史获取成功(功能待实现)' + }; + } +} \ No newline at end of file diff --git a/src/business/user-mgmt/user-mgmt.module.ts b/src/business/user-mgmt/user-mgmt.module.ts new file mode 100644 index 0000000..95f80c7 --- /dev/null +++ b/src/business/user-mgmt/user-mgmt.module.ts @@ -0,0 +1,30 @@ +/** + * 用户管理业务模块 + * + * 功能描述: + * - 整合用户状态管理相关的所有组件 + * - 提供用户生命周期管理功能 + * - 支持批量操作和状态统计 + * + * 依赖关系: + * - 依赖 AdminModule 提供底层管理功能 + * - 依赖 Core 模块提供基础设施 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-12-24 + */ + +import { Module } from '@nestjs/common'; +import { UserStatusController } from './controllers/user-status.controller'; +import { UserManagementService } from './services/user-management.service'; +import { AdminModule } from '../admin/admin.module'; +import { AdminCoreModule } from '../../core/admin_core/admin_core.module'; + +@Module({ + imports: [AdminModule, AdminCoreModule], + controllers: [UserStatusController], + providers: [UserManagementService], + exports: [UserManagementService], +}) +export class UserMgmtModule {} \ No newline at end of file