诚实性修复: API限流Retry-After(wait=60s)+断言 / purge定时接线(04:00 cron+actor+测试) / Channel表单校验(ref+rules+validate) / README PG数字513
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""P0-5 · 基于 Django cache 的通用限流/防爆破计数器。
|
||||
|
||||
设计:
|
||||
- 后端无关:locmem(开发/测试)与 Redis(生产)都可用同一 API。
|
||||
- 登录防爆破:按 `(用户名 + IP)` 记连续失败次数,达阈值锁定 M 分钟;
|
||||
成功登录清零。失败计数只在"凭证错误"时 +1,避免把不相关 400 计入。
|
||||
- API Key 限流:按 key prefix 记滑动分钟窗口计数,超 `rate_limit` 抛 Throttled。
|
||||
- `rate_limit` 下界:<=0 视为 1(防止被设成 0/负数导致全拒绝或无限)。
|
||||
|
||||
常量:
|
||||
- LOGIN_MAX_FAILURES=5, LOGIN_LOCK_SECONDS=900(15 分钟)
|
||||
- API KEY 默认窗口 60s,限额取 `max(1, key.rate_limit)`。
|
||||
"""
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
LOGIN_MAX_FAILURES = 5
|
||||
LOGIN_LOCK_SECONDS = 15 * 60
|
||||
APIKEY_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
def _login_fail_key(username: str, ip: str) -> str:
|
||||
return f"dealerhub:login-fail:{username}:{ip}"
|
||||
|
||||
|
||||
def _login_lock_key(username: str, ip: str) -> str:
|
||||
return f"dealerhub:login-lock:{username}:{ip}"
|
||||
|
||||
|
||||
def is_login_locked(username: str, ip: str) -> bool:
|
||||
return bool(cache.get(_login_lock_key(username, ip)))
|
||||
|
||||
|
||||
def record_login_failure(username: str, ip: str) -> int:
|
||||
"""失败 +1;达阈值则加锁并返回当前失败数。"""
|
||||
fails = (cache.get(_login_fail_key(username, ip)) or 0) + 1
|
||||
cache.set(_login_fail_key(username, ip), fails, LOGIN_LOCK_SECONDS)
|
||||
if fails >= LOGIN_MAX_FAILURES:
|
||||
cache.set(_login_lock_key(username, ip), 1, LOGIN_LOCK_SECONDS)
|
||||
return fails
|
||||
|
||||
|
||||
def clear_login_failures(username: str, ip: str) -> None:
|
||||
cache.delete(_login_fail_key(username, ip))
|
||||
cache.delete(_login_lock_key(username, ip))
|
||||
|
||||
|
||||
def effective_rate_limit(raw) -> int:
|
||||
"""rate_limit 下界保护:非正数/非法值 → 1。"""
|
||||
try:
|
||||
v = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return v if v >= 1 else 1
|
||||
|
||||
|
||||
def check_apikey_rate_limit(key_prefix: str, limit: int) -> None:
|
||||
"""分钟窗口计数;超限抛 DRF Throttled(429 + Retry-After 由 DRF 补)。
|
||||
|
||||
注意:必须传 `wait=`,DRF 只在 `exc.wait` 非空时才回 `Retry-After` 头
|
||||
(`rest_framework/views.py::exception_handler`)。不传则只有 429 无头,
|
||||
客户端无法知道何时重试。
|
||||
"""
|
||||
from rest_framework.exceptions import Throttled
|
||||
|
||||
window_key = f"dealerhub:apikey:{key_prefix}"
|
||||
count = cache.get(window_key)
|
||||
if count is None:
|
||||
cache.set(window_key, 1, APIKEY_WINDOW_SECONDS)
|
||||
return
|
||||
if int(count) >= effective_rate_limit(limit):
|
||||
raise Throttled(
|
||||
wait=APIKEY_WINDOW_SECONDS,
|
||||
detail="API Key 请求频率超限,请稍后重试",
|
||||
)
|
||||
try:
|
||||
cache.incr(window_key)
|
||||
except ValueError:
|
||||
cache.set(window_key, 1, APIKEY_WINDOW_SECONDS)
|
||||
|
||||
|
||||
def client_ip(request) -> str:
|
||||
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.META.get("REMOTE_ADDR", "") or "unknown"
|
||||
Reference in New Issue
Block a user