mirror of
https://gh-proxy.org/https://github.com/klinsmaya/SQLBot.git
synced 2026-11-08 01:06:59 +08:00
feat: Backend cache
This commit is contained in:
@@ -29,5 +29,5 @@ def upgrade():
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(op.f('ix_sys_user_ws_id'), table_name='sys_user_ws')
|
||||
#op.drop_index(op.f('ix_sys_user_ws_id'), table_name='sys_user_ws')
|
||||
op.drop_table('sys_user_ws')
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
from apps.system.crud.user import get_db_user
|
||||
from apps.system.models.user import UserModel
|
||||
from apps.system.schemas.auth import CacheName, CacheNamespace
|
||||
from apps.system.schemas.system_schema import PwdEditor, UserCreator, UserEditor, UserGrid, UserLanguage
|
||||
from common.core.deps import CurrentUser, SessionDep
|
||||
from common.core.pagination import Paginator
|
||||
from common.core.schemas import PaginatedResponse, PaginationParams
|
||||
from common.core.security import md5pwd, verify_md5pwd
|
||||
|
||||
from common.core.sqlbot_cache import clear_cache
|
||||
router = APIRouter(tags=["user"], prefix="/user")
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def user_info(session: SessionDep, current_user: CurrentUser):
|
||||
db_user = get_db_user(session=session, user_id=current_user.id)
|
||||
if not db_user:
|
||||
return {"message": "User not found"}
|
||||
db_user.password = None
|
||||
return db_user
|
||||
async def user_info(current_user: CurrentUser):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[UserGrid])
|
||||
@@ -48,6 +44,7 @@ async def create(session: SessionDep, creator: UserCreator):
|
||||
session.commit()
|
||||
|
||||
@router.put("")
|
||||
@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="editor.id")
|
||||
async def update(session: SessionDep, editor: UserEditor):
|
||||
user_model: UserModel = get_db_user(session = session, user_id = editor.id)
|
||||
data = editor.model_dump(exclude_unset=True)
|
||||
@@ -55,13 +52,15 @@ async def update(session: SessionDep, editor: UserEditor):
|
||||
session.add(user_model)
|
||||
session.commit()
|
||||
|
||||
@router.delete("/{id}")
|
||||
@router.delete("/{id}")
|
||||
@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id")
|
||||
async def delete(session: SessionDep, id: int):
|
||||
user_model: UserModel = get_db_user(session = session, user_id = id)
|
||||
session.delete(user_model)
|
||||
session.commit()
|
||||
|
||||
@router.put("/language")
|
||||
@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id")
|
||||
async def langChange(session: SessionDep, current_user: CurrentUser, language: UserLanguage):
|
||||
lang = language.language
|
||||
if lang not in ["zh-CN", "en"]:
|
||||
@@ -73,6 +72,7 @@ async def langChange(session: SessionDep, current_user: CurrentUser, language: U
|
||||
return {"message": "Language changed successfully", "language": lang}
|
||||
|
||||
@router.put("/pwd")
|
||||
@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id")
|
||||
async def pwdUpdate(session: SessionDep, current_user: CurrentUser, editor: PwdEditor):
|
||||
db_user: UserModel = get_db_user(session=session, user_id=current_user.id)
|
||||
if not verify_md5pwd(editor.pwd, db_user.password):
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from apps.system.schemas.system_schema import BaseUserDTO
|
||||
from apps.system.models.system_model import UserWsModel
|
||||
from apps.system.schemas.auth import CacheName, CacheNamespace
|
||||
from apps.system.schemas.system_schema import BaseUserDTO, UserInfoDTO
|
||||
from common.core.sqlbot_cache import cache
|
||||
from ..models.user import UserModel
|
||||
from common.core.security import verify_md5pwd
|
||||
|
||||
def get_db_user(*, session: Session, user_id: int) -> UserModel:
|
||||
db_user = session.get(UserModel, user_id)
|
||||
if not db_user:
|
||||
raise RuntimeError("user not exist")
|
||||
return db_user
|
||||
|
||||
def get_user_by_account(*, session: Session, account: str) -> BaseUserDTO | None:
|
||||
@@ -18,9 +19,16 @@ def get_user_by_account(*, session: Session, account: str) -> BaseUserDTO | None
|
||||
return None
|
||||
return BaseUserDTO.model_validate(db_user.model_dump())
|
||||
|
||||
def get_user_info(*, session: Session, user_id: int) -> BaseUserDTO | None:
|
||||
db_user = get_db_user(session = session, user_id = user_id)
|
||||
return BaseUserDTO.model_validate(db_user.model_dump())
|
||||
@cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="user_id")
|
||||
async def get_user_info(*, session: Session, user_id: int) -> UserInfoDTO | None:
|
||||
db_user: UserModel = get_db_user(session = session, user_id = user_id)
|
||||
userInfo = UserInfoDTO.model_validate(db_user.model_dump())
|
||||
userInfo.isAdmin = userInfo.id == 1 and userInfo.account == 'admin'
|
||||
if userInfo.isAdmin:
|
||||
return userInfo
|
||||
ws_model: UserWsModel = session.exec(select(UserWsModel).where(UserWsModel.uid == userInfo.id, UserWsModel.oid == userInfo.oid)).first()
|
||||
userInfo.weight = ws_model.weight if ws_model else -1
|
||||
return userInfo
|
||||
|
||||
def authenticate(*, session: Session, account: str, password: str) -> BaseUserDTO | None:
|
||||
db_user = get_user_by_account(session=session, account=account)
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from enum import Enum
|
||||
|
||||
class LocalLoginSchema(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
password: str
|
||||
|
||||
class CacheNamespace(Enum):
|
||||
AUTH_INFO = "sqlbot:auth"
|
||||
def __str__(self):
|
||||
return self.value
|
||||
class CacheName(Enum):
|
||||
USER_INFO = "user:info"
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
@@ -25,8 +25,6 @@ class BaseUserDTO(BaseUser, BaseCreatorDTO):
|
||||
"oid": self.oid
|
||||
}
|
||||
|
||||
|
||||
|
||||
class UserCreator(BaseUser):
|
||||
name: str
|
||||
email: str
|
||||
@@ -47,4 +45,10 @@ class UserWsBase(BaseModel):
|
||||
uid: int
|
||||
oid: int
|
||||
class UserWsDTO(UserWsBase):
|
||||
weight: int = 0
|
||||
weight: int = 0
|
||||
|
||||
|
||||
class UserInfoDTO(UserEditor):
|
||||
language: str = "zh-CN"
|
||||
weight: int = 0
|
||||
isAdmin: bool = False
|
||||
+10
-12
@@ -6,13 +6,12 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session
|
||||
from apps.system.crud.user import get_db_user
|
||||
from apps.system.schemas.system_schema import BaseUserDTO
|
||||
from apps.system.crud.user import get_user_info
|
||||
from apps.system.schemas.system_schema import UserInfoDTO
|
||||
from common.core.schemas import TokenPayload, XOAuth2PasswordBearer
|
||||
from common.core import security
|
||||
from common.core.config import settings
|
||||
from common.core.db import get_session
|
||||
from apps.system.models.user import UserModel
|
||||
from common.utils.locale import I18n
|
||||
reusable_oauth2 = XOAuth2PasswordBearer(
|
||||
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
||||
@@ -28,7 +27,7 @@ async def get_i18n(request: Request):
|
||||
return i18n(request)
|
||||
|
||||
Trans = Annotated[I18n, Depends(get_i18n)]
|
||||
async def get_current_user(session: SessionDep, token: TokenDep) -> BaseUserDTO:
|
||||
async def get_current_user(session: SessionDep, token: TokenDep) -> UserInfoDTO:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||
@@ -39,16 +38,15 @@ async def get_current_user(session: SessionDep, token: TokenDep) -> BaseUserDTO:
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
session_user: UserModel = get_db_user(session = session, user_id = token_data.id)
|
||||
session_user = await get_user_info(session = session, user_id = token_data.id)
|
||||
session_user = UserInfoDTO.model_validate(session_user)
|
||||
if not session_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user = BaseUserDTO.model_validate(session_user.model_dump())
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
""" if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user") """
|
||||
return user
|
||||
CurrentUser = Annotated[BaseUserDTO, Depends(get_current_user)]
|
||||
|
||||
if session_user.status != 1:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return session_user
|
||||
CurrentUser = Annotated[UserInfoDTO, Depends(get_current_user)]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.decorator import cache as original_cache
|
||||
from functools import partial, wraps
|
||||
from typing import Optional, Set, Any, Dict, Tuple
|
||||
from inspect import Parameter, signature
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def should_skip_param(param: Parameter) -> bool:
|
||||
"""判断参数是否应该被忽略(依赖注入参数)"""
|
||||
return (
|
||||
param.kind == Parameter.VAR_KEYWORD or # **kwargs
|
||||
param.kind == Parameter.VAR_POSITIONAL or # *args
|
||||
hasattr(param.annotation, "__module__") and
|
||||
param.annotation.__module__.startswith(('fastapi', 'starlette', "sqlmodel.orm.session"))
|
||||
)
|
||||
|
||||
def custom_key_builder(
|
||||
func: Any,
|
||||
namespace: str = "",
|
||||
*,
|
||||
args: Tuple[Any, ...] = (),
|
||||
kwargs: Dict[str, Any],
|
||||
additional_skip_args: Optional[Set[str]] = None,
|
||||
cacheName: Optional[str] = None,
|
||||
keyExpression: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
完全兼容FastAPICache的键生成器
|
||||
"""
|
||||
if cacheName:
|
||||
base_key = f"{namespace}:{cacheName}:"
|
||||
|
||||
if keyExpression:
|
||||
try:
|
||||
sig = signature(func)
|
||||
bound_args = sig.bind_partial(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
|
||||
if keyExpression.startswith("args["):
|
||||
import re
|
||||
match = re.match(r"args\[(\d+)\]", keyExpression)
|
||||
if match:
|
||||
index = int(match.group(1))
|
||||
value = bound_args.args[index]
|
||||
base_key += f"{value}:"
|
||||
else:
|
||||
|
||||
parts = keyExpression.split('.')
|
||||
value = bound_args.arguments[parts[0]]
|
||||
for part in parts[1:]:
|
||||
value = getattr(value, part)
|
||||
base_key += f"{value}:"
|
||||
|
||||
except (IndexError, KeyError, AttributeError) as e:
|
||||
logger.warning(f"Failed to evaluate keyExpression '{keyExpression}': {str(e)}")
|
||||
|
||||
return base_key
|
||||
# 获取函数签名
|
||||
sig = signature(func)
|
||||
|
||||
# 自动识别要跳过的参数
|
||||
auto_skip_args = {
|
||||
name for name, param in sig.parameters.items()
|
||||
if should_skip_param(param)
|
||||
}
|
||||
|
||||
# 合并用户指定的额外跳过参数
|
||||
skip_args = auto_skip_args.union(additional_skip_args or set())
|
||||
|
||||
# 过滤kwargs
|
||||
filtered_kwargs = {
|
||||
k: v for k, v in kwargs.items() if k not in skip_args
|
||||
}
|
||||
|
||||
# 过滤args - 将位置参数映射到它们的参数名
|
||||
bound_args = sig.bind_partial(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
filtered_args = []
|
||||
for i, (name, value) in enumerate(bound_args.arguments.items()):
|
||||
# 只处理位置参数 (在args中的参数)
|
||||
if i < len(args) and name not in skip_args:
|
||||
filtered_args.append(value)
|
||||
filtered_args = tuple(filtered_args)
|
||||
|
||||
# 获取默认键生成器
|
||||
default_key_builder = FastAPICache.get_key_builder()
|
||||
# 调用默认键生成器(严格按照其要求的参数格式)
|
||||
return default_key_builder(
|
||||
func=func,
|
||||
namespace=namespace,
|
||||
args=filtered_args,
|
||||
kwargs=filtered_kwargs,
|
||||
)
|
||||
|
||||
def cache(
|
||||
expire: Optional[int] = 60 * 60 * 24,
|
||||
namespace: Optional[str] = None,
|
||||
key_builder: Optional[Any] = None,
|
||||
*,
|
||||
additional_skip_args: Optional[Set[str]] = None,
|
||||
cacheName: Optional[str] = None,
|
||||
keyExpression: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
完全兼容的缓存装饰器
|
||||
"""
|
||||
def decorator(func):
|
||||
if key_builder is None:
|
||||
used_key_builder = partial(
|
||||
custom_key_builder,
|
||||
additional_skip_args=additional_skip_args,
|
||||
cacheName=cacheName,
|
||||
keyExpression=keyExpression
|
||||
)
|
||||
else:
|
||||
used_key_builder = key_builder
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 准备键生成器参数
|
||||
key_builder_args = {
|
||||
"func": func,
|
||||
"namespace": namespace,
|
||||
"args": args,
|
||||
"kwargs": kwargs
|
||||
}
|
||||
|
||||
# 生成缓存键
|
||||
cache_key = used_key_builder(**key_builder_args)
|
||||
logger.debug(f"Generated cache key: {cache_key}")
|
||||
|
||||
# 使用原始缓存装饰器
|
||||
return await original_cache(
|
||||
expire=expire,
|
||||
namespace=namespace,
|
||||
key_builder=lambda *_, **__: cache_key # 直接使用预生成的key
|
||||
)(func)(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
def clear_cache(
|
||||
namespace: Optional[str] = None,
|
||||
cacheName: Optional[str] = None,
|
||||
keyExpression: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
清除缓存的装饰器,参数与 @cache 保持一致
|
||||
使用方式:
|
||||
@clear_cache(namespace="user", cacheName="info", keyExpression="user_id")
|
||||
async def update_user(user_id: int):
|
||||
...
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 1. 生成缓存键(复用 custom_key_builder 逻辑)
|
||||
cache_key = custom_key_builder(
|
||||
func=func,
|
||||
namespace=namespace or "",
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
cacheName=cacheName,
|
||||
keyExpression=keyExpression,
|
||||
)
|
||||
|
||||
logger.debug(f"Clearing cache for key: {cache_key}")
|
||||
|
||||
# 2. 清除缓存
|
||||
await FastAPICache.clear(key=cache_key)
|
||||
|
||||
# 3. 执行原函数
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
+4
-1
@@ -14,7 +14,8 @@ 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
|
||||
|
||||
def run_migrations():
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
@@ -24,6 +25,8 @@ def run_migrations():
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
run_migrations()
|
||||
FastAPICache.init(InMemoryBackend())
|
||||
print("✅ FastAPICache 初始化完成")
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ dependencies = [
|
||||
"fastapi-mcp (>=0.3.4,<0.4.0)",
|
||||
"tabulate>=0.9.0",
|
||||
"sqlbot-xpack==0.0.3.2",
|
||||
"fastapi-cache2>=0.2.2",
|
||||
]
|
||||
[[tool.uv.index]]
|
||||
name = "default"
|
||||
|
||||
Reference in New Issue
Block a user