feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { getServerTimestamp } from './serverTime'
|
||||
|
||||
// 简易的内存限流器实现,带有最大容量限制防止内存溢出
|
||||
// 如果项目扩展为多实例/Serverless,建议替换为 Redis 或基于数据库的实现
|
||||
|
||||
interface RateLimitRecord {
|
||||
count: number
|
||||
resetTime: number
|
||||
}
|
||||
|
||||
const MAX_STORE_SIZE = 10000 // 最大允许存储的IP记录数
|
||||
const REDIS_ERROR_LOG_INTERVAL_MS = 60 * 1000
|
||||
const store = new Map<string, RateLimitRecord>()
|
||||
let lastRedisErrorLogTime = 0
|
||||
const DISTRIBUTED_RATE_LIMIT_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
if count == 1 or ttl < 0 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
ttl = tonumber(ARGV[1])
|
||||
end
|
||||
return { count, ttl }
|
||||
`
|
||||
|
||||
/**
|
||||
* 检查并增加限流计数
|
||||
* @param key 限流的唯一键 (如 IP、用户名等)
|
||||
* @param limit 在指定时间窗口内允许的最大请求数
|
||||
* @param windowMs 时间窗口大小(毫秒)
|
||||
* @returns { isAllowed: boolean, remaining: number, resetTime: number }
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs: number
|
||||
): { isAllowed: boolean; remaining: number; resetTime: number } {
|
||||
const now = getServerTimestamp()
|
||||
let record = store.get(key)
|
||||
|
||||
if (!record || now > record.resetTime) {
|
||||
// 防止内存溢出:如果达到上限,清理部分旧数据
|
||||
if (store.size >= MAX_STORE_SIZE) {
|
||||
cleanupRateLimits(true)
|
||||
}
|
||||
|
||||
// 记录不存在或已过期,重置计数
|
||||
record = {
|
||||
count: 1,
|
||||
resetTime: now + windowMs
|
||||
}
|
||||
store.set(key, record)
|
||||
return { isAllowed: true, remaining: limit - 1, resetTime: record.resetTime }
|
||||
}
|
||||
|
||||
// 记录未过期,增加计数
|
||||
record.count++
|
||||
const remaining = Math.max(0, limit - record.count)
|
||||
const isAllowed = record.count <= limit
|
||||
|
||||
return { isAllowed, remaining, resetTime: record.resetTime }
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先使用 Redis 执行跨实例限流,Redis 不可用时回退到当前进程内存。
|
||||
* 适用于需要在多实例或 Serverless 环境中保持一致计数的敏感接口。
|
||||
*/
|
||||
export async function checkDistributedRateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs: number
|
||||
): Promise<{ isAllowed: boolean; remaining: number; resetTime: number }> {
|
||||
try {
|
||||
const redisModule = await import('./redis')
|
||||
const client = await redisModule.getRedisClient()
|
||||
|
||||
if (client) {
|
||||
const redisKey = redisModule.buildRedisKey('ratelimit', key)
|
||||
const result = await client.eval(DISTRIBUTED_RATE_LIMIT_SCRIPT, {
|
||||
keys: [redisKey],
|
||||
arguments: [String(windowMs)]
|
||||
})
|
||||
if (!Array.isArray(result) || result.length < 2) {
|
||||
throw new Error('Redis 限流脚本返回了无效结果')
|
||||
}
|
||||
|
||||
const count = Number(result[0])
|
||||
const ttl = Math.max(0, Number(result[1]))
|
||||
if (!Number.isFinite(count) || !Number.isFinite(ttl)) {
|
||||
throw new Error('Redis 限流脚本返回了无效计数')
|
||||
}
|
||||
|
||||
return {
|
||||
isAllowed: count <= limit,
|
||||
remaining: Math.max(0, limit - count),
|
||||
resetTime: getServerTimestamp() + ttl
|
||||
}
|
||||
}
|
||||
|
||||
if (redisModule.isRedisConfigured()) {
|
||||
throw new Error('Redis 分布式限流服务当前不可用')
|
||||
}
|
||||
} catch (error) {
|
||||
const now = Date.now()
|
||||
if (now - lastRedisErrorLogTime >= REDIS_ERROR_LOG_INTERVAL_MS) {
|
||||
console.error('[RateLimit] Redis 限流失败,已回退到内存限流', error)
|
||||
lastRedisErrorLogTime = now
|
||||
}
|
||||
}
|
||||
|
||||
return checkRateLimit(key, limit, windowMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的限流记录
|
||||
* @param force 如果为 true,在没有过期数据时也会强制删除最旧的记录
|
||||
*/
|
||||
export function cleanupRateLimits(force = false) {
|
||||
const now = getServerTimestamp()
|
||||
let deletedCount = 0
|
||||
|
||||
for (const [key, record] of store.entries()) {
|
||||
if (now > record.resetTime) {
|
||||
store.delete(key)
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 如果强制清理且没有过期数据可删,则一次性删除 10% 的最早插入记录
|
||||
// 避免高并发下 Map 始终满载导致频繁触发清理逻辑
|
||||
if (force && deletedCount === 0 && store.size > 0) {
|
||||
const itemsToDelete = Math.ceil(MAX_STORE_SIZE * 0.1)
|
||||
const keys = store.keys()
|
||||
|
||||
for (let i = 0; i < itemsToDelete; i++) {
|
||||
const key = keys.next().value
|
||||
if (key !== undefined) {
|
||||
store.delete(key)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试设置定时清理器,但在 Serverless 环境中可能不会按预期工作,所以主要依赖惰性清理
|
||||
if (typeof setInterval !== 'undefined') {
|
||||
setInterval(() => cleanupRateLimits(false), 60 * 60 * 1000)
|
||||
}
|
||||
Reference in New Issue
Block a user