import type { NextFunction, Request, Response } from "express"; import { buildErrorPayload, createHttpError } from "../utils/http.js"; import { logWarn } from "../utils/logger.js"; type RateLimitKeyResolver = (req: Request) => string; type RateLimitExceededContext = { scope: string; retryAfterSeconds: number; }; type RateLimitOptions = { scope: string; windowMs: number; max: number; key?: RateLimitKeyResolver; onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void; }; type RateLimitBucket = { count: number; resetAt: number; }; const buckets = new Map(); let lastCleanupAt = 0; export function createRateLimitMiddleware({ scope, windowMs, max, key = defaultRateLimitKey, onLimit, }: RateLimitOptions) { const normalizedScope = String(scope || "default").trim() || "default"; const normalizedWindowMs = Math.max(1000, Number(windowMs || 0)); const normalizedMax = Math.max(1, Number(max || 0)); return (req: Request, res: Response, next: NextFunction): void => { const now = Date.now(); cleanupExpiredBuckets(now); const bucketKey = `${normalizedScope}:${key(req)}`; const current = buckets.get(bucketKey); const bucket = current && current.resetAt > now ? current : { count: 0, resetAt: now + normalizedWindowMs }; bucket.count += 1; buckets.set(bucketKey, bucket); if (bucket.count <= normalizedMax) { next(); return; } const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); res.setHeader("Retry-After", String(retryAfterSeconds)); logWarn("[rate-limit]", "请求触发限流", { scope: normalizedScope, ip: req.ip, originalUrl: req.originalUrl, retryAfterSeconds, }); if (onLimit) { onLimit(req, res, { scope: normalizedScope, retryAfterSeconds, }); return; } const error = createHttpError("请求过于频繁,请稍后再试", { statusCode: 429, errorCode: "rate_limited", }); res.status(429).json(buildErrorPayload(error, "请求过于频繁,请稍后再试")); }; } export function resetRateLimitBucketsForTest(): void { buckets.clear(); lastCleanupAt = 0; } export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver { return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.body?.[fieldName])].join(":"); } export function getParamRateLimitKey(paramName: string): RateLimitKeyResolver { return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.params?.[paramName])].join(":"); } function defaultRateLimitKey(req: Request): string { return normalizeKeyPart( req.ip || String(req.headers["x-forwarded-for"] || "").split(",")[0] || req.socket.remoteAddress || "unknown", ); } function normalizeKeyPart(value: unknown): string { const normalized = String(value || "").trim().toLowerCase(); return normalized || "unknown"; } function cleanupExpiredBuckets(now: number): void { if (now - lastCleanupAt < 60_000) { return; } lastCleanupAt = now; for (const [key, bucket] of buckets.entries()) { if (bucket.resetAt <= now) { buckets.delete(key); } } }