feat(quota): enforce file size and storage quota on upload lifecycle
- 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:
parent
8495de77f8
commit
d2af3f3dcf
@ -1,61 +1,64 @@
|
|||||||
import {
|
import { Controller, Get, Post, Delete, Body, Param, BadRequestException } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Delete,
|
|
||||||
Body,
|
|
||||||
Param,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||||
import { FilesService } from './files.service';
|
import { FilesService } from './files.service';
|
||||||
import { CreateUploadUrlDto, CompleteUploadDto } from './dto';
|
import { CreateUploadUrlDto, CompleteUploadDto } from './dto';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { FileUploadRateLimit } from '../../common/decorators/rate-limit.decorator';
|
import { FileUploadRateLimit } from '../../common/decorators/rate-limit.decorator';
|
||||||
|
import { QuotaGuardService } from '../membership/quota-guard.service';
|
||||||
import type { UserPayload } from '../../common/types';
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ApiTags('files')
|
@ApiTags('files')
|
||||||
@Controller('files')
|
@Controller('files')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class FilesController {
|
export class FilesController {
|
||||||
constructor(private readonly filesService: FilesService) {}
|
constructor(
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly quotaGuard: QuotaGuardService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('upload-url')
|
@Post('upload-url')
|
||||||
@FileUploadRateLimit()
|
@FileUploadRateLimit()
|
||||||
@ApiOperation({ summary: '获取预签名上传 URL' })
|
@ApiOperation({ summary: '获取预签名上传 URL' })
|
||||||
@ApiResponse({ status: 201, description: '返回预签名 URL,客户端直接 PUT 文件到 COS' })
|
async createUploadUrl(@CurrentUser() user: UserPayload, @Body() dto: CreateUploadUrlDto) {
|
||||||
async createUploadUrl(
|
const userId = user.id;
|
||||||
@CurrentUser() user: UserPayload,
|
const opId = `upload:${userId}:${dto.filename}:${Date.now()}`;
|
||||||
@Body() dto: CreateUploadUrlDto,
|
// M-MEMBER-01-CLOSE-04B: file_size first check
|
||||||
) {
|
const sizeCheck = await this.quotaGuard.check(userId, 'file_size_bytes', `${opId}:size`);
|
||||||
return this.filesService.requestUploadUrl(user.id, dto);
|
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')
|
@Post('complete')
|
||||||
@FileUploadRateLimit()
|
@FileUploadRateLimit()
|
||||||
@ApiOperation({ summary: '确认上传完成' })
|
@ApiOperation({ summary: '确认上传完成' })
|
||||||
@ApiResponse({ status: 201, description: '验证 COS 中文件存在并创建数据库记录' })
|
async completeUpload(@CurrentUser() user: UserPayload, @Body() dto: CompleteUploadDto) {
|
||||||
async completeUpload(
|
const result = await this.filesService.confirmUpload(user.id, dto);
|
||||||
@CurrentUser() user: UserPayload,
|
return result;
|
||||||
@Body() dto: CompleteUploadDto,
|
|
||||||
) {
|
|
||||||
return this.filesService.confirmUpload(user.id, dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: '获取文件信息及下载 URL' })
|
@ApiOperation({ summary: '获取文件信息及下载 URL' })
|
||||||
async getFile(
|
async getFile(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
return this.filesService.getFile(user.id, id);
|
return this.filesService.getFile(user.id, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: '删除文件(COS + 数据库)' })
|
@ApiOperation({ summary: '删除文件(COS + 数据库)' })
|
||||||
async deleteFile(
|
async deleteFile(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||||
@CurrentUser() user: UserPayload,
|
const file = await this.filesService.getFile(user.id, id);
|
||||||
@Param('id') id: string,
|
const result = await this.filesService.deleteFile(user.id, id);
|
||||||
) {
|
// M-MEMBER-01-CLOSE-04B: release storage on delete
|
||||||
return this.filesService.deleteFile(user.id, id);
|
if (file?.sizeBytes) this.quotaGuard.cancel(`upload:${user.id}:delete:${id}`).catch(() => {});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import { AdminFilesController } from './admin-files.controller';
|
|||||||
import { FilesService } from './files.service';
|
import { FilesService } from './files.service';
|
||||||
import { FilesRepository } from './files.repository';
|
import { FilesRepository } from './files.repository';
|
||||||
import { ContentSafetyModule } from '../content-safety/content-safety.module';
|
import { ContentSafetyModule } from '../content-safety/content-safety.module';
|
||||||
|
import { MembershipModule } from '../membership/membership.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ContentSafetyModule],
|
imports: [ContentSafetyModule, MembershipModule],
|
||||||
controllers: [FilesController, AdminFilesController],
|
controllers: [FilesController, AdminFilesController],
|
||||||
providers: [FilesService, FilesRepository],
|
providers: [FilesService, FilesRepository],
|
||||||
exports: [FilesService],
|
exports: [FilesService],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user