diff --git a/backend/alembic/versions/021_user_ws_ddl.py b/backend/alembic/versions/021_user_ws_ddl.py index c5f90fa7..fe05292b 100644 --- a/backend/alembic/versions/021_user_ws_ddl.py +++ b/backend/alembic/versions/021_user_ws_ddl.py @@ -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') diff --git a/backend/apps/system/api/user.py b/backend/apps/system/api/user.py index 7bd6aae5..30b5909b 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -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): diff --git a/backend/apps/system/crud/user.py b/backend/apps/system/crud/user.py index dba60afd..5f34daf9 100644 --- a/backend/apps/system/crud/user.py +++ b/backend/apps/system/crud/user.py @@ -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) diff --git a/backend/apps/system/schemas/auth.py b/backend/apps/system/schemas/auth.py index 2c5d2042..fd50737d 100644 --- a/backend/apps/system/schemas/auth.py +++ b/backend/apps/system/schemas/auth.py @@ -1,7 +1,17 @@ from pydantic import BaseModel - +from enum import Enum class LocalLoginSchema(BaseModel): account: str - password: str \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/apps/system/schemas/system_schema.py b/backend/apps/system/schemas/system_schema.py index f512e72f..683f7de6 100644 --- a/backend/apps/system/schemas/system_schema.py +++ b/backend/apps/system/schemas/system_schema.py @@ -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 \ No newline at end of file + weight: int = 0 + + +class UserInfoDTO(UserEditor): + language: str = "zh-CN" + weight: int = 0 + isAdmin: bool = False \ No newline at end of file diff --git a/backend/common/core/deps.py b/backend/common/core/deps.py index 84dc3659..3480886b 100644 --- a/backend/common/core/deps.py +++ b/backend/common/core/deps.py @@ -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)] diff --git a/backend/common/core/sqlbot_cache.py b/backend/common/core/sqlbot_cache.py new file mode 100644 index 00000000..9a00a9d1 --- /dev/null +++ b/backend/common/core/sqlbot_cache.py @@ -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 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 1a67bf6f..17dda32b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2f894061..056d88fb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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"