diff --git a/apps/api/src/file/dto/file.dto.ts b/apps/api/src/file/dto/file.dto.ts index 804aede..ff28edc 100644 --- a/apps/api/src/file/dto/file.dto.ts +++ b/apps/api/src/file/dto/file.dto.ts @@ -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; + + @ApiProperty({ + example: { 'image/jpeg': 30, 'image/png': 20 }, + description: '按 MIME 类型分类统计', + }) + byMimeType: Record; +} diff --git a/apps/api/src/file/file.controller.ts b/apps/api/src/file/file.controller.ts index 3f63fcb..fb8271e 100644 --- a/apps/api/src/file/file.controller.ts +++ b/apps/api/src/file/file.controller.ts @@ -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( diff --git a/apps/api/src/file/file.service.ts b/apps/api/src/file/file.service.ts index 3d8a354..100b7ad 100644 --- a/apps/api/src/file/file.service.ts +++ b/apps/api/src/file/file.service.ts @@ -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 { + // 获取所有文件 + 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 = {}; + for (const file of files) { + byPurpose[file.purpose] = (byPurpose[file.purpose] || 0) + 1; + } + + // 按 MIME 类型分类统计 + const byMimeType: Record = {}; + 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]}`; + } }