From 82fcaa1f2f9cb8eba880cd110046c806bf0f3350 Mon Sep 17 00:00:00 2001 From: WangDL Date: Mon, 18 May 2026 10:23:19 +0800 Subject: [PATCH] fix: replace RateLimitService with global RateLimitGuard RateLimitService could not be injected into feature modules due to NestJS DI module isolation. Replaced with a global Guard that uses @RateLimit() decorator metadata to apply per-endpoint limits. - RateLimitGuard: checks Redis counters, throws 429 on exceed - Decorators: LoginRateLimit, FeedbackRateLimit, AiAnalysisRateLimit, FileUploadRateLimit - Applied to: auth (login), feedback, ai-analysis, files endpoints Co-Authored-By: Claude Opus 4.7 --- src/app.module.ts | 4 +- src/common/decorators/rate-limit.decorator.ts | 29 +++++++++++ src/common/guards/rate-limit.guard.ts | 49 +++++++++++++++++++ src/common/utils/rate-limit.service.ts | 46 ----------------- .../ai-analysis/ai-analysis.controller.ts | 3 ++ src/modules/auth/auth.controller.ts | 3 ++ src/modules/feedback/feedback.controller.ts | 2 + src/modules/files/files.controller.ts | 3 ++ 8 files changed, 91 insertions(+), 48 deletions(-) create mode 100644 src/common/decorators/rate-limit.decorator.ts create mode 100644 src/common/guards/rate-limit.guard.ts delete mode 100644 src/common/utils/rate-limit.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 8c0b181..ac35796 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,9 +29,9 @@ import { WaitlistModule } from './modules/waitlist/waitlist.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; +import { RateLimitGuard } from './common/guards/rate-limit.guard'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { StrictValidationPipe } from './common/pipes/strict-validation.pipe'; -import { RateLimitService } from './common/utils/rate-limit.service'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { AiAnalysisWorker } from './workers/ai-analysis.worker'; @@ -93,12 +93,12 @@ import appleConfig from './config/apple.config'; WaitlistModule, ], providers: [ + { provide: APP_GUARD, useClass: RateLimitGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useClass: StrictValidationPipe }, { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, - RateLimitService, AiAnalysisWorker, DocumentImportWorker, NotificationWorker, diff --git a/src/common/decorators/rate-limit.decorator.ts b/src/common/decorators/rate-limit.decorator.ts new file mode 100644 index 0000000..187fdfe --- /dev/null +++ b/src/common/decorators/rate-limit.decorator.ts @@ -0,0 +1,29 @@ +import { SetMetadata } from '@nestjs/common'; + +export interface RateLimitConfig { + key: string; + maxRequests: number; + windowSeconds: number; + /** 按 IP 限流(默认按 userId,未登录时 fallback 到 IP) */ + byIp?: boolean; +} + +export const RATE_LIMIT_KEY = 'rate-limit'; + +export const RateLimit = (config: RateLimitConfig) => SetMetadata(RATE_LIMIT_KEY, config); + +/** 登录:单 IP 每 30 分钟 20 次 */ +export const LoginRateLimit = () => + RateLimit({ key: 'login', maxRequests: 20, windowSeconds: 1800, byIp: true }); + +/** 反馈:单 IP 每小时 5 次 */ +export const FeedbackRateLimit = () => + RateLimit({ key: 'feedback', maxRequests: 5, windowSeconds: 3600, byIp: true }); + +/** AI 分析:单用户每天 50 次 */ +export const AiAnalysisRateLimit = () => + RateLimit({ key: 'ai', maxRequests: 50, windowSeconds: 86400 }); + +/** 文件上传:单用户每小时 10 次 */ +export const FileUploadRateLimit = () => + RateLimit({ key: 'upload', maxRequests: 10, windowSeconds: 3600 }); diff --git a/src/common/guards/rate-limit.guard.ts b/src/common/guards/rate-limit.guard.ts new file mode 100644 index 0000000..81be0ca --- /dev/null +++ b/src/common/guards/rate-limit.guard.ts @@ -0,0 +1,49 @@ +import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RedisService } from '../../infrastructure/redis/redis.service'; +import { RATE_LIMIT_KEY, type RateLimitConfig } from '../decorators/rate-limit.decorator'; + +@Injectable() +export class RateLimitGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly redis: RedisService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const config = this.reflector.get( + RATE_LIMIT_KEY, + context.getHandler(), + ); + if (!config) return true; + + const request = context.switchToHttp().getRequest(); + + let identifier: string; + if (config.byIp) { + identifier = request.ip || request.connection?.remoteAddress || 'unknown'; + } else { + identifier = request.user?.id || request.ip || request.connection?.remoteAddress || 'unknown'; + } + + const key = `rate:${config.key}:${identifier}`; + + const count = await this.redis.incr(key); + if (count === 1) { + await this.redis.expire(key, config.windowSeconds); + } + + if (count > config.maxRequests) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: `请求过于频繁,请${config.windowSeconds >= 3600 ? `${Math.round(config.windowSeconds / 3600)}小时` : `${Math.round(config.windowSeconds / 60)}分钟`}后再试`, + retryAfter: config.windowSeconds, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + return true; + } +} diff --git a/src/common/utils/rate-limit.service.ts b/src/common/utils/rate-limit.service.ts deleted file mode 100644 index 053a7f6..0000000 --- a/src/common/utils/rate-limit.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; -import { RedisService } from '../../infrastructure/redis/redis.service'; - -@Injectable() -export class RateLimitService { - constructor(private readonly redis: RedisService) {} - - async checkLimit( - key: string, - maxRequests: number, - windowSeconds: number, - ): Promise { - const count = await this.redis.incr(key); - if (count === 1) { - await this.redis.expire(key, windowSeconds); - } - if (count > maxRequests) { - throw new HttpException( - { - statusCode: HttpStatus.TOO_MANY_REQUESTS, - message: `请求过于频繁,请${windowSeconds}秒后再试`, - retryAfter: windowSeconds, - }, - HttpStatus.TOO_MANY_REQUESTS, - ); - } - } - - async loginLimit(ip: string): Promise { - const today = new Date().toISOString().split('T')[0]; - await this.checkLimit(`rate:ip:${ip}:login:${today}`, 20, 1800); - } - - async feedbackLimit(ip: string): Promise { - await this.checkLimit(`rate:ip:${ip}:feedback:hourly`, 5, 3600); - } - - async aiAnalysisLimit(userId: string): Promise { - const today = new Date().toISOString().split('T')[0]; - await this.checkLimit(`rate:user:${userId}:ai:daily:${today}`, 50, 86400); - } - - async fileUploadLimit(userId: string): Promise { - await this.checkLimit(`rate:user:${userId}:upload:hourly`, 10, 3600); - } -} diff --git a/src/modules/ai-analysis/ai-analysis.controller.ts b/src/modules/ai-analysis/ai-analysis.controller.ts index 1835221..fcb4cf2 100644 --- a/src/modules/ai-analysis/ai-analysis.controller.ts +++ b/src/modules/ai-analysis/ai-analysis.controller.ts @@ -2,6 +2,7 @@ import { Controller, Post, Get, Body, Param } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { AiAnalysisService } from './ai-analysis.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { AiAnalysisRateLimit } from '../../common/decorators/rate-limit.decorator'; import type { UserPayload } from '../../common/types'; @ApiTags('ai-analysis') @@ -10,6 +11,7 @@ export class AiAnalysisController { constructor(private readonly service: AiAnalysisService) {} @Post() + @AiAnalysisRateLimit() @ApiOperation({ summary: '提交主动回忆分析(异步)' }) async analyze( @CurrentUser() user: UserPayload, @@ -25,6 +27,7 @@ export class AiAnalysisController { } @Post('feynman') + @AiAnalysisRateLimit() @ApiOperation({ summary: '提交费曼解释评估(异步)' }) async evaluateFeynman( @CurrentUser() user: UserPayload, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ff290e7..ec85fa8 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { Controller, Post, Body, HttpCode, HttpStatus, Req } from '@nestjs/commo import { AuthService } from './auth.service'; import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto'; import { Public } from '../../common/decorators/public.decorator'; +import { LoginRateLimit } from '../../common/decorators/rate-limit.decorator'; import type { Request } from 'express'; @ApiTags('auth') @@ -13,6 +14,7 @@ export class AuthController { @Public() @Post('dev-login') @HttpCode(HttpStatus.OK) + @LoginRateLimit() @ApiOperation({ summary: '开发登录(仅非生产环境)' }) @ApiResponse({ status: 200, description: '登录成功' }) @ApiResponse({ status: 403, description: '生产环境禁用' }) @@ -23,6 +25,7 @@ export class AuthController { @Public() @Post('apple') @HttpCode(HttpStatus.OK) + @LoginRateLimit() @ApiOperation({ summary: 'Apple 登录' }) @ApiResponse({ status: 200, description: '登录成功' }) @ApiResponse({ status: 401, description: '身份验证失败' }) diff --git a/src/modules/feedback/feedback.controller.ts b/src/modules/feedback/feedback.controller.ts index fdc015f..13c6284 100644 --- a/src/modules/feedback/feedback.controller.ts +++ b/src/modules/feedback/feedback.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/ import { FeedbackService } from './feedback.service'; import { CreateFeedbackDto } from './dto/create-feedback.dto'; import { Public } from '../../common/decorators/public.decorator'; +import { FeedbackRateLimit } from '../../common/decorators/rate-limit.decorator'; @ApiTags('feedback') @Controller('feedback') @@ -11,6 +12,7 @@ export class FeedbackController { @Public() @Post() + @FeedbackRateLimit() @ApiOperation({ summary: '提交反馈' }) @ApiResponse({ status: 201, description: '反馈提交成功' }) async create(@Body() dto: CreateFeedbackDto) { diff --git a/src/modules/files/files.controller.ts b/src/modules/files/files.controller.ts index c53843b..b6d4ef1 100644 --- a/src/modules/files/files.controller.ts +++ b/src/modules/files/files.controller.ts @@ -10,6 +10,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagg 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 type { UserPayload } from '../../common/types'; @ApiTags('files') @@ -19,6 +20,7 @@ export class FilesController { constructor(private readonly filesService: FilesService) {} @Post('upload-url') + @FileUploadRateLimit() @ApiOperation({ summary: '获取预签名上传 URL' }) @ApiResponse({ status: 201, description: '返回预签名 URL,客户端直接 PUT 文件到 COS' }) async createUploadUrl( @@ -29,6 +31,7 @@ export class FilesController { } @Post('complete') + @FileUploadRateLimit() @ApiOperation({ summary: '确认上传完成' }) @ApiResponse({ status: 201, description: '验证 COS 中文件存在并创建数据库记录' }) async completeUpload(