初始提交
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from fastapi import Depends, Request, params
|
||||
from sqlalchemy import ColumnElement, func, or_, select
|
||||
|
||||
from common.context import RequestContext
|
||||
from config.database import Base
|
||||
from module_admin.entity.do.dept_do import SysDept
|
||||
from module_admin.entity.do.role_do import SysRoleDept
|
||||
from utils.dependency_util import DependencyUtil
|
||||
|
||||
|
||||
class GetDataScope:
|
||||
"""
|
||||
获取当前用户数据权限对应的查询sql语句
|
||||
"""
|
||||
|
||||
DATA_SCOPE_ALL = '1'
|
||||
DATA_SCOPE_CUSTOM = '2'
|
||||
DATA_SCOPE_DEPT = '3'
|
||||
DATA_SCOPE_DEPT_AND_CHILD = '4'
|
||||
DATA_SCOPE_SELF = '5'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
query_alias: Base,
|
||||
user_alias: str | None = 'user_id',
|
||||
dept_alias: str | None = 'dept_id',
|
||||
) -> None:
|
||||
"""
|
||||
获取当前用户数据权限对应的查询sql语句
|
||||
|
||||
:param query_alias: 所要查询表对应的sqlalchemy模型类,不可为空
|
||||
:param user_alias: 用户id字段别名,默认为'user_id'
|
||||
:param dept_alias: 部门id字段别名,默认为'dept_id'
|
||||
"""
|
||||
self.query_alias = query_alias
|
||||
self.user_alias = user_alias
|
||||
self.dept_alias = dept_alias
|
||||
|
||||
def __call__(self, request: Request) -> ColumnElement:
|
||||
DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用GetDataScope依赖项')
|
||||
current_user = RequestContext.get_current_user()
|
||||
user_id = current_user.user.user_id
|
||||
dept_id = current_user.user.dept_id
|
||||
custom_data_scope_role_id_list = [
|
||||
item.role_id for item in current_user.user.role if item.data_scope == self.DATA_SCOPE_CUSTOM
|
||||
]
|
||||
param_sql_list = []
|
||||
for role in current_user.user.role:
|
||||
if current_user.user.admin or role.data_scope == self.DATA_SCOPE_ALL:
|
||||
param_sql_list = [True]
|
||||
break
|
||||
if role.data_scope == self.DATA_SCOPE_CUSTOM:
|
||||
if len(custom_data_scope_role_id_list) > 1:
|
||||
param_sql_list.append(
|
||||
getattr(self.query_alias, self.dept_alias).in_(
|
||||
select(SysRoleDept.dept_id).where(SysRoleDept.role_id.in_(custom_data_scope_role_id_list))
|
||||
)
|
||||
if hasattr(self.query_alias, self.dept_alias)
|
||||
else False
|
||||
)
|
||||
else:
|
||||
param_sql_list.append(
|
||||
getattr(self.query_alias, self.dept_alias).in_(
|
||||
select(SysRoleDept.dept_id).where(SysRoleDept.role_id == role.role_id)
|
||||
)
|
||||
if hasattr(self.query_alias, self.dept_alias)
|
||||
else False
|
||||
)
|
||||
elif role.data_scope == self.DATA_SCOPE_DEPT:
|
||||
param_sql_list.append(
|
||||
getattr(self.query_alias, self.dept_alias) == dept_id
|
||||
if hasattr(self.query_alias, self.dept_alias)
|
||||
else False
|
||||
)
|
||||
elif role.data_scope == self.DATA_SCOPE_DEPT_AND_CHILD:
|
||||
param_sql_list.append(
|
||||
getattr(self.query_alias, self.dept_alias).in_(
|
||||
select(SysDept.dept_id).where(
|
||||
or_(SysDept.dept_id == dept_id, func.find_in_set(dept_id, SysDept.ancestors))
|
||||
)
|
||||
)
|
||||
if hasattr(self.query_alias, self.dept_alias)
|
||||
else False
|
||||
)
|
||||
elif role.data_scope == self.DATA_SCOPE_SELF:
|
||||
param_sql_list.append(
|
||||
getattr(self.query_alias, self.user_alias) == user_id
|
||||
if hasattr(self.query_alias, self.user_alias)
|
||||
else False
|
||||
)
|
||||
else:
|
||||
param_sql_list.append(False)
|
||||
param_sql_list = list(dict.fromkeys(param_sql_list))
|
||||
param_sql = or_(*param_sql_list)
|
||||
|
||||
return param_sql
|
||||
|
||||
|
||||
def DataScopeDependency( # noqa: N802
|
||||
query_alias: Base,
|
||||
user_alias: str | None = 'user_id',
|
||||
dept_alias: str | None = 'dept_id',
|
||||
) -> params.Depends:
|
||||
"""
|
||||
当前用户数据权限依赖
|
||||
|
||||
:param query_alias: 所要查询表对应的sqlalchemy模型类,不可为空
|
||||
:param user_alias: 用户id字段别名,默认为'user_id'
|
||||
:param dept_alias: 部门id字段别名,默认为'dept_id'
|
||||
:return: 当前用户数据权限依赖
|
||||
"""
|
||||
return Depends(GetDataScope(query_alias, user_alias, dept_alias))
|
||||
@@ -0,0 +1,12 @@
|
||||
from fastapi import Depends, params
|
||||
|
||||
from config.get_db import get_db
|
||||
|
||||
|
||||
def DBSessionDependency() -> params.Depends: # noqa: N802
|
||||
"""
|
||||
数据库会话依赖
|
||||
|
||||
:return: 数据库会话依赖
|
||||
"""
|
||||
return Depends(get_db)
|
||||
@@ -0,0 +1,94 @@
|
||||
from fastapi import Depends, Request, params
|
||||
|
||||
from common.context import RequestContext
|
||||
from exceptions.exception import PermissionException
|
||||
from utils.dependency_util import DependencyUtil
|
||||
|
||||
|
||||
class CheckUserInterfaceAuth:
|
||||
"""
|
||||
校验当前用户是否具有相应的接口权限
|
||||
"""
|
||||
|
||||
def __init__(self, perm: str | list, is_strict: bool = False) -> None:
|
||||
"""
|
||||
校验当前用户是否具有相应的接口权限
|
||||
|
||||
:param perm: 权限标识
|
||||
:param is_strict: 当传入的权限标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个权限标识,所有的校验结果都需要为True才会通过
|
||||
"""
|
||||
self.perm = perm
|
||||
self.is_strict = is_strict
|
||||
|
||||
def __call__(self, request: Request) -> bool:
|
||||
DependencyUtil.check_exclude_routes(
|
||||
request, err_msg='当前路由不在认证规则内,不可使用CheckUserInterfaceAuth依赖项'
|
||||
)
|
||||
current_user = RequestContext.get_current_user()
|
||||
user_auth_list = current_user.permissions
|
||||
if '*:*:*' in user_auth_list:
|
||||
return True
|
||||
if isinstance(self.perm, str) and self.perm in user_auth_list:
|
||||
return True
|
||||
if isinstance(self.perm, list):
|
||||
if self.is_strict:
|
||||
if all(perm_str in user_auth_list for perm_str in self.perm):
|
||||
return True
|
||||
elif any(perm_str in user_auth_list for perm_str in self.perm):
|
||||
return True
|
||||
raise PermissionException(data='', message='该用户无此接口权限')
|
||||
|
||||
|
||||
class CheckRoleInterfaceAuth:
|
||||
"""
|
||||
根据角色校验当前用户是否具有相应的接口权限
|
||||
"""
|
||||
|
||||
def __init__(self, role_key: str | list, is_strict: bool = False) -> None:
|
||||
"""
|
||||
根据角色校验当前用户是否具有相应的接口权限
|
||||
|
||||
:param role_key: 角色标识
|
||||
:param is_strict: 当传入的角色标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个角色标识,所有的校验结果都需要为True才会通过
|
||||
"""
|
||||
self.role_key = role_key
|
||||
self.is_strict = is_strict
|
||||
|
||||
def __call__(self, request: Request) -> bool:
|
||||
DependencyUtil.check_exclude_routes(
|
||||
request, err_msg='当前路由不在认证规则内,不可使用CheckRoleInterfaceAuth依赖项'
|
||||
)
|
||||
current_user = RequestContext.get_current_user()
|
||||
user_role_list = current_user.user.role
|
||||
user_role_key_list = [role.role_key for role in user_role_list]
|
||||
if isinstance(self.role_key, str) and self.role_key in user_role_key_list:
|
||||
return True
|
||||
if isinstance(self.role_key, list):
|
||||
if self.is_strict:
|
||||
if all(role_key_str in user_role_key_list for role_key_str in self.role_key):
|
||||
return True
|
||||
elif any(role_key_str in user_role_key_list for role_key_str in self.role_key):
|
||||
return True
|
||||
raise PermissionException(data='', message='该用户无此接口权限')
|
||||
|
||||
|
||||
def UserInterfaceAuthDependency(perm: str | list, is_strict: bool = False) -> params.Depends: # noqa: N802
|
||||
"""
|
||||
根据权限标识校验当前用户接口权限依赖
|
||||
|
||||
:param perm: 权限标识
|
||||
:param is_strict: 当传入的权限标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个权限标识,所有的校验结果都需要为True才会通过
|
||||
:return: 根据权限标识校验当前用户接口权限依赖
|
||||
"""
|
||||
return Depends(CheckUserInterfaceAuth(perm, is_strict))
|
||||
|
||||
|
||||
def RoleInterfaceAuthDependency(role_key: str | list, is_strict: bool = False) -> params.Depends: # noqa: N802
|
||||
"""
|
||||
根据角色校验当前用户接口权限依赖
|
||||
|
||||
:param role_key: 角色标识
|
||||
:param is_strict: 当传入的角色标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个角色标识,所有的校验结果都需要为True才会通过
|
||||
:return: 根据角色校验当前用户接口权限依赖
|
||||
"""
|
||||
return Depends(CheckRoleInterfaceAuth(role_key, is_strict))
|
||||
@@ -0,0 +1,146 @@
|
||||
import re
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from fastapi import Depends, Request, params
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.context import RequestContext
|
||||
from config.env import AppConfig
|
||||
from config.get_db import get_db
|
||||
from exceptions.exception import AuthException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_admin.service.login_service import LoginService
|
||||
|
||||
|
||||
# 定义排除路由的字典结构
|
||||
class ExcludeRoute(TypedDict, total=False):
|
||||
"""
|
||||
排除路由的字典结构
|
||||
|
||||
:param path: 路由路径(必填)
|
||||
:param methods: HTTP方法列表,空列表表示所有方法(可选,默认为[])
|
||||
:param ignore_paths: 需要忽略的特定路径列表,即使匹配通配符也不排除(可选,默认为[])
|
||||
"""
|
||||
|
||||
path: str
|
||||
methods: list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']]
|
||||
ignore_paths: list[str]
|
||||
|
||||
|
||||
# 创建OAuth2PasswordBearer对象
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/login')
|
||||
|
||||
|
||||
class PreAuth:
|
||||
"""
|
||||
登录认证前置校验依赖类
|
||||
"""
|
||||
|
||||
def __init__(self, exclude_routes: list[ExcludeRoute] | None = None) -> None:
|
||||
"""
|
||||
初始化登录认证前置校验依赖
|
||||
|
||||
:param exclude_routes: 需要排除的路由列表,格式为:
|
||||
[{'path': '/path1', 'methods': ['GET', 'POST']}, {'path': '/path2/{param}', 'methods': ['GET']}]
|
||||
methods 可以是字符串或列表,空列表表示所有方法
|
||||
"""
|
||||
self.exclude_routes = exclude_routes or []
|
||||
# 编译排除路径为正则表达式模式,并存储方法信息
|
||||
self.exclude_patterns = []
|
||||
|
||||
for route in self.exclude_routes:
|
||||
# 使用TypedDict,确保路由字典包含path字段
|
||||
path = route.get('path', '')
|
||||
methods = route.get('methods', [])
|
||||
ignore_paths = route.get('ignore_paths', [])
|
||||
|
||||
# 编译路径为正则表达式
|
||||
pattern = self._compile_path_pattern(path)
|
||||
# 存储编译后的模式和方法信息
|
||||
self.exclude_patterns.append(
|
||||
{
|
||||
'pattern': pattern,
|
||||
'methods': [method.upper() for method in methods],
|
||||
'original_path': path,
|
||||
'ignore_paths': ignore_paths,
|
||||
}
|
||||
)
|
||||
|
||||
def _compile_path_pattern(self, path: str) -> re.Pattern:
|
||||
"""
|
||||
将FastAPI路径转换为正则表达式模式
|
||||
|
||||
:param path: FastAPI路径(如 /configKey/{config_key})
|
||||
:return: 编译后的正则表达式模式
|
||||
"""
|
||||
# 将FastAPI路径参数转换为正则表达式
|
||||
# 例如:/configKey/{config_key} -> /configKey/[^/]+
|
||||
pattern_str = re.sub(r'\{[^}]+\}', r'[^/]+', path)
|
||||
# 添加开始和结束锚点,确保精确匹配
|
||||
return re.compile(f'^{pattern_str}$')
|
||||
|
||||
async def __call__(self, request: Request, db: AsyncSession = Depends(get_db)) -> CurrentUserModel | None:
|
||||
"""
|
||||
执行登录认证校验
|
||||
|
||||
:param request: 当前请求对象
|
||||
:param db: 数据库会话
|
||||
:return: 当前用户信息
|
||||
"""
|
||||
# 获取当前请求路径和方法
|
||||
path = request.url.path
|
||||
method = request.method.upper()
|
||||
|
||||
# 从配置中获取APP_ROOT_PATH
|
||||
app_root_path = AppConfig.app_root_path
|
||||
|
||||
# 去掉APP_ROOT_PATH前缀
|
||||
if app_root_path and path.startswith(app_root_path):
|
||||
path = path[len(app_root_path) :]
|
||||
|
||||
# 设置上下文变量
|
||||
RequestContext.set_current_exclude_patterns(self.exclude_patterns)
|
||||
|
||||
# 检查路径和方法是否匹配排除模式
|
||||
for item in self.exclude_patterns:
|
||||
pattern = item['pattern']
|
||||
exclude_methods = item['methods']
|
||||
ignore_paths = item['ignore_paths']
|
||||
|
||||
# 检查当前路径是否在忽略列表中
|
||||
if path in ignore_paths:
|
||||
continue
|
||||
|
||||
# 检查路径是否匹配,并且methods为空列表(匹配所有方法)或者当前方法在允许列表中
|
||||
if pattern.match(path) and (not exclude_methods or method in exclude_methods):
|
||||
# 跳过认证
|
||||
return None
|
||||
|
||||
# 否则执行正常认证
|
||||
token = request.headers.get('Authorization')
|
||||
if not token:
|
||||
raise AuthException(data='', message='用户未登录,请先完成登录')
|
||||
current_user = await LoginService.get_current_user(request, token, db)
|
||||
return current_user
|
||||
|
||||
|
||||
def PreAuthDependency(exclude_routes: list[ExcludeRoute] | None = None) -> params.Depends: # noqa: N802
|
||||
"""
|
||||
登录认证前置校验依赖
|
||||
|
||||
:param exclude_routes: 需要排除的路由列表,格式为:
|
||||
[{'path': '/path1', 'methods': ['GET', 'POST']}, {'path': '/path2/{param}', 'methods': ['GET']}]
|
||||
methods 可以是字符串或列表,空列表表示所有方法
|
||||
:return: 登录认证前置校验依赖
|
||||
"""
|
||||
return Depends(PreAuth(exclude_routes))
|
||||
|
||||
|
||||
def CurrentUserDependency() -> params.Depends: # noqa: N802
|
||||
"""
|
||||
当前登录用户信息依赖
|
||||
|
||||
:return: 当前登录用户信息依赖
|
||||
"""
|
||||
return Depends(LoginService.get_current_user)
|
||||
@@ -0,0 +1,856 @@
|
||||
from config.env import DataBaseConfig
|
||||
|
||||
|
||||
class CommonConstant:
|
||||
"""
|
||||
常用常量
|
||||
|
||||
PASSWORD_ERROR_COUNT: 密码错误次数
|
||||
WWW: www主域
|
||||
HTTP: http请求
|
||||
HTTPS: https请求
|
||||
LOOKUP_RMI: RMI远程方法调用
|
||||
LOOKUP_LDAP: LDAP远程方法调用
|
||||
LOOKUP_LDAPS: LDAPS远程方法调用
|
||||
YES: 是否为系统默认(是)
|
||||
NO: 是否为系统默认(否)
|
||||
DEPT_NORMAL: 部门正常状态
|
||||
DEPT_DISABLE: 部门停用状态
|
||||
UNIQUE: 校验是否唯一的返回标识(是)
|
||||
NOT_UNIQUE: 校验是否唯一的返回标识(否)
|
||||
"""
|
||||
|
||||
PASSWORD_ERROR_COUNT = 5
|
||||
WWW = 'www.'
|
||||
HTTP = 'http://'
|
||||
HTTPS = 'https://'
|
||||
LOOKUP_RMI = 'rmi:'
|
||||
LOOKUP_LDAP = 'ldap:'
|
||||
LOOKUP_LDAPS = 'ldaps:'
|
||||
YES = 'Y'
|
||||
NO = 'N'
|
||||
DEPT_NORMAL = '0'
|
||||
DEPT_DISABLE = '1'
|
||||
UNIQUE = True
|
||||
NOT_UNIQUE = False
|
||||
|
||||
|
||||
class HttpStatusConstant:
|
||||
"""
|
||||
返回状态码
|
||||
|
||||
SUCCESS: 操作成功
|
||||
CREATED: 对象创建成功
|
||||
ACCEPTED: 请求已经被接受
|
||||
NO_CONTENT: 操作已经执行成功,但是没有返回数据
|
||||
MOVED_PERM: 资源已被移除
|
||||
SEE_OTHER: 重定向
|
||||
NOT_MODIFIED: 资源没有被修改
|
||||
BAD_REQUEST: 参数列表错误(缺少,格式不匹配)
|
||||
UNAUTHORIZED: 未授权
|
||||
FORBIDDEN: 访问受限,授权过期
|
||||
NOT_FOUND: 资源,服务未找到
|
||||
BAD_METHOD: 不允许的http方法
|
||||
CONFLICT: 资源冲突,或者资源被锁
|
||||
UNSUPPORTED_TYPE: 不支持的数据,媒体类型
|
||||
TOO_MANY_REQUESTS: 请求过于频繁
|
||||
ERROR: 系统内部错误
|
||||
NOT_IMPLEMENTED: 接口未实现
|
||||
WARN: 系统警告消息
|
||||
"""
|
||||
|
||||
SUCCESS = 200
|
||||
CREATED = 201
|
||||
ACCEPTED = 202
|
||||
NO_CONTENT = 204
|
||||
MOVED_PERM = 301
|
||||
SEE_OTHER = 303
|
||||
NOT_MODIFIED = 304
|
||||
BAD_REQUEST = 400
|
||||
UNAUTHORIZED = 401
|
||||
FORBIDDEN = 403
|
||||
NOT_FOUND = 404
|
||||
BAD_METHOD = 405
|
||||
CONFLICT = 409
|
||||
UNSUPPORTED_TYPE = 415
|
||||
TOO_MANY_REQUESTS = 429
|
||||
ERROR = 500
|
||||
NOT_IMPLEMENTED = 501
|
||||
WARN = 601
|
||||
|
||||
|
||||
class JobConstant:
|
||||
"""
|
||||
定时任务常量
|
||||
|
||||
JOB_ERROR_LIST: 定时任务禁止调用模块及违规字符串列表
|
||||
JOB_WHITE_LIST: 定时任务允许调用模块列表
|
||||
"""
|
||||
|
||||
JOB_ERROR_LIST = [
|
||||
'app',
|
||||
'config',
|
||||
'exceptions',
|
||||
'import ',
|
||||
'middlewares',
|
||||
'module_admin',
|
||||
'open(',
|
||||
'os.',
|
||||
'server',
|
||||
'sub_applications',
|
||||
'subprocess.',
|
||||
'sys.',
|
||||
'utils',
|
||||
'while ',
|
||||
'__import__',
|
||||
'"',
|
||||
"'",
|
||||
',',
|
||||
'?',
|
||||
':',
|
||||
';',
|
||||
'/',
|
||||
'|',
|
||||
'+',
|
||||
'-',
|
||||
'=',
|
||||
'~',
|
||||
'!',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'^',
|
||||
'&',
|
||||
'*',
|
||||
'<',
|
||||
'>',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
' ',
|
||||
]
|
||||
JOB_WHITE_LIST = ['module_task']
|
||||
|
||||
|
||||
class LockConstant:
|
||||
"""
|
||||
分布式锁常量
|
||||
"""
|
||||
|
||||
APP_STARTUP_LOCK_KEY = 'app:startup:lock'
|
||||
LOCK_EXPIRE_SECONDS = 60
|
||||
LOCK_RENEWAL_INTERVAL = 20
|
||||
|
||||
|
||||
class ApiNamespace:
|
||||
"""
|
||||
接口注解通用命名空间常量
|
||||
|
||||
这一组常量统一提供给 `ApiCache`、`ApiCacheEvict`、`ApiRateLimit`
|
||||
等接口注解使用,用同一套“模块:功能”命名规则收敛缓存和限流场景。
|
||||
|
||||
LOGIN: 登录接口命名空间
|
||||
REGISTER: 注册接口命名空间
|
||||
LOGIN_USER_INFO: 登录用户信息接口命名空间
|
||||
LOGIN_USER_ROUTERS: 登录用户路由接口命名空间
|
||||
CAPTCHA_IMAGE: 图片验证码接口命名空间
|
||||
COMMON_UPLOAD: 通用上传接口命名空间
|
||||
TRANSPORT_CRYPTO_PUBLIC_KEY: 传输层加密公钥接口命名空间
|
||||
TRANSPORT_CRYPTO_FRONTEND_CONFIG: 传输层加密前端配置接口命名空间
|
||||
|
||||
MONITOR_SERVER_INFO: 服务监控信息接口命名空间
|
||||
MONITOR_CACHE_CLEAR_NAME: 缓存名称清理接口命名空间
|
||||
MONITOR_CACHE_CLEAR_KEY: 缓存键清理接口命名空间
|
||||
MONITOR_CACHE_CLEAR_ALL: 缓存全量清理接口命名空间
|
||||
MONITOR_ONLINE_FORCE_LOGOUT: 在线用户强退接口命名空间
|
||||
MONITOR_OPERLOG_CLEAN: 操作日志清空接口命名空间
|
||||
MONITOR_OPERLOG_DELETE: 操作日志删除接口命名空间
|
||||
MONITOR_OPERLOG_EXPORT: 操作日志导出接口命名空间
|
||||
MONITOR_LOGININFO_CLEAN: 登录日志清空接口命名空间
|
||||
MONITOR_LOGININFO_DELETE: 登录日志删除接口命名空间
|
||||
MONITOR_LOGININFO_UNLOCK: 账户解锁接口命名空间
|
||||
MONITOR_LOGININFO_EXPORT: 登录日志导出接口命名空间
|
||||
MONITOR_JOB_LIST: 定时任务分页列表接口命名空间
|
||||
MONITOR_JOB_DETAIL: 定时任务详情接口命名空间
|
||||
MONITOR_JOB_RUN: 定时任务执行接口命名空间
|
||||
MONITOR_JOB_DELETE: 定时任务删除接口命名空间
|
||||
MONITOR_JOB_EXPORT: 定时任务导出接口命名空间
|
||||
MONITOR_JOB_LOG_CLEAN: 定时任务日志清空接口命名空间
|
||||
MONITOR_JOB_LOG_DELETE: 定时任务日志删除接口命名空间
|
||||
MONITOR_JOB_LOG_EXPORT: 定时任务日志导出接口命名空间
|
||||
|
||||
SYSTEM_DEPT_EDIT_TREE: 部门编辑树接口命名空间
|
||||
SYSTEM_DEPT_LIST: 部门列表接口命名空间
|
||||
SYSTEM_DEPT_DETAIL: 部门详情接口命名空间
|
||||
SYSTEM_CONFIG_LIST: 参数配置列表接口命名空间
|
||||
SYSTEM_CONFIG_DETAIL: 参数配置详情接口命名空间
|
||||
SYSTEM_CONFIG_REFRESH_CACHE: 参数缓存刷新接口命名空间
|
||||
SYSTEM_CONFIG_EXPORT: 参数导出接口命名空间
|
||||
SYSTEM_DICT_TYPE_LIST: 字典类型列表接口命名空间
|
||||
SYSTEM_DICT_TYPE_OPTIONS: 字典类型选项接口命名空间
|
||||
SYSTEM_DICT_TYPE_DETAIL: 字典类型详情接口命名空间
|
||||
SYSTEM_DICT_REFRESH_CACHE: 字典缓存刷新接口命名空间
|
||||
SYSTEM_DICT_TYPE_EXPORT: 字典类型导出接口命名空间
|
||||
SYSTEM_DICT_DATA_LIST: 字典数据列表接口命名空间
|
||||
SYSTEM_DICT_DATA_DETAIL: 字典数据详情接口命名空间
|
||||
SYSTEM_DICT_DATA_EXPORT: 字典数据导出接口命名空间
|
||||
SYSTEM_MENU_TREE: 菜单树接口命名空间
|
||||
SYSTEM_MENU_ROLE_TREE: 角色菜单树接口命名空间
|
||||
SYSTEM_MENU_LIST: 菜单分页列表接口命名空间
|
||||
SYSTEM_MENU_DETAIL: 菜单详情接口命名空间
|
||||
SYSTEM_NOTICE_LIST: 通知公告列表接口命名空间
|
||||
SYSTEM_NOTICE_DETAIL: 通知公告详情接口命名空间
|
||||
SYSTEM_POST_LIST: 岗位列表接口命名空间
|
||||
SYSTEM_POST_DETAIL: 岗位详情接口命名空间
|
||||
SYSTEM_POST_EXPORT: 岗位导出接口命名空间
|
||||
SYSTEM_ROLE_DEPT_TREE: 角色部门树接口命名空间
|
||||
SYSTEM_ROLE_LIST: 角色列表接口命名空间
|
||||
SYSTEM_ROLE_DETAIL: 角色详情接口命名空间
|
||||
SYSTEM_ROLE_ALLOCATED_USER_LIST: 已分配用户角色列表接口命名空间
|
||||
SYSTEM_ROLE_UNALLOCATED_USER_LIST: 未分配用户角色列表接口命名空间
|
||||
SYSTEM_ROLE_EXPORT: 角色导出接口命名空间
|
||||
SYSTEM_ROLE_AUTH_USER_SELECT_ALL: 角色批量分配用户接口命名空间
|
||||
SYSTEM_ROLE_AUTH_USER_CANCEL: 角色取消分配用户接口命名空间
|
||||
SYSTEM_ROLE_AUTH_USER_CANCEL_ALL: 角色批量取消分配用户接口命名空间
|
||||
SYSTEM_USER_DEPT_TREE: 用户部门树接口命名空间
|
||||
SYSTEM_USER_LIST: 用户列表接口命名空间
|
||||
SYSTEM_USER_PROFILE: 用户个人信息接口命名空间
|
||||
SYSTEM_USER_DETAIL: 用户详情接口命名空间
|
||||
SYSTEM_USER_PROFILE_AVATAR: 用户头像上传接口命名空间
|
||||
SYSTEM_USER_IMPORT: 用户导入接口命名空间
|
||||
SYSTEM_USER_EXPORT: 用户导出接口命名空间
|
||||
|
||||
AI_MODEL_LIST: AI模型列表接口命名空间
|
||||
AI_MODEL_ALL: AI模型全量列表接口命名空间
|
||||
AI_MODEL_DETAIL: AI模型详情接口命名空间
|
||||
AI_CHAT_CONFIG: AI对话配置接口命名空间
|
||||
AI_CHAT_SEND: AI对话发送接口命名空间
|
||||
AI_CHAT_CANCEL: AI对话取消接口命名空间
|
||||
|
||||
TOOL_GEN_LIST: 代码生成列表接口命名空间
|
||||
TOOL_GEN_DB_LIST: 代码生成数据源列表接口命名空间
|
||||
TOOL_GEN_DETAIL: 代码生成详情接口命名空间
|
||||
TOOL_GEN_PREVIEW: 代码生成预览接口命名空间
|
||||
TOOL_GEN_IMPORT_TABLE: 代码生成导入表接口命名空间
|
||||
TOOL_GEN_CREATE_TABLE: 代码生成建表接口命名空间
|
||||
TOOL_GEN_BATCH_GEN_CODE: 代码生成批量下载接口命名空间
|
||||
TOOL_GEN_GEN_CODE_LOCAL: 代码生成到本地接口命名空间
|
||||
TOOL_GEN_SYNC_DB: 代码生成同步库结构接口命名空间
|
||||
"""
|
||||
|
||||
LOGIN = 'login'
|
||||
REGISTER = 'register'
|
||||
LOGIN_USER_INFO = 'login:user:info'
|
||||
LOGIN_USER_ROUTERS = 'login:user:routers'
|
||||
CAPTCHA_IMAGE = 'captcha:image'
|
||||
COMMON_UPLOAD = 'common:upload'
|
||||
TRANSPORT_CRYPTO_PUBLIC_KEY = 'transport-crypto:public-key'
|
||||
TRANSPORT_CRYPTO_FRONTEND_CONFIG = 'transport-crypto:frontend-config'
|
||||
|
||||
MONITOR_SERVER_INFO = 'monitor:server:info'
|
||||
MONITOR_CACHE_CLEAR_NAME = 'monitor:cache:clear-name'
|
||||
MONITOR_CACHE_CLEAR_KEY = 'monitor:cache:clear-key'
|
||||
MONITOR_CACHE_CLEAR_ALL = 'monitor:cache:clear-all'
|
||||
MONITOR_ONLINE_FORCE_LOGOUT = 'monitor:online:force-logout'
|
||||
MONITOR_OPERLOG_CLEAN = 'monitor:operlog:clean'
|
||||
MONITOR_OPERLOG_DELETE = 'monitor:operlog:delete'
|
||||
MONITOR_OPERLOG_EXPORT = 'monitor:operlog:export'
|
||||
MONITOR_LOGININFO_CLEAN = 'monitor:logininfor:clean'
|
||||
MONITOR_LOGININFO_DELETE = 'monitor:logininfor:delete'
|
||||
MONITOR_LOGININFO_UNLOCK = 'monitor:logininfor:unlock'
|
||||
MONITOR_LOGININFO_EXPORT = 'monitor:logininfor:export'
|
||||
MONITOR_JOB_LIST = 'monitor:job:list'
|
||||
MONITOR_JOB_DETAIL = 'monitor:job:detail'
|
||||
MONITOR_JOB_RUN = 'monitor:job:run'
|
||||
MONITOR_JOB_DELETE = 'monitor:job:delete'
|
||||
MONITOR_JOB_EXPORT = 'monitor:job:export'
|
||||
MONITOR_JOB_LOG_CLEAN = 'monitor:job-log:clean'
|
||||
MONITOR_JOB_LOG_DELETE = 'monitor:job-log:delete'
|
||||
MONITOR_JOB_LOG_EXPORT = 'monitor:job-log:export'
|
||||
|
||||
SYSTEM_DEPT_EDIT_TREE = 'system:dept:edit-tree'
|
||||
SYSTEM_DEPT_LIST = 'system:dept:list'
|
||||
SYSTEM_DEPT_DETAIL = 'system:dept:detail'
|
||||
|
||||
SYSTEM_CONFIG_LIST = 'system:config:list'
|
||||
SYSTEM_CONFIG_DETAIL = 'system:config:detail'
|
||||
SYSTEM_CONFIG_REFRESH_CACHE = 'system:config:refresh-cache'
|
||||
SYSTEM_CONFIG_EXPORT = 'system:config:export'
|
||||
|
||||
SYSTEM_DICT_TYPE_LIST = 'system:dict:type-list'
|
||||
SYSTEM_DICT_TYPE_OPTIONS = 'system:dict:type-options'
|
||||
SYSTEM_DICT_TYPE_DETAIL = 'system:dict:type-detail'
|
||||
SYSTEM_DICT_REFRESH_CACHE = 'system:dict:refresh-cache'
|
||||
SYSTEM_DICT_TYPE_EXPORT = 'system:dict:type-export'
|
||||
SYSTEM_DICT_DATA_LIST = 'system:dict:data-list'
|
||||
SYSTEM_DICT_DATA_DETAIL = 'system:dict:data-detail'
|
||||
SYSTEM_DICT_DATA_EXPORT = 'system:dict:data-export'
|
||||
|
||||
SYSTEM_MENU_TREE = 'system:menu:tree'
|
||||
SYSTEM_MENU_ROLE_TREE = 'system:menu:role-tree'
|
||||
SYSTEM_MENU_LIST = 'system:menu:list'
|
||||
SYSTEM_MENU_DETAIL = 'system:menu:detail'
|
||||
|
||||
SYSTEM_NOTICE_LIST = 'system:notice:list'
|
||||
SYSTEM_NOTICE_DETAIL = 'system:notice:detail'
|
||||
|
||||
SYSTEM_POST_LIST = 'system:post:list'
|
||||
SYSTEM_POST_DETAIL = 'system:post:detail'
|
||||
SYSTEM_POST_EXPORT = 'system:post:export'
|
||||
|
||||
SYSTEM_ROLE_DEPT_TREE = 'system:role:dept-tree'
|
||||
SYSTEM_ROLE_LIST = 'system:role:list'
|
||||
SYSTEM_ROLE_DETAIL = 'system:role:detail'
|
||||
SYSTEM_ROLE_ALLOCATED_USER_LIST = 'system:role:allocated-user-list'
|
||||
SYSTEM_ROLE_UNALLOCATED_USER_LIST = 'system:role:unallocated-user-list'
|
||||
SYSTEM_ROLE_EXPORT = 'system:role:export'
|
||||
SYSTEM_ROLE_AUTH_USER_SELECT_ALL = 'system:role:auth-user-select-all'
|
||||
SYSTEM_ROLE_AUTH_USER_CANCEL = 'system:role:auth-user-cancel'
|
||||
SYSTEM_ROLE_AUTH_USER_CANCEL_ALL = 'system:role:auth-user-cancel-all'
|
||||
|
||||
SYSTEM_USER_DEPT_TREE = 'system:user:dept-tree'
|
||||
SYSTEM_USER_LIST = 'system:user:list'
|
||||
SYSTEM_USER_PROFILE = 'system:user:profile'
|
||||
SYSTEM_USER_DETAIL = 'system:user:detail'
|
||||
SYSTEM_USER_PROFILE_AVATAR = 'system:user:profile-avatar'
|
||||
SYSTEM_USER_IMPORT = 'system:user:import'
|
||||
SYSTEM_USER_EXPORT = 'system:user:export'
|
||||
|
||||
AI_MODEL_LIST = 'ai:model:list'
|
||||
AI_MODEL_ALL = 'ai:model:all'
|
||||
AI_MODEL_DETAIL = 'ai:model:detail'
|
||||
AI_CHAT_CONFIG = 'ai:chat:config'
|
||||
AI_CHAT_SEND = 'ai:chat:send'
|
||||
AI_CHAT_CANCEL = 'ai:chat:cancel'
|
||||
|
||||
TOOL_GEN_LIST = 'tool:gen:list'
|
||||
TOOL_GEN_DB_LIST = 'tool:gen:db-list'
|
||||
TOOL_GEN_DETAIL = 'tool:gen:detail'
|
||||
TOOL_GEN_PREVIEW = 'tool:gen:preview'
|
||||
|
||||
TOOL_GEN_IMPORT_TABLE = 'tool:gen:import-table'
|
||||
TOOL_GEN_CREATE_TABLE = 'tool:gen:create-table'
|
||||
TOOL_GEN_BATCH_GEN_CODE = 'tool:gen:batch-gen-code'
|
||||
TOOL_GEN_GEN_CODE_LOCAL = 'tool:gen:gen-code-local'
|
||||
TOOL_GEN_SYNC_DB = 'tool:gen:sync-db'
|
||||
|
||||
|
||||
class ApiGroup:
|
||||
"""
|
||||
接口命名空间分组常量
|
||||
|
||||
当前主要用于 `ApiCacheEvict` 批量清理关联命名空间,分组成员均来自
|
||||
`ApiNamespace`,按业务写操作影响范围收敛管理。
|
||||
|
||||
PERMISSION_MUTATION: 权限与菜单视图关联命名空间分组
|
||||
DATA_SCOPE_MUTATION: 数据范围相关视图关联命名空间分组
|
||||
MENU_MUTATION: 菜单写操作关联命名空间分组
|
||||
JOB_MUTATION: 定时任务写操作关联命名空间分组
|
||||
POST_MUTATION: 岗位写操作关联命名空间分组
|
||||
NOTICE_MUTATION: 通知公告写操作关联命名空间分组
|
||||
ROLE_ENTITY_MUTATION: 角色实体信息变更关联命名空间分组
|
||||
ROLE_PERMISSION_MUTATION: 角色权限变更关联命名空间分组
|
||||
ROLE_MUTATION: 角色通用写操作关联命名空间组合分组
|
||||
USER_ENTITY_MUTATION: 用户实体信息变更关联命名空间分组
|
||||
USER_PERMISSION_MUTATION: 用户权限变更关联命名空间分组
|
||||
USER_INFO_MUTATION: 用户资料与安全相关写操作关联命名空间分组
|
||||
LOGIN_SUCCESS_MUTATION: 登录成功后关联命名空间分组
|
||||
LOGOUT_MUTATION: 登出后关联命名空间分组
|
||||
CONFIG_MUTATION: 参数配置写操作关联命名空间分组
|
||||
DICT_TYPE_MUTATION: 字典类型写操作关联命名空间分组
|
||||
DICT_DATA_MUTATION: 字典数据写操作关联命名空间分组
|
||||
AI_MODEL_MUTATION: AI模型写操作关联命名空间分组
|
||||
AI_CHAT_CONFIG_MUTATION: AI对话配置写操作关联命名空间分组
|
||||
GEN_MUTATION: 代码生成写操作关联命名空间分组
|
||||
"""
|
||||
|
||||
PERMISSION_MUTATION = (
|
||||
ApiNamespace.LOGIN_USER_INFO,
|
||||
ApiNamespace.LOGIN_USER_ROUTERS,
|
||||
ApiNamespace.SYSTEM_MENU_TREE,
|
||||
ApiNamespace.SYSTEM_MENU_ROLE_TREE,
|
||||
ApiNamespace.SYSTEM_MENU_LIST,
|
||||
)
|
||||
|
||||
DATA_SCOPE_MUTATION = (
|
||||
ApiNamespace.LOGIN_USER_INFO,
|
||||
ApiNamespace.SYSTEM_DEPT_EDIT_TREE,
|
||||
ApiNamespace.SYSTEM_DEPT_LIST,
|
||||
ApiNamespace.SYSTEM_DEPT_DETAIL,
|
||||
ApiNamespace.SYSTEM_ROLE_DEPT_TREE,
|
||||
ApiNamespace.SYSTEM_ROLE_LIST,
|
||||
ApiNamespace.SYSTEM_ROLE_DETAIL,
|
||||
ApiNamespace.SYSTEM_ROLE_ALLOCATED_USER_LIST,
|
||||
ApiNamespace.SYSTEM_ROLE_UNALLOCATED_USER_LIST,
|
||||
ApiNamespace.SYSTEM_USER_DEPT_TREE,
|
||||
ApiNamespace.SYSTEM_USER_LIST,
|
||||
ApiNamespace.SYSTEM_USER_DETAIL,
|
||||
ApiNamespace.SYSTEM_USER_PROFILE,
|
||||
ApiNamespace.AI_MODEL_LIST,
|
||||
ApiNamespace.AI_MODEL_ALL,
|
||||
ApiNamespace.AI_MODEL_DETAIL,
|
||||
)
|
||||
|
||||
MENU_MUTATION = (
|
||||
*PERMISSION_MUTATION,
|
||||
ApiNamespace.SYSTEM_MENU_DETAIL,
|
||||
)
|
||||
|
||||
JOB_MUTATION = (
|
||||
ApiNamespace.MONITOR_JOB_LIST,
|
||||
ApiNamespace.MONITOR_JOB_DETAIL,
|
||||
)
|
||||
|
||||
POST_MUTATION = (
|
||||
ApiNamespace.SYSTEM_POST_LIST,
|
||||
ApiNamespace.SYSTEM_POST_DETAIL,
|
||||
ApiNamespace.SYSTEM_USER_DETAIL,
|
||||
ApiNamespace.SYSTEM_USER_PROFILE,
|
||||
)
|
||||
|
||||
NOTICE_MUTATION = (
|
||||
ApiNamespace.SYSTEM_NOTICE_LIST,
|
||||
ApiNamespace.SYSTEM_NOTICE_DETAIL,
|
||||
)
|
||||
|
||||
ROLE_ENTITY_MUTATION = (
|
||||
ApiNamespace.SYSTEM_ROLE_DEPT_TREE,
|
||||
ApiNamespace.SYSTEM_ROLE_LIST,
|
||||
ApiNamespace.SYSTEM_ROLE_DETAIL,
|
||||
ApiNamespace.SYSTEM_ROLE_ALLOCATED_USER_LIST,
|
||||
ApiNamespace.SYSTEM_ROLE_UNALLOCATED_USER_LIST,
|
||||
ApiNamespace.SYSTEM_MENU_ROLE_TREE,
|
||||
ApiNamespace.SYSTEM_USER_DETAIL,
|
||||
)
|
||||
|
||||
ROLE_PERMISSION_MUTATION = (
|
||||
*ROLE_ENTITY_MUTATION,
|
||||
ApiNamespace.SYSTEM_USER_PROFILE,
|
||||
ApiNamespace.LOGIN_USER_INFO,
|
||||
*PERMISSION_MUTATION,
|
||||
)
|
||||
|
||||
ROLE_MUTATION = (
|
||||
*ROLE_PERMISSION_MUTATION,
|
||||
*DATA_SCOPE_MUTATION,
|
||||
)
|
||||
|
||||
USER_ENTITY_MUTATION = (
|
||||
ApiNamespace.SYSTEM_USER_LIST,
|
||||
ApiNamespace.SYSTEM_USER_DETAIL,
|
||||
ApiNamespace.SYSTEM_ROLE_ALLOCATED_USER_LIST,
|
||||
ApiNamespace.SYSTEM_ROLE_UNALLOCATED_USER_LIST,
|
||||
)
|
||||
|
||||
USER_PERMISSION_MUTATION = (
|
||||
*DATA_SCOPE_MUTATION,
|
||||
*PERMISSION_MUTATION,
|
||||
)
|
||||
|
||||
USER_INFO_MUTATION = (
|
||||
ApiNamespace.SYSTEM_USER_LIST,
|
||||
ApiNamespace.SYSTEM_USER_DETAIL,
|
||||
ApiNamespace.SYSTEM_USER_PROFILE,
|
||||
ApiNamespace.LOGIN_USER_INFO,
|
||||
)
|
||||
|
||||
LOGIN_SUCCESS_MUTATION = (
|
||||
ApiNamespace.SYSTEM_USER_LIST,
|
||||
ApiNamespace.LOGIN_USER_INFO,
|
||||
ApiNamespace.LOGIN_USER_ROUTERS,
|
||||
ApiNamespace.SYSTEM_USER_PROFILE,
|
||||
ApiNamespace.SYSTEM_USER_DETAIL,
|
||||
)
|
||||
|
||||
LOGOUT_MUTATION = (
|
||||
ApiNamespace.LOGIN_USER_INFO,
|
||||
ApiNamespace.LOGIN_USER_ROUTERS,
|
||||
)
|
||||
|
||||
CONFIG_MUTATION = (
|
||||
ApiNamespace.SYSTEM_CONFIG_LIST,
|
||||
ApiNamespace.SYSTEM_CONFIG_DETAIL,
|
||||
)
|
||||
|
||||
DICT_TYPE_MUTATION = (
|
||||
ApiNamespace.SYSTEM_DICT_TYPE_LIST,
|
||||
ApiNamespace.SYSTEM_DICT_TYPE_OPTIONS,
|
||||
ApiNamespace.SYSTEM_DICT_TYPE_DETAIL,
|
||||
ApiNamespace.SYSTEM_DICT_DATA_LIST,
|
||||
ApiNamespace.SYSTEM_DICT_DATA_DETAIL,
|
||||
)
|
||||
|
||||
DICT_DATA_MUTATION = (
|
||||
ApiNamespace.SYSTEM_DICT_DATA_LIST,
|
||||
ApiNamespace.SYSTEM_DICT_DATA_DETAIL,
|
||||
)
|
||||
|
||||
AI_MODEL_MUTATION = (
|
||||
ApiNamespace.AI_MODEL_LIST,
|
||||
ApiNamespace.AI_MODEL_ALL,
|
||||
ApiNamespace.AI_MODEL_DETAIL,
|
||||
)
|
||||
|
||||
AI_CHAT_CONFIG_MUTATION = (ApiNamespace.AI_CHAT_CONFIG,)
|
||||
|
||||
GEN_MUTATION = (
|
||||
ApiNamespace.TOOL_GEN_LIST,
|
||||
ApiNamespace.TOOL_GEN_DB_LIST,
|
||||
ApiNamespace.TOOL_GEN_DETAIL,
|
||||
ApiNamespace.TOOL_GEN_PREVIEW,
|
||||
)
|
||||
|
||||
|
||||
class MenuConstant:
|
||||
"""
|
||||
菜单常量
|
||||
|
||||
TYPE_DIR: 菜单类型(目录)
|
||||
TYPE_MENU: 菜单类型(菜单)
|
||||
TYPE_BUTTON: 菜单类型(按钮)
|
||||
YES_FRAME: 是否菜单外链(是)
|
||||
NO_FRAME: 是否菜单外链(否)
|
||||
LAYOUT: Layout组件标识
|
||||
PARENT_VIEW: ParentView组件标识
|
||||
INNER_LINK: InnerLink组件标识
|
||||
"""
|
||||
|
||||
TYPE_DIR = 'M'
|
||||
TYPE_MENU = 'C'
|
||||
TYPE_BUTTON = 'F'
|
||||
YES_FRAME = 0
|
||||
NO_FRAME = 1
|
||||
LAYOUT = 'Layout'
|
||||
PARENT_VIEW = 'ParentView'
|
||||
INNER_LINK = 'InnerLink'
|
||||
|
||||
|
||||
class GenConstant:
|
||||
"""
|
||||
代码生成常量
|
||||
|
||||
TPL_CRUD: 单表(增删改查
|
||||
TPL_TREE: 树表(增删改查)
|
||||
TPL_SUB: 主子表(增删改查)
|
||||
TREE_CODE: 树编码字段
|
||||
TREE_PARENT_CODE: 树父编码字段
|
||||
TREE_NAME: 树名称字段
|
||||
PARENT_MENU_ID: 上级菜单ID字段
|
||||
PARENT_MENU_NAME: 上级菜单名称字段
|
||||
COLUMNTYPE_STR: 数据库字符串类型
|
||||
COLUMNTYPE_TEXT: 数据库文本类型
|
||||
COLUMNTYPE_TIME: 数据库时间类型
|
||||
COLUMNTYPE_GEOMETRY: 数据库字空间类型
|
||||
COLUMNTYPE_NUMBER: 数据库数字类型
|
||||
COLUMNNAME_NOT_EDIT: 页面不需要编辑字段
|
||||
COLUMNNAME_NOT_LIST: 页面不需要显示的列表字段
|
||||
COLUMNNAME_NOT_QUERY: 页面不需要查询字段
|
||||
BASE_ENTITY: Entity基类字段
|
||||
TREE_ENTITY: Tree基类字段
|
||||
HTML_INPUT: 文本框
|
||||
HTML_TEXTAREA: 文本域
|
||||
HTML_SELECT: 下拉框
|
||||
HTML_RADIO: 单选框
|
||||
HTML_CHECKBOX: 复选框
|
||||
HTML_DATETIME: 日期控件
|
||||
HTML_IMAGE_UPLOAD: 图片上传控件
|
||||
HTML_FILE_UPLOAD: 文件上传控件
|
||||
HTML_EDITOR: 富文本控件
|
||||
TYPE_DECIMAL: 高精度计算类型
|
||||
TYPE_DATE: 时间类型
|
||||
QUERY_LIKE: 模糊查询
|
||||
QUERY_EQ: 相等查询
|
||||
REQUIRE: 需要
|
||||
DB_TO_SQLALCHEMY_TYPE_MAPPING: 数据库类型与sqlalchemy类型映射
|
||||
DB_TO_PYTHON_TYPE_MAPPING: 数据库类型与python类型映射
|
||||
"""
|
||||
|
||||
TPL_CRUD = 'crud'
|
||||
TPL_TREE = 'tree'
|
||||
TPL_SUB = 'sub'
|
||||
TREE_CODE = 'treeCode'
|
||||
TREE_PARENT_CODE = 'treeParentCode'
|
||||
TREE_NAME = 'treeName'
|
||||
PARENT_MENU_ID = 'parentMenuId'
|
||||
PARENT_MENU_NAME = 'parentMenuName'
|
||||
COLUMNTYPE_STR = (
|
||||
['character varying', 'varchar', 'character', 'char']
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else ['char', 'varchar', 'nvarchar', 'varchar2']
|
||||
)
|
||||
COLUMNTYPE_TEXT = (
|
||||
['text', 'citext'] if DataBaseConfig.db_type == 'postgresql' else ['tinytext', 'text', 'mediumtext', 'longtext']
|
||||
)
|
||||
COLUMNTYPE_TIME = (
|
||||
[
|
||||
'date',
|
||||
'time',
|
||||
'time with time zone',
|
||||
'time without time zone',
|
||||
'timestamp',
|
||||
'timestamp with time zone',
|
||||
'timestamp without time zone',
|
||||
'interval',
|
||||
]
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else ['datetime', 'time', 'date', 'timestamp']
|
||||
)
|
||||
COLUMNTYPE_GEOMETRY = (
|
||||
['point', 'line', 'lseg', 'box', 'path', 'polygon', 'circle']
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else [
|
||||
'geometry',
|
||||
'point',
|
||||
'linestring',
|
||||
'polygon',
|
||||
'multipoint',
|
||||
'multilinestring',
|
||||
'multipolygon',
|
||||
'geometrycollection',
|
||||
]
|
||||
)
|
||||
COLUMNTYPE_NUMBER = [
|
||||
'tinyint',
|
||||
'smallint',
|
||||
'mediumint',
|
||||
'int',
|
||||
'number',
|
||||
'integer',
|
||||
'bit',
|
||||
'bigint',
|
||||
'float',
|
||||
'double',
|
||||
'decimal',
|
||||
]
|
||||
COLUMNNAME_NOT_ADD_SHOW = ['create_by', 'create_time']
|
||||
COLUMNNAME_NOT_EDIT_SHOW = ['update_by', 'update_time']
|
||||
COLUMNNAME_NOT_EDIT = ['id', 'create_by', 'create_time', 'del_flag']
|
||||
COLUMNNAME_NOT_LIST = ['id', 'create_by', 'create_time', 'del_flag', 'update_by', 'update_time']
|
||||
COLUMNNAME_NOT_QUERY = ['id', 'create_by', 'create_time', 'del_flag', 'update_by', 'update_time', 'remark']
|
||||
BASE_ENTITY = ['createBy', 'createTime', 'updateBy', 'updateTime', 'remark']
|
||||
TREE_ENTITY = ['parentName', 'parentId', 'orderNum', 'ancestors', 'children']
|
||||
HTML_INPUT = 'input'
|
||||
HTML_TEXTAREA = 'textarea'
|
||||
HTML_SELECT = 'select'
|
||||
HTML_RADIO = 'radio'
|
||||
HTML_CHECKBOX = 'checkbox'
|
||||
HTML_DATETIME = 'datetime'
|
||||
HTML_IMAGE_UPLOAD = 'imageUpload'
|
||||
HTML_FILE_UPLOAD = 'fileUpload'
|
||||
HTML_EDITOR = 'editor'
|
||||
TYPE_DECIMAL = 'Decimal'
|
||||
TYPE_DATE = ['date', 'time', 'datetime']
|
||||
QUERY_LIKE = 'LIKE'
|
||||
QUERY_EQ = 'EQ'
|
||||
REQUIRE = '1'
|
||||
DB_TO_SQLALCHEMY_TYPE_MAPPING = (
|
||||
{
|
||||
'boolean': 'Boolean',
|
||||
'smallint': 'SmallInteger',
|
||||
'integer': 'Integer',
|
||||
'bigint': 'BigInteger',
|
||||
'real': 'Float',
|
||||
'double precision': 'Float',
|
||||
'numeric': 'Numeric',
|
||||
'character varying': 'String',
|
||||
'character': 'String',
|
||||
'text': 'Text',
|
||||
'bytea': 'LargeBinary',
|
||||
'date': 'Date',
|
||||
'time': 'Time',
|
||||
'time with time zone': 'Time',
|
||||
'time without time zone': 'Time',
|
||||
'timestamp': 'DateTime',
|
||||
'timestamp with time zone': 'DateTime',
|
||||
'timestamp without time zone': 'DateTime',
|
||||
'interval': 'Interval',
|
||||
'json': 'JSON',
|
||||
'jsonb': 'JSONB',
|
||||
'uuid': 'Uuid',
|
||||
'inet': 'INET',
|
||||
'cidr': 'CIDR',
|
||||
'macaddr': 'MACADDR',
|
||||
'point': 'Geometry',
|
||||
'line': 'Geometry',
|
||||
'lseg': 'Geometry',
|
||||
'box': 'Geometry',
|
||||
'path': 'Geometry',
|
||||
'polygon': 'Geometry',
|
||||
'circle': 'Geometry',
|
||||
'bit': 'Bit',
|
||||
'bit varying': 'Bit',
|
||||
'tsvector': 'TSVECTOR',
|
||||
'tsquery': 'TSQUERY',
|
||||
'xml': 'String',
|
||||
'array': 'ARRAY',
|
||||
'composite': 'JSON',
|
||||
'enum': 'Enum',
|
||||
'range': 'Range',
|
||||
'money': 'Numeric',
|
||||
'pg_lsn': 'BigInteger',
|
||||
'txid_snapshot': 'String',
|
||||
'oid': 'BigInteger',
|
||||
'regproc': 'String',
|
||||
'regclass': 'String',
|
||||
'regtype': 'String',
|
||||
'regrole': 'String',
|
||||
'regnamespace': 'String',
|
||||
'int2vector': 'ARRAY',
|
||||
'oidvector': 'ARRAY',
|
||||
'pg_node_tree': 'Text',
|
||||
}
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else {
|
||||
# 数值类型
|
||||
'TINYINT': 'SmallInteger',
|
||||
'SMALLINT': 'SmallInteger',
|
||||
'MEDIUMINT': 'Integer',
|
||||
'INT': 'Integer',
|
||||
'INTEGER': 'Integer',
|
||||
'BIGINT': 'BigInteger',
|
||||
'FLOAT': 'Float',
|
||||
'DOUBLE': 'Float',
|
||||
'DECIMAL': 'DECIMAL',
|
||||
'BIT': 'Integer',
|
||||
# 日期和时间类型
|
||||
'DATE': 'Date',
|
||||
'TIME': 'Time',
|
||||
'DATETIME': 'DateTime',
|
||||
'TIMESTAMP': 'TIMESTAMP',
|
||||
'YEAR': 'Integer',
|
||||
# 字符串类型
|
||||
'CHAR': 'CHAR',
|
||||
'VARCHAR': 'String',
|
||||
'TINYTEXT': 'Text',
|
||||
'TEXT': 'Text',
|
||||
'MEDIUMTEXT': 'Text',
|
||||
'LONGTEXT': 'Text',
|
||||
'BINARY': 'BINARY',
|
||||
'VARBINARY': 'VARBINARY',
|
||||
'TINYBLOB': 'LargeBinary',
|
||||
'BLOB': 'LargeBinary',
|
||||
'MEDIUMBLOB': 'LargeBinary',
|
||||
'LONGBLOB': 'LargeBinary',
|
||||
# 枚举和集合类型
|
||||
'ENUM': 'Enum',
|
||||
'SET': 'String',
|
||||
# JSON 类型
|
||||
'JSON': 'JSON',
|
||||
# 空间数据类型(需要扩展支持,如 GeoAlchemy2)
|
||||
'GEOMETRY': 'Geometry', # 需要安装 geoalchemy2
|
||||
'POINT': 'Geometry',
|
||||
'LINESTRING': 'Geometry',
|
||||
'POLYGON': 'Geometry',
|
||||
'MULTIPOINT': 'Geometry',
|
||||
'MULTILINESTRING': 'Geometry',
|
||||
'MULTIPOLYGON': 'Geometry',
|
||||
'GEOMETRYCOLLECTION': 'Geometry',
|
||||
}
|
||||
)
|
||||
DB_TO_PYTHON_TYPE_MAPPING = (
|
||||
{
|
||||
'boolean': 'bool',
|
||||
'smallint': 'int',
|
||||
'integer': 'int',
|
||||
'bigint': 'int',
|
||||
'real': 'float',
|
||||
'double precision': 'float',
|
||||
'numeric': 'Decimal',
|
||||
'character varying': 'str',
|
||||
'character': 'str',
|
||||
'text': 'str',
|
||||
'bytea': 'bytes',
|
||||
'date': 'date',
|
||||
'time': 'time',
|
||||
'time with time zone': 'time',
|
||||
'time without time zone': 'time',
|
||||
'timestamp': 'datetime',
|
||||
'timestamp with time zone': 'datetime',
|
||||
'timestamp without time zone': 'datetime',
|
||||
'interval': 'timedelta',
|
||||
'json': 'dict',
|
||||
'jsonb': 'dict',
|
||||
'uuid': 'str',
|
||||
'inet': 'str',
|
||||
'cidr': 'str',
|
||||
'macaddr': 'str',
|
||||
'point': 'list',
|
||||
'line': 'list',
|
||||
'lseg': 'list',
|
||||
'box': 'list',
|
||||
'path': 'list',
|
||||
'polygon': 'list',
|
||||
'circle': 'list',
|
||||
'bit': 'int',
|
||||
'bit varying': 'int',
|
||||
'tsvector': 'str',
|
||||
'tsquery': 'str',
|
||||
'xml': 'str',
|
||||
'array': 'list',
|
||||
'composite': 'dict',
|
||||
'enum': 'str',
|
||||
'range': 'list',
|
||||
'money': 'Decimal',
|
||||
'pg_lsn': 'int',
|
||||
'txid_snapshot': 'str',
|
||||
'oid': 'int',
|
||||
'regproc': 'str',
|
||||
'regclass': 'str',
|
||||
'regtype': 'str',
|
||||
'regrole': 'str',
|
||||
'regnamespace': 'str',
|
||||
'int2vector': 'list',
|
||||
'oidvector': 'list',
|
||||
'pg_node_tree': 'str',
|
||||
}
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else {
|
||||
# 数值类型
|
||||
'TINYINT': 'int',
|
||||
'SMALLINT': 'int',
|
||||
'MEDIUMINT': 'int',
|
||||
'INT': 'int',
|
||||
'INTEGER': 'int',
|
||||
'BIGINT': 'int',
|
||||
'FLOAT': 'float',
|
||||
'DOUBLE': 'float',
|
||||
'DECIMAL': 'Decimal',
|
||||
'BIT': 'int',
|
||||
# 日期和时间类型
|
||||
'DATE': 'date',
|
||||
'TIME': 'time',
|
||||
'DATETIME': 'datetime',
|
||||
'TIMESTAMP': 'datetime',
|
||||
'YEAR': 'int',
|
||||
# 字符串类型
|
||||
'CHAR': 'str',
|
||||
'VARCHAR': 'str',
|
||||
'TINYTEXT': 'str',
|
||||
'TEXT': 'str',
|
||||
'MEDIUMTEXT': 'str',
|
||||
'LONGTEXT': 'str',
|
||||
'BINARY': 'bytes',
|
||||
'VARBINARY': 'bytes',
|
||||
'TINYBLOB': 'bytes',
|
||||
'BLOB': 'bytes',
|
||||
'MEDIUMBLOB': 'bytes',
|
||||
'LONGBLOB': 'bytes',
|
||||
# 枚举和集合类型
|
||||
'ENUM': 'str',
|
||||
'SET': 'str',
|
||||
# JSON 类型
|
||||
'JSON': 'dict',
|
||||
# 空间数据类型(通常需要特殊处理)
|
||||
'GEOMETRY': 'bytes',
|
||||
'POINT': 'bytes',
|
||||
'LINESTRING': 'bytes',
|
||||
'POLYGON': 'bytes',
|
||||
'MULTIPOINT': 'bytes',
|
||||
'MULTILINESTRING': 'bytes',
|
||||
'MULTIPOLYGON': 'bytes',
|
||||
'GEOMETRYCOLLECTION': 'bytes',
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
import re
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Literal
|
||||
|
||||
from exceptions.exception import LoginException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
|
||||
# 定义上下文变量
|
||||
# 存储当前请求的编译后的排除路由模式列表
|
||||
current_exclude_patterns: ContextVar[
|
||||
list[dict[str, str | list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] | re.Pattern]] | None
|
||||
] = ContextVar('current_exclude_patterns', default=None)
|
||||
# 存储当前用户信息
|
||||
current_user: ContextVar[CurrentUserModel | None] = ContextVar('current_user', default=None)
|
||||
|
||||
|
||||
class RequestContext:
|
||||
"""
|
||||
请求上下文管理类,用于设置和清理上下文变量
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def set_current_exclude_patterns(
|
||||
exclude_patterns: list[
|
||||
dict[str, str | list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] | re.Pattern]
|
||||
],
|
||||
) -> Token:
|
||||
"""
|
||||
设置当前请求的编译后的排除路由模式列表
|
||||
|
||||
:param exclude_patterns: 编译后的排除路由模式列表
|
||||
:return: 上下文变量令牌,用于重置
|
||||
"""
|
||||
return current_exclude_patterns.set(exclude_patterns)
|
||||
|
||||
@staticmethod
|
||||
def get_current_exclude_patterns() -> list[
|
||||
dict[str, str | list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] | re.Pattern]
|
||||
]:
|
||||
"""
|
||||
获取当前请求的编译后的排除路由模式列表
|
||||
|
||||
:return: 编译后的排除路由模式列表
|
||||
"""
|
||||
_exclude_patterns = current_exclude_patterns.get()
|
||||
if _exclude_patterns is None:
|
||||
_exclude_patterns = []
|
||||
return _exclude_patterns
|
||||
|
||||
@staticmethod
|
||||
def set_current_user(user: CurrentUserModel) -> Token:
|
||||
"""
|
||||
设置当前用户信息
|
||||
|
||||
:param user: 用户信息
|
||||
:return: 上下文变量令牌,用于重置
|
||||
"""
|
||||
return current_user.set(user)
|
||||
|
||||
@staticmethod
|
||||
def get_current_user() -> CurrentUserModel:
|
||||
"""
|
||||
获取当前用户信息
|
||||
|
||||
:return: 用户信息
|
||||
"""
|
||||
_current_user = current_user.get()
|
||||
if _current_user is None:
|
||||
raise LoginException(data='', message='当前用户信息为空,请检查是否已登录')
|
||||
return _current_user
|
||||
|
||||
@staticmethod
|
||||
def reset_current_exclude_patterns(token: Token) -> None:
|
||||
"""
|
||||
重置当前请求的编译后的排除路由模式列表
|
||||
|
||||
:param token: 设置编译后的排除路由模式列表时返回的令牌
|
||||
"""
|
||||
current_exclude_patterns.reset(token)
|
||||
|
||||
@staticmethod
|
||||
def reset_current_user(token: Token) -> None:
|
||||
"""
|
||||
重置当前用户信息
|
||||
|
||||
:param token: 设置用户信息时返回的令牌
|
||||
"""
|
||||
current_user.reset(token)
|
||||
|
||||
@staticmethod
|
||||
def clear_all() -> None:
|
||||
"""
|
||||
清除所有上下文变量
|
||||
"""
|
||||
current_exclude_patterns.set(None)
|
||||
current_user.set(None)
|
||||
@@ -0,0 +1,79 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class HttpMethod(str, Enum):
|
||||
"""
|
||||
HTTP请求方法枚举
|
||||
|
||||
GET: 获取资源
|
||||
POST: 创建资源
|
||||
PUT: 整体更新资源
|
||||
DELETE: 删除资源
|
||||
PATCH: 局部更新资源
|
||||
HEAD: 获取响应头
|
||||
OPTIONS: 获取允许的方法信息
|
||||
TRACE: 回显诊断请求
|
||||
CONNECT: 建立隧道连接
|
||||
"""
|
||||
|
||||
GET = 'GET'
|
||||
POST = 'POST'
|
||||
PUT = 'PUT'
|
||||
DELETE = 'DELETE'
|
||||
PATCH = 'PATCH'
|
||||
HEAD = 'HEAD'
|
||||
OPTIONS = 'OPTIONS'
|
||||
TRACE = 'TRACE'
|
||||
CONNECT = 'CONNECT'
|
||||
|
||||
|
||||
class BusinessType(Enum):
|
||||
"""
|
||||
业务操作类型
|
||||
|
||||
OTHER: 其它
|
||||
INSERT: 新增
|
||||
UPDATE: 修改
|
||||
DELETE: 删除
|
||||
GRANT: 授权
|
||||
EXPORT: 导出
|
||||
IMPORT: 导入
|
||||
FORCE: 强退
|
||||
GENCODE: 生成代码
|
||||
CLEAN: 清空数据
|
||||
"""
|
||||
|
||||
OTHER = 0
|
||||
INSERT = 1
|
||||
UPDATE = 2
|
||||
DELETE = 3
|
||||
GRANT = 4
|
||||
EXPORT = 5
|
||||
IMPORT = 6
|
||||
FORCE = 7
|
||||
GENCODE = 8
|
||||
CLEAN = 9
|
||||
|
||||
|
||||
class RedisInitKeyConfig(Enum):
|
||||
"""
|
||||
系统内置Redis键名
|
||||
"""
|
||||
|
||||
@property
|
||||
def key(self) -> str | None:
|
||||
return self.value.get('key')
|
||||
|
||||
@property
|
||||
def remark(self) -> str | None:
|
||||
return self.value.get('remark')
|
||||
|
||||
ACCESS_TOKEN = {'key': 'access_token', 'remark': '登录令牌信息'}
|
||||
SYS_DICT = {'key': 'sys_dict', 'remark': '数据字典'}
|
||||
SYS_CONFIG = {'key': 'sys_config', 'remark': '配置信息'}
|
||||
API_CACHE = {'key': 'api_cache', 'remark': '接口响应缓存'}
|
||||
API_RATE_LIMIT = {'key': 'api_rate_limit', 'remark': '接口限流'}
|
||||
CAPTCHA_CODES = {'key': 'captcha_codes', 'remark': '图片验证码'}
|
||||
ACCOUNT_LOCK = {'key': 'account_lock', 'remark': '用户锁定'}
|
||||
PASSWORD_ERROR_COUNT = {'key': 'password_error_count', 'remark': '密码错误次数'}
|
||||
SMS_CODE = {'key': 'sms_code', 'remark': '短信验证码'}
|
||||
@@ -0,0 +1,393 @@
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from annotated_doc import Doc
|
||||
from fastapi import FastAPI, params
|
||||
from fastapi.datastructures import Default
|
||||
from fastapi.routing import APIRoute, APIRouter
|
||||
from fastapi.utils import generate_unique_id
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import BaseRoute
|
||||
from starlette.types import ASGIApp, Lifespan
|
||||
from typing_extensions import deprecated
|
||||
|
||||
|
||||
class APIRouterPro(APIRouter):
|
||||
"""
|
||||
`APIRouterPro` class, inherited from the `APIRouter` class, it has all the functions of `APIRouter` and provides some additional parameter settings.
|
||||
`APIRouter` class, used to group *path operations*, for example to structure
|
||||
an app in multiple files. It would then be included in the `FastAPI` app, or
|
||||
in another `APIRouter` (ultimately included in the app).
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/).
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from common.router import APIRouterPro, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
router = APIRouterPro()
|
||||
|
||||
|
||||
@router.get('/users/', tags=['users'])
|
||||
async def read_users():
|
||||
return [{'username': 'Rick'}, {'username': 'Morty'}]
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
prefix: Annotated[str, Doc('An optional path prefix for the router.')] = '',
|
||||
order_num: Annotated[int, Doc('An optional order number for the router.')] = 100,
|
||||
auto_register: Annotated[bool, Doc('An optional auto register flag for the router.')] = True,
|
||||
tags: Annotated[
|
||||
list[str | Enum] | None,
|
||||
Doc(
|
||||
"""
|
||||
A list of tags to be applied to all the *path operations* in this
|
||||
router.
|
||||
|
||||
It will be added to the generated OpenAPI (e.g. visible at `/docs`).
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
dependencies: Annotated[
|
||||
Sequence[params.Depends] | None,
|
||||
Doc(
|
||||
"""
|
||||
A list of dependencies (using `Depends()`) to be applied to all the
|
||||
*path operations* in this router.
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
default_response_class: Annotated[
|
||||
type[Response],
|
||||
Doc(
|
||||
"""
|
||||
The default response class to be used.
|
||||
|
||||
Read more in the
|
||||
[FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#default-response-class).
|
||||
"""
|
||||
),
|
||||
] = Default(JSONResponse),
|
||||
responses: Annotated[
|
||||
dict[int | str, dict[str, Any]] | None,
|
||||
Doc(
|
||||
"""
|
||||
Additional responses to be shown in OpenAPI.
|
||||
|
||||
It will be added to the generated OpenAPI (e.g. visible at `/docs`).
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for Additional Responses in OpenAPI](https://fastapi.tiangolo.com/advanced/additional-responses/).
|
||||
|
||||
And in the
|
||||
[FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
callbacks: Annotated[
|
||||
list[BaseRoute] | None,
|
||||
Doc(
|
||||
"""
|
||||
OpenAPI callbacks that should apply to all *path operations* in this
|
||||
router.
|
||||
|
||||
It will be added to the generated OpenAPI (e.g. visible at `/docs`).
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
routes: Annotated[
|
||||
list[BaseRoute] | None,
|
||||
Doc(
|
||||
"""
|
||||
**Note**: you probably shouldn't use this parameter, it is inherited
|
||||
from Starlette and supported for compatibility.
|
||||
|
||||
---
|
||||
|
||||
A list of routes to serve incoming HTTP and WebSocket requests.
|
||||
"""
|
||||
),
|
||||
deprecated(
|
||||
"""
|
||||
You normally wouldn't use this parameter with FastAPI, it is inherited
|
||||
from Starlette and supported for compatibility.
|
||||
|
||||
In FastAPI, you normally would use the *path operation methods*,
|
||||
like `router.get()`, `router.post()`, etc.
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
redirect_slashes: Annotated[
|
||||
bool,
|
||||
Doc(
|
||||
"""
|
||||
Whether to detect and redirect slashes in URLs when the client doesn't
|
||||
use the same format.
|
||||
"""
|
||||
),
|
||||
] = True,
|
||||
default: Annotated[
|
||||
ASGIApp | None,
|
||||
Doc(
|
||||
"""
|
||||
Default function handler for this router. Used to handle
|
||||
404 Not Found errors.
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
dependency_overrides_provider: Annotated[
|
||||
Any | None,
|
||||
Doc(
|
||||
"""
|
||||
Only used internally by FastAPI to handle dependency overrides.
|
||||
|
||||
You shouldn't need to use it. It normally points to the `FastAPI` app
|
||||
object.
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
route_class: Annotated[
|
||||
type[APIRoute],
|
||||
Doc(
|
||||
"""
|
||||
Custom route (*path operation*) class to be used by this router.
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for Custom Request and APIRoute class](https://fastapi.tiangolo.com/how-to/custom-request-and-route/#custom-apiroute-class-in-a-router).
|
||||
"""
|
||||
),
|
||||
] = APIRoute,
|
||||
on_startup: Annotated[
|
||||
Sequence[Callable[[], Any]] | None,
|
||||
Doc(
|
||||
"""
|
||||
A list of startup event handler functions.
|
||||
|
||||
You should instead use the `lifespan` handlers.
|
||||
|
||||
Read more in the [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
on_shutdown: Annotated[
|
||||
Sequence[Callable[[], Any]] | None,
|
||||
Doc(
|
||||
"""
|
||||
A list of shutdown event handler functions.
|
||||
|
||||
You should instead use the `lifespan` handlers.
|
||||
|
||||
Read more in the
|
||||
[FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
# the generic to Lifespan[AppType] is the type of the top level application
|
||||
# which the router cannot know statically, so we use typing.Any
|
||||
lifespan: Annotated[
|
||||
Lifespan[Any] | None,
|
||||
Doc(
|
||||
"""
|
||||
A `Lifespan` context manager handler. This replaces `startup` and
|
||||
`shutdown` functions with a single context manager.
|
||||
|
||||
Read more in the
|
||||
[FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
deprecated: Annotated[
|
||||
bool | None,
|
||||
Doc(
|
||||
"""
|
||||
Mark all *path operations* in this router as deprecated.
|
||||
|
||||
It will be added to the generated OpenAPI (e.g. visible at `/docs`).
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
include_in_schema: Annotated[
|
||||
bool,
|
||||
Doc(
|
||||
"""
|
||||
To include (or not) all the *path operations* in this router in the
|
||||
generated OpenAPI.
|
||||
|
||||
This affects the generated OpenAPI (e.g. visible at `/docs`).
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi).
|
||||
"""
|
||||
),
|
||||
] = True,
|
||||
generate_unique_id_function: Annotated[
|
||||
Callable[[APIRoute], str],
|
||||
Doc(
|
||||
"""
|
||||
Customize the function used to generate unique IDs for the *path
|
||||
operations* shown in the generated OpenAPI.
|
||||
|
||||
This is particularly useful when automatically generating clients or
|
||||
SDKs for your API.
|
||||
|
||||
Read more about it in the
|
||||
[FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function).
|
||||
"""
|
||||
),
|
||||
] = Default(generate_unique_id),
|
||||
) -> None:
|
||||
self.order_num = order_num
|
||||
self.auto_register = auto_register
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
tags=tags,
|
||||
dependencies=dependencies,
|
||||
default_response_class=default_response_class,
|
||||
responses=responses,
|
||||
callbacks=callbacks,
|
||||
routes=routes,
|
||||
redirect_slashes=redirect_slashes,
|
||||
default=default,
|
||||
dependency_overrides_provider=dependency_overrides_provider,
|
||||
route_class=route_class,
|
||||
on_startup=on_startup,
|
||||
on_shutdown=on_shutdown,
|
||||
lifespan=lifespan,
|
||||
deprecated=deprecated,
|
||||
include_in_schema=include_in_schema,
|
||||
generate_unique_id_function=generate_unique_id_function,
|
||||
)
|
||||
|
||||
|
||||
class RouterRegister:
|
||||
"""
|
||||
路由注册器,用于自动注册所有controller目录下的路由
|
||||
"""
|
||||
|
||||
def __init__(self, app: FastAPI) -> None:
|
||||
"""
|
||||
初始化路由注册器
|
||||
|
||||
:param app: FastAPI对象
|
||||
"""
|
||||
self.app = app
|
||||
# 获取项目根目录
|
||||
self.project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
sys.path.insert(0, self.project_root)
|
||||
|
||||
def _find_controller_files(self) -> list[str]:
|
||||
"""
|
||||
查找所有controller目录下的py文件
|
||||
|
||||
:return: py文件路径列表
|
||||
"""
|
||||
pattern = os.path.join(self.project_root, '*', 'controller', '[!_]*.py')
|
||||
return sorted(glob.glob(pattern))
|
||||
|
||||
def _import_module_and_get_routers(self, controller_files: list[str]) -> list[tuple[str, APIRouter]]:
|
||||
"""
|
||||
导入模块并获取路由实例
|
||||
|
||||
:param controller_files: controller目录下的py文件路径列表
|
||||
:return: 路由实例列表
|
||||
"""
|
||||
routers = []
|
||||
for file_path in controller_files:
|
||||
# 计算模块路径
|
||||
relative_path = os.path.relpath(file_path, self.project_root)
|
||||
module_name = relative_path.replace(os.sep, '.')[:-3]
|
||||
|
||||
# 动态导入模块
|
||||
module = importlib.import_module(module_name)
|
||||
# 直接遍历模块__dict__,只检查模块自身定义的属性
|
||||
for attr_name, attr in module.__dict__.items():
|
||||
# 对于APIRouterPro实例,只有当auto_register=True时才添加
|
||||
if isinstance(attr, APIRouterPro):
|
||||
if attr.auto_register:
|
||||
routers.append((attr_name, attr))
|
||||
# 对于APIRouter实例,直接添加
|
||||
elif isinstance(attr, APIRouter):
|
||||
routers.append((attr_name, attr))
|
||||
return routers
|
||||
|
||||
def _sort_routers(self, routers: list[tuple[str, APIRouter]]) -> list[tuple[str, APIRouter]]:
|
||||
"""
|
||||
按规则排序路由
|
||||
|
||||
:param routers: 路由实例列表
|
||||
:return: 排序后的路由实例列表
|
||||
"""
|
||||
|
||||
# 按规则排序路由
|
||||
def sort_key(item: tuple[str, APIRouter]) -> tuple[Literal[0], int, str] | tuple[Literal[1], str]:
|
||||
attr_name, router = item
|
||||
# APIRouterPro实例按order_num排序,序号越小越靠前
|
||||
if isinstance(router, APIRouterPro):
|
||||
return (0, router.order_num, attr_name)
|
||||
# APIRouter实例按变量名首字母排序
|
||||
return (1, attr_name)
|
||||
|
||||
return sorted(routers, key=sort_key)
|
||||
|
||||
def _register_routers_to_app(self, routers: list[tuple[str, APIRouter]]) -> None:
|
||||
"""
|
||||
将路由注册到FastAPI应用
|
||||
|
||||
:param routers: 排序后的路由实例列表
|
||||
:return: None
|
||||
"""
|
||||
for _attr_name, router in routers:
|
||||
self.app.include_router(router=router)
|
||||
|
||||
def register_routers(self) -> None:
|
||||
"""
|
||||
自动注册所有controller目录下的路由
|
||||
|
||||
:return: None
|
||||
"""
|
||||
# 查找所有controller目录下的py文件
|
||||
controller_files = self._find_controller_files()
|
||||
# 导入模块并获取路由实例
|
||||
routers = self._import_module_and_get_routers(controller_files)
|
||||
# 按规则排序路由
|
||||
sorted_routers = self._sort_routers(routers)
|
||||
# 注册路由到FastAPI应用
|
||||
self._register_routers_to_app(sorted_routers)
|
||||
|
||||
|
||||
def auto_register_routers(app: FastAPI) -> None:
|
||||
"""
|
||||
自动注册所有controller目录下的路由
|
||||
|
||||
:param app: FastAPI对象
|
||||
:return: None
|
||||
"""
|
||||
# 使用路由注册器进行注册
|
||||
router_register = RouterRegister(app)
|
||||
router_register.register_routers()
|
||||
@@ -0,0 +1,105 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, create_model
|
||||
from pydantic.alias_generators import to_camel
|
||||
from typing_extensions import Self
|
||||
|
||||
from common.constant import HttpStatusConstant
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class CrudResponseModel(BaseModel):
|
||||
"""
|
||||
操作响应模型
|
||||
"""
|
||||
|
||||
is_success: bool = Field(description='操作是否成功')
|
||||
message: str = Field(description='响应信息')
|
||||
result: Any | None = Field(default=None, description='响应结果')
|
||||
|
||||
|
||||
class ResponseBaseModel(BaseModel):
|
||||
"""
|
||||
响应模型
|
||||
"""
|
||||
|
||||
code: int = Field(default=HttpStatusConstant.SUCCESS, description='响应码')
|
||||
msg: str = Field(default='操作成功', description='响应信息')
|
||||
success: bool = Field(default=True, description='响应是否成功')
|
||||
time: datetime = Field(default_factory=datetime.now, description='响应时间')
|
||||
|
||||
|
||||
class DynamicResponseModel(ResponseBaseModel, Generic[T]):
|
||||
"""
|
||||
动态响应模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
def __class_getitem__(cls, item: Any) -> Any | Self:
|
||||
"""
|
||||
当使用 DynamicResponseModel[Item] 语法时,动态创建一个包含所有字段的新模型
|
||||
"""
|
||||
# 检查是否已经为该类型创建了模型
|
||||
if not hasattr(cls, '_cached_models'):
|
||||
cls._cached_models = {}
|
||||
|
||||
if item in cls._cached_models:
|
||||
return cls._cached_models[item]
|
||||
|
||||
# 检查item是否为Pydantic模型
|
||||
if not hasattr(item, 'model_fields'):
|
||||
raise TypeError(f'{item} 不是一个Pydantic模型,请使用Pydantic模型作为泛型参数')
|
||||
|
||||
# 获取ResponseBaseModel的字段
|
||||
base_fields = {}
|
||||
for field_name, field in cls.model_fields.items():
|
||||
base_fields[field_name] = (field.annotation, field)
|
||||
|
||||
# 获取泛型类型的字段
|
||||
item_fields = {}
|
||||
for field_name, field in item.model_fields.items():
|
||||
item_fields[field_name] = (field.annotation, field)
|
||||
|
||||
# 合并所有字段
|
||||
all_fields = {**base_fields, **item_fields}
|
||||
|
||||
# 动态创建新模型
|
||||
new_model = create_model(
|
||||
f'DynamicResponseModel[{item.__name__}]', __base__=cls, __config__=cls.model_config, **all_fields
|
||||
)
|
||||
|
||||
# 缓存模型
|
||||
cls._cached_models[item] = new_model
|
||||
|
||||
return new_model
|
||||
|
||||
|
||||
class PageModel(BaseModel, Generic[T]):
|
||||
"""
|
||||
分页模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
rows: list[T] = Field(description='记录列表')
|
||||
page_num: int = Field(description='当前页码')
|
||||
page_size: int = Field(description='每页记录数')
|
||||
total: int = Field(description='总记录数')
|
||||
has_next: bool = Field(description='是否有下一页')
|
||||
|
||||
|
||||
class PageResponseModel(PageModel, ResponseBaseModel, Generic[T]):
|
||||
"""
|
||||
分页响应模型
|
||||
"""
|
||||
|
||||
|
||||
class DataResponseModel(ResponseBaseModel, Generic[T]):
|
||||
"""
|
||||
数据响应模型
|
||||
"""
|
||||
|
||||
data: T = Field(description='响应数据')
|
||||
Reference in New Issue
Block a user