mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 21:15:13 +00:00
Add multi level caching and optimize caching (#1054)
* Add multi level caching and optimize caching * optimize current caching * improved decorators * improved local * add pub/sub * fix serialize * Improve pub and sub * Add comment * Fix lru_cache maxsize * Fix lint * Fix config warmup
This commit is contained in:
@@ -7,8 +7,6 @@ from backend.app.admin.crud.crud_dept import dept_dao
|
||||
from backend.app.admin.model import Dept
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.build_tree import get_tree_data
|
||||
|
||||
|
||||
@@ -115,8 +113,6 @@ class DeptService:
|
||||
if children:
|
||||
raise errors.ConflictError(msg='部门下存在子部门,无法删除')
|
||||
count = await dept_dao.delete(db, pk)
|
||||
for user in dept.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class PluginService:
|
||||
bacup_dir = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup'
|
||||
shutil.move(plugin_dir, bacup_dir)
|
||||
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
@staticmethod
|
||||
async def update_status(*, plugin: str) -> None:
|
||||
|
||||
@@ -57,11 +57,19 @@ class UserPasswordHistoryService:
|
||||
failure_count = await redis_client.get(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
||||
failure_count = int(failure_count) if failure_count else 0
|
||||
failure_count += 1
|
||||
await redis_client.set(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}', str(failure_count))
|
||||
await redis_client.setex(
|
||||
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
|
||||
settings.USER_LOCK_SECONDS,
|
||||
str(failure_count),
|
||||
)
|
||||
|
||||
if failure_count >= settings.USER_LOCK_THRESHOLD:
|
||||
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
|
||||
await redis_client.set(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}', timezone.to_str(locked_until))
|
||||
await redis_client.setex(
|
||||
f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}',
|
||||
settings.USER_LOCK_SECONDS,
|
||||
timezone.to_str(locked_until),
|
||||
)
|
||||
raise errors.AuthorizationError(msg='登录失败次数过多,账号已被锁定')
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -212,7 +212,7 @@ class UserService:
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
|
||||
]
|
||||
for prefix in key_prefix:
|
||||
await redis_client.delete(prefix)
|
||||
await redis_client.delete_prefix(prefix)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -314,6 +314,7 @@ class UserService:
|
||||
key_prefix = [
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
|
||||
]
|
||||
for key in key_prefix:
|
||||
await redis_client.delete_prefix(key)
|
||||
|
||||
Vendored
+226
@@ -0,0 +1,226 @@
|
||||
import functools
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
from cachebox import make_hash_key
|
||||
from msgspec import json
|
||||
|
||||
from backend.common.cache.local import local_cache_manager
|
||||
from backend.common.cache.pubsub import cache_pubsub_manager
|
||||
from backend.common.context import ctx
|
||||
from backend.common.exception import errors
|
||||
from backend.common.log import log
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.serializers import select_columns_serialize, select_list_serialize
|
||||
|
||||
P = ParamSpec('P')
|
||||
T = TypeVar('T')
|
||||
|
||||
# 哈希缓存键排除参数
|
||||
_EXCLUDE_PARAMS = frozenset({'db', 'session', 'self', 'cls', 'request', 'response'})
|
||||
|
||||
|
||||
def build_cache_key(
|
||||
name: str,
|
||||
key: str | None,
|
||||
key_builder: Callable[..., str] | None,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""构建缓存 Key"""
|
||||
if key_builder:
|
||||
return f'{name}:{key_builder(*args, **kwargs)}'
|
||||
|
||||
if key:
|
||||
value = kwargs.get(key)
|
||||
if value is None:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{key}" 不存在或值为空')
|
||||
return f'{name}:{value}'
|
||||
|
||||
filtered = {k: v for k, v in kwargs.items() if k not in _EXCLUDE_PARAMS and v is not None}
|
||||
|
||||
if filtered:
|
||||
hash_suffix = make_hash_key(*args, **kwargs)
|
||||
return f'{name}:{hash_suffix}'
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def user_key_builder() -> str:
|
||||
"""基于当前用户 ID 生成缓存 Key"""
|
||||
user_id = ctx.user_id
|
||||
if user_id is None:
|
||||
raise errors.ServerError(msg='用户缓存键构建失败')
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def _serialize_result(result: Any) -> bytes:
|
||||
"""
|
||||
序列化缓存结果
|
||||
|
||||
:param result: 需要进行序列化的结果
|
||||
:return:
|
||||
"""
|
||||
# SQLAlchemy 查询表
|
||||
if hasattr(result, '__table__'):
|
||||
return json.encode(select_columns_serialize(result))
|
||||
|
||||
# SQLAlchemy 查询列表
|
||||
if (
|
||||
isinstance(result, Sequence)
|
||||
and not isinstance(result, (str, bytes))
|
||||
and len(result) > 0
|
||||
and hasattr(result[0], '__table__')
|
||||
):
|
||||
return json.encode(select_list_serialize(result))
|
||||
|
||||
# 基本类型
|
||||
return json.encode(result)
|
||||
|
||||
|
||||
def _deserialize_result(value: bytes) -> Any:
|
||||
"""
|
||||
反序列化缓存结果
|
||||
|
||||
:param value: 缓存结果
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
return json.decode(value)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def cached( # noqa: C901
|
||||
name: str,
|
||||
*,
|
||||
key: str | None = None,
|
||||
key_builder: Callable[..., str] | None = None,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
||||
"""
|
||||
缓存装饰器
|
||||
|
||||
:param name: 缓存名称(通常为缓存 Key 前缀)
|
||||
:param key: 从方法参数中获取指定参数名的值作为缓存 Key,与 key_builder 互斥
|
||||
:param key_builder: 自定义 Key 生成函数,与 key 互斥
|
||||
:return:
|
||||
"""
|
||||
if key is not None and key_builder is not None:
|
||||
raise errors.ServerError(msg='缓存 key 和 key_builder 不能同时使用')
|
||||
|
||||
def decorator(func: Callable[P, T]) -> Callable[P, T]: # noqa: C901
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
cache_key = build_cache_key(name, key, key_builder, *args, **kwargs)
|
||||
|
||||
# L1: 本地缓存
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
local_value = local_cache_manager.get(cache_key)
|
||||
if local_value is not None:
|
||||
return local_value
|
||||
|
||||
# L2: Redis 缓存
|
||||
try:
|
||||
redis_value = await redis_client.get(cache_key)
|
||||
if redis_value is not None:
|
||||
result = _deserialize_result(redis_value)
|
||||
# 回填 L1
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
local_cache_manager.set(cache_key, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning(f'[Cache] GET error: {e}')
|
||||
|
||||
# 缓存未命中
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
if result is not None:
|
||||
try:
|
||||
# 回填 L1
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
local_cache_manager.set(cache_key, result)
|
||||
|
||||
# 回填 L2
|
||||
serialized_result = _serialize_result(result)
|
||||
if settings.CACHE_REDIS_TTL:
|
||||
await redis_client.setex(cache_key, settings.CACHE_REDIS_TTL, serialized_result)
|
||||
else:
|
||||
await redis_client.set(cache_key, serialized_result)
|
||||
except Exception as e:
|
||||
log.warning(f'[Cache] SET error: {e}')
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cache_invalidate( # noqa: C901
|
||||
name: str,
|
||||
*,
|
||||
key: str | None = None,
|
||||
key_builder: Callable[..., str] | None = None,
|
||||
atomic: bool = True,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
||||
"""
|
||||
缓存失效装饰器
|
||||
|
||||
:param name: 缓存名称(通常为缓存 Key 前缀)
|
||||
:param key: 从方法参数中获取指定参数名的值作为缓存 Key,与 key_builder 互斥
|
||||
:param key_builder: 自定义 Key 生成函数,与 key 互斥
|
||||
:param atomic: 是否保证缓存原子性
|
||||
:return:
|
||||
"""
|
||||
if key is not None and key_builder is not None:
|
||||
raise errors.ServerError(msg='缓存 key 和 key_builder 不能同时使用')
|
||||
|
||||
def decorator(func: Callable[P, T]) -> Callable[P, T]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 尝试失效缓存
|
||||
invalidate_success = False
|
||||
invalidate_error = None
|
||||
|
||||
try:
|
||||
invalidate_key = build_cache_key(name, key, key_builder, *args, **kwargs)
|
||||
|
||||
# L2 缓存失效
|
||||
if invalidate_key == name:
|
||||
await redis_client.delete(invalidate_key)
|
||||
else:
|
||||
await redis_client.delete_prefix(invalidate_key)
|
||||
|
||||
# L1 缓存失效
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
if invalidate_key == name:
|
||||
local_cache_manager.delete(invalidate_key)
|
||||
else:
|
||||
local_cache_manager.delete_prefix(invalidate_key)
|
||||
|
||||
# 广播失效消息(通知其他节点清除本地缓存)
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
if invalidate_key == name:
|
||||
await cache_pubsub_manager.publish_invalidation(invalidate_key)
|
||||
else:
|
||||
await cache_pubsub_manager.publish_invalidation(invalidate_key, is_delete_prefix=True)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'[Cache] INVALIDATE error: {e}')
|
||||
invalidate_error = e
|
||||
else:
|
||||
invalidate_success = True
|
||||
|
||||
# 原子性检查
|
||||
if atomic and not invalidate_success:
|
||||
raise errors.ServerError(msg='缓存失效失败,数据可能不一致', data=invalidate_error)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
from typing import Any
|
||||
|
||||
import cachebox
|
||||
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
class LocalCacheManager:
|
||||
"""本地缓存管理器"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.hot_cache: cachebox.TTLCache = cachebox.TTLCache(
|
||||
settings.CACHE_LOCAL_MAXSIZE, ttl=settings.CACHE_LOCAL_TTL
|
||||
)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""获取缓存"""
|
||||
try:
|
||||
return self.hot_cache[key]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""设置缓存"""
|
||||
self.hot_cache[key] = value
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""删除缓存"""
|
||||
try:
|
||||
del self.hot_cache[key]
|
||||
except KeyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空缓存"""
|
||||
self.hot_cache.clear()
|
||||
|
||||
def delete_prefix(self, prefix: str, exclude: str | list[str] | None = None) -> None:
|
||||
"""
|
||||
删除指定前缀的缓存
|
||||
|
||||
:param prefix: 要删除的键前缀
|
||||
:param exclude: 要排除的键或键列表
|
||||
:return:
|
||||
"""
|
||||
exclude_set = set(exclude) if isinstance(exclude, list) else {exclude} if isinstance(exclude, str) else set()
|
||||
for key in list(self.hot_cache.keys()):
|
||||
if key.startswith(prefix) and key not in exclude_set:
|
||||
try:
|
||||
del self.hot_cache[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
local_cache_manager = LocalCacheManager()
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from backend.common.cache.local import local_cache_manager
|
||||
from backend.common.log import log
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import RedisCli, redis_client
|
||||
|
||||
|
||||
class CachePubSubManager:
|
||||
"""缓存 Pub/Sub 管理器"""
|
||||
|
||||
_pubsub_task: asyncio.Task | None = None
|
||||
|
||||
@staticmethod
|
||||
async def publish_invalidation(key: str, *, is_delete_prefix: bool) -> None:
|
||||
"""
|
||||
发布缓存失效通知
|
||||
|
||||
:param key: 缓存键
|
||||
:param is_delete_prefix: 是否删除符合前缀的所有缓存
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
message = json.dumps({'key': key, 'is_delete_prefix': is_delete_prefix})
|
||||
await redis_client.publish(settings.CACHE_PUBSUB_CHANNEL, message)
|
||||
except Exception as e:
|
||||
log.warning(f'[CachePubSub] 发布通知失败: {e}')
|
||||
|
||||
@staticmethod
|
||||
async def subscribe_and_listen() -> None: # noqa: C901
|
||||
"""订阅并监听缓存失效通知"""
|
||||
reconnect_attempts = 0
|
||||
|
||||
while reconnect_attempts < settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS:
|
||||
pubsub_client: RedisCli | None = None
|
||||
pubsub = None
|
||||
|
||||
try:
|
||||
# 使用独立连接
|
||||
pubsub_client = RedisCli()
|
||||
pubsub = pubsub_client.pubsub()
|
||||
await pubsub.subscribe(settings.CACHE_PUBSUB_CHANNEL)
|
||||
|
||||
# 发布订阅成功
|
||||
reconnect_attempts = 0
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message':
|
||||
try:
|
||||
data = json.loads(message['data'])
|
||||
key = data['key']
|
||||
if not data['is_delete_prefix']:
|
||||
local_cache_manager.delete(key)
|
||||
else:
|
||||
local_cache_manager.delete_prefix(key)
|
||||
except json.JSONDecodeError as e:
|
||||
log.warning(f'[CachePubSub] 消息格式错误 {e}')
|
||||
except Exception as e:
|
||||
log.error(f'[CachePubSub] 处理通知失败: {e}')
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
reconnect_attempts += 1
|
||||
log.error(
|
||||
f'[CachePubSub] 订阅异常 ({reconnect_attempts}/{settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS}): {e}'
|
||||
)
|
||||
|
||||
if reconnect_attempts >= settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS:
|
||||
log.error('[CachePubSub] 达到最大重连次数,停止订阅')
|
||||
break
|
||||
|
||||
await asyncio.sleep(settings.CACHE_PUBSUB_RECONNECT_DELAY)
|
||||
finally:
|
||||
if pubsub_client:
|
||||
try:
|
||||
await pubsub_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
if pubsub:
|
||||
try:
|
||||
await pubsub.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def start_listener(cls) -> None:
|
||||
"""启动缓存 Pub/Sub 监听器"""
|
||||
if not settings.CACHE_LOCAL_ENABLED:
|
||||
return
|
||||
|
||||
if cls._pubsub_task is None or cls._pubsub_task.done():
|
||||
cls._pubsub_task = asyncio.create_task(cls.subscribe_and_listen())
|
||||
|
||||
@classmethod
|
||||
async def stop_listener(cls) -> None:
|
||||
"""停止缓存 Pub/Sub 监听器"""
|
||||
if cls._pubsub_task is None:
|
||||
return
|
||||
|
||||
if not cls._pubsub_task.done():
|
||||
cls._pubsub_task.cancel()
|
||||
try:
|
||||
await cls._pubsub_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
cls._pubsub_task = None
|
||||
|
||||
|
||||
cache_pubsub_manager = CachePubSubManager()
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
from backend.common.log import log
|
||||
from backend.database.db import async_db_session
|
||||
from backend.plugin.config.enums import ConfigType
|
||||
|
||||
|
||||
async def cache_warmup() -> None:
|
||||
"""缓存预热"""
|
||||
await _warmup_config()
|
||||
await _warmup_dict()
|
||||
|
||||
|
||||
async def _warmup_config() -> None:
|
||||
"""预热参数配置缓存"""
|
||||
try:
|
||||
from backend.plugin.config.service.config_service import config_service
|
||||
|
||||
async with async_db_session() as db:
|
||||
for type in ConfigType.get_member_values():
|
||||
await config_service.get_all(db=db, type=type)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f'[Warmup] 参数配置缓存预热失败: {e}')
|
||||
|
||||
|
||||
async def _warmup_dict() -> None:
|
||||
"""预热数据字典缓存"""
|
||||
try:
|
||||
from backend.plugin.dict.service.dict_data_service import dict_data_service
|
||||
|
||||
async with async_db_session() as db:
|
||||
await dict_data_service.get_all(db=db)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f'[Warmup] 数据字典缓存预热失败: {e}')
|
||||
@@ -21,6 +21,8 @@ class TypedContextProtocol(Protocol):
|
||||
permission: str | None
|
||||
language: str
|
||||
|
||||
user_id: int | None
|
||||
|
||||
|
||||
class TypedContext(TypedContextProtocol, _Context):
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
|
||||
@@ -40,7 +40,8 @@ class RequestPermission:
|
||||
if settings.RBAC_ROLE_MENU_MODE:
|
||||
if not isinstance(self.value, str):
|
||||
raise errors.ServerError
|
||||
# 附加权限标识到请求状态
|
||||
|
||||
# 设置权限标识到上下文
|
||||
ctx.permission = self.value
|
||||
|
||||
|
||||
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
import shutil
|
||||
|
||||
from functools import lru_cache
|
||||
from functools import cache
|
||||
from re import Pattern
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -68,6 +68,17 @@ class Settings(BaseSettings):
|
||||
# Redis
|
||||
REDIS_TIMEOUT: int = 5
|
||||
|
||||
# 缓存
|
||||
CACHE_LOCAL_ENABLED: bool = True
|
||||
CACHE_LOCAL_MAXSIZE: int = 100000
|
||||
CACHE_LOCAL_TTL: int = 60 * 60 * 2 # 2 小时
|
||||
CACHE_REDIS_TTL: int = 60 * 60 * 2 # 2 小时
|
||||
CACHE_CONFIG_REDIS_PREFIX: str = 'fba:cache:config'
|
||||
CACHE_DICT_REDIS_PREFIX: str = 'fba:cache:dict'
|
||||
CACHE_PUBSUB_CHANNEL: str = 'fba:cache:invalidate'
|
||||
CACHE_PUBSUB_RECONNECT_DELAY: int = 5 # 重连延迟(秒)
|
||||
CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS: int = 10 # 最大重连次数
|
||||
|
||||
# .env Snowflake
|
||||
SNOWFLAKE_DATACENTER_ID: int | None = None
|
||||
SNOWFLAKE_WORKER_ID: int | None = None
|
||||
@@ -213,6 +224,7 @@ class Settings(BaseSettings):
|
||||
'new_password',
|
||||
'confirm_password',
|
||||
]
|
||||
OPERA_LOG_QUEUE_MAXSIZE: int = 100000
|
||||
OPERA_LOG_QUEUE_BATCH_CONSUME_SIZE: int = 100
|
||||
OPERA_LOG_QUEUE_TIMEOUT: int = 60 # 1 分钟
|
||||
|
||||
@@ -303,7 +315,7 @@ class Settings(BaseSettings):
|
||||
return values
|
||||
|
||||
|
||||
@lru_cache
|
||||
@cache
|
||||
def get_settings() -> Settings:
|
||||
"""获取全局配置单例"""
|
||||
if not ENV_FILE_PATH.exists():
|
||||
|
||||
@@ -17,6 +17,8 @@ from starlette_context.middleware import ContextMiddleware
|
||||
from starlette_context.plugins import RequestIdPlugin
|
||||
|
||||
from backend import __version__
|
||||
from backend.common.cache.pubsub import cache_pubsub_manager
|
||||
from backend.common.cache.warmup import cache_warmup
|
||||
from backend.common.exception.exception_handler import register_exception
|
||||
from backend.common.log import set_custom_logfile, setup_logging
|
||||
from backend.common.response.response_code import StandardResponseCode
|
||||
@@ -66,8 +68,17 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# 创建操作日志任务
|
||||
create_task(OperaLogMiddleware.consumer())
|
||||
|
||||
# 缓存预热
|
||||
await cache_warmup()
|
||||
|
||||
# 启动缓存 Pub/Sub 监听器
|
||||
cache_pubsub_manager.start_listener()
|
||||
|
||||
yield
|
||||
|
||||
# 停止缓存 Pub/Sub 监听器
|
||||
await cache_pubsub_manager.stop_listener()
|
||||
|
||||
# 释放 snowflake 节点
|
||||
await snowflake.shutdown()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from starlette.authentication import AuthenticationError as StarletteAuthenticat
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||
from backend.common.context import ctx
|
||||
from backend.common.exception.errors import TokenError
|
||||
from backend.common.log import log
|
||||
from backend.common.security.jwt import jwt_authentication
|
||||
@@ -95,6 +96,9 @@ class JwtAuthMiddleware(AuthenticationBackend):
|
||||
log.exception(f'JWT 授权异常:{e}')
|
||||
raise AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error'))
|
||||
|
||||
# 设置用户 ID 到上下文
|
||||
ctx.user_id = user.id
|
||||
|
||||
# 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
|
||||
# 标准返回模式请查看:https://www.starlette.io/authentication/
|
||||
return AuthCredentials(['authenticated']), user
|
||||
|
||||
@@ -31,7 +31,7 @@ from backend.utils.trace_id import get_request_trace_id
|
||||
class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
"""操作日志中间件"""
|
||||
|
||||
opera_log_queue: Queue = Queue(maxsize=100000)
|
||||
opera_log_queue: Queue = Queue(maxsize=settings.OPERA_LOG_QUEUE_MAXSIZE)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
"""
|
||||
|
||||
@@ -3,8 +3,10 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.common.cache.decorator import cache_invalidate, cached
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.core.conf import settings
|
||||
from backend.plugin.config.crud.crud_config import config_dao
|
||||
from backend.plugin.config.model import Config
|
||||
from backend.plugin.config.schema.config import (
|
||||
@@ -26,13 +28,16 @@ class ConfigService:
|
||||
:param pk: 参数配置 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
config = await config_dao.get(db, pk)
|
||||
if not config:
|
||||
raise errors.NotFoundError(msg='参数配置不存在')
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
@cached(
|
||||
settings.CACHE_CONFIG_REDIS_PREFIX,
|
||||
key_builder=lambda *, db, type: f'type:{type}',
|
||||
)
|
||||
async def get_all(*, db: AsyncSession, type: str | None) -> Sequence[Config | None]:
|
||||
"""
|
||||
获取所有参数配置
|
||||
@@ -41,7 +46,6 @@ class ConfigService:
|
||||
:param type: 参数配置类型
|
||||
:return:
|
||||
"""
|
||||
|
||||
return await config_dao.get_all(db, type)
|
||||
|
||||
@staticmethod
|
||||
@@ -58,6 +62,7 @@ class ConfigService:
|
||||
return await paging_data(db, config_select)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def create(*, db: AsyncSession, obj: CreateConfigParam) -> None:
|
||||
"""
|
||||
创建参数配置
|
||||
@@ -66,13 +71,13 @@ class ConfigService:
|
||||
:param obj: 参数配置创建参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
config = await config_dao.get_by_key(db, obj.key)
|
||||
if config:
|
||||
raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在')
|
||||
await config_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateConfigParam) -> int:
|
||||
"""
|
||||
更新参数配置
|
||||
@@ -82,7 +87,6 @@ class ConfigService:
|
||||
:param obj: 参数配置更新参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
config = await config_dao.get(db, pk)
|
||||
if not config:
|
||||
raise errors.NotFoundError(msg='参数配置不存在')
|
||||
@@ -94,6 +98,7 @@ class ConfigService:
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def bulk_update(*, db: AsyncSession, objs: list[UpdateConfigsParam]) -> int:
|
||||
"""
|
||||
批量更新参数配置
|
||||
@@ -102,7 +107,6 @@ class ConfigService:
|
||||
:param objs: 参数配置批量更新参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
for _batch in range(0, len(objs), 1000):
|
||||
for obj in objs:
|
||||
config = await config_dao.get(db, obj.id)
|
||||
@@ -116,6 +120,7 @@ class ConfigService:
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def delete(*, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除参数配置
|
||||
@@ -124,7 +129,6 @@ class ConfigService:
|
||||
:param pks: 参数配置 ID 列表
|
||||
:return:
|
||||
"""
|
||||
|
||||
count = await config_dao.delete(db, pks)
|
||||
return count
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ from backend.utils.async_helper import run_await
|
||||
from backend.utils.dynamic_import import get_model_objects, import_module_cached
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_plugins() -> list[str]:
|
||||
@lru_cache(maxsize=128)
|
||||
def get_plugins() -> tuple[str, ...]:
|
||||
"""获取插件列表"""
|
||||
plugin_packages = []
|
||||
|
||||
@@ -37,7 +37,7 @@ def get_plugins() -> list[str]:
|
||||
if os.path.isdir(item_path) and '__init__.py' in os.listdir(item_path):
|
||||
plugin_packages.append(item)
|
||||
|
||||
return plugin_packages
|
||||
return tuple(plugin_packages)
|
||||
|
||||
|
||||
def get_plugin_models() -> list[object]:
|
||||
@@ -104,7 +104,7 @@ def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
|
||||
plugins = get_plugins()
|
||||
|
||||
# 使用独立单例,避免与主线程冲突
|
||||
# 使用独立连接
|
||||
current_redis_client = RedisCli()
|
||||
run_await(current_redis_client.init)()
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.common.cache.decorator import cache_invalidate, cached
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.core.conf import settings
|
||||
from backend.plugin.dict.crud.crud_dict_data import dict_data_dao
|
||||
from backend.plugin.dict.crud.crud_dict_type import dict_type_dao
|
||||
from backend.plugin.dict.model import DictData
|
||||
@@ -23,13 +25,16 @@ class DictDataService:
|
||||
:param pk: 字典数据 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
dict_data = await dict_data_dao.get(db, pk)
|
||||
if not dict_data:
|
||||
raise errors.NotFoundError(msg='字典数据不存在')
|
||||
return dict_data
|
||||
|
||||
@staticmethod
|
||||
@cached(
|
||||
settings.CACHE_DICT_REDIS_PREFIX,
|
||||
key_builder=lambda *, db, code: f'type:{code}',
|
||||
)
|
||||
async def get_by_type_code(*, db: AsyncSession, code: str) -> Sequence[DictData]:
|
||||
"""
|
||||
获取字典数据详情
|
||||
@@ -38,13 +43,16 @@ class DictDataService:
|
||||
:param code: 字典类型编码
|
||||
:return:
|
||||
"""
|
||||
|
||||
dict_datas = await dict_data_dao.get_by_type_code(db, code)
|
||||
if not dict_datas:
|
||||
raise errors.NotFoundError(msg='字典数据不存在')
|
||||
return dict_datas
|
||||
|
||||
@staticmethod
|
||||
@cached(
|
||||
settings.CACHE_DICT_REDIS_PREFIX,
|
||||
key_builder=lambda *, db: 'all',
|
||||
)
|
||||
async def get_all(*, db: AsyncSession) -> Sequence[DictData]:
|
||||
"""
|
||||
获取所有字典数据
|
||||
@@ -86,6 +94,7 @@ class DictDataService:
|
||||
return await paging_data(db, dict_data_select)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_DICT_REDIS_PREFIX)
|
||||
async def create(*, db: AsyncSession, obj: CreateDictDataParam) -> None:
|
||||
"""
|
||||
创建字典数据
|
||||
@@ -103,6 +112,7 @@ class DictDataService:
|
||||
await dict_data_dao.create(db, obj, dict_type.code)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_DICT_REDIS_PREFIX)
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateDictDataParam) -> int:
|
||||
"""
|
||||
更新字典数据
|
||||
@@ -112,7 +122,6 @@ class DictDataService:
|
||||
:param obj: 字典数据更新参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
dict_data = await dict_data_dao.get(db, pk)
|
||||
if not dict_data:
|
||||
raise errors.NotFoundError(msg='字典数据不存在')
|
||||
@@ -127,6 +136,7 @@ class DictDataService:
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_DICT_REDIS_PREFIX)
|
||||
async def delete(*, db: AsyncSession, obj: DeleteDictDataParam) -> int:
|
||||
"""
|
||||
批量删除字典数据
|
||||
@@ -135,7 +145,6 @@ class DictDataService:
|
||||
:param obj: 字典数据 ID 列表
|
||||
:return:
|
||||
"""
|
||||
|
||||
count = await dict_data_dao.delete(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ async def install_zip_plugin(file: UploadFile | str) -> str:
|
||||
|
||||
await _append_env_example(full_plugin_path)
|
||||
await install_requirements_async(plugin_dir_name)
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
return plugin_name
|
||||
|
||||
@@ -133,6 +133,6 @@ async def install_git_plugin(repo_url: str) -> str:
|
||||
|
||||
await _append_env_example(path)
|
||||
await install_requirements_async(repo_name)
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
return repo_name
|
||||
|
||||
@@ -9,20 +9,10 @@ from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import PLUGIN_DIR
|
||||
from backend.plugin.core import get_plugins
|
||||
from backend.plugin.errors import PluginInstallError
|
||||
|
||||
|
||||
def get_plugins() -> list[str]:
|
||||
"""
|
||||
获取插件列表
|
||||
|
||||
注意:此函数从 backend.plugin.core 导入以避免循环依赖
|
||||
"""
|
||||
from backend.plugin.core import get_plugins as _get_plugins
|
||||
|
||||
return _get_plugins()
|
||||
|
||||
|
||||
def _is_in_virtualenv() -> bool:
|
||||
"""检测当前是否在虚拟环境中运行"""
|
||||
return hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)
|
||||
|
||||
@@ -13,7 +13,7 @@ from backend.common.log import log
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
@lru_cache(maxsize=128)
|
||||
def import_module_cached(module_path: str) -> Any:
|
||||
"""
|
||||
缓存导入模块
|
||||
@@ -84,9 +84,9 @@ def get_app_models() -> list[object]:
|
||||
return objs
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_all_models() -> list[object]:
|
||||
@lru_cache(256)
|
||||
def get_all_models() -> tuple[object, ...]:
|
||||
"""获取所有模型类"""
|
||||
from backend.plugin.core import get_plugin_models
|
||||
|
||||
return get_app_models() + get_plugin_models()
|
||||
return tuple(get_app_models() + get_plugin_models())
|
||||
|
||||
Reference in New Issue
Block a user