Files
workphone-sdk/sdk/app/services/rate_limiter.py

285 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
防封模块 — 三级频率限制器
L1 全局限流 / L2 设备限流 / L3 动作限流(按平台+操作类型)
所有自动化操作必须经过本限流器检查,超频则等待或拒绝。
依赖 RedisRedis 不可用时退化为内存字典(重启丢失)。
"""
import asyncio
import logging
import random
import time
from typing import Dict, Optional, Tuple
logger = logging.getLogger(__name__)
_redis = None
_redis_last_check: float = 0.0
_REDIS_CHECK_INTERVAL = 60.0
async def _get_redis():
global _redis, _redis_last_check
now = time.time()
if _redis is not None:
if now - _redis_last_check > _REDIS_CHECK_INTERVAL:
try:
await _redis.ping()
_redis_last_check = now
except Exception:
logger.warning("Redis 连接失效,重置并退化内存模式")
_redis = None
if _redis is not None:
return _redis
try:
import redis.asyncio as aioredis
from config import settings
_redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
await _redis.ping()
_redis_last_check = now
return _redis
except Exception as e:
logger.warning(f"Redis 不可用,退化为内存限流: {e}")
_redis = None
return None
_memory_store: Dict[str, float] = {}
_memory_daily: Dict[str, int] = {}
_daily_date: str = ""
def _today() -> str:
return time.strftime("%Y-%m-%d")
PLATFORM_LIMITS: Dict[str, Dict[str, Dict]] = {
"wechat": {
"add_friend": {"interval": (180, 300), "daily_max": 50, "new_daily_max": 10},
"send_message": {"interval": (30, 60), "daily_max": 200, "new_daily_max": 20},
"post_moments": {"interval": (7200, 10800), "daily_max": 5, "new_daily_max": 2},
"batch_send": {"interval": (300, 600), "daily_max": 50, "new_daily_max": 5},
"like_moments": {"interval": (15, 45), "daily_max": 100, "new_daily_max": 20},
"comment_moments": {"interval": (60, 180), "daily_max": 50, "new_daily_max": 10},
"send_group_message": {"interval": (60, 120), "daily_max": 100, "new_daily_max": 15},
},
"douyin": {
"send_message": {"interval": (60, 120), "daily_max": 100, "new_daily_max": 15},
"follow": {"interval": (30, 60), "daily_max": 200, "new_daily_max": 30},
"like": {"interval": (5, 15), "daily_max": 500, "new_daily_max": 100},
"comment": {"interval": (60, 180), "daily_max": 50, "new_daily_max": 10},
},
"xhs": {
"send_message": {"interval": (60, 180), "daily_max": 50, "new_daily_max": 10},
"like": {"interval": (15, 30), "daily_max": 300, "new_daily_max": 50},
"collect": {"interval": (30, 60), "daily_max": 200, "new_daily_max": 30},
"comment": {"interval": (60, 180), "daily_max": 30, "new_daily_max": 5},
},
"xianyu": {
"send_message": {"interval": (60, 120), "daily_max": 80, "new_daily_max": 15},
},
"soul": {
"send_message": {"interval": (30, 90), "daily_max": 100, "new_daily_max": 20},
},
}
L1_GLOBAL_RPM = 100
L2_DEVICE_RPM = 10
OPERATION_HOURS = (7, 23)
class DailyLimitExceeded(Exception):
"""日操作上限已达"""
pass
class OutsideOperationHours(Exception):
"""非操作时段"""
pass
class RateLimitWaiting(Exception):
"""需要等待(信息性,不应到达调用方)"""
pass
class AntiDetectRateLimiter:
"""三级防封限流器(单例)"""
def get_limit(self, platform: str, action: str) -> Optional[Dict]:
plat = PLATFORM_LIMITS.get(platform)
if not plat:
return None
for key in (action, action.replace("-", "_")):
if key in plat:
return plat[key]
return None
def check_operation_hours(self) -> bool:
"""检查当前时间是否在允许操作时段内(默认 7:00-23:00"""
import datetime
hour = datetime.datetime.now().hour
return OPERATION_HOURS[0] <= hour < OPERATION_HOURS[1]
async def _check_l1_global(self) -> bool:
"""L1 全局限流:所有设备总请求 <= L1_GLOBAL_RPM/分钟"""
now = time.time()
window_key = f"rl:l1:global:{int(now // 60)}"
r = await _get_redis()
if r:
try:
count = await r.incr(window_key)
if count == 1:
await r.expire(window_key, 120)
if count > L1_GLOBAL_RPM:
logger.warning(f"[RateLimiter] L1 全局限流触发: {count}/{L1_GLOBAL_RPM} RPM")
return False
return True
except Exception:
pass
win_key = f"l1:{int(now // 60)}"
_memory_daily[win_key] = _memory_daily.get(win_key, 0) + 1
return _memory_daily[win_key] <= L1_GLOBAL_RPM
async def _check_l2_device(self, device_id: str) -> bool:
"""L2 设备限流:单设备总请求 <= L2_DEVICE_RPM/分钟"""
now = time.time()
window_key = f"rl:l2:{device_id}:{int(now // 60)}"
r = await _get_redis()
if r:
try:
count = await r.incr(window_key)
if count == 1:
await r.expire(window_key, 120)
if count > L2_DEVICE_RPM:
logger.warning(f"[RateLimiter] L2 设备限流触发 {device_id}: {count}/{L2_DEVICE_RPM} RPM")
return False
return True
except Exception:
pass
win_key = f"l2:{device_id}:{int(now // 60)}"
_memory_daily[win_key] = _memory_daily.get(win_key, 0) + 1
return _memory_daily[win_key] <= L2_DEVICE_RPM
async def check_and_wait(
self,
device_id: str,
platform: str,
action: str,
is_new_account: bool = False,
) -> float:
"""
三级检查L1全局 → L2设备 → L3动作 + 时段检查。
返回实际等待的秒数。
若日上限已到,抛 DailyLimitExceeded。
"""
if not self.check_operation_hours():
raise OutsideOperationHours(
f"当前不在操作时段 ({OPERATION_HOURS[0]}:00-{OPERATION_HOURS[1]}:00)"
)
if not await self._check_l1_global():
wait = random.uniform(3.0, 8.0)
logger.info(f"[RateLimiter] L1 全局超限,等待 {wait:.1f}s")
await asyncio.sleep(wait)
if not await self._check_l2_device(device_id):
wait = random.uniform(5.0, 15.0)
logger.info(f"[RateLimiter] L2 设备超限 {device_id},等待 {wait:.1f}s")
await asyncio.sleep(wait)
limit = self.get_limit(platform, action)
if limit is None:
await asyncio.sleep(random.uniform(0.5, 2.0))
return 0.0
daily_max = limit.get("new_daily_max" if is_new_account else "daily_max", 9999)
daily_count = await self._get_daily_count(device_id, platform, action)
if daily_count >= daily_max:
raise DailyLimitExceeded(
f"{platform}.{action} 已达日上限 {daily_max}(当前 {daily_count}"
)
interval_range: Tuple[float, float] = limit["interval"]
min_wait = random.uniform(*interval_range)
last_ts = await self._get_last_ts(device_id, platform, action)
elapsed = time.time() - last_ts if last_ts else float("inf")
wait = max(0.0, min_wait - elapsed)
if wait > 0:
jitter = random.uniform(-wait * 0.1, wait * 0.15)
wait = max(0.3, wait + jitter)
logger.info(
f"[RateLimiter] {device_id}/{platform}.{action} 等待 {wait:.1f}s "
f"(interval={interval_range}, daily={daily_count}/{daily_max})"
)
await asyncio.sleep(wait)
await self._record(device_id, platform, action)
return wait
async def get_daily_count(self, device_id: str, platform: str, action: str) -> int:
return await self._get_daily_count(device_id, platform, action)
def get_platform_config(self, platform: str, action: str) -> Optional[Dict]:
"""获取某平台某动作的限流配置(供风控看板展示)"""
return self.get_limit(platform, action)
# ---- 存储层 ----
async def _get_last_ts(self, device_id: str, platform: str, action: str) -> float:
key = f"rl:ts:{device_id}:{platform}:{action}"
r = await _get_redis()
if r:
try:
val = await r.get(key)
return float(val) if val else 0.0
except Exception:
pass
return _memory_store.get(key, 0.0)
async def _get_daily_count(self, device_id: str, platform: str, action: str) -> int:
global _daily_date
today = _today()
key = f"rl:daily:{today}:{device_id}:{platform}:{action}"
r = await _get_redis()
if r:
try:
val = await r.get(key)
return int(val) if val else 0
except Exception:
pass
if _daily_date != today:
_memory_daily.clear()
_daily_date = today
return _memory_daily.get(key, 0)
async def _record(self, device_id: str, platform: str, action: str):
now = time.time()
today = _today()
ts_key = f"rl:ts:{device_id}:{platform}:{action}"
daily_key = f"rl:daily:{today}:{device_id}:{platform}:{action}"
r = await _get_redis()
if r:
try:
pipe = r.pipeline()
pipe.set(ts_key, str(now), ex=86400)
pipe.incr(daily_key)
pipe.expire(daily_key, 86400)
await pipe.execute()
return
except Exception:
pass
_memory_store[ts_key] = now
global _daily_date
if _daily_date != today:
_memory_daily.clear()
_daily_date = today
_memory_daily[daily_key] = _memory_daily.get(daily_key, 0) + 1
rate_limiter = AntiDetectRateLimiter()