diff --git a/src/modules/files/files.controller.ts b/src/modules/files/files.controller.ts index b6d4ef1..0ee4ec2 100644 --- a/src/modules/files/files.controller.ts +++ b/src/modules/files/files.controller.ts @@ -1,61 +1,64 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, -} from '@nestjs/common'; +import { Controller, Get, Post, Delete, Body, Param, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { FilesService } from './files.service'; import { CreateUploadUrlDto, CompleteUploadDto } from './dto'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { FileUploadRateLimit } from '../../common/decorators/rate-limit.decorator'; +import { QuotaGuardService } from '../membership/quota-guard.service'; import type { UserPayload } from '../../common/types'; @ApiTags('files') @Controller('files') @ApiBearerAuth() export class FilesController { - constructor(private readonly filesService: FilesService) {} + constructor( + private readonly filesService: FilesService, + private readonly quotaGuard: QuotaGuardService, + ) {} @Post('upload-url') @FileUploadRateLimit() @ApiOperation({ summary: '获取预签名上传 URL' }) - @ApiResponse({ status: 201, description: '返回预签名 URL,客户端直接 PUT 文件到 COS' }) - async createUploadUrl( - @CurrentUser() user: UserPayload, - @Body() dto: CreateUploadUrlDto, - ) { - return this.filesService.requestUploadUrl(user.id, dto); + async createUploadUrl(@CurrentUser() user: UserPayload, @Body() dto: CreateUploadUrlDto) { + const userId = user.id; + const opId = `upload:${userId}:${dto.filename}:${Date.now()}`; + // M-MEMBER-01-CLOSE-04B: file_size first check + const sizeCheck = await this.quotaGuard.check(userId, 'file_size_bytes', `${opId}:size`); + const declSize = (dto as any).sizeBytes ?? 0; + if (sizeCheck.limit > 0 && declSize > sizeCheck.limit) { + throw new BadRequestException({ errorCode: 'FILE_SIZE_LIMIT_EXCEEDED', message: 'File too large', quotaType: 'file_size_bytes', limit: sizeCheck.limit, used: 0, remaining: 0, resetAt: null, upgradeAvailable: true }); + } + // Reserve storage bytes + const storageCheck = await this.quotaGuard.check(userId, 'storage_bytes', `${opId}:storage`, declSize); + if (!storageCheck.allowed) throw new BadRequestException(this.quotaGuard.buildError(storageCheck)); + + try { + const result = await this.filesService.requestUploadUrl(userId, dto); + return { ...result, _quotaOp: opId }; + } catch { this.quotaGuard.cancel(`${opId}:storage`).catch(() => {}); throw new BadRequestException('Upload init failed'); } } @Post('complete') @FileUploadRateLimit() @ApiOperation({ summary: '确认上传完成' }) - @ApiResponse({ status: 201, description: '验证 COS 中文件存在并创建数据库记录' }) - async completeUpload( - @CurrentUser() user: UserPayload, - @Body() dto: CompleteUploadDto, - ) { - return this.filesService.confirmUpload(user.id, dto); + async completeUpload(@CurrentUser() user: UserPayload, @Body() dto: CompleteUploadDto) { + const result = await this.filesService.confirmUpload(user.id, dto); + return result; } @Get(':id') @ApiOperation({ summary: '获取文件信息及下载 URL' }) - async getFile( - @CurrentUser() user: UserPayload, - @Param('id') id: string, - ) { + async getFile(@CurrentUser() user: UserPayload, @Param('id') id: string) { return this.filesService.getFile(user.id, id); } @Delete(':id') @ApiOperation({ summary: '删除文件(COS + 数据库)' }) - async deleteFile( - @CurrentUser() user: UserPayload, - @Param('id') id: string, - ) { - return this.filesService.deleteFile(user.id, id); + async deleteFile(@CurrentUser() user: UserPayload, @Param('id') id: string) { + const file = await this.filesService.getFile(user.id, id); + const result = await this.filesService.deleteFile(user.id, id); + // M-MEMBER-01-CLOSE-04B: release storage on delete + if (file?.sizeBytes) this.quotaGuard.cancel(`upload:${user.id}:delete:${id}`).catch(() => {}); + return result; } } diff --git a/src/modules/files/files.module.ts b/src/modules/files/files.module.ts index 1dc84a5..1610163 100644 --- a/src/modules/files/files.module.ts +++ b/src/modules/files/files.module.ts @@ -4,9 +4,10 @@ import { AdminFilesController } from './admin-files.controller'; import { FilesService } from './files.service'; import { FilesRepository } from './files.repository'; import { ContentSafetyModule } from '../content-safety/content-safety.module'; +import { MembershipModule } from '../membership/membership.module'; @Module({ - imports: [ContentSafetyModule], + imports: [ContentSafetyModule, MembershipModule], controllers: [FilesController, AdminFilesController], providers: [FilesService, FilesRepository], exports: [FilesService],