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 { 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,
|
||||
|
||||
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 { 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,
|
||||
|
||||
@ -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: '身份验证失败' })
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user