feat: Backend cache

This commit is contained in:
fit2cloud-chenyw
2025-07-08 17:48:50 +08:00
parent b22111a7b7
commit c8fc4762bd
9 changed files with 237 additions and 34 deletions
+1 -1
View File
@@ -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')
+9 -9
View File
@@ -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):
+14 -6
View File
@@ -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)
+12 -2
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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)]
+179
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"