## 安全修复 (12项) - Webhook接口添加全局Token认证,过滤敏感请求头 - 修复JWT Base64 padding公式,防止签名验证绕过 - 数据库密码/飞书Token从源码移除,改为环境变量 - 工作流引擎添加路径遍历防护 (_resolve_safe_path) - eval()添加模板长度上限检查 - 审批API添加认证依赖 - 前端v-html增强XSS转义,console.log仅开发模式输出 - 500错误不再暴露内部异常详情 ## Agent运行时修复 (7项) - 删除_inject_knowledge_context中未定义db变量的finally块 - 工具执行添加try/except保护,异常不崩溃Agent - LLM重试计入budget计数器 - self_review异常时passed=False - max_iterations截断标记success=False - 工具参数JSON解析失败时记录警告日志 - run()开始时重置_llm_invocations计数器 ## 配置与基础设施 - DEBUG默认False,SQL_ECHO独立配置项 - init_db()补全13个缺失模型导入 - 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项 - 新增.env.example模板文件 ## 前端修复 (12项) - 登录改用URLSearchParams替代FormData - 401拦截器通过Pinia store统一清理状态 - SSE流超时从60s延长至300s - final/error事件时清除streamTimeout - localStorage聊天记录添加24h TTL - safeParseArgCount替代模板中裸JSON.parse - fetchUser 401时同时清除user对象 ## 新增模块 - 知识进化: knowledge_extractor/retriever/tasks - 数字孪生: shadow_executor/comparison模型 - 行为采集: behavior_middleware/collector/fingerprint_engine - 代码审查: code_review_agent/document_review_agent - 反馈学习: feedback_learner - 瓶颈检测/优化引擎/成本估算/需求估算 - 速率限制器 (rate_limiter) - Alembic迁移 015-020 ## 文档 - 商业化落地计划 - 8篇docs文档 (架构/API/部署/开发/贡献等) - Docker Compose生产配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
137 lines
4.1 KiB
Python
137 lines
4.1 KiB
Python
"""API 限流中间件 — 基于滑动窗口的简易限流器(Redis 优先,内存 fallback)"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from collections import defaultdict
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
from fastapi import Request, Response
|
||
from starlette.middleware.base import BaseHTTPMiddleware
|
||
from starlette.responses import JSONResponse
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 默认限流配置
|
||
DEFAULT_RATE_LIMIT = 120 # 每窗口最大请求数
|
||
DEFAULT_WINDOW_SEC = 60 # 窗口时长(秒)
|
||
# 敏感端点更严格
|
||
SENSITIVE_PATH_PREFIXES = [
|
||
"/api/v1/auth/login",
|
||
"/api/v1/agent-chat",
|
||
]
|
||
|
||
# ─── 内存存储(单进程 / 无 Redis 时使用) ───
|
||
|
||
_memory_store: Dict[str, List[float]] = defaultdict(list)
|
||
|
||
|
||
def _get_redis():
|
||
"""尝试获取 Redis 客户端。"""
|
||
try:
|
||
from app.core.redis_client import get_redis_client
|
||
client = get_redis_client()
|
||
if client:
|
||
try:
|
||
client.ping()
|
||
return client
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _check_memory(
|
||
key: str, max_requests: int, window_sec: float
|
||
) -> Tuple[bool, int]:
|
||
"""内存滑动窗口检查。返回 (allowed, remaining)。"""
|
||
now = time.monotonic()
|
||
window = _memory_store[key]
|
||
# 清理过期记录
|
||
cutoff = now - window_sec
|
||
while window and window[0] < cutoff:
|
||
window.pop(0)
|
||
if len(window) < max_requests:
|
||
window.append(now)
|
||
return True, max_requests - len(window)
|
||
return False, 0
|
||
|
||
|
||
def _check_redis(
|
||
client, key: str, max_requests: int, window_sec: int
|
||
) -> Tuple[bool, int]:
|
||
"""Redis 滑动窗口检查。"""
|
||
now_ms = int(time.time() * 1000)
|
||
window_ms = window_sec * 1000
|
||
pipe = client.pipeline()
|
||
member = f"{now_ms}:{now_ms}"
|
||
pipe.zadd(key, {member: now_ms})
|
||
pipe.zremrangebyscore(key, 0, now_ms - window_ms)
|
||
pipe.zcard(key)
|
||
pipe.expire(key, window_sec * 2)
|
||
_, _, count, _ = pipe.execute()
|
||
remaining = max(0, max_requests - count)
|
||
if count <= max_requests:
|
||
return True, remaining
|
||
return False, remaining
|
||
|
||
|
||
class RateLimiterMiddleware(BaseHTTPMiddleware):
|
||
"""API 限流中间件。
|
||
|
||
规则:
|
||
- 默认: 120 req / 60s per IP
|
||
- 敏感端点 (login, agent-chat): 30 req / 60s per IP
|
||
- 限流时返回 429 + Retry-After
|
||
"""
|
||
|
||
async def dispatch(self, request: Request, call_next) -> Response:
|
||
path = request.url.path
|
||
|
||
# 跳过非 API 路径
|
||
if not path.startswith("/api/"):
|
||
return await call_next(request)
|
||
|
||
# 确定限流配置
|
||
is_sensitive = any(path.startswith(p) for p in SENSITIVE_PATH_PREFIXES)
|
||
max_requests = 30 if is_sensitive else DEFAULT_RATE_LIMIT
|
||
window_sec = DEFAULT_WINDOW_SEC
|
||
|
||
# 构建 key: ip + path 前缀
|
||
client_ip = request.client.host if request.client else "unknown"
|
||
rate_key = f"rl:{client_ip}:{'sensitive' if is_sensitive else 'normal'}"
|
||
|
||
# 检查限流
|
||
redis_client = _get_redis()
|
||
if redis_client:
|
||
allowed, remaining = _check_redis(
|
||
redis_client, rate_key, max_requests, window_sec
|
||
)
|
||
else:
|
||
allowed, remaining = _check_memory(
|
||
rate_key, max_requests, window_sec
|
||
)
|
||
|
||
if not allowed:
|
||
retry_after = window_sec
|
||
logger.warning(
|
||
"API 限流触发: ip=%s path=%s max=%d/%ds",
|
||
client_ip, path, max_requests, window_sec,
|
||
)
|
||
return JSONResponse(
|
||
status_code=429,
|
||
content={
|
||
"detail": f"请求过于频繁,请 {retry_after}s 后重试",
|
||
"retry_after": retry_after,
|
||
},
|
||
headers={"Retry-After": str(retry_after)},
|
||
)
|
||
|
||
response = await call_next(request)
|
||
|
||
# 注入限流头
|
||
response.headers["X-RateLimit-Limit"] = str(max_requests)
|
||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||
return response
|