修复backend的gitignore规则并添加日志模块

- 修复.gitignore中logs规则匹配范围过大的问题
- 添加后端日志管理模块源代码

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zhangxiaohua 2026-01-25 16:58:50 +08:00
parent fe91f88350
commit 00491c7ac4
6 changed files with 366 additions and 1 deletions

2
backend/.gitignore vendored
View File

@ -3,7 +3,7 @@
/node_modules
# Logs
logs
/logs
*.log
npm-debug.log*
pnpm-debug.log*

View File

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

View File

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

View File

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

View File

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

View File

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