diff --git a/backend/.gitignore b/backend/.gitignore index 62bdd70..0a34713 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -3,7 +3,7 @@ /node_modules # Logs -logs +/logs *.log npm-debug.log* pnpm-debug.log* diff --git a/backend/src/logs/dto/create-log.dto.ts b/backend/src/logs/dto/create-log.dto.ts new file mode 100644 index 0000000..21c239b --- /dev/null +++ b/backend/src/logs/dto/create-log.dto.ts @@ -0,0 +1,22 @@ +import { IsNumber, IsString, IsOptional } from 'class-validator'; + +export class CreateLogDto { + @IsOptional() + @IsNumber() + userId?: number; + + @IsString() + action: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + ip?: string; + + @IsOptional() + @IsString() + userAgent?: string; +} diff --git a/backend/src/logs/dto/query-log.dto.ts b/backend/src/logs/dto/query-log.dto.ts new file mode 100644 index 0000000..6cf0ca0 --- /dev/null +++ b/backend/src/logs/dto/query-log.dto.ts @@ -0,0 +1,39 @@ +import { IsNumber, IsString, IsOptional, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryLogDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + pageSize?: number = 20; + + @IsOptional() + @Type(() => Number) + @IsNumber() + userId?: number; + + @IsOptional() + @IsString() + action?: string; + + @IsOptional() + @IsString() + keyword?: string; + + @IsOptional() + @IsString() + ip?: string; + + @IsOptional() + @IsDateString() + startTime?: string; + + @IsOptional() + @IsDateString() + endTime?: string; +} diff --git a/backend/src/logs/logs.controller.ts b/backend/src/logs/logs.controller.ts new file mode 100644 index 0000000..50afced --- /dev/null +++ b/backend/src/logs/logs.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { LogsService } from './logs.service'; +import { QueryLogDto } from './dto/query-log.dto'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; + +@Controller('logs') +export class LogsController { + constructor(private readonly logsService: LogsService) {} + + /** + * 查询日志列表(分页) + */ + @Get() + @RequirePermission('log:read') + async findAll(@Query() queryLogDto: QueryLogDto) { + return this.logsService.findAll(queryLogDto); + } + + /** + * 获取日志统计信息 + */ + @Get('statistics') + @RequirePermission('log:read') + async getStatistics(@Query('days') days?: string) { + const daysNum = days ? parseInt(days, 10) : 7; + return this.logsService.getStatistics(daysNum); + } + + /** + * 查询单条日志详情 + */ + @Get(':id') + @RequirePermission('log:read') + async findOne(@Param('id', ParseIntPipe) id: number) { + return this.logsService.findOne(id); + } + + /** + * 批量删除日志 + */ + @Delete() + @RequirePermission('log:delete') + async remove(@Body('ids') ids: number[]) { + return this.logsService.remove(ids); + } + + /** + * 清理过期日志 + */ + @Post('clean') + @RequirePermission('log:delete') + async cleanOldLogs(@Body('daysToKeep') daysToKeep?: number) { + return this.logsService.cleanOldLogs(daysToKeep || 90); + } +} diff --git a/backend/src/logs/logs.module.ts b/backend/src/logs/logs.module.ts new file mode 100644 index 0000000..3d02d10 --- /dev/null +++ b/backend/src/logs/logs.module.ts @@ -0,0 +1,13 @@ +import { Module, Global } from '@nestjs/common'; +import { LogsService } from './logs.service'; +import { LogsController } from './logs.controller'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Global() // 设为全局模块,让 LoggingInterceptor 可以注入 LogsService +@Module({ + imports: [PrismaModule], + controllers: [LogsController], + providers: [LogsService], + exports: [LogsService], +}) +export class LogsModule {} diff --git a/backend/src/logs/logs.service.ts b/backend/src/logs/logs.service.ts new file mode 100644 index 0000000..2d468f1 --- /dev/null +++ b/backend/src/logs/logs.service.ts @@ -0,0 +1,227 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateLogDto } from './dto/create-log.dto'; +import { QueryLogDto } from './dto/query-log.dto'; + +@Injectable() +export class LogsService { + constructor(private prisma: PrismaService) {} + + /** + * 创建日志记录 + */ + async create(createLogDto: CreateLogDto) { + return this.prisma.log.create({ + data: { + userId: createLogDto.userId, + action: createLogDto.action, + content: createLogDto.content, + ip: createLogDto.ip, + userAgent: createLogDto.userAgent, + }, + }); + } + + /** + * 查询日志列表(分页) + */ + async findAll(queryLogDto: QueryLogDto) { + const { + page = 1, + pageSize = 20, + userId, + action, + keyword, + ip, + startTime, + endTime, + } = queryLogDto; + + const skip = (page - 1) * pageSize; + + // 构建查询条件 + const where: any = {}; + + if (userId) { + where.userId = userId; + } + + if (action) { + where.action = { + contains: action, + }; + } + + if (keyword) { + where.OR = [ + { action: { contains: keyword } }, + { content: { contains: keyword } }, + ]; + } + + if (ip) { + where.ip = { + contains: ip, + }; + } + + // 时间范围筛选 + if (startTime || endTime) { + where.createTime = {}; + if (startTime) { + where.createTime.gte = new Date(startTime); + } + if (endTime) { + where.createTime.lte = new Date(endTime); + } + } + + const [list, total] = await Promise.all([ + this.prisma.log.findMany({ + where, + skip, + take: pageSize, + include: { + user: { + select: { + id: true, + username: true, + nickname: true, + }, + }, + }, + orderBy: { createTime: 'desc' }, + }), + this.prisma.log.count({ where }), + ]); + + return { + list, + total, + page, + pageSize, + }; + } + + /** + * 查询单条日志详情 + */ + async findOne(id: number) { + return this.prisma.log.findUnique({ + where: { id }, + include: { + user: { + select: { + id: true, + username: true, + nickname: true, + }, + }, + }, + }); + } + + /** + * 删除日志(支持批量删除) + */ + async remove(ids: number[]) { + return this.prisma.log.deleteMany({ + where: { + id: { in: ids }, + }, + }); + } + + /** + * 清理过期日志(默认保留90天) + */ + async cleanOldLogs(daysToKeep: number = 90) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await this.prisma.log.deleteMany({ + where: { + createTime: { + lt: cutoffDate, + }, + }, + }); + + return { + deleted: result.count, + cutoffDate, + }; + } + + /** + * 获取日志统计信息 + */ + async getStatistics(days: number = 7) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + startDate.setHours(0, 0, 0, 0); + + // 总日志数 + const totalCount = await this.prisma.log.count(); + + // 近N天日志数 + const recentCount = await this.prisma.log.count({ + where: { + createTime: { + gte: startDate, + }, + }, + }); + + // 按操作类型统计(取前10) + const actionStats = await this.prisma.log.groupBy({ + by: ['action'], + _count: { + action: true, + }, + where: { + createTime: { + gte: startDate, + }, + }, + orderBy: { + _count: { + action: 'desc', + }, + }, + take: 10, + }); + + // 按天统计近N天的日志数量 + const dailyStats = await this.prisma.$queryRaw` + SELECT + DATE(create_time) as date, + COUNT(*) as count + FROM logs + WHERE create_time >= ${startDate} + GROUP BY DATE(create_time) + ORDER BY date DESC + ` as Array<{ date: Date; count: bigint }>; + + return { + totalCount, + recentCount, + days, + actionStats: actionStats.map((item) => ({ + action: item.action, + count: item._count.action, + })), + dailyStats: dailyStats.map((item) => ({ + date: item.date, + count: Number(item.count), + })), + }; + } + + /** + * 获取用户操作日志 + */ + async getUserLogs(userId: number, page: number = 1, pageSize: number = 20) { + return this.findAll({ userId, page, pageSize }); + } +}