ForcePilot/backend/package/yuxi/channel/security/rate_limit.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

97 lines
2.8 KiB
Python

"""多渠道网关 Redis 滑动窗口限流。"""
from __future__ import annotations
import time
import uuid
from typing import TYPE_CHECKING
from redis.asyncio import Redis
from yuxi.channel.constants import InboundRejectionReason, channel_rate_limit_key
from .policy import SecurityCheckResult
if TYPE_CHECKING:
from .registry import SecurityContext
class RateLimiter:
"""基于 Redis 滑动窗口的限流器。
使用 Lua 脚本保证 "检查窗口 -> 计数 -> 写入" 的原子性,
避免超限请求仍被计入窗口。
"""
name = "rate_limit"
default_priority = 400
_ALLOW_SCRIPT = """
local key = KEYS[1]
local window_start = tonumber(ARGV[1])
local max_requests = tonumber(ARGV[2])
local member_score = tonumber(ARGV[3])
local member = ARGV[4]
local window_seconds = tonumber(ARGV[5])
redis.call('zremrangebyscore', key, 0, window_start)
local current = redis.call('zcard', key)
if current >= max_requests then
return 0
end
redis.call('zadd', key, member_score, member)
redis.call('expire', key, window_seconds)
return 1
"""
def __init__(self, redis: Redis):
self.redis = redis
async def check(self, ctx: SecurityContext) -> SecurityCheckResult | None:
"""SecurityChecker 协议入口,保持原 is_allowed 方法可用。"""
max_requests = self._resolve_max_requests(ctx)
actor_id = ctx.resolved_sender_id or ctx.inbound.sender_id or ctx.inbound.peer_id or ""
rate_key = channel_rate_limit_key(
ctx.inbound.channel_type,
ctx.inbound.account_id or "",
actor_id,
)
if await self.is_allowed(rate_key, max_requests, window_seconds=60):
return None
return SecurityCheckResult(
allowed=False,
reason=InboundRejectionReason.RATE_LIMITED,
)
def _resolve_max_requests(self, ctx: SecurityContext) -> int:
resolve = getattr(ctx.plugin, "resolve_rate_limit_policy", None)
if resolve is not None:
rate_policy = resolve(ctx.config, ctx.inbound.account_id)
if rate_policy is not None:
return rate_policy.max_requests_per_minute
return int(ctx.config.get("rate_limit", {}).get("max_requests_per_minute", 60))
async def is_allowed(
self,
key: str,
max_requests: int,
window_seconds: int,
) -> bool:
"""原有公开方法,继续可用。"""
now = time.time()
window_start = now - window_seconds
member = f"{now}:{uuid.uuid4().hex}"
allowed = await self.redis.eval(
self._ALLOW_SCRIPT,
1,
key,
window_start,
max_requests,
now,
member,
window_seconds,
)
return bool(allowed)