初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -0,0 +1,502 @@
import hashlib
import json
from collections.abc import Awaitable, Callable, Sequence
from datetime import datetime
from functools import wraps
from typing import Any, TypeVar
from fastapi import Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse, ORJSONResponse, Response, StreamingResponse, UJSONResponse
from redis import asyncio as aioredis
from typing_extensions import ParamSpec
from common.constant import HttpStatusConstant
from common.context import RequestContext
from common.enums import HttpMethod, RedisInitKeyConfig
from exceptions.exception import LoginException
from utils.api_annotation_util import ApiAnnotationUtil
from utils.api_response_header_util import ApiResponseHeaderUtil
from utils.log_util import logger
P = ParamSpec('P')
R = TypeVar('R')
class ApiCacheManager:
"""
接口缓存键与命名空间管理工具类
"""
@classmethod
async def clear_namespace(cls, redis: aioredis.Redis, namespace: str) -> int:
"""
清理指定命名空间下的接口缓存
:param redis: Redis连接对象
:param namespace: 缓存命名空间
:return: 删除的缓存数量
"""
return await cls._clear_by_pattern(redis, cls.build_namespace_pattern(namespace))
@classmethod
async def clear_all(cls, redis: aioredis.Redis) -> int:
"""
清理所有接口缓存
:param redis: Redis连接对象
:return: 删除的缓存数量
"""
return await cls._clear_by_pattern(redis, cls.build_namespace_pattern('*'))
@classmethod
async def clear_namespaces(cls, redis: aioredis.Redis, namespaces: list[str] | tuple[str, ...] | set[str]) -> int:
"""
批量清理多个命名空间下的接口缓存
:param redis: Redis连接对象
:param namespaces: 需要清理的缓存命名空间列表
:return: 删除的缓存数量
"""
deleted_count = 0
for namespace in set(namespaces):
deleted_count += await cls.clear_namespace(redis, namespace)
return deleted_count
@classmethod
async def clear_namespace_prefix(cls, redis: aioredis.Redis, namespace_prefix: str) -> int:
"""
按命名空间前缀清理接口缓存
:param redis: Redis连接对象
:param namespace_prefix: 缓存命名空间前缀
:return: 删除的缓存数量
"""
return await cls._clear_by_pattern(redis, cls.build_namespace_prefix_pattern(namespace_prefix))
@classmethod
async def clear_namespace_prefixes(
cls,
redis: aioredis.Redis,
namespace_prefixes: list[str] | tuple[str, ...] | set[str],
) -> int:
"""
批量按命名空间前缀清理接口缓存
:param redis: Redis连接对象
:param namespace_prefixes: 需要清理的缓存命名空间前缀列表
:return: 删除的缓存数量
"""
deleted_count = 0
for namespace_prefix in set(namespace_prefixes):
deleted_count += await cls.clear_namespace_prefix(redis, namespace_prefix)
return deleted_count
@classmethod
def build_namespace_pattern(cls, namespace: str) -> str:
"""
生成命名空间扫描表达式
:param namespace: 缓存命名空间
:return: 缓存键扫描匹配模式
"""
return f'{RedisInitKeyConfig.API_CACHE.key}:{namespace}:*'
@classmethod
def build_namespace_prefix_pattern(cls, namespace_prefix: str) -> str:
"""
生成命名空间前缀扫描表达式
:param namespace_prefix: 缓存命名空间前缀
:return: 缓存键扫描匹配模式
"""
return f'{RedisInitKeyConfig.API_CACHE.key}:{namespace_prefix}*'
@classmethod
async def _clear_by_pattern(cls, redis: aioredis.Redis, pattern: str) -> int:
"""
根据扫描表达式清理匹配的接口缓存
:param redis: Redis连接对象
:param pattern: 缓存键扫描匹配模式
:return: 删除的缓存数量
"""
cache_keys = [key async for key in redis.scan_iter(match=pattern)]
if not cache_keys:
return 0
return await redis.delete(*cache_keys)
class _ApiCacheSupport:
"""
接口缓存装饰器共用工具基类
"""
def _load_json_content(self, response_body: str) -> Any | None:
"""
解析JSON响应体内容
:param response_body: JSON字符串响应体
:return: 解析后的Python对象,解析失败时返回None
"""
try:
return json.loads(response_body)
except json.JSONDecodeError:
return None
def _extract_json_response_content(self, result: Any) -> Any | None:
"""
提取响应中的JSON内容
:param result: 原始接口返回结果
:return: 解析后的JSON内容,无法提取时返回None
"""
if result is None or isinstance(result, StreamingResponse):
return None
if isinstance(result, (JSONResponse, ORJSONResponse, UJSONResponse)):
return self._load_json_content(result.body.decode('utf-8'))
if isinstance(result, Response):
return None
return jsonable_encoder(result)
def _match_response_codes(self, response_content: Any, response_codes: set[int]) -> bool:
"""
判断响应内容中的业务响应码是否匹配
:param response_content: JSON响应内容
:param response_codes: 允许的业务响应码集合
:return: 是否匹配
"""
if not isinstance(response_content, dict):
return True
response_code = response_content.get('code')
if response_code is None:
return True
return response_code in response_codes
def _get_matched_response_content(self, result: Any, response_codes: set[int]) -> Any | None:
"""
提取并校验满足业务响应码要求的JSON响应内容
:param result: 原始接口返回结果
:param response_codes: 允许的业务响应码集合
:return: 匹配成功的JSON响应内容,不匹配时返回None
"""
response_content = self._extract_json_response_content(result)
if response_content is None or not self._match_response_codes(response_content, response_codes):
return None
return response_content
class ApiCache(_ApiCacheSupport):
"""
接口缓存装饰器,仅用于幂等且返回JSON的接口
"""
def __init__(
self,
namespace: str,
expire_seconds: int = 10,
vary_by_user: bool = True,
methods: Sequence[HttpMethod] | None = None,
cache_response_codes: set[int] | None = None,
) -> None:
"""
初始化接口缓存装饰器
:param namespace: 缓存命名空间,用于区分不同接口类型
:param expire_seconds: 缓存过期时间,单位秒
:param vary_by_user: 是否按当前登录用户隔离缓存
:param methods: 允许启用缓存的HttpMethod枚举列表,为None时默认仅缓存GET请求
:param cache_response_codes: 允许缓存的业务响应码,为None时默认仅缓存成功响应
"""
self.namespace = namespace
self.expire_seconds = expire_seconds
self.vary_by_user = vary_by_user
self.methods = ApiAnnotationUtil.normalize_http_methods(methods, default_methods=(HttpMethod.GET,))
self.cache_response_codes = (
cache_response_codes if cache_response_codes is not None else {HttpStatusConstant.SUCCESS}
)
def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""
为目标异步接口函数增加接口缓存能力
:param func: 需要被缓存的异步接口函数
:return: 包装后的异步接口函数
"""
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
request, redis = ApiAnnotationUtil.resolve_request_redis(
func, '当前应用未初始化Redis连接,跳过接口缓存', *args, **kwargs
)
if request is None or redis is None:
return await func(*args, **kwargs)
if not self._is_request_method_allowed(request):
return await func(*args, **kwargs)
cache_key = await self._build_cache_key(request)
cached_response = await redis.get(cache_key)
if cached_response:
return self._build_cached_response(cached_response) # type: ignore[return-value]
result = await func(*args, **kwargs)
await self._cache_response(redis, cache_key, result)
ApiResponseHeaderUtil.merge_headers(request, {'X-Api-Cache': 'MISS'})
return result
return wrapper
def _is_request_method_allowed(self, request: Request) -> bool:
"""
判断当前请求方法是否允许启用接口缓存
:param request: 当前请求对象
:return: 是否允许缓存
"""
return request.method.upper() in self.methods
async def _build_cache_key(self, request: Request) -> str:
"""
根据当前请求生成稳定缓存键
:param request: 当前请求对象
:return: 接口缓存键
"""
request_body = await request.body()
user_scope = self._get_user_scope(request) if self.vary_by_user else ''
key_material = {
'method': request.method,
'path': request.url.path,
'path_params': dict(sorted(request.path_params.items())),
'query_params': sorted(request.query_params.multi_items()),
'body_digest': hashlib.sha256(request_body).hexdigest() if request_body else '',
'user_scope': user_scope,
}
key_digest = hashlib.sha256(
json.dumps(
key_material,
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
default=str,
).encode('utf-8')
).hexdigest()
return f'{RedisInitKeyConfig.API_CACHE.key}:{self.namespace}:{key_digest}'
def _get_user_scope(self, request: Request) -> str:
"""
获取用户隔离维度
优先使用当前登录用户ID,未登录时回退到Authorization摘要。
:param request: 当前请求对象
:return: 用户隔离字符串
"""
try:
current_user = RequestContext.get_current_user()
return str(current_user.user.user_id)
except LoginException:
authorization = request.headers.get('Authorization', '')
return hashlib.sha256(authorization.encode('utf-8')).hexdigest() if authorization else ''
def _serialize_response(self, result: Any) -> str | None:
"""
将响应对象序列化为可存入缓存的字符串
:param result: 原始接口返回结果
:return: 序列化后的缓存内容,不可缓存时返回None
"""
response_payload = self._extract_response_payload(result)
if response_payload is None:
return None
return json.dumps(response_payload, ensure_ascii=False)
def _extract_response_payload(self, result: Any) -> dict[str, Any] | None:
"""
提取可缓存的响应载荷
:param result: 原始接口返回结果
:return: 可缓存的响应载荷,不可缓存时返回None
"""
if isinstance(result, StreamingResponse):
return None
if isinstance(result, (JSONResponse, ORJSONResponse, UJSONResponse)):
# 命中缓存时无法安全复放后台任务,因此带有后台任务的响应不参与缓存
if getattr(result, 'background', None) is not None:
return None
content = self._get_matched_response_content(result, self.cache_response_codes)
if content is None:
return None
return {
'content': content,
'status_code': result.status_code,
'media_type': result.media_type,
'headers': self._filter_response_headers(result.headers),
}
if isinstance(result, Response):
return None
json_content = self._get_matched_response_content(result, self.cache_response_codes)
if json_content is None:
return None
return {
'content': json_content,
'status_code': HttpStatusConstant.SUCCESS,
'media_type': 'application/json',
'headers': {},
}
def _build_cached_response(self, cached_response: str) -> JSONResponse:
"""
根据缓存内容重建JSON响应对象
:param cached_response: 缓存中读取到的响应字符串
:return: 重建后的JSON响应对象
"""
cached_payload = json.loads(cached_response)
cached_content = self._refresh_response_time(cached_payload['content'])
response = JSONResponse(
status_code=cached_payload['status_code'],
content=jsonable_encoder(cached_content),
headers=cached_payload.get('headers'),
media_type=cached_payload.get('media_type'),
)
response.headers['X-Api-Cache'] = 'HIT'
return response
async def _cache_response(self, redis: aioredis.Redis, cache_key: str, result: Any) -> None:
"""
将接口响应写入接口缓存
:param redis: Redis连接对象
:param cache_key: 接口缓存键
:param result: 原始接口返回结果
:return: None
"""
serialized_response = self._serialize_response(result)
if serialized_response is None:
return
await redis.set(cache_key, serialized_response, ex=self.expire_seconds)
logger.debug(f'接口缓存写入成功: {cache_key}')
def _filter_response_headers(self, headers: dict[str, str]) -> dict[str, str]:
"""
过滤不适合直接回放的响应头
:param headers: 原始响应头
:return: 过滤后的响应头
"""
excluded_headers = {'content-length', 'content-type', 'set-cookie'}
return {key: value for key, value in headers.items() if key.lower() not in excluded_headers}
def _refresh_response_time(self, response_content: Any) -> Any:
"""
刷新项目统一响应体中的time字段
:param response_content: JSON响应内容
:return: 刷新time后的响应内容
"""
if not isinstance(response_content, dict):
return response_content
if {'code', 'msg', 'success', 'time'}.issubset(response_content):
refreshed_content = response_content.copy()
refreshed_content['time'] = datetime.now()
return refreshed_content
return response_content
class ApiCacheEvict(_ApiCacheSupport):
"""
接口缓存失效装饰器,用于在写操作成功后统一清理相关缓存
"""
def __init__(
self,
namespaces: Sequence[str] | None = None,
namespace_prefixes: Sequence[str] | None = None,
evict_response_codes: set[int] | None = None,
) -> None:
"""
初始化接口缓存失效装饰器
:param namespaces: 需要失效的缓存命名空间列表
:param namespace_prefixes: 需要失效的缓存命名空间前缀列表
:param evict_response_codes: 允许触发失效的业务响应码,默认为仅成功响应触发
"""
self.namespaces = tuple(dict.fromkeys(namespaces or ()))
self.namespace_prefixes = tuple(dict.fromkeys(namespace_prefixes or ()))
if not self.namespaces and not self.namespace_prefixes:
raise ValueError('ApiCacheEvict至少需要指定namespaces或namespace_prefixes')
self.evict_response_codes = (
evict_response_codes if evict_response_codes is not None else {HttpStatusConstant.SUCCESS}
)
def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""
为目标异步接口函数增加缓存失效能力
:param func: 需要在成功后触发缓存失效的异步接口函数
:return: 包装后的异步接口函数
"""
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
result = await func(*args, **kwargs)
_, redis = ApiAnnotationUtil.resolve_request_redis(
func, '当前应用未初始化Redis连接,跳过接口缓存失效', *args, **kwargs
)
if redis is None:
return result
if self._should_evict(result):
await self._evict_cache(redis)
return result
return wrapper
async def _evict_cache(self, redis: aioredis.Redis) -> None:
"""
根据配置统一执行接口缓存失效
:param redis: Redis连接对象
:return: None
"""
if self.namespaces:
await ApiCacheManager.clear_namespaces(redis, self.namespaces)
if self.namespace_prefixes:
await ApiCacheManager.clear_namespace_prefixes(redis, self.namespace_prefixes)
def _should_evict(self, result: Any) -> bool:
"""
判断当前响应是否应触发缓存失效
:param result: 原始接口返回结果
:return: 是否触发缓存失效
"""
return self._get_matched_response_content(result, self.evict_response_codes) is not None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import inspect
from typing import TYPE_CHECKING, TypeVar
from fastapi import Form, Query
from pydantic import BaseModel
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
BaseModelVar = TypeVar('BaseModelVar', bound=BaseModel)
def as_query(cls: type[BaseModelVar]) -> type[BaseModelVar]:
"""
pydantic模型查询参数装饰器,将pydantic模型用于接收查询参数
"""
new_parameters = []
for model_field in cls.model_fields.values():
model_field: FieldInfo
if not model_field.is_required():
new_parameters.append(
inspect.Parameter(
model_field.alias,
inspect.Parameter.POSITIONAL_ONLY,
default=Query(default=model_field.default, description=model_field.description),
annotation=model_field.annotation,
)
)
else:
new_parameters.append(
inspect.Parameter(
model_field.alias,
inspect.Parameter.POSITIONAL_ONLY,
default=Query(..., description=model_field.description),
annotation=model_field.annotation,
)
)
async def as_query_func(**data) -> type[BaseModelVar]:
return cls(**data)
sig = inspect.signature(as_query_func)
sig = sig.replace(parameters=new_parameters)
as_query_func.__signature__ = sig
cls.as_query = as_query_func
return cls
def as_form(cls: type[BaseModelVar]) -> type[BaseModelVar]:
"""
pydantic模型表单参数装饰器,将pydantic模型用于接收表单参数
"""
new_parameters = []
for model_field in cls.model_fields.values():
model_field: FieldInfo
if not model_field.is_required():
new_parameters.append(
inspect.Parameter(
model_field.alias,
inspect.Parameter.POSITIONAL_ONLY,
default=Form(default=model_field.default, description=model_field.description),
annotation=model_field.annotation,
)
)
else:
new_parameters.append(
inspect.Parameter(
model_field.alias,
inspect.Parameter.POSITIONAL_ONLY,
default=Form(..., description=model_field.description),
annotation=model_field.annotation,
)
)
async def as_form_func(**data) -> type[BaseModelVar]:
return cls(**data)
sig = inspect.signature(as_form_func)
sig = sig.replace(parameters=new_parameters)
as_form_func.__signature__ = sig
cls.as_form = as_form_func
return cls
@@ -0,0 +1,720 @@
import hashlib
import json
import time
import uuid
from collections import deque
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from functools import wraps
from typing import Literal, TypeVar
from fastapi import Request
from redis import asyncio as aioredis
from typing_extensions import ParamSpec
from common.context import RequestContext
from common.enums import HttpMethod, RedisInitKeyConfig
from exceptions.exception import LoginException
from module_admin.entity.vo.user_vo import CurrentUserModel
from utils.api_annotation_util import ApiAnnotationUtil
from utils.api_response_header_util import ApiResponseHeaderUtil
from utils.client_ip_util import ClientIPUtil
from utils.log_util import logger
from utils.response_util import ResponseUtil
P = ParamSpec('P')
R = TypeVar('R')
RateLimitScope = Literal['ip', 'user', 'user_or_ip']
RateLimitAlgorithm = Literal['fixed_window', 'sliding_window']
RateLimitFailStrategy = Literal['open', 'closed', 'local_fallback']
@dataclass(frozen=True)
class ApiRateLimitPresetConfig:
"""
接口限流预设配置
"""
name: str
limit: int
window_seconds: int
scope: RateLimitScope = 'ip'
algorithm: RateLimitAlgorithm = 'fixed_window'
fail_strategy: RateLimitFailStrategy = 'open'
methods: tuple[HttpMethod, ...] | None = None
message: str = '请求过于频繁,请稍后再试'
@dataclass(frozen=True)
class ApiRateLimitBypassConfig:
"""
接口限流角色豁免配置
"""
roles: tuple[str, ...]
class ApiRateLimitPreset:
"""
接口限流预设
ANON_AUTH_LOGIN: 匿名登录类接口限流预设
ANON_AUTH_REGISTER: 匿名注册类接口限流预设
ANON_AUTH_CAPTCHA: 匿名验证码类接口限流预设
ANON_PUBLIC_METADATA: 匿名公开元数据接口限流预设
COMMON_UPLOAD: 通用上传接口限流预设
USER_INTERACTIVE_HIGH_FREQ: 用户高频交互接口限流预设
USER_RESOURCE_EXECUTION: 用户执行类接口限流预设
USER_COMMON_MUTATION: 用户普通写操作接口限流预设
USER_SECURITY_MUTATION: 用户安全敏感操作接口限流预设
USER_DESTRUCTIVE_MUTATION: 用户破坏性操作接口限流预设
USER_RESOURCE_EXPORT: 用户导出类接口限流预设
USER_RESOURCE_IMPORT: 用户导入类接口限流预设
USER_RESOURCE_UPLOAD: 用户上传类接口限流预设
USER_RESOURCE_GENERATE: 用户生成类接口限流预设
USER_RESOURCE_DOWNLOAD: 用户下载类接口限流预设
USER_RESOURCE_SYNC: 用户同步类接口限流预设
"""
ANON_AUTH_LOGIN = ApiRateLimitPresetConfig(
name='ANON_AUTH_LOGIN',
limit=12,
window_seconds=60,
algorithm='sliding_window',
fail_strategy='local_fallback',
)
ANON_AUTH_REGISTER = ApiRateLimitPresetConfig(
name='ANON_AUTH_REGISTER',
limit=6,
window_seconds=120,
algorithm='sliding_window',
fail_strategy='local_fallback',
)
ANON_AUTH_CAPTCHA = ApiRateLimitPresetConfig(
name='ANON_AUTH_CAPTCHA',
limit=36,
window_seconds=60,
algorithm='sliding_window',
fail_strategy='local_fallback',
)
ANON_PUBLIC_METADATA = ApiRateLimitPresetConfig(
name='ANON_PUBLIC_METADATA',
limit=30,
window_seconds=60,
algorithm='sliding_window',
fail_strategy='local_fallback',
)
COMMON_UPLOAD = ApiRateLimitPresetConfig(
name='COMMON_UPLOAD',
limit=24,
window_seconds=60,
scope='user_or_ip',
)
USER_INTERACTIVE_HIGH_FREQ = ApiRateLimitPresetConfig(
name='USER_INTERACTIVE_HIGH_FREQ',
limit=40,
window_seconds=60,
scope='user',
)
USER_RESOURCE_EXECUTION = ApiRateLimitPresetConfig(
name='USER_RESOURCE_EXECUTION',
limit=12,
window_seconds=60,
scope='user',
)
USER_COMMON_MUTATION = ApiRateLimitPresetConfig(
name='USER_COMMON_MUTATION',
limit=24,
window_seconds=120,
scope='user',
)
USER_SECURITY_MUTATION = ApiRateLimitPresetConfig(
name='USER_SECURITY_MUTATION',
limit=12,
window_seconds=120,
scope='user',
)
USER_DESTRUCTIVE_MUTATION = ApiRateLimitPresetConfig(
name='USER_DESTRUCTIVE_MUTATION',
limit=6,
window_seconds=120,
scope='user',
)
USER_RESOURCE_EXPORT = ApiRateLimitPresetConfig(
name='USER_RESOURCE_EXPORT',
limit=15,
window_seconds=120,
scope='user',
)
USER_RESOURCE_IMPORT = ApiRateLimitPresetConfig(
name='USER_RESOURCE_IMPORT',
limit=8,
window_seconds=120,
scope='user',
)
USER_RESOURCE_UPLOAD = ApiRateLimitPresetConfig(
name='USER_RESOURCE_UPLOAD',
limit=12,
window_seconds=120,
scope='user',
)
USER_RESOURCE_GENERATE = ApiRateLimitPresetConfig(
name='USER_RESOURCE_GENERATE',
limit=8,
window_seconds=120,
scope='user',
)
USER_RESOURCE_DOWNLOAD = ApiRateLimitPresetConfig(
name='USER_RESOURCE_DOWNLOAD',
limit=12,
window_seconds=60,
scope='user',
)
USER_RESOURCE_SYNC = ApiRateLimitPresetConfig(
name='USER_RESOURCE_SYNC',
limit=15,
window_seconds=120,
scope='user',
)
class ApiRateLimit:
"""
接口限流装饰器,支持Redis固定窗口、滑动窗口及本地应急兜底。
`local_fallback` 仅作为Redis异常场景下的进程内应急保护,能提供单进程内的
基础限流能力,但不保证多 worker / 多实例部署下的全局一致性。
`ip` 维度限流通过 `ClientIPUtil` 提取客户端地址,仅当请求来源命中
`APP_TRUSTED_PROXY_IPS` 且 `APP_TRUSTED_PROXY_HOPS` 大于0时,才会解析
`X-Forwarded-For` / `X-Real-IP` 请求头;否则回退到直接连接来源地址。
"""
_SUPPORTED_SCOPES: tuple[RateLimitScope, ...] = ('ip', 'user', 'user_or_ip')
_SUPPORTED_ALGORITHMS: tuple[RateLimitAlgorithm, ...] = ('fixed_window', 'sliding_window')
_SUPPORTED_FAIL_STRATEGIES: tuple[RateLimitFailStrategy, ...] = ('open', 'closed', 'local_fallback')
_FIXED_WINDOW_LUA_SCRIPT = """
local current = redis.call('INCR', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1])
if current == 1 or ttl < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
ttl = redis.call('PTTL', KEYS[1])
end
local limit = tonumber(ARGV[2])
local remaining = limit - current
if remaining < 0 then
remaining = 0
end
local allowed = 0
if current <= limit then
allowed = 1
end
return {allowed, current, remaining, ttl}
"""
_SLIDING_WINDOW_LUA_SCRIPT = """
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
local current = redis.call('ZCARD', KEYS[1])
local limit = tonumber(ARGV[4])
local ttl = ARGV[2]
if current >= limit then
local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
if earliest[2] ~= nil then
ttl = tonumber(ARGV[2]) - (tonumber(ARGV[1]) - tonumber(earliest[2]))
end
if ttl < 1 then
ttl = 1
end
return {0, current, 0, ttl}
end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
current = current + 1
local remaining = limit - current
if remaining < 0 then
remaining = 0
end
local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
if earliest[2] ~= nil then
ttl = tonumber(ARGV[2]) - (tonumber(ARGV[1]) - tonumber(earliest[2]))
end
if ttl < 1 then
ttl = 1
end
return {1, current, remaining, ttl}
"""
# 仅用于Redis异常时的单进程本地兜底,不承担分布式一致性职责。
_LOCAL_FALLBACK_STORE: dict[str, deque[int]] = {}
def __init__(
self,
namespace: str,
limit: int | None = None,
window_seconds: int | None = None,
scope: RateLimitScope | None = None,
algorithm: RateLimitAlgorithm | None = None,
fail_strategy: RateLimitFailStrategy | None = None,
bypass: ApiRateLimitBypassConfig | None = None,
methods: Sequence[HttpMethod] | None = None,
message: str | None = None,
preset: ApiRateLimitPresetConfig | None = None,
) -> None:
"""
初始化接口限流装饰器
:param namespace: 限流命名空间,用于区分不同接口
:param limit: 窗口内允许的最大请求次数,可覆盖预设
:param window_seconds: 限流窗口时长,单位秒,可覆盖预设
:param scope: 限流作用域,ip: 按客户端IP限流,客户端IP由可信代理配置控制提取,user: 仅按当前登录用户限流,未登录请求跳过限流,user_or_ip: 已登录按用户限流,未登录按客户端IP限流,可覆盖预设
:param algorithm: 限流算法,fixed_window: 固定窗口,sliding_window: 滑动窗口,可覆盖预设
:param fail_strategy: 限流组件异常时的故障策略,open: 放行,closed: 直接拦截,local_fallback: 使用进程内内存做应急兜底限流,仅保证单进程内生效,可覆盖预设
:param bypass: 角色豁免配置,仅在显式传入时生效
:param methods: 需要限流的HttpMethod枚举列表,为None时默认限制所有方法,可覆盖预设
:param message: 触发限流后的提示信息,可覆盖预设
:param preset: 限流预设配置
"""
resolved_limit = limit if limit is not None else preset.limit if preset else None
resolved_window_seconds = (
window_seconds if window_seconds is not None else preset.window_seconds if preset else None
)
resolved_scope = scope if scope is not None else preset.scope if preset else 'ip'
resolved_algorithm = algorithm if algorithm is not None else preset.algorithm if preset else 'fixed_window'
resolved_fail_strategy = (
fail_strategy if fail_strategy is not None else preset.fail_strategy if preset else 'open'
)
resolved_methods = methods if methods is not None else preset.methods if preset else None
resolved_message = message if message is not None else preset.message if preset else '请求过于频繁,请稍后再试'
resolved_preset_name = preset.name if preset else 'CUSTOM'
if not namespace:
raise ValueError('ApiRateLimit的namespace不能为空')
if resolved_limit is None or resolved_limit <= 0:
raise ValueError('ApiRateLimit的limit必须大于0')
if resolved_window_seconds is None or resolved_window_seconds <= 0:
raise ValueError('ApiRateLimit的window_seconds必须大于0')
if resolved_scope not in self._SUPPORTED_SCOPES:
raise ValueError(f'ApiRateLimit的scope仅支持: {", ".join(self._SUPPORTED_SCOPES)}')
if resolved_algorithm not in self._SUPPORTED_ALGORITHMS:
raise ValueError(f'ApiRateLimit的algorithm仅支持: {", ".join(self._SUPPORTED_ALGORITHMS)}')
if resolved_fail_strategy not in self._SUPPORTED_FAIL_STRATEGIES:
raise ValueError(f'ApiRateLimit的fail_strategy仅支持: {", ".join(self._SUPPORTED_FAIL_STRATEGIES)}')
if bypass and resolved_scope == 'ip':
raise ValueError('ApiRateLimit在scope=ip时不支持角色豁免配置')
self.namespace = namespace
self.preset_name = resolved_preset_name
self.limit = resolved_limit
self.window_seconds = resolved_window_seconds
self.scope = resolved_scope
self.algorithm = resolved_algorithm
self.fail_strategy = resolved_fail_strategy
self.bypass_roles = self._normalize_bypass_roles(bypass.roles if bypass else None)
self.methods = ApiAnnotationUtil.normalize_http_methods(resolved_methods)
self.message = resolved_message
def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""
为目标异步接口函数增加接口限流能力
:param func: 需要限流的异步接口函数
:return: 包装后的异步接口函数
"""
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
request = ApiAnnotationUtil.get_request(func, *args, **kwargs)
if request is None:
return await func(*args, **kwargs)
if not self._is_request_method_allowed(request):
return await func(*args, **kwargs)
bypass_role = self._match_bypass_role()
if bypass_role is not None:
self._log_rate_limit_bypass(request, bypass_role)
return await func(*args, **kwargs)
rate_limit_result: dict[str, int | bool] | None = None
redis = getattr(request.app.state, 'redis', None)
if redis is None:
rate_limit_result = self._resolve_failed_rate_limit(
request, reason='redis_unavailable', error_message='redis client is not initialized'
)
if rate_limit_result is None:
return await func(*args, **kwargs)
else:
try:
rate_limit_result = await self._acquire_rate_limit(redis, request)
except Exception as exc:
rate_limit_result = self._resolve_failed_rate_limit(
request, reason='redis_error', error_message=str(exc)
)
if rate_limit_result is None:
return await func(*args, **kwargs)
if rate_limit_result is None:
return await func(*args, **kwargs)
headers = self._build_rate_limit_headers(rate_limit_result)
if not rate_limit_result['allowed']:
self._log_rate_limit_hit(request, rate_limit_result)
return ResponseUtil.too_many_requests(msg=self.message, headers=headers) # type: ignore[return-value]
ApiResponseHeaderUtil.merge_headers(request, headers)
result = await func(*args, **kwargs)
return result
return wrapper
def _is_request_method_allowed(self, request: Request) -> bool:
"""
判断当前请求方法是否启用限流
:param request: 当前请求对象
:return: 是否启用限流
"""
return not self.methods or request.method.upper() in self.methods
def _normalize_bypass_roles(self, bypass_roles: Sequence[str] | None) -> tuple[str, ...]:
"""
标准化角色豁免配置
:param bypass_roles: 原始角色标识列表
:return: 去重后的角色标识元组
"""
if not bypass_roles:
return ()
normalized_roles: list[str] = []
for role in bypass_roles:
normalized_role = role.strip()
if not normalized_role:
raise ValueError('ApiRateLimit的bypass_roles不能包含空角色标识')
if normalized_role not in normalized_roles:
normalized_roles.append(normalized_role)
return tuple(normalized_roles)
def _match_bypass_role(self) -> str | None:
"""
判断当前登录用户是否命中角色豁免
:return: 命中的角色标识,未命中时返回None
"""
if not self.bypass_roles:
return None
current_user = self._get_current_user()
if current_user is None:
return None
user_roles = {str(role).strip() for role in current_user.roles if str(role).strip()}
for role in self.bypass_roles:
if role in user_roles:
return role
return None
async def _acquire_rate_limit(self, redis: aioredis.Redis, request: Request) -> dict[str, int | bool] | None:
"""
获取当前请求的限流计数结果
:param redis: Redis连接对象
:param request: 当前请求对象
:return: 限流结果,当前请求不适用限流时返回None
"""
current_time_ms = int(time.time() * 1000)
rate_limit_key = self._build_rate_limit_key(request, current_time_ms)
if rate_limit_key is None:
return None
if self.algorithm == 'sliding_window':
allowed, current, remaining, reset_after_ms = await redis.eval(
self._SLIDING_WINDOW_LUA_SCRIPT,
1,
rate_limit_key,
current_time_ms,
self.window_seconds * 1000,
f'{current_time_ms}-{time.time_ns()}-{uuid.uuid4().hex}',
self.limit,
)
else:
window_ms = self.window_seconds * 1000
window_bucket = current_time_ms // window_ms
window_end_ms = (window_bucket + 1) * window_ms
ttl_ms = max(window_end_ms - current_time_ms, 1)
allowed, current, remaining, reset_after_ms = await redis.eval(
self._FIXED_WINDOW_LUA_SCRIPT, 1, rate_limit_key, ttl_ms, self.limit
)
reset_after_ms = max(int(reset_after_ms), 1)
return {
'allowed': bool(int(allowed)),
'current': int(current),
'remaining': int(remaining),
'reset_after_seconds': max((reset_after_ms + 999) // 1000, 1),
'reset_at': (current_time_ms + reset_after_ms + 999) // 1000,
}
def _build_rate_limit_key(
self, request: Request, current_time_ms: int, include_window_bucket: bool = True
) -> str | None:
"""
构建当前请求的限流键
:param request: 当前请求对象
:param current_time_ms: 当前时间戳,单位毫秒
:return: 限流键,当前请求不适用限流时返回None
"""
scope_value = self._get_scope_value(request)
if scope_value is None:
return None
key_material = {
'method': request.method.upper(),
'path': self._get_route_path(request),
'scope': self.scope,
'scope_value': scope_value,
}
key_digest = hashlib.sha256(
json.dumps(
key_material,
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
).encode('utf-8')
).hexdigest()
key_prefix = f'{RedisInitKeyConfig.API_RATE_LIMIT.key}:{self.namespace}:{self.algorithm}:{key_digest}'
if self.algorithm == 'fixed_window' and include_window_bucket:
window_bucket = current_time_ms // (self.window_seconds * 1000)
return f'{key_prefix}:{window_bucket}'
return key_prefix
def _resolve_failed_rate_limit(
self, request: Request, reason: str, error_message: str
) -> dict[str, int | bool] | None:
"""
处理Redis不可用或执行异常时的限流故障策略
:param request: 当前请求对象
:param reason: 降级原因
:param error_message: 错误详情
:return: 限流结果,为None时表示按策略放行
"""
self._log_rate_limit_degrade(request, reason, error_message)
if self.fail_strategy == 'open':
return None
if self.fail_strategy == 'closed':
return self._build_closed_rate_limit_result()
return self._acquire_local_fallback_rate_limit(request)
def _acquire_local_fallback_rate_limit(self, request: Request) -> dict[str, int | bool] | None:
"""
使用进程内内存进行应急兜底限流,仅在Redis异常时启用
该兜底能力仅在当前进程内生效,不保证多 worker / 多实例场景下的
全局一致限流,更适合作为短时故障期间的降级保护。
:param request: 当前请求对象
:return: 限流结果,当前请求不适用限流时返回None
"""
current_time_ms = int(time.time() * 1000)
rate_limit_key = self._build_rate_limit_key(request, current_time_ms, include_window_bucket=False)
if rate_limit_key is None:
return None
window_ms = self.window_seconds * 1000
window_start_ms = current_time_ms - window_ms
local_window = self._LOCAL_FALLBACK_STORE.setdefault(rate_limit_key, deque())
while local_window and local_window[0] <= window_start_ms:
local_window.popleft()
if not local_window:
self._LOCAL_FALLBACK_STORE.pop(rate_limit_key, None)
local_window = deque()
self._LOCAL_FALLBACK_STORE[rate_limit_key] = local_window
current = len(local_window)
if current >= self.limit:
reset_after_ms = max(local_window[0] + window_ms - current_time_ms, 1) if local_window else window_ms
return {
'allowed': False,
'current': current,
'remaining': 0,
'reset_after_seconds': max((reset_after_ms + 999) // 1000, 1),
'reset_at': (current_time_ms + reset_after_ms + 999) // 1000,
}
local_window.append(current_time_ms)
current += 1
reset_after_ms = max(local_window[0] + window_ms - current_time_ms, 1)
return {
'allowed': True,
'current': current,
'remaining': max(self.limit - current, 0),
'reset_after_seconds': max((reset_after_ms + 999) // 1000, 1),
'reset_at': (current_time_ms + reset_after_ms + 999) // 1000,
}
def _build_closed_rate_limit_result(self) -> dict[str, int | bool]:
"""
构建故障关闭策略下的拦截结果
:return: 限流结果
"""
reset_after_seconds = max(self.window_seconds, 1)
current_time_ms = int(time.time() * 1000)
reset_after_ms = reset_after_seconds * 1000
return {
'allowed': False,
'current': self.limit,
'remaining': 0,
'reset_after_seconds': reset_after_seconds,
'reset_at': (current_time_ms + reset_after_ms + 999) // 1000,
}
def _get_route_path(self, request: Request) -> str:
"""
获取当前请求的路由模板路径
:param request: 当前请求对象
:return: 路由模板路径
"""
route = request.scope.get('route')
route_path = getattr(route, 'path', None)
return route_path or request.url.path
def _get_scope_value(self, request: Request) -> str | None:
"""
获取当前请求的限流作用域值
:param request: 当前请求对象
:return: 作用域值,当前请求不适用限流时返回None
"""
if self.scope == 'ip':
return f'ip:{self._get_client_ip(request)}'
current_user_id = self._get_current_user_id()
if current_user_id is not None:
return f'user:{current_user_id}'
if self.scope == 'user':
return None
return f'ip:{self._get_client_ip(request)}'
def _get_current_user_id(self) -> int | None:
"""
获取当前登录用户ID
:return: 用户ID,未登录时返回None
"""
current_user = self._get_current_user()
return current_user.user.user_id if current_user and current_user.user else None
def _get_current_user(self) -> CurrentUserModel | None:
"""
获取当前登录用户
:return: 当前登录用户,未登录时返回None
"""
try:
return RequestContext.get_current_user()
except LoginException:
return None
def _get_client_ip(self, request: Request) -> str:
"""
获取客户端IP地址
:param request: 当前请求对象
:return: 客户端IP地址
"""
return ClientIPUtil.get_client_ip(request)
def _build_rate_limit_headers(self, rate_limit_result: dict[str, int | bool]) -> dict[str, str]:
"""
构建限流响应头
:param rate_limit_result: 限流结果
:return: 限流响应头
"""
headers = {
'X-RateLimit-Limit': str(self.limit),
'X-RateLimit-Remaining': str(rate_limit_result['remaining']),
'X-RateLimit-Reset': str(rate_limit_result['reset_at']),
}
if not rate_limit_result['allowed']:
headers['Retry-After'] = str(rate_limit_result['reset_after_seconds'])
return headers
def _log_rate_limit_hit(self, request: Request, rate_limit_result: dict[str, int | bool]) -> None:
"""
记录限流命中日志
:param request: 当前请求对象
:param rate_limit_result: 限流结果
:return: None
"""
logger.warning(
'接口限流命中: namespace={} preset={} algorithm={} fail_strategy={} method={} path={} scope={} scope_value={} current={} limit={} retry_after={}s',
self.namespace,
self.preset_name,
self.algorithm,
self.fail_strategy,
request.method.upper(),
self._get_route_path(request),
self.scope,
self._get_scope_value(request),
rate_limit_result['current'],
self.limit,
rate_limit_result['reset_after_seconds'],
)
def _log_rate_limit_degrade(self, request: Request, reason: str, error_message: str) -> None:
"""
记录限流组件异常时的降级日志
:param request: 当前请求对象
:param reason: 降级原因
:param error_message: 错误详情
:return: None
"""
log_message = (
'接口限流降级: namespace={} preset={} algorithm={} fail_strategy={} '
'method={} path={} scope={} reason={} error={}'
)
log_args: list[str] = [
self.namespace,
self.preset_name,
self.algorithm,
self.fail_strategy,
request.method.upper(),
self._get_route_path(request),
self.scope,
reason,
error_message,
]
if self.fail_strategy == 'local_fallback':
log_message += ' local_fallback_scope={}'
log_args.append('process_local_only')
logger.warning(log_message, *log_args)
def _log_rate_limit_bypass(self, request: Request, bypass_role: str) -> None:
"""
记录角色豁免限流日志
:param request: 当前请求对象
:param bypass_role: 命中的角色标识
:return: None
"""
logger.info(
'接口限流绕过: namespace={} preset={} method={} path={} scope={} bypass_role={}',
self.namespace,
self.preset_name,
request.method.upper(),
self._get_route_path(request),
self.scope,
bypass_role,
)