feat(quota): enforce file size and storage quota on upload lifecycle
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 29s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped

- file_size_bytes: first check at upload-url (declared size)
- storage_bytes: reserve at upload-url, release on failure
- storage release on file delete
- POST /files/upload-url: size check + storage reserve
- DELETE /files/🆔 storage release

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-27 09:19:09 +08:00
parent 8495de77f8
commit d2af3f3dcf
2 changed files with 35 additions and 31 deletions

View File

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

View File

@ -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],