初始提交

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