fix: replace RateLimitService with global RateLimitGuard
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 59s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 59s
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 <noreply@anthropic.com>
This commit is contained in:
parent
b1a6160d29
commit
82fcaa1f2f
@ -29,9 +29,9 @@ import { WaitlistModule } from './modules/waitlist/waitlist.module';
|
|||||||
|
|
||||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './common/guards/roles.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 { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
||||||
import { RateLimitService } from './common/utils/rate-limit.service';
|
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
|
|
||||||
import { AiAnalysisWorker } from './workers/ai-analysis.worker';
|
import { AiAnalysisWorker } from './workers/ai-analysis.worker';
|
||||||
@ -93,12 +93,12 @@ import appleConfig from './config/apple.config';
|
|||||||
WaitlistModule,
|
WaitlistModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||||
RateLimitService,
|
|
||||||
AiAnalysisWorker,
|
AiAnalysisWorker,
|
||||||
DocumentImportWorker,
|
DocumentImportWorker,
|
||||||
NotificationWorker,
|
NotificationWorker,
|
||||||
|
|||||||
29
src/common/decorators/rate-limit.decorator.ts
Normal file
29
src/common/decorators/rate-limit.decorator.ts
Normal file
@ -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 });
|
||||||
49
src/common/guards/rate-limit.guard.ts
Normal file
49
src/common/guards/rate-limit.guard.ts
Normal file
@ -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<boolean> {
|
||||||
|
const config = this.reflector.get<RateLimitConfig>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
await this.checkLimit(`rate:ip:${ip}:login:${today}`, 20, 1800);
|
|
||||||
}
|
|
||||||
|
|
||||||
async feedbackLimit(ip: string): Promise<void> {
|
|
||||||
await this.checkLimit(`rate:ip:${ip}:feedback:hourly`, 5, 3600);
|
|
||||||
}
|
|
||||||
|
|
||||||
async aiAnalysisLimit(userId: string): Promise<void> {
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
await this.checkLimit(`rate:user:${userId}:ai:daily:${today}`, 50, 86400);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fileUploadLimit(userId: string): Promise<void> {
|
|
||||||
await this.checkLimit(`rate:user:${userId}:upload:hourly`, 10, 3600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@ import { Controller, Post, Get, Body, Param } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { AiAnalysisService } from './ai-analysis.service';
|
import { AiAnalysisService } from './ai-analysis.service';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { AiAnalysisRateLimit } from '../../common/decorators/rate-limit.decorator';
|
||||||
import type { UserPayload } from '../../common/types';
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ApiTags('ai-analysis')
|
@ApiTags('ai-analysis')
|
||||||
@ -10,6 +11,7 @@ export class AiAnalysisController {
|
|||||||
constructor(private readonly service: AiAnalysisService) {}
|
constructor(private readonly service: AiAnalysisService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@AiAnalysisRateLimit()
|
||||||
@ApiOperation({ summary: '提交主动回忆分析(异步)' })
|
@ApiOperation({ summary: '提交主动回忆分析(异步)' })
|
||||||
async analyze(
|
async analyze(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@ -25,6 +27,7 @@ export class AiAnalysisController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('feynman')
|
@Post('feynman')
|
||||||
|
@AiAnalysisRateLimit()
|
||||||
@ApiOperation({ summary: '提交费曼解释评估(异步)' })
|
@ApiOperation({ summary: '提交费曼解释评估(异步)' })
|
||||||
async evaluateFeynman(
|
async evaluateFeynman(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Controller, Post, Body, HttpCode, HttpStatus, Req } from '@nestjs/commo
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto';
|
import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import { LoginRateLimit } from '../../common/decorators/rate-limit.decorator';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@ -13,6 +14,7 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('dev-login')
|
@Post('dev-login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@LoginRateLimit()
|
||||||
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
|
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
|
||||||
@ApiResponse({ status: 200, description: '登录成功' })
|
@ApiResponse({ status: 200, description: '登录成功' })
|
||||||
@ApiResponse({ status: 403, description: '生产环境禁用' })
|
@ApiResponse({ status: 403, description: '生产环境禁用' })
|
||||||
@ -23,6 +25,7 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('apple')
|
@Post('apple')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@LoginRateLimit()
|
||||||
@ApiOperation({ summary: 'Apple 登录' })
|
@ApiOperation({ summary: 'Apple 登录' })
|
||||||
@ApiResponse({ status: 200, description: '登录成功' })
|
@ApiResponse({ status: 200, description: '登录成功' })
|
||||||
@ApiResponse({ status: 401, description: '身份验证失败' })
|
@ApiResponse({ status: 401, description: '身份验证失败' })
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/
|
|||||||
import { FeedbackService } from './feedback.service';
|
import { FeedbackService } from './feedback.service';
|
||||||
import { CreateFeedbackDto } from './dto/create-feedback.dto';
|
import { CreateFeedbackDto } from './dto/create-feedback.dto';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import { FeedbackRateLimit } from '../../common/decorators/rate-limit.decorator';
|
||||||
|
|
||||||
@ApiTags('feedback')
|
@ApiTags('feedback')
|
||||||
@Controller('feedback')
|
@Controller('feedback')
|
||||||
@ -11,6 +12,7 @@ export class FeedbackController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post()
|
@Post()
|
||||||
|
@FeedbackRateLimit()
|
||||||
@ApiOperation({ summary: '提交反馈' })
|
@ApiOperation({ summary: '提交反馈' })
|
||||||
@ApiResponse({ status: 201, description: '反馈提交成功' })
|
@ApiResponse({ status: 201, description: '反馈提交成功' })
|
||||||
async create(@Body() dto: CreateFeedbackDto) {
|
async create(@Body() dto: CreateFeedbackDto) {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagg
|
|||||||
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 type { UserPayload } from '../../common/types';
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ApiTags('files')
|
@ApiTags('files')
|
||||||
@ -19,6 +20,7 @@ export class FilesController {
|
|||||||
constructor(private readonly filesService: FilesService) {}
|
constructor(private readonly filesService: FilesService) {}
|
||||||
|
|
||||||
@Post('upload-url')
|
@Post('upload-url')
|
||||||
|
@FileUploadRateLimit()
|
||||||
@ApiOperation({ summary: '获取预签名上传 URL' })
|
@ApiOperation({ summary: '获取预签名上传 URL' })
|
||||||
@ApiResponse({ status: 201, description: '返回预签名 URL,客户端直接 PUT 文件到 COS' })
|
@ApiResponse({ status: 201, description: '返回预签名 URL,客户端直接 PUT 文件到 COS' })
|
||||||
async createUploadUrl(
|
async createUploadUrl(
|
||||||
@ -29,6 +31,7 @@ export class FilesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('complete')
|
@Post('complete')
|
||||||
|
@FileUploadRateLimit()
|
||||||
@ApiOperation({ summary: '确认上传完成' })
|
@ApiOperation({ summary: '确认上传完成' })
|
||||||
@ApiResponse({ status: 201, description: '验证 COS 中文件存在并创建数据库记录' })
|
@ApiResponse({ status: 201, description: '验证 COS 中文件存在并创建数据库记录' })
|
||||||
async completeUpload(
|
async completeUpload(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user