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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user