feat(api): 文件管理模块添加列表和统计接口

- 添加 GET /files 分页查询接口
- 添加 GET /files/stats 统计信息接口
- FileService 使用 @CrudOptions 配置过滤字段
- 新增 FileQueryDto、PaginatedFileResponseDto、FileStatsResponseDto

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 13:47:56 +08:00
parent 76f835a2ad
commit 0acea22262
3 changed files with 157 additions and 1 deletions

View File

@@ -1,8 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { IsEnum, IsOptional, IsInt, IsString, Min, Max, Matches } from 'class-validator';
import { FilePurpose } from '../file.constants';
import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
/** 上传文件请求 DTO */
export class UploadFileDto {
@ApiProperty({
@@ -14,6 +17,55 @@ export class UploadFileDto {
purpose: FilePurpose;
}
/** 文件列表查询 DTO */
export class FileQueryDto {
@ApiPropertyOptional({ description: '页码', default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@IsOptional()
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量(-1 或 0 表示不分页)',
default: 20,
})
@Type(() => Number)
@IsInt()
@IsOptional()
pageSize?: number = 20;
@ApiPropertyOptional({ description: '排序字段', example: 'createdAt' })
@IsString()
@IsOptional()
sortBy?: string;
@ApiPropertyOptional({ description: '排序方向', example: 'desc' })
@IsString()
@Matches(/^(asc|desc)(,(asc|desc))*$/, {
message: 'sortOrder 必须是 asc 或 desc',
})
@IsOptional()
sortOrder?: string = 'desc';
@ApiPropertyOptional({
enum: Object.values(FilePurpose),
description: '文件用途筛选',
})
@IsEnum(FilePurpose)
@IsOptional()
purpose?: FilePurpose;
@ApiPropertyOptional({ description: '上传者 ID 筛选' })
@IsString()
@IsOptional()
uploaderId?: string;
@ApiPropertyOptional({ description: 'MIME 类型筛选(支持前缀匹配,如 image/' })
@IsString()
@IsOptional()
mimeType?: string;
}
/** 文件响应 DTO */
export class FileResponseDto {
@ApiProperty({ example: 'clxxx123', description: '文件 ID' })
@@ -59,3 +111,30 @@ export class PresignedUrlResponseDto {
@ApiProperty({ example: 300, description: '有效期(秒)' })
expiresIn: number;
}
/** 分页文件响应 DTO */
export class PaginatedFileResponseDto extends createPaginatedResponseDto(FileResponseDto) {}
/** 文件统计响应 DTO */
export class FileStatsResponseDto {
@ApiProperty({ example: 100, description: '文件总数' })
totalFiles: number;
@ApiProperty({ example: 10485760, description: '存储总量(字节)' })
totalSize: number;
@ApiProperty({ example: '10 MB', description: '存储总量(格式化)' })
totalSizeFormatted: string;
@ApiProperty({
example: { avatar: 50, attachment: 50 },
description: '按用途分类统计',
})
byPurpose: Record<string, number>;
@ApiProperty({
example: { 'image/jpeg': 30, 'image/png': 20 },
description: '按 MIME 类型分类统计',
})
byMimeType: Record<string, number>;
}

View File

@@ -24,9 +24,12 @@ import {
import {
UploadFileDto,
FileQueryDto,
FileResponseDto,
GetPresignedUrlDto,
PresignedUrlResponseDto,
PaginatedFileResponseDto,
FileStatsResponseDto,
} from './dto/file.dto';
import { FileService } from './file.service';
@@ -68,6 +71,20 @@ const fileFilter = (
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get()
@ApiOperation({ summary: '获取文件列表(分页)' })
@ApiOkResponse({ type: PaginatedFileResponseDto, description: '文件列表' })
findAll(@Query() query: FileQueryDto) {
return this.fileService.findAll(query);
}
@Get('stats')
@ApiOperation({ summary: '获取文件统计信息' })
@ApiOkResponse({ type: FileStatsResponseDto, description: '文件统计' })
getStats() {
return this.fileService.getStats();
}
@Post('upload')
@SkipEncryption()
@UseInterceptors(

View File

@@ -1,13 +1,22 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import type { File, Prisma } from '@prisma/client';
import type { FileStatsResponseDto } from './dto/file.dto';
import { DEFAULT_PRESIGNED_URL_EXPIRES, FilePurpose, FILE_PURPOSE_CONFIG } from './file.constants';
import { CrudOptions } from '@/common/crud/crud.decorator';
import { CrudService } from '@/common/crud/crud.service';
import { StorageService } from '@/common/storage/storage.service';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
@CrudOptions({
filterableFields: [
'purpose',
'uploaderId',
{ field: 'mimeType', operator: 'startsWith' },
],
})
export class FileService extends CrudService<
File,
Prisma.FileCreateInput,
@@ -93,4 +102,55 @@ export class FileService extends CrudService<
protected override getNotFoundMessage(id: string): string {
return `文件不存在: ${id}`;
}
/**
* 获取文件统计信息
*/
async getStats(): Promise<FileStatsResponseDto> {
// 获取所有文件
const files = await this.model.findMany({
select: {
size: true,
purpose: true,
mimeType: true,
},
});
// 计算统计数据
const totalFiles = files.length;
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
// 按用途分类统计
const byPurpose: Record<string, number> = {};
for (const file of files) {
byPurpose[file.purpose] = (byPurpose[file.purpose] || 0) + 1;
}
// 按 MIME 类型分类统计
const byMimeType: Record<string, number> = {};
for (const file of files) {
byMimeType[file.mimeType] = (byMimeType[file.mimeType] || 0) + 1;
}
return {
totalFiles,
totalSize,
totalSizeFormatted: this.formatFileSize(totalSize),
byPurpose,
byMimeType,
};
}
/**
* 格式化文件大小
*/
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${units[i]}`;
}
}