diff --git a/.env.example b/.env.example index 10f5bd88..aa73850c 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ LOG_DIR="/opt/sqlbot/logs" LOG_FORMAT="%(asctime)s - %(name)s - %(levelname)s:%(lineno)d - %(message)s" SQL_DEBUG=False +CACHE_TYPE="memory" +CACHE_REDIS_URL="redis://127.0.0.1:6379" + # Postgres POSTGRES_SERVER=localhost POSTGRES_PORT=5432 diff --git a/backend/common/core/config.py b/backend/common/core/config.py index f9026b09..482f4d61 100644 --- a/backend/common/core/config.py +++ b/backend/common/core/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): DEFAULT_PWD: str = "SQLBot@123456" ASSISTANT_TOKEN_KEY: str = "X-SQLBOT-ASSISTANT-TOKEN" + CACHE_TYPE: Literal["redis", "memory", "None"] = "memory" + CACHE_REDIS_URL: str | None = None # Redis URL, e.g., "redis://[[username]:[password]]@localhost:6379/0" + LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR LOG_DIR: str = "logs" LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s:%(lineno)d - %(message)s" diff --git a/backend/common/core/sqlbot_cache.py b/backend/common/core/sqlbot_cache.py index 15d58308..cce07c06 100644 --- a/backend/common/core/sqlbot_cache.py +++ b/backend/common/core/sqlbot_cache.py @@ -1,52 +1,12 @@ -import contextvars from fastapi_cache import FastAPICache from functools import partial, wraps from typing import Optional, Any, Dict, Tuple from inspect import signature -from contextlib import asynccontextmanager -import asyncio -import random -from collections import defaultdict - -from apps.system.schemas.auth import CacheName -from apps.system.schemas.system_schema import UserInfoDTO +from common.core.config import settings from common.utils.utils import SQLBotLogUtil +from fastapi_cache.backends.inmemory import InMemoryBackend -# 使用contextvar来跟踪当前线程已持有的锁 -_held_locks = contextvars.ContextVar('held_locks', default=set()) -# 高效锁管理器 -class LockManager: - _locks = defaultdict(asyncio.Lock) - - @classmethod - def get_lock(cls, key: str) -> asyncio.Lock: - return cls._locks[key] - -@asynccontextmanager -async def _get_cache_lock(key: str): - # 获取当前已持有的锁集合 - current_locks = _held_locks.get() - - # 如果已经持有这个锁,直接yield(锁传递) - if key in current_locks: - yield - return - - # 否则获取锁并添加到当前上下文中 - lock = LockManager.get_lock(key) - try: - await lock.acquire() - # 更新当前持有的锁集合 - new_locks = current_locks | {key} - token = _held_locks.set(new_locks) - - yield - - finally: - # 恢复之前的锁集合 - _held_locks.reset(token) - if lock.locked(): - lock.release() +from fastapi_cache.decorator import cache as original_cache def custom_key_builder( func: Any, @@ -93,7 +53,6 @@ def cache( *, cacheName: str, # 必须提供cacheName keyExpression: Optional[str] = None, - jitter: int = 60, # 默认抖动60秒 ): def decorator(func): # 预先生成key builder @@ -105,40 +64,22 @@ def cache( @wraps(func) async def wrapper(*args, **kwargs): + if not settings.CACHE_TYPE or settings.CACHE_TYPE.lower() == "none": + return await func(*args, **kwargs) # 生成缓存键 cache_key = used_key_builder( func=func, - namespace=namespace, + namespace=str(namespace) if namespace else "", args=args, kwargs=kwargs ) - # 防击穿锁 - async with _get_cache_lock(cache_key): - backend = FastAPICache.get_backend() - - # 双重检查 - if (cached := await backend.get(cache_key)) is not None: - SQLBotLogUtil.debug(f"Cache hit: {cache_key}") - if CacheName.USER_INFO.value in cache_key: - user = UserInfoDTO.model_validate(cached) - SQLBotLogUtil.info(f"User cache hit: [uid: {user.id}, account: {user.account}, oid: {user.oid}]") - return cached - - # 执行函数并缓存结果 - result = await func(*args, **kwargs) - - actual_expire = expire + random.randint(-jitter, jitter) - if await backend.get(cache_key): - await backend.clear(cache_key) - await backend.set(cache_key, result, actual_expire) - - SQLBotLogUtil.debug(f"Cache set: {cache_key} (expire: {actual_expire}s)") - if CacheName.USER_INFO.value in cache_key: - user = UserInfoDTO.model_validate(result) - SQLBotLogUtil.info(f"User cache set: [uid: {user.id}, account: {user.account}, oid: {user.oid}]") - return result - + return await original_cache( + expire=expire, + namespace=str(namespace) if namespace else "", + key_builder=lambda *_, **__: cache_key + )(func)(*args, **kwargs) + return wrapper return decorator @@ -148,30 +89,47 @@ def clear_cache( cacheName: str, keyExpression: Optional[str] = None, ): - """精确清除单个缓存项的装饰器""" def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): + if not settings.CACHE_TYPE or settings.CACHE_TYPE.lower() == "none": + return await func(*args, **kwargs) cache_key = custom_key_builder( func=func, - namespace=namespace, + namespace=str(namespace) if namespace else "", args=args, kwargs=kwargs, cacheName=cacheName, keyExpression=keyExpression, ) - - # 加锁防止竞争 - async with _get_cache_lock(cache_key): - backend = FastAPICache.get_backend() - result = None - if await backend.get(cache_key): - await backend.clear(cache_key) - result = await func(*args, **kwargs) - if await backend.get(cache_key): - await backend.clear(cache_key) - SQLBotLogUtil.info(f"Cache cleared: {cache_key}") - return result + backend = FastAPICache.get_backend() + if await backend.get(cache_key): + if settings.CACHE_TYPE.lower() == "redis": + redis = backend.redis + await redis.delete(cache_key) + else: + await backend.clear(key=cache_key) + SQLBotLogUtil.debug(f"Cache cleared: {cache_key}") + return await func(*args, **kwargs) return wrapper - return decorator \ No newline at end of file + return decorator + + +def init_sqlbot_cache(): + cache_type: str = settings.CACHE_TYPE + if cache_type == "memory": + FastAPICache.init(InMemoryBackend()) + SQLBotLogUtil.info("SQLBot 使用内存缓存, 仅支持单进程模式") + elif cache_type == "redis": + from fastapi_cache.backends.redis import RedisBackend + import redis.asyncio as redis + from redis.asyncio.connection import ConnectionPool + redis_url = settings.CACHE_REDIS_URL or "redis://localhost:6379/0" + pool = ConnectionPool.from_url(url=redis_url) + redis_client = redis.Redis(connection_pool=pool) + FastAPICache.init(RedisBackend(redis_client), prefix="sqlbot-cache") + SQLBotLogUtil.info(f"SQLBot 使用Redis缓存, 可使用多进程模式") + else: + SQLBotLogUtil.warning("SQLBot 未启用缓存, 可使用多进程模式") + \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 54410660..c4571893 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,10 +14,9 @@ from alembic import command from fastapi_mcp import FastApiMCP from fastapi.staticfiles import StaticFiles import sqlbot_xpack -from fastapi_cache import FastAPICache -from fastapi_cache.backends.inmemory import InMemoryBackend from common.utils.utils import SQLBotLogUtil +from common.core.sqlbot_cache import init_sqlbot_cache def run_migrations(): alembic_cfg = Config("alembic.ini") @@ -27,7 +26,7 @@ def run_migrations(): @asynccontextmanager async def lifespan(app: FastAPI): run_migrations() - FastAPICache.init(InMemoryBackend()) + init_sqlbot_cache() init_dynamic_cors(app) SQLBotLogUtil.info("✅ SQLBot 初始化完成") yield diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 093e01ce..1de57be8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "sqlbot-xpack==0.0.3.7", "fastapi-cache2>=0.2.2", "sqlparse>=0.5.3", + "redis>=6.2.0", ] [[tool.uv.index]] name = "default" diff --git a/frontend/src/assets/embedded/icon_embedded.svg b/frontend/src/assets/embedded/icon_embedded.svg new file mode 100644 index 00000000..077648fd --- /dev/null +++ b/frontend/src/assets/embedded/icon_embedded.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/svg/menu/icon-setting.svg b/frontend/src/assets/svg/menu/icon-setting.svg new file mode 100644 index 00000000..41335368 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon-setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_chat_filled.svg b/frontend/src/assets/svg/menu/icon_chat_filled.svg new file mode 100644 index 00000000..93417643 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_chat_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_chat_outlined.svg b/frontend/src/assets/svg/menu/icon_chat_outlined.svg new file mode 100644 index 00000000..e679fef4 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_chat_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_dashboard_filled.svg b/frontend/src/assets/svg/menu/icon_dashboard_filled.svg new file mode 100644 index 00000000..7fd632b9 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_dashboard_filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/svg/menu/icon_dashboard_outlined.svg b/frontend/src/assets/svg/menu/icon_dashboard_outlined.svg new file mode 100644 index 00000000..a209c844 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_dashboard_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_database_filled.svg b/frontend/src/assets/svg/menu/icon_database_filled.svg new file mode 100644 index 00000000..46c5d5cc --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_database_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/svg/menu/icon_database_outlined.svg b/frontend/src/assets/svg/menu/icon_database_outlined.svg new file mode 100644 index 00000000..5a8f405f --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_database_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_dataset_filled.svg b/frontend/src/assets/svg/menu/icon_dataset_filled.svg new file mode 100644 index 00000000..923fe6d6 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_dataset_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_dataset_outlined.svg b/frontend/src/assets/svg/menu/icon_dataset_outlined.svg new file mode 100644 index 00000000..891ab45f --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_dataset_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_embedded_filled.svg b/frontend/src/assets/svg/menu/icon_embedded_filled.svg new file mode 100644 index 00000000..70a595c8 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_embedded_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_embedded_outlined.svg b/frontend/src/assets/svg/menu/icon_embedded_outlined.svg new file mode 100644 index 00000000..077648fd --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_embedded_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/svg/menu/icon_member_filled.svg b/frontend/src/assets/svg/menu/icon_member_filled.svg new file mode 100644 index 00000000..fded3173 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_member_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/svg/menu/icon_member_outlined.svg b/frontend/src/assets/svg/menu/icon_member_outlined.svg new file mode 100644 index 00000000..0424c855 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_member_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/menu/icon_moments-categories_filled.svg b/frontend/src/assets/svg/menu/icon_moments-categories_filled.svg new file mode 100644 index 00000000..7b4cde3b --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_moments-categories_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/svg/menu/icon_moments-categories_outlined.svg b/frontend/src/assets/svg/menu/icon_moments-categories_outlined.svg new file mode 100644 index 00000000..88cef093 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_moments-categories_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/svg/menu/icon_setting_filled.svg b/frontend/src/assets/svg/menu/icon_setting_filled.svg new file mode 100644 index 00000000..8c1a1927 --- /dev/null +++ b/frontend/src/assets/svg/menu/icon_setting_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/layout/MenuItem.vue b/frontend/src/components/layout/MenuItem.vue index 31a9af05..2995ade5 100644 --- a/frontend/src/components/layout/MenuItem.vue +++ b/frontend/src/components/layout/MenuItem.vue @@ -1,34 +1,43 @@