resolve: 解决ANGJustinl-main与main分支的合并冲突

- 修复文件路径冲突(business/login -> business/auth结构调整)
- 保留ANGJustinl分支的验证码登录功能
- 合并main分支的用户状态管理和项目结构改进
- 修复邮件服务中缺失的login_verification模板问题
- 更新测试用例以包含验证码登录功能
- 统一导入路径以适配新的目录结构
This commit is contained in:
moyin
2025-12-25 15:11:14 +08:00
94 changed files with 8364 additions and 2029 deletions

View File

View File

@@ -1,8 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AppService } from './app.service';
import { AppStatusResponseDto } from './dto/app.dto';
import { ErrorResponseDto } from './dto/error_response.dto';
import { AppStatusResponseDto, ErrorResponseDto } from './business/shared';
/**
* 应用根控制器

View File

@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common';
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { LoggerModule } from './core/utils/logger/logger.module';
import { UsersModule } from './core/db/users/users.module';
import { LoginCoreModule } from './core/login_core/login_core.module';
import { LoginModule } from './business/login/login.module';
import { AuthModule } from './business/auth/auth.module';
import { RedisModule } from './core/redis/redis.module';
import { AdminModule } from './business/admin/admin.module';
import { UserMgmtModule } from './business/user-mgmt/user-mgmt.module';
import { SecurityModule } from './business/security/security.module';
import { MaintenanceMiddleware } from './business/security/middleware/maintenance.middleware';
import { ContentTypeMiddleware } from './business/security/middleware/content-type.middleware';
/**
* 检查数据库配置是否完整 by angjustinl 2025-12-17
@@ -60,9 +66,32 @@ function isDatabaseConfigured(): boolean {
// 根据数据库配置选择用户模块模式
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
LoginCoreModule,
LoginModule,
AuthModule,
UserMgmtModule,
AdminModule,
SecurityModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
// 注意全局拦截器现在由SecurityModule提供
],
})
export class AppModule {}
export class AppModule implements NestModule {
/**
* 配置中间件
*
* @param consumer 中间件消费者
*/
configure(consumer: MiddlewareConsumer) {
// 1. 维护模式中间件 - 最高优先级
consumer
.apply(MaintenanceMiddleware)
.forRoutes('*');
// 2. 内容类型检查中间件
consumer
.apply(ContentTypeMiddleware)
.forRoutes('*');
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppStatusResponseDto } from './dto/app.dto';
import { AppStatusResponseDto } from './business/shared';
/**
* 应用服务类

View File

@@ -0,0 +1,184 @@
/**
* 管理员控制器
*
* API端点
* - POST /admin/auth/login 管理员登录
* - GET /admin/users 用户列表需要管理员Token
* - GET /admin/users/:id 用户详情需要管理员Token
* - POST /admin/users/:id/reset-password 重置指定用户密码需要管理员Token
* - GET /admin/logs/runtime 获取运行日志尾部需要管理员Token
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
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 './guards/admin.guard';
import { AdminService } from './admin.service';
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';
import { spawn } from 'child_process';
import { pipeline } from 'stream';
@ApiTags('admin')
@Controller('admin')
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
@ApiBody({ type: AdminLoginDto })
@ApiResponse({ status: 200, description: '登录成功', type: AdminLoginResponseDto })
@ApiResponse({ status: 401, description: '登录失败' })
@ApiResponse({ status: 403, description: '权限不足或账户被禁用' })
@ApiResponse({ status: 429, description: '登录尝试过于频繁' })
@Throttle(ThrottlePresets.LOGIN)
@Post('auth/login')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async login(@Body() dto: AdminLoginDto) {
return await this.adminService.login(dto.identifier, dto.password);
}
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: '获取用户列表', description: '后台用户管理:分页获取用户列表' })
@ApiQuery({ name: 'limit', required: false, description: '返回数量默认100' })
@ApiQuery({ name: 'offset', required: false, description: '偏移量默认0' })
@ApiResponse({ status: 200, description: '获取成功', type: AdminUsersResponseDto })
@UseGuards(AdminGuard)
@Get('users')
async listUsers(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
const parsedLimit = limit ? Number(limit) : 100;
const parsedOffset = offset ? Number(offset) : 0;
return await this.adminService.listUsers(parsedLimit, parsedOffset);
}
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: '获取用户详情' })
@ApiParam({ name: 'id', description: '用户ID' })
@ApiResponse({ status: 200, description: '获取成功', type: AdminUserResponseDto })
@UseGuards(AdminGuard)
@Get('users/:id')
async getUser(@Param('id') id: string) {
return await this.adminService.getUser(BigInt(id));
}
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: '重置用户密码', description: '管理员直接为用户设置新密码(需满足密码强度规则)' })
@ApiParam({ name: 'id', description: '用户ID' })
@ApiBody({ type: AdminResetPasswordDto })
@ApiResponse({ status: 200, description: '重置成功', type: AdminCommonResponseDto })
@ApiResponse({ status: 429, description: '操作过于频繁' })
@UseGuards(AdminGuard)
@Throttle(ThrottlePresets.ADMIN_OPERATION)
@Post('users/:id/reset-password')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
return await this.adminService.resetPassword(BigInt(id), dto.new_password);
}
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: '获取运行日志尾部', description: '从 logs/ 目录读取最近的日志行默认200行' })
@ApiQuery({ name: 'lines', required: false, description: '返回行数默认200最大2000' })
@ApiResponse({ status: 200, description: '获取成功', type: AdminRuntimeLogsResponseDto })
@UseGuards(AdminGuard)
@Get('logs/runtime')
async getRuntimeLogs(@Query('lines') lines?: string) {
const parsedLines = lines ? Number(lines) : undefined;
return await this.adminService.getRuntimeLogs(parsedLines);
}
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: '下载全部运行日志', description: '将 logs/ 目录打包为 tar.gz 并下载需要管理员Token' })
@ApiProduces('application/gzip')
@ApiResponse({ status: 200, description: '打包下载成功tar.gz 二进制流)' })
@UseGuards(AdminGuard)
@Get('logs/archive')
async downloadLogsArchive(@Res() res: Response) {
const logDir = this.adminService.getLogDirAbsolutePath();
if (!fs.existsSync(logDir)) {
res.status(404).json({ success: false, message: '日志目录不存在' });
return;
}
const stats = fs.statSync(logDir);
if (!stats.isDirectory()) {
res.status(404).json({ success: false, message: '日志目录不可用' });
return;
}
const parentDir = path.dirname(logDir);
const baseName = path.basename(logDir);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `logs-${ts}.tar.gz`;
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Cache-Control', 'no-store');
const tar = spawn('tar', ['-czf', '-', '-C', parentDir, baseName], {
stdio: ['ignore', 'pipe', 'pipe'],
});
tar.stderr.on('data', (chunk: Buffer) => {
const msg = chunk.toString('utf8').trim();
if (msg) {
this.logger.warn(`tar stderr: ${msg}`);
}
});
tar.on('error', (err: any) => {
this.logger.error('打包日志失败tar 进程启动失败)', err?.stack || String(err));
if (!res.headersSent) {
const msg = err?.code === 'ENOENT' ? '服务器缺少 tar 命令,无法打包日志' : '日志打包失败';
res.status(500).json({ success: false, message: msg });
} else {
res.end();
}
});
const pipelinePromise = new Promise<void>((resolve, reject) => {
pipeline(tar.stdout, res, (err) => (err ? reject(err) : resolve()));
});
const exitPromise = new Promise<void>((resolve, reject) => {
tar.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`tar exited with code ${code ?? 'unknown'}`));
}
});
});
try {
await pipelinePromise;
await exitPromise;
} catch (err) {
this.logger.error('打包日志失败tar 执行或输出失败)', err instanceof Error ? err.stack : String(err));
if (!res.headersSent) {
res.status(500).json({ success: false, message: '日志打包失败' });
} else {
res.end();
}
}
}
}

View File

@@ -0,0 +1,26 @@
/**
* 管理员业务模块
*
* 功能描述:
* - 提供后台管理的HTTP API管理员登录、用户管理、密码重置等
* - 仅负责HTTP层与业务流程编排
* - 核心鉴权与密码策略由 AdminCoreService 提供
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
import { Module } from '@nestjs/common';
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
import { LoggerModule } from '../../core/utils/logger/logger.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AdminCoreModule, LoggerModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService], // 导出AdminService供其他模块使用
})
export class AdminModule {}

View File

@@ -0,0 +1,159 @@
import { NotFoundException } from '@nestjs/common';
import { AdminService } from './admin.service';
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
import { LogManagementService } from '../../core/utils/logger/log_management.service';
import { Users } from '../../core/db/users/users.entity';
describe('AdminService', () => {
let service: AdminService;
const adminCoreServiceMock: Pick<AdminCoreService, 'login' | 'resetUserPassword'> = {
login: jest.fn(),
resetUserPassword: jest.fn(),
};
const usersServiceMock = {
findAll: jest.fn(),
findOne: jest.fn(),
};
const logManagementServiceMock: Pick<LogManagementService, 'getRuntimeLogTail' | 'getLogDirAbsolutePath'> = {
getRuntimeLogTail: jest.fn(),
getLogDirAbsolutePath: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
service = new AdminService(
adminCoreServiceMock as unknown as AdminCoreService,
usersServiceMock as any,
logManagementServiceMock as unknown as LogManagementService,
);
});
it('should login admin successfully', async () => {
(adminCoreServiceMock.login as jest.Mock).mockResolvedValue({
admin: { id: '1', username: 'admin', nickname: '管理员', role: 9 },
access_token: 'token',
expires_at: 123,
});
const res = await service.login('admin', 'Admin123456');
expect(adminCoreServiceMock.login).toHaveBeenCalledWith({ identifier: 'admin', password: 'Admin123456' });
expect(res.success).toBe(true);
expect(res.data?.admin?.role).toBe(9);
expect(res.message).toBe('管理员登录成功');
});
it('should handle login failure', async () => {
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue(new Error('密码错误'));
const res = await service.login('admin', 'bad');
expect(res.success).toBe(false);
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
expect(res.message).toBe('密码错误');
});
it('should handle non-Error login failure', async () => {
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue('boom');
const res = await service.login('admin', 'bad');
expect(res.success).toBe(false);
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
expect(res.message).toBe('管理员登录失败');
});
it('should list users with pagination', async () => {
const user = {
id: BigInt(1),
username: 'u1',
nickname: 'U1',
email: 'u1@test.com',
email_verified: true,
phone: null,
avatar_url: null,
role: 1,
created_at: new Date('2025-01-01T00:00:00Z'),
updated_at: new Date('2025-01-02T00:00:00Z'),
} as unknown as Users;
usersServiceMock.findAll.mockResolvedValue([user]);
const res = await service.listUsers(100, 0);
expect(usersServiceMock.findAll).toHaveBeenCalledWith(100, 0);
expect(res.success).toBe(true);
expect(res.data?.users).toHaveLength(1);
expect(res.data?.users[0]).toMatchObject({
id: '1',
username: 'u1',
nickname: 'U1',
role: 1,
});
});
it('should get user by id', async () => {
const user = {
id: BigInt(3),
username: 'u3',
nickname: 'U3',
email: null,
email_verified: false,
phone: '123',
avatar_url: null,
role: 1,
created_at: new Date('2025-01-01T00:00:00Z'),
updated_at: new Date('2025-01-02T00:00:00Z'),
} as unknown as Users;
usersServiceMock.findOne.mockResolvedValue(user);
const res = await service.getUser(BigInt(3));
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(3));
expect(res.success).toBe(true);
expect(res.data?.user).toMatchObject({ id: '3', username: 'u3', nickname: 'U3' });
});
it('should reset user password', async () => {
usersServiceMock.findOne.mockResolvedValue({ id: BigInt(2) } as unknown as Users);
(adminCoreServiceMock.resetUserPassword as jest.Mock).mockResolvedValue(undefined);
const res = await service.resetPassword(BigInt(2), 'NewPass1234');
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(2));
expect(adminCoreServiceMock.resetUserPassword).toHaveBeenCalledWith(BigInt(2), 'NewPass1234');
expect(res).toEqual({ success: true, message: '密码重置成功' });
});
it('should throw NotFoundException when resetting password for missing user', async () => {
usersServiceMock.findOne.mockRejectedValue(new Error('not found'));
await expect(service.resetPassword(BigInt(999), 'NewPass1234')).rejects.toBeInstanceOf(NotFoundException);
});
it('should get runtime logs', async () => {
(logManagementServiceMock.getRuntimeLogTail as jest.Mock).mockResolvedValue({
file: 'dev.log',
updated_at: '2025-01-01T00:00:00.000Z',
lines: ['a', 'b'],
});
const res = await service.getRuntimeLogs(2);
expect(logManagementServiceMock.getRuntimeLogTail).toHaveBeenCalledWith({ lines: 2 });
expect(res.success).toBe(true);
expect(res.data?.file).toBe('dev.log');
expect(res.data?.lines).toEqual(['a', 'b']);
});
it('should expose log dir absolute path', () => {
(logManagementServiceMock.getLogDirAbsolutePath as jest.Mock).mockReturnValue('/abs/logs');
expect(service.getLogDirAbsolutePath()).toBe('/abs/logs');
expect(logManagementServiceMock.getLogDirAbsolutePath).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,434 @@
/**
* 管理员业务服务
*
* 功能描述:
* - 调用核心服务完成管理员登录
* - 提供用户列表查询
* - 提供用户密码重置能力
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
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<T = any> {
success: boolean;
data?: T;
message: string;
error_code?: string;
}
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
private readonly adminCoreService: AdminCoreService,
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
private readonly logManagementService: LogManagementService,
) {}
getLogDirAbsolutePath(): string {
return this.logManagementService.getLogDirAbsolutePath();
}
async login(identifier: string, password: string): Promise<AdminApiResponse> {
try {
const result = await this.adminCoreService.login({ identifier, password });
return { success: true, data: result, message: '管理员登录成功' };
} catch (error) {
this.logger.error(`管理员登录失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '管理员登录失败',
error_code: 'ADMIN_LOGIN_FAILED',
};
}
}
async listUsers(limit: number, offset: number): Promise<AdminApiResponse<{ users: any[]; limit: number; offset: number }>> {
const users = await this.usersService.findAll(limit, offset);
return {
success: true,
data: {
users: users.map((u: Users) => this.formatUser(u)),
limit,
offset,
},
message: '用户列表获取成功',
};
}
async getUser(id: bigint): Promise<AdminApiResponse<{ user: any }>> {
const user = await this.usersService.findOne(id);
return {
success: true,
data: { user: this.formatUser(user) },
message: '用户信息获取成功',
};
}
async resetPassword(id: bigint, newPassword: string): Promise<AdminApiResponse> {
// 确认用户存在
const user = await this.usersService.findOne(id).catch((): null => null);
if (!user) {
throw new NotFoundException('用户不存在');
}
await this.adminCoreService.resetUserPassword(id, newPassword);
this.logger.log(`管理员重置密码成功: userId=${id.toString()}`);
return { success: true, message: '密码重置成功' };
}
async getRuntimeLogs(lines?: number): Promise<AdminApiResponse<{ file: string; updated_at: string; lines: string[] }>> {
const result = await this.logManagementService.getRuntimeLogTail({ lines });
return {
success: true,
data: result,
message: '运行日志获取成功',
};
}
private formatUser(user: Users) {
return {
id: user.id.toString(),
username: user.username,
nickname: user.nickname,
email: user.email,
email_verified: user.email_verified,
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<UserStatusResponseDto> {
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<BatchUserStatusResponseDto> {
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<UserStatusStatsResponseDto> {
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'
};
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,43 @@
/**
* 管理员鉴权守卫
*
* 功能描述:
* - 保护后台管理接口
* - 校验 Authorization: Bearer <admin_token>
* - 仅允许 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<AdminRequest>();
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;
}
}

View File

@@ -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';

81
src/business/admin/tests Normal file
View File

@@ -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<AdminCoreService, 'verifyToken'> = {
verifyToken: jest.fn(),
};
const makeContext = (authorization?: any) => {
const req: any = { headers: {} };
if (authorization !== undefined) {
req.headers['authorization'] = authorization;
}
const ctx: Partial<ExecutionContext> = {
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);
});
});

View File

@@ -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 {}

View File

@@ -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, VerificationCodeLoginDto, SendLoginVerificationCodeDto } from '../../dto/login.dto';
import { LoginService, ApiResponse, LoginResponse } from '../services/login.service';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto } 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,14 +67,35 @@ 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 }))
async login(@Body() loginDto: LoginDto): Promise<ApiResponse<LoginResponse>> {
return await this.loginService.login({
async login(@Body() loginDto: LoginDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.login({
identifier: loginDto.identifier,
password: loginDto.password
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
// 根据错误类型设置不同的状态码
if (result.error_code === 'LOGIN_FAILED') {
res.status(HttpStatus.UNAUTHORIZED).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
}
/**
@@ -99,11 +122,16 @@ 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 }))
async register(@Body() registerDto: RegisterDto): Promise<ApiResponse<LoginResponse>> {
return await this.loginService.register({
async register(@Body() registerDto: RegisterDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.register({
username: registerDto.username,
password: registerDto.password,
nickname: registerDto.nickname,
@@ -111,6 +139,18 @@ export class LoginController {
phone: registerDto.phone,
email_verification_code: registerDto.email_verification_code
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.CREATED).json(result);
} else {
// 根据错误类型设置不同的状态码
if (result.error_code === 'REGISTER_FAILED') {
res.status(HttpStatus.BAD_REQUEST).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
}
/**
@@ -138,16 +178,22 @@ export class LoginController {
description: 'GitHub认证失败'
})
@Post('github')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async githubOAuth(@Body() githubDto: GitHubOAuthDto): Promise<ApiResponse<LoginResponse>> {
return await this.loginService.githubOAuth({
async githubOAuth(@Body() githubDto: GitHubOAuthDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.githubOAuth({
github_id: githubDto.github_id,
username: githubDto.username,
nickname: githubDto.nickname,
email: githubDto.email,
avatar_url: githubDto.avatar_url
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
/**
@@ -180,6 +226,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,15 +273,26 @@ 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 }))
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto): Promise<ApiResponse> {
return await this.loginService.resetPassword({
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.resetPassword({
identifier: resetPasswordDto.identifier,
verificationCode: resetPasswordDto.verification_code,
newPassword: resetPasswordDto.new_password
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
/**
@@ -258,18 +320,24 @@ export class LoginController {
description: '用户不存在'
})
@Put('change-password')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async changePassword(@Body() changePasswordDto: ChangePasswordDto): Promise<ApiResponse> {
async changePassword(@Body() changePasswordDto: ChangePasswordDto, @Res() res: Response): Promise<void> {
// 实际应用中应从JWT令牌中获取用户ID
// 这里为了演示使用请求体中的用户ID
const userId = BigInt(changePasswordDto.user_id);
return await this.loginService.changePassword(
const result = await this.loginService.changePassword(
userId,
changePasswordDto.old_password,
changePasswordDto.new_password
);
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
/**
@@ -302,6 +370,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(
@@ -341,13 +411,19 @@ export class LoginController {
description: '验证码错误或已过期'
})
@Post('verify-email')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto): Promise<ApiResponse> {
return await this.loginService.verifyEmailCode(
async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.verifyEmailCode(
emailVerificationDto.email,
emailVerificationDto.verification_code
);
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
/**
@@ -380,6 +456,7 @@ export class LoginController {
status: 429,
description: '发送频率过高'
})
@Throttle(ThrottlePresets.SEND_CODE)
@Post('resend-email-verification')
@UsePipes(new ValidationPipe({ transform: true }))
async resendEmailVerification(
@@ -501,9 +578,28 @@ export class LoginController {
})
@ApiBody({ type: SendEmailVerificationDto })
@Post('debug-verification-code')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async debugVerificationCode(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<any> {
return await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
async debugVerificationCode(@Body() sendEmailVerificationDto: SendEmailVerificationDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
// 调试接口总是返回200
res.status(HttpStatus.OK).json(result);
}
/**
*
*/
@ApiOperation({
summary: '清除限流记录',
description: '清除所有限流记录(仅开发环境使用)'
})
@Post('debug-clear-throttle')
async clearThrottle(@Res() res: Response): Promise<void> {
// 注入ThrottleGuard并清除记录
// 这里需要通过依赖注入获取ThrottleGuard实例
res.status(HttpStatus.OK).json({
success: true,
message: '限流记录已清除'
});
}
}

View File

@@ -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';

View File

@@ -17,8 +17,8 @@
*/
import { Injectable, Logger } from '@nestjs/common';
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../core/login_core/login_core.service';
import { Users } from '../../core/db/users/users.entity';
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service';
import { Users } from '../../../core/db/users/users.entity';
/**
*

View File

@@ -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 {}

View File

@@ -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分钟10次开发环境放宽限制 */
REGISTER: { limit: 10, 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;

View File

@@ -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;

View File

@@ -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<string, ThrottleRecord>();
/**
* 清理过期记录的间隔(毫秒)
*/
private readonly cleanupInterval = 60000; // 1分钟
constructor(private readonly reflector: Reflector) {
// 启动定期清理任务
this.startCleanupTask();
}
/**
* 守卫检查函数
*
* @param context 执行上下文
* @returns 是否允许通过
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. 获取频率限制配置
const throttleConfig = this.getThrottleConfig(context);
if (!throttleConfig) {
// 没有配置频率限制,直接通过
return true;
}
// 2. 获取请求信息
const request = context.switchToHttp().getRequest<Request>();
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<ThrottleConfig>(
THROTTLE_KEY,
context.getHandler()
);
if (methodConfig) {
return methodConfig;
}
// 从类装饰器获取配置
const classConfig = this.reflector.get<ThrottleConfig>(
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);
}
}

View File

@@ -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';

View File

@@ -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<any> {
// 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<TimeoutConfig>(
TIMEOUT_KEY,
context.getHandler()
);
if (methodConfig) {
return methodConfig;
}
// 从类装饰器获取配置
const classConfig = this.reflector.get<TimeoutConfig>(
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分钟
);
}
}

View File

@@ -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);
}
}

View File

@@ -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<string>('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<string>('MAINTENANCE_START_TIME') || new Date().toISOString();
const maintenanceEndTime = this.configService.get<string>('MAINTENANCE_END_TIME');
const maintenanceReason = this.configService.get<string>('MAINTENANCE_REASON') || '系统维护升级';
const retryAfter = this.configService.get<number>('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<string>('MAINTENANCE_MODE') === 'true';
}
/**
* 获取维护信息
*
* @returns 维护配置信息
*/
getMaintenanceInfo() {
return {
enabled: this.isMaintenanceEnabled(),
startTime: this.configService.get<string>('MAINTENANCE_START_TIME'),
endTime: this.configService.get<string>('MAINTENANCE_END_TIME'),
reason: this.configService.get<string>('MAINTENANCE_REASON'),
retryAfter: this.configService.get<number>('MAINTENANCE_RETRY_AFTER')
};
}
}

View File

@@ -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 {}

View File

@@ -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';

View File

@@ -0,0 +1,14 @@
/**
* 共享模块统一导出
*
* 功能描述:
* - 导出所有共享的组件和类型
* - 提供统一的导入入口
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-12-24
*/
// DTO
export * from './dto';

View File

@@ -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<UserStatusResponseDto> {
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<BatchUserStatusResponseDto> {
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<UserStatusStatsResponseDto> {
this.logger.log('管理员获取用户状态统计', {
operation: 'get_user_status_stats',
timestamp: new Date().toISOString()
});
return await this.userManagementService.getUserStatusStats();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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<UserStatusResponseDto> {
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<BatchUserStatusResponseDto> {
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<UserStatusStatsResponseDto> {
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: '状态变更历史获取成功(功能待实现)'
};
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1,27 @@
/**
* 管理员核心模块
*
* 功能描述:
* - 提供管理员登录鉴权能力签名Token
* - 提供管理员账户启动引导(可选)
* - 为业务层 AdminModule 提供可复用的核心服务
*
* 依赖模块:
* - UsersModule: 用户数据访问(数据库/内存双模式)
* - ConfigModule: 环境变量与配置读取
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AdminCoreService } from './admin_core.service';
@Module({
imports: [ConfigModule],
providers: [AdminCoreService],
exports: [AdminCoreService],
})
export class AdminCoreModule {}

View File

@@ -0,0 +1,459 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { AdminAuthPayload, AdminCoreService } from './admin_core.service';
import { Users } from '../db/users/users.entity';
jest.mock('bcrypt', () => ({
compare: jest.fn(),
hash: jest.fn(),
}));
type UsersServiceLike = {
findByUsername: jest.Mock;
findByEmail: jest.Mock;
findAll: jest.Mock;
update: jest.Mock;
create: jest.Mock;
};
describe('AdminCoreService', () => {
let configService: Pick<ConfigService, 'get'>;
let usersService: UsersServiceLike;
let service: AdminCoreService;
const secret = '0123456789abcdef';
const signToken = (payload: AdminAuthPayload, tokenSecret: string): string => {
const payloadJson = JSON.stringify(payload);
const payloadPart = Buffer.from(payloadJson, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const signature = crypto
.createHmac('sha256', tokenSecret)
.update(payloadPart)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
return `${payloadPart}.${signature}`;
};
beforeEach(() => {
jest.restoreAllMocks();
configService = {
get: jest.fn((key: string, defaultValue?: any) => {
if (key === 'ADMIN_TOKEN_SECRET') return secret;
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return defaultValue ?? '28800';
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
return defaultValue;
}),
};
usersService = {
findByUsername: jest.fn(),
findByEmail: jest.fn(),
findAll: jest.fn(),
update: jest.fn(),
create: jest.fn(),
};
service = new AdminCoreService(configService as ConfigService, usersService as any);
});
describe('login', () => {
it('should reject when admin does not exist', async () => {
usersService.findByUsername.mockResolvedValue(null);
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should reject non-admin user', async () => {
usersService.findByUsername.mockResolvedValue({
id: BigInt(1),
username: 'user',
nickname: 'U',
role: 1,
password_hash: 'hash',
} as unknown as Users);
await expect(service.login({ identifier: 'user', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should reject admin without password_hash', async () => {
usersService.findByUsername.mockResolvedValue({
id: BigInt(1),
username: 'admin',
nickname: '管理员',
role: 9,
password_hash: null,
} as unknown as Users);
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should reject wrong password', async () => {
usersService.findByUsername.mockResolvedValue({
id: BigInt(1),
username: 'admin',
nickname: '管理员',
role: 9,
password_hash: 'hash',
} as unknown as Users);
const bcryptAny = bcrypt as any;
bcryptAny.compare.mockResolvedValue(false);
await expect(service.login({ identifier: 'admin', password: 'bad' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should login with valid credentials and generate verifiable token', async () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
usersService.findByUsername.mockResolvedValue({
id: BigInt(1),
username: 'admin',
nickname: '管理员',
role: 9,
password_hash: 'hash',
} as unknown as Users);
const bcryptAny = bcrypt as any;
bcryptAny.compare.mockResolvedValue(true);
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
expect(result.admin).toEqual({ id: '1', username: 'admin', nickname: '管理员', role: 9 });
expect(result.access_token).toContain('.');
expect(result.expires_at).toBeGreaterThan(now);
const payload = service.verifyToken(result.access_token);
expect(payload).toMatchObject({ adminId: '1', username: 'admin', role: 9 });
expect(payload.iat).toBe(now);
expect(payload.exp).toBe(result.expires_at);
});
it('should find admin by email identifier', async () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
usersService.findByUsername.mockResolvedValue(null);
usersService.findByEmail.mockResolvedValue({
id: BigInt(1),
username: 'admin',
nickname: '管理员',
role: 9,
password_hash: 'hash',
} as unknown as Users);
const bcryptAny = bcrypt as any;
bcryptAny.compare.mockResolvedValue(true);
const result = await service.login({ identifier: 'admin@test.com', password: 'Admin123456' });
expect(result.admin.role).toBe(9);
});
it('should find admin by phone identifier', async () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
usersService.findByUsername.mockResolvedValue(null);
usersService.findByEmail.mockResolvedValue(null);
usersService.findAll.mockResolvedValue([
{
id: BigInt(2),
username: 'admin',
nickname: '管理员',
role: 9,
phone: '+86 13800000000',
password_hash: 'hash',
} as unknown as Users,
]);
const bcryptAny = bcrypt as any;
bcryptAny.compare.mockResolvedValue(true);
const result = await service.login({ identifier: '+86 13800000000', password: 'Admin123456' });
expect(result.admin.id).toBe('2');
});
it('should return phone-matched user via findUserByIdentifier (coverage for line 168)', async () => {
usersService.findByUsername.mockResolvedValue(null);
usersService.findByEmail.mockResolvedValue(null);
usersService.findAll.mockResolvedValue([
{
id: BigInt(10),
username: 'admin',
nickname: '管理员',
role: 9,
phone: '13800000000',
} as unknown as Users,
]);
const found = await (service as any).findUserByIdentifier('13800000000');
expect(found?.id?.toString()).toBe('10');
});
it('should fallback to default TTL when ADMIN_TOKEN_TTL_SECONDS is invalid', async () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ADMIN_TOKEN_SECRET') return secret;
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return 'not-a-number';
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
return defaultValue;
});
usersService.findByUsername.mockResolvedValue({
id: BigInt(1),
username: 'admin',
nickname: '管理员',
role: 9,
password_hash: 'hash',
} as unknown as Users);
const bcryptAny = bcrypt as any;
bcryptAny.compare.mockResolvedValue(true);
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
expect(result.expires_at).toBe(now + 28800 * 1000);
});
});
describe('verifyToken', () => {
it('should reject token when secret missing/too short', () => {
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ADMIN_TOKEN_SECRET') return 'short';
return defaultValue;
});
expect(() => service.verifyToken('a.b')).toThrow(BadRequestException);
});
it('should reject invalid token format', () => {
expect(() => service.verifyToken('no-dot')).toThrow(UnauthorizedException);
});
it('should reject token when payload JSON cannot be parsed (but signature valid)', () => {
const payloadPart = Buffer.from('not-json', 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const signature = crypto
.createHmac('sha256', secret)
.update(payloadPart)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
expect(() => service.verifyToken(`${payloadPart}.${signature}`)).toThrow(UnauthorizedException);
});
it('should accept valid signed token and return payload', () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
const payload: AdminAuthPayload = {
adminId: '1',
username: 'admin',
role: 9,
iat: now,
exp: now + 1000,
};
const token = signToken(payload, secret);
expect(service.verifyToken(token)).toEqual(payload);
});
it('should reject expired token', () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
const payload: AdminAuthPayload = {
adminId: '1',
username: 'admin',
role: 9,
iat: now - 1000,
exp: now - 1,
};
const token = signToken(payload, secret);
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
});
it('should reject token with invalid signature', () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
const payload: AdminAuthPayload = {
adminId: '1',
username: 'admin',
role: 9,
iat: now,
exp: now + 1000,
};
const token = signToken(payload, 'different_secret_012345');
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
});
it('should reject token with non-admin role', () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
const payload: AdminAuthPayload = {
adminId: '1',
username: 'user',
role: 1,
iat: now,
exp: now + 1000,
};
const token = signToken(payload, secret);
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
});
it('should reject token when signature length mismatches expected', () => {
const now = 1735689600000;
jest.spyOn(Date, 'now').mockReturnValue(now);
const payload: AdminAuthPayload = {
adminId: '1',
username: 'admin',
role: 9,
iat: now,
exp: now + 1000,
};
// Valid payloadPart, but deliberately wrong signature length
const payloadJson = JSON.stringify(payload);
const payloadPart = Buffer.from(payloadJson, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
expect(() => service.verifyToken(`${payloadPart}.x`)).toThrow(UnauthorizedException);
});
});
describe('resetUserPassword', () => {
it('should update user password_hash when password is strong', async () => {
const bcryptAny = bcrypt as any;
bcryptAny.hash.mockResolvedValue('hashed');
usersService.update.mockResolvedValue({} as any);
await service.resetUserPassword(BigInt(5), 'NewPass1234');
expect(usersService.update).toHaveBeenCalledWith(BigInt(5), { password_hash: 'hashed' });
});
it('should reject weak password', async () => {
await expect(service.resetUserPassword(BigInt(5), 'short')).rejects.toBeInstanceOf(BadRequestException);
});
it('should validate password strength directly (letters + numbers, 8+)', () => {
expect(() => (service as any).validatePasswordStrength('12345678')).toThrow(BadRequestException);
expect(() => (service as any).validatePasswordStrength('abcdefgh')).toThrow(BadRequestException);
expect(() => (service as any).validatePasswordStrength('Abcdef12')).not.toThrow();
});
it('should reject too-long password (>128)', () => {
const long = `Abc1${'x'.repeat(200)}`;
expect(() => (service as any).validatePasswordStrength(long)).toThrow(BadRequestException);
});
});
describe('bootstrapAdminIfEnabled', () => {
it('should do nothing when bootstrap disabled', async () => {
await service.onModuleInit();
expect(usersService.findByUsername).not.toHaveBeenCalled();
expect(usersService.create).not.toHaveBeenCalled();
});
it('should skip when enabled but missing username/password', async () => {
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
if (key === 'ADMIN_USERNAME') return undefined;
if (key === 'ADMIN_PASSWORD') return undefined;
if (key === 'ADMIN_NICKNAME') return defaultValue ?? '管理员';
if (key === 'ADMIN_TOKEN_SECRET') return secret;
return defaultValue;
});
await service.onModuleInit();
expect(usersService.findByUsername).not.toHaveBeenCalled();
expect(usersService.create).not.toHaveBeenCalled();
});
it('should skip when existing user already present', async () => {
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
if (key === 'ADMIN_USERNAME') return 'admin';
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
if (key === 'ADMIN_NICKNAME') return '管理员';
if (key === 'ADMIN_TOKEN_SECRET') return secret;
return defaultValue;
});
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 9 } as any);
await service.onModuleInit();
expect(usersService.create).not.toHaveBeenCalled();
});
it('should skip and warn when existing user has same username but non-admin role', async () => {
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
if (key === 'ADMIN_USERNAME') return 'admin';
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
if (key === 'ADMIN_NICKNAME') return '管理员';
if (key === 'ADMIN_TOKEN_SECRET') return secret;
return defaultValue;
});
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 1 } as any);
await service.onModuleInit();
expect(usersService.create).not.toHaveBeenCalled();
});
it('should create admin user when enabled and not existing', async () => {
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
if (key === 'ADMIN_USERNAME') return 'admin';
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
if (key === 'ADMIN_NICKNAME') return '管理员';
if (key === 'ADMIN_TOKEN_SECRET') return secret;
return defaultValue;
});
usersService.findByUsername.mockResolvedValue(null);
const bcryptAny = bcrypt as any;
bcryptAny.hash.mockResolvedValue('hashed');
await service.onModuleInit();
expect(usersService.create).toHaveBeenCalledWith({
username: 'admin',
password_hash: 'hashed',
nickname: '管理员',
role: 9,
email_verified: true,
});
});
});
});

View File

@@ -0,0 +1,285 @@
/**
* 管理员核心服务
*
* 功能描述:
* - 管理员登录校验(仅允许 role=9
* - 生成/验证管理员签名TokenHMAC-SHA256
* - 启动时可选引导创建管理员账号(通过环境变量启用)
*
* 安全说明:
* - 本服务生成的Token为对称签名Token非JWT标准实现但具备有效期与签名校验
* - 仅用于后台管理用途;生产环境务必配置强随机的 ADMIN_TOKEN_SECRET
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { Users } from '../db/users/users.entity';
import { UsersService } from '../db/users/users.service';
import { UsersMemoryService } from '../db/users/users_memory.service';
export interface AdminLoginRequest {
identifier: string;
password: string;
}
export interface AdminAuthPayload {
adminId: string;
username: string;
role: number;
iat: number;
exp: number;
}
export interface AdminLoginResult {
admin: {
id: string;
username: string;
nickname: string;
role: number;
};
access_token: string;
expires_at: number;
}
@Injectable()
export class AdminCoreService implements OnModuleInit {
private readonly logger = new Logger(AdminCoreService.name);
constructor(
private readonly configService: ConfigService,
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
) {}
async onModuleInit(): Promise<void> {
await this.bootstrapAdminIfEnabled();
}
/**
* 管理员登录
*/
async login(request: AdminLoginRequest): Promise<AdminLoginResult> {
const { identifier, password } = request;
const adminUser = await this.findUserByIdentifier(identifier);
if (!adminUser) {
throw new UnauthorizedException('管理员账号不存在');
}
if (adminUser.role !== 9) {
throw new UnauthorizedException('无管理员权限');
}
if (!adminUser.password_hash) {
throw new UnauthorizedException('管理员账户未设置密码,无法登录');
}
const ok = await bcrypt.compare(password, adminUser.password_hash);
if (!ok) {
throw new UnauthorizedException('密码错误');
}
const ttlSeconds = this.getAdminTokenTtlSeconds();
const now = Date.now();
const payload: AdminAuthPayload = {
adminId: adminUser.id.toString(),
username: adminUser.username,
role: adminUser.role,
iat: now,
exp: now + ttlSeconds * 1000,
};
const token = this.signPayload(payload);
return {
admin: {
id: adminUser.id.toString(),
username: adminUser.username,
nickname: adminUser.nickname,
role: adminUser.role,
},
access_token: token,
expires_at: payload.exp,
};
}
/**
* 校验管理员Token并返回Payload
*/
verifyToken(token: string): AdminAuthPayload {
const secret = this.getAdminTokenSecret();
const [payloadPart, signaturePart] = token.split('.');
if (!payloadPart || !signaturePart) {
throw new UnauthorizedException('Token格式错误');
}
const expected = this.hmacSha256Base64Url(payloadPart, secret);
if (!this.safeEqual(signaturePart, expected)) {
throw new UnauthorizedException('Token签名无效');
}
const payloadJson = Buffer.from(this.base64UrlToBase64(payloadPart), 'base64').toString('utf-8');
let payload: AdminAuthPayload;
try {
payload = JSON.parse(payloadJson) as AdminAuthPayload;
} catch {
throw new UnauthorizedException('Token解析失败');
}
if (!payload?.adminId || payload.role !== 9) {
throw new UnauthorizedException('无管理员权限');
}
if (typeof payload.exp !== 'number' || Date.now() > payload.exp) {
throw new UnauthorizedException('Token已过期');
}
return payload;
}
/**
* 管理员重置用户密码(直接设置新密码)
*/
async resetUserPassword(userId: bigint, newPassword: string): Promise<void> {
this.validatePasswordStrength(newPassword);
const passwordHash = await this.hashPassword(newPassword);
await this.usersService.update(userId, { password_hash: passwordHash });
}
private async findUserByIdentifier(identifier: string): Promise<Users | null> {
const byUsername = await this.usersService.findByUsername(identifier);
if (byUsername) return byUsername;
if (this.isEmail(identifier)) {
const byEmail = await this.usersService.findByEmail(identifier);
if (byEmail) return byEmail;
}
if (this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll(1000, 0);
return users.find((u: Users) => u.phone === identifier) || null;
}
return null;
}
private async bootstrapAdminIfEnabled(): Promise<void> {
const enabled = this.configService.get<string>('ADMIN_BOOTSTRAP_ENABLED', 'false') === 'true';
if (!enabled) return;
const username = this.configService.get<string>('ADMIN_USERNAME');
const password = this.configService.get<string>('ADMIN_PASSWORD');
const nickname = this.configService.get<string>('ADMIN_NICKNAME', '管理员');
if (!username || !password) {
this.logger.warn('已启用管理员引导,但未配置 ADMIN_USERNAME / ADMIN_PASSWORD跳过创建');
return;
}
const existing = await this.usersService.findByUsername(username);
if (existing) {
if (existing.role !== 9) {
this.logger.warn(`管理员引导发现同名用户但role!=9${username},跳过`);
}
return;
}
this.validatePasswordStrength(password);
const passwordHash = await this.hashPassword(password);
await this.usersService.create({
username,
password_hash: passwordHash,
nickname,
role: 9,
email_verified: true,
});
this.logger.log(`管理员账号已创建:${username} (role=9)`);
}
private getAdminTokenSecret(): string {
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
if (!secret || secret.length < 16) {
throw new BadRequestException('ADMIN_TOKEN_SECRET 未配置或过短至少16字符');
}
return secret;
}
private getAdminTokenTtlSeconds(): number {
const raw = this.configService.get<string>('ADMIN_TOKEN_TTL_SECONDS', '28800'); // 8h
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 28800;
}
return parsed;
}
private signPayload(payload: AdminAuthPayload): string {
const secret = this.getAdminTokenSecret();
const payloadJson = JSON.stringify(payload);
const payloadPart = this.base64ToBase64Url(Buffer.from(payloadJson, 'utf-8').toString('base64'));
const signature = this.hmacSha256Base64Url(payloadPart, secret);
return `${payloadPart}.${signature}`;
}
private hmacSha256Base64Url(data: string, secret: string): string {
const digest = crypto.createHmac('sha256', secret).update(data).digest('base64');
return this.base64ToBase64Url(digest);
}
private base64ToBase64Url(base64: string): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
private base64UrlToBase64(base64Url: string): string {
const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (padded.length % 4)) % 4;
return padded + '='.repeat(padLen);
}
private safeEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}
private isEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
private isPhoneNumber(value: string): boolean {
return /^\+?[0-9\-\s]{6,20}$/.test(value);
}
private validatePasswordStrength(password: string): void {
if (password.length < 8) {
throw new BadRequestException('密码长度至少8位');
}
if (password.length > 128) {
throw new BadRequestException('密码长度不能超过128位');
}
const hasLetter = /[a-zA-Z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasLetter || !hasNumber) {
throw new BadRequestException('密码必须包含字母和数字');
}
}
private async hashPassword(password: string): Promise<string> {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
}

View File

@@ -24,8 +24,10 @@ import {
Max,
IsOptional,
Length,
IsNotEmpty
IsNotEmpty,
IsEnum
} from 'class-validator';
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
/**
* 创建用户数据传输对象
@@ -232,4 +234,30 @@ export class CreateUserDto {
*/
@IsOptional()
email_verified?: boolean = false;
/**
* 用户状态
*
* 业务规则:
* - 可选字段默认为active正常状态
* - 控制用户账户的可用性和权限
* - 支持多种状态:正常、未激活、锁定、禁用等
* - 影响用户登录和API访问权限
*
* 验证规则:
* - 可选字段验证
* - 枚举类型验证
* - 默认值active正常状态
*
* 状态说明:
* - active: 正常状态,可以正常使用
* - inactive: 未激活,需要邮箱验证
* - locked: 已锁定,临时禁用
* - banned: 已禁用,管理员操作
* - deleted: 已删除,软删除状态
* - pending: 待审核,需要管理员审核
*/
@IsOptional()
@IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' })
status?: UserStatus = UserStatus.ACTIVE;
}

View File

@@ -20,6 +20,7 @@
*/
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
/**
* 用户实体类
@@ -337,6 +338,44 @@ export class Users {
})
role: number;
/**
* 用户状态
*
* 数据库设计:
* - 类型VARCHAR(20),存储状态枚举值
* - 约束:非空、默认值'active'
* - 索引:用于状态查询和统计
*
* 业务规则:
* - 控制用户账户的可用性和权限
* - active正常状态可以正常使用
* - inactive未激活需要邮箱验证
* - locked已锁定临时禁用
* - banned已禁用管理员操作
* - deleted已删除软删除状态
* - pending待审核需要管理员审核
*
* 安全控制:
* - 登录时检查状态权限
* - API访问时验证状态
* - 状态变更记录审计日志
* - 支持批量状态管理
*
* 应用场景:
* - 账户安全管理
* - 用户生命周期控制
* - 违规用户处理
* - 系统维护和升级
*/
@Column({
type: 'varchar',
length: 20,
nullable: true,
default: UserStatus.ACTIVE,
comment: '用户状态active-正常inactive-未激活locked-锁定banned-禁用deleted-删除pending-待审核'
})
status?: UserStatus;
/**
* 创建时间
*

View File

@@ -16,6 +16,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Users } from './users.entity';
import { CreateUserDto } from './users.dto';
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
@@ -31,7 +32,6 @@ export class UsersService {
*
* @param createUserDto 创建用户的数据传输对象
* @returns 创建的用户实体
* @throws ConflictException 当用户名、邮箱或手机号已存在时
* @throws BadRequestException 当数据验证失败时
*/
async create(createUserDto: CreateUserDto): Promise<Users> {
@@ -46,6 +46,32 @@ export class UsersService {
throw new BadRequestException(`数据验证失败: ${errorMessages}`);
}
// 创建用户实体
const user = new Users();
user.username = createUserDto.username;
user.email = createUserDto.email || null;
user.phone = createUserDto.phone || null;
user.password_hash = createUserDto.password_hash || null;
user.nickname = createUserDto.nickname;
user.github_id = createUserDto.github_id || null;
user.avatar_url = createUserDto.avatar_url || null;
user.role = createUserDto.role || 1;
user.email_verified = createUserDto.email_verified || false;
user.status = createUserDto.status || UserStatus.ACTIVE;
// 保存到数据库
return await this.usersRepository.save(user);
}
/**
* 创建新用户(带重复检查)
*
* @param createUserDto 创建用户的数据传输对象
* @returns 创建的用户实体
* @throws ConflictException 当用户名、邮箱或手机号已存在时
* @throws BadRequestException 当数据验证失败时
*/
async createWithDuplicateCheck(createUserDto: CreateUserDto): Promise<Users> {
// 检查用户名是否已存在
if (createUserDto.username) {
const existingUser = await this.usersRepository.findOne({
@@ -86,20 +112,8 @@ export class UsersService {
}
}
// 创建用户实体
const user = new Users();
user.username = createUserDto.username;
user.email = createUserDto.email || null;
user.phone = createUserDto.phone || null;
user.password_hash = createUserDto.password_hash || null;
user.nickname = createUserDto.nickname;
user.github_id = createUserDto.github_id || null;
user.avatar_url = createUserDto.avatar_url || null;
user.role = createUserDto.role || 1;
user.email_verified = createUserDto.email_verified || false;
// 保存到数据库
return await this.usersRepository.save(user);
// 调用普通的创建方法
return await this.create(createUserDto);
}
/**

View File

@@ -24,6 +24,7 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { Users } from './users.entity';
import { CreateUserDto } from './users.dto';
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
@@ -98,6 +99,7 @@ export class UsersMemoryService {
user.avatar_url = createUserDto.avatar_url || null;
user.role = createUserDto.role || 1;
user.email_verified = createUserDto.email_verified || false;
user.status = createUserDto.status || UserStatus.ACTIVE;
user.created_at = new Date();
user.updated_at = new Date();

View File

@@ -7,7 +7,8 @@ import { LoginCoreService } from './login_core.service';
import { UsersService } from '../db/users/users.service';
import { EmailService } from '../utils/email/email.service';
import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service';
import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { UserStatus } from '../../business/user-mgmt/enums/user-status.enum';
describe('LoginCoreService', () => {
let service: LoginCoreService;
@@ -26,6 +27,7 @@ describe('LoginCoreService', () => {
avatar_url: null as string | null,
role: 1,
email_verified: false,
status: UserStatus.ACTIVE, // 使用正确的枚举类型
created_at: new Date(),
updated_at: new Date()
};
@@ -105,7 +107,9 @@ describe('LoginCoreService', () => {
});
it('should throw UnauthorizedException for wrong password', async () => {
usersService.findByUsername.mockResolvedValue(mockUser);
// 创建一个正常状态的用户来测试密码验证
const activeUser = { ...mockUser, status: UserStatus.ACTIVE };
usersService.findByUsername.mockResolvedValue(activeUser);
jest.spyOn(service as any, 'verifyPassword').mockResolvedValue(false);
await expect(service.login({
@@ -113,6 +117,17 @@ describe('LoginCoreService', () => {
password: 'wrongpassword'
})).rejects.toThrow(UnauthorizedException);
});
it('should throw ForbiddenException for inactive user', async () => {
// 测试非活跃用户状态
const inactiveUser = { ...mockUser, status: UserStatus.INACTIVE };
usersService.findByUsername.mockResolvedValue(inactiveUser);
await expect(service.login({
identifier: 'testuser',
password: 'password123'
})).rejects.toThrow(ForbiddenException);
});
});
describe('register', () => {
@@ -376,4 +391,132 @@ describe('LoginCoreService', () => {
.rejects.toThrow('用户不存在');
});
});
describe('verificationCodeLogin', () => {
it('should successfully login with email verification code', async () => {
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(true);
const result = await service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '123456'
});
expect(result.user).toEqual(verifiedUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.verifyCode).toHaveBeenCalledWith(
'test@example.com',
VerificationCodeType.EMAIL_VERIFICATION,
'123456'
);
});
it('should successfully login with phone verification code', async () => {
const phoneUser = { ...mockUser, phone: '+8613800138000' };
usersService.findAll.mockResolvedValue([phoneUser]);
verificationService.verifyCode.mockResolvedValue(true);
const result = await service.verificationCodeLogin({
identifier: '+8613800138000',
verificationCode: '123456'
});
expect(result.user).toEqual(phoneUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.verifyCode).toHaveBeenCalledWith(
'+8613800138000',
VerificationCodeType.SMS_VERIFICATION,
'123456'
);
});
it('should reject unverified email user', async () => {
usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false
await expect(service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '123456'
})).rejects.toThrow('邮箱未验证,请先验证邮箱后再使用验证码登录');
});
it('should reject non-existent user', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(service.verificationCodeLogin({
identifier: 'nonexistent@example.com',
verificationCode: '123456'
})).rejects.toThrow('用户不存在,请先注册账户');
});
it('should reject invalid verification code', async () => {
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(false);
await expect(service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '999999'
})).rejects.toThrow('验证码验证失败');
});
it('should reject invalid identifier format', async () => {
await expect(service.verificationCodeLogin({
identifier: 'invalid-identifier',
verificationCode: '123456'
})).rejects.toThrow('请提供有效的邮箱或手机号');
});
});
describe('sendLoginVerificationCode', () => {
it('should successfully send email login verification code', async () => {
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.generateCode.mockResolvedValue('123456');
emailService.sendVerificationCode.mockResolvedValue({
success: true,
isTestMode: false
});
const result = await service.sendLoginVerificationCode('test@example.com');
expect(result.code).toBe('123456');
expect(result.isTestMode).toBe(false);
expect(emailService.sendVerificationCode).toHaveBeenCalledWith({
email: 'test@example.com',
code: '123456',
nickname: mockUser.nickname,
purpose: 'login_verification'
});
});
it('should return verification code in test mode', async () => {
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.generateCode.mockResolvedValue('123456');
emailService.sendVerificationCode.mockResolvedValue({
success: true,
isTestMode: true
});
const result = await service.sendLoginVerificationCode('test@example.com');
expect(result.code).toBe('123456');
expect(result.isTestMode).toBe(true);
});
it('should reject unverified email', async () => {
usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false
await expect(service.sendLoginVerificationCode('test@example.com'))
.rejects.toThrow('邮箱未验证,无法使用验证码登录');
});
it('should reject non-existent user', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(service.sendLoginVerificationCode('nonexistent@example.com'))
.rejects.toThrow('用户不存在');
});
});
});

View File

@@ -16,11 +16,12 @@
* @since 2025-12-17
*/
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, ForbiddenException, Inject } from '@nestjs/common';
import { Users } from '../db/users/users.entity';
import { UsersService } from '../db/users/users.service';
import { EmailService, EmailSendResult } from '../utils/email/email.service';
import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service';
import { UserStatus, canUserLogin, getUserStatusErrorMessage } from '../../business/user-mgmt/enums/user-status.enum';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@@ -150,6 +151,11 @@ export class LoginCoreService {
throw new UnauthorizedException('用户名、邮箱或手机号不存在');
}
// 检查用户状态
if (!canUserLogin(user.status)) {
throw new ForbiddenException(getUserStatusErrorMessage(user.status));
}
// 检查是否为OAuth用户没有密码
if (!user.password_hash) {
throw new UnauthorizedException('该账户使用第三方登录,请使用对应的登录方式');
@@ -178,6 +184,29 @@ export class LoginCoreService {
async register(registerRequest: RegisterRequest): Promise<AuthResult> {
const { username, password, nickname, email, phone, email_verification_code } = registerRequest;
// 先检查用户是否已存在,避免消费验证码后才发现用户存在
const existingUser = await this.usersService.findByUsername(username);
if (existingUser) {
throw new ConflictException('用户名已存在');
}
// 检查邮箱是否已存在
if (email) {
const existingEmail = await this.usersService.findByEmail(email);
if (existingEmail) {
throw new ConflictException('邮箱已存在');
}
}
// 检查手机号是否已存在
if (phone) {
const users = await this.usersService.findAll();
const existingPhone = users.find((u: Users) => u.phone === phone);
if (existingPhone) {
throw new ConflictException('手机号已存在');
}
}
// 如果提供了邮箱,必须验证邮箱验证码
if (email) {
if (!email_verification_code) {
@@ -206,6 +235,7 @@ export class LoginCoreService {
email,
phone,
role: 1, // 默认普通用户
status: UserStatus.ACTIVE, // 默认激活状态
email_verified: email ? true : false // 如果提供了邮箱且验证码验证通过,则标记为已验证
});
@@ -267,6 +297,7 @@ export class LoginCoreService {
github_id,
avatar_url,
role: 1, // 默认普通用户
status: UserStatus.ACTIVE, // GitHub用户直接激活
email_verified: email ? true : false // GitHub邮箱直接验证
});
@@ -726,7 +757,7 @@ export class LoginCoreService {
email: identifier,
code: verificationCode,
nickname: user.nickname,
purpose: 'login_verification'
purpose: 'password_reset'
});
if (!result.success) {

View File

@@ -47,7 +47,7 @@ export interface VerificationEmailOptions {
/** 用户昵称 */
nickname?: string;
/** 验证码用途 */
purpose: 'email_verification' | 'password_reset' | 'login_verification';
purpose: 'email_verification' | 'password_reset';
}
/**
@@ -167,15 +167,9 @@ export class EmailService {
if (purpose === 'email_verification') {
subject = '【Whale Town】邮箱验证码';
template = this.getEmailVerificationTemplate(code, nickname);
} else if (purpose === 'password_reset') {
} else {
subject = '【Whale Town】密码重置验证码';
template = this.getPasswordResetTemplate(code, nickname);
} else if (purpose === 'login_verification') {
subject = '【Whale Town】登录验证码';
template = this.getLoginVerificationTemplate(code, nickname);
} else {
subject = '【Whale Town】验证码';
template = this.getEmailVerificationTemplate(code, nickname);
}
return await this.sendEmail({

View File

@@ -61,6 +61,15 @@ export class LogManagementService {
this.maxSize = this.configService.get('LOG_MAX_SIZE', '10m');
}
/**
* 获取日志目录的绝对路径
*
* 说明:用于后台打包下载 logs/ 整目录。
*/
getLogDirAbsolutePath(): string {
return path.resolve(this.logDir);
}
/**
* 定期清理过期日志文件
*
@@ -307,6 +316,67 @@ export class LogManagementService {
}
}
/**
* 获取运行日志尾部(用于后台查看)
*
* 说明:
* - 开发环境默认读取 dev.log
* - 生产环境默认读取 app.log可选 access/error
* - 通过读取文件尾部一定字节数实现“近似 tail”避免大文件全量读取
*/
async getRuntimeLogTail(options?: {
type?: 'app' | 'access' | 'error' | 'dev';
lines?: number;
}): Promise<{
file: string;
updated_at: string;
lines: string[];
}> {
const isProduction = this.configService.get('NODE_ENV') === 'production';
const requestedLines = Math.max(1, Math.min(Number(options?.lines ?? 200), 2000));
const requestedType = options?.type;
const allowedFiles = isProduction
? {
app: 'app.log',
access: 'access.log',
error: 'error.log',
}
: {
dev: 'dev.log',
};
const defaultType = isProduction ? 'app' : 'dev';
const typeKey = (requestedType && requestedType in allowedFiles ? requestedType : defaultType) as keyof typeof allowedFiles;
const fileName = allowedFiles[typeKey];
const filePath = path.join(this.logDir, fileName);
if (!fs.existsSync(filePath)) {
return { file: fileName, updated_at: new Date().toISOString(), lines: [] };
}
const stats = fs.statSync(filePath);
const maxBytes = 256 * 1024; // 256KB 足够覆盖常见的数百行日志
const readBytes = Math.min(stats.size, maxBytes);
const startPos = Math.max(0, stats.size - readBytes);
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(readBytes);
fs.readSync(fd, buffer, 0, readBytes, startPos);
const text = buffer.toString('utf8');
const allLines = text.split(/\r?\n/).filter((l) => l.length > 0);
const tailLines = allLines.slice(-requestedLines);
return {
file: fileName,
updated_at: stats.mtime.toISOString(),
lines: tailLines,
};
} finally {
fs.closeSync(fd);
}
}
/**
* 解析最大文件数配置
*

View File

@@ -39,6 +39,12 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'],
});
// 允许前端后台如Vite/React跨域访问
app.enableCors({
origin: true,
credentials: true,
});
// 全局启用校验管道(核心配置)
app.useGlobalPipes(
@@ -55,6 +61,7 @@ async function bootstrap() {
.setDescription('像素游戏服务器API文档 - 包含用户认证、登录注册等功能')
.setVersion('1.0.0')
.addTag('auth', '用户认证相关接口')
.addTag('admin', '管理员后台相关接口')
.addBearerAuth(
{
type: 'http',