From 20b7184b448a830e51c34df134d020cd038efe27 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Fri, 7 Aug 2026 21:47:51 +0800 Subject: [PATCH] =?UTF-8?q?checkpoint:=20=E6=9D=83=E9=99=90=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E7=94=9F=E6=95=88=E2=80=94=E2=80=94=E8=A7=92=E8=89=B2?= =?UTF-8?q?/=E7=94=A8=E6=88=B7=E5=8F=98=E6=9B=B4=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=B8=A2=E4=B8=8B=E7=BA=BF=20+=20roleless=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=87=AA=E8=AF=BB=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - online/service: kick_user_sessions 扫描 USER_SESSION 匹配目标用户,删三键使登录时固化的权限快照即时失效 - role service+controller: 更新/删除/改状态/改权限后踢下线绑定该角色的用户 - user service+controller: 停用/改密等变更后踢下线(对应用户即时 401 重登) - permission: 无角色用户读自身记录时按 id 匹配放行(修复 409 真 bug) --- .../api/v1/module_monitor/online/service.py | 46 +++++++++++++++++++ .../api/v1/module_system/role/controller.py | 15 ++++-- .../app/api/v1/module_system/role/service.py | 36 +++++++++++++-- .../api/v1/module_system/user/controller.py | 15 ++++-- .../app/api/v1/module_system/user/service.py | 36 ++++++++++++--- backend/app/core/permission.py | 4 ++ 6 files changed, 132 insertions(+), 20 deletions(-) diff --git a/backend/app/api/v1/module_monitor/online/service.py b/backend/app/api/v1/module_monitor/online/service.py index 6844454..cdca2a8 100644 --- a/backend/app/api/v1/module_monitor/online/service.py +++ b/backend/app/api/v1/module_monitor/online/service.py @@ -78,6 +78,52 @@ class OnlineService: await RedisCURD(redis).delete(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}") logger.info(f"强制下线用户会话: {session_id}") + @staticmethod + async def kick_user_sessions(redis: Redis, user_ids: list[int]) -> int: + """强制下线指定用户的全部在线会话(权限变更/停用/改密后自动踢人)。 + + 扫描 USER_SESSION:{session_id} 匹配 user_id,删除该会话的 + ACCESS_TOKEN / REFRESH_TOKEN / USER_SESSION 三键,使登录时固化的 + 权限快照即时失效(用户下次请求 401,重新登录拿新权限)。 + 返回踢下线会话数;redis 不可用时仅记日志,不影响业务主流程。 + """ + if not user_ids: + return 0 + target = {int(uid) for uid in user_ids} + keys = await RedisCURD(redis).scan_keys(f"{RedisInitKeyConfig.USER_SESSION.key}:*") + if not keys: + return 0 + raws = await RedisCURD(redis).mget(keys) + session_ids: list[str] = [] + for key, raw in zip(keys, raws, strict=False): + if not raw: + continue + try: + info = json.loads(raw) + except (json.JSONDecodeError, TypeError): + continue + try: + uid = int(info.get("user_id")) + except (TypeError, ValueError): + continue + if uid in target: + key_str = key.decode() if isinstance(key, bytes) else key + session_ids.append(key_str.split(":")[-1]) + if not session_ids: + return 0 + delete_keys: list[str] = [] + for sid in session_ids: + delete_keys.extend( + [ + f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{sid}", + f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{sid}", + f"{RedisInitKeyConfig.USER_SESSION.key}:{sid}", + ] + ) + await RedisCURD(redis).delete(*delete_keys) + logger.info(f"权限变更自动踢下线: user_ids={sorted(target)} sessions={len(session_ids)}") + return len(session_ids) + @staticmethod async def clear_online(redis: Redis) -> None: await RedisCURD(redis).clear(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*") diff --git a/backend/app/api/v1/module_system/role/controller.py b/backend/app/api/v1/module_system/role/controller.py index 8172e96..b92a998 100644 --- a/backend/app/api/v1/module_system/role/controller.py +++ b/backend/app/api/v1/module_system/role/controller.py @@ -2,11 +2,12 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path, Query, Security, status from fastapi.responses import JSONResponse, StreamingResponse +from redis.asyncio.client import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.common.response import ResponseSchema, StreamResponse, SuccessResponse from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam -from app.core.dependencies import AuthPermission, db_getter +from app.core.dependencies import AuthPermission, db_getter, redis_getter from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -56,10 +57,11 @@ async def create_role_controller( async def update_role_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:update"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], id: Annotated[int, Path(description="角色ID", ge=1)], data: Annotated[RoleUpdateSchema, Body(description="角色修改参数")], ) -> JSONResponse: - result_dict = await RoleService(auth, db).update(id=id, data=data) + result_dict = await RoleService(auth, db).update(id=id, data=data, redis=redis) return SuccessResponse(data=result_dict, msg="修改角色成功") @@ -67,9 +69,10 @@ async def update_role_controller( async def delete_role_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:delete"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], ids: Annotated[list[int], Body(description="ID列表")], ) -> JSONResponse: - await RoleService(auth, db).delete(ids=ids) + await RoleService(auth, db).delete(ids=ids, redis=redis) return SuccessResponse(msg="删除角色成功") @@ -77,9 +80,10 @@ async def delete_role_controller( async def batch_set_available_role_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:patch"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], data: Annotated[BatchSetAvailable, Body(description="状态设置")], ) -> JSONResponse: - await RoleService(auth, db).set_available(data=data) + await RoleService(auth, db).set_available(data=data, redis=redis) return SuccessResponse(msg="批量修改角色状态成功") @@ -87,9 +91,10 @@ async def batch_set_available_role_controller( async def set_role_permission_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:permission"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], data: Annotated[RolePermissionSettingSchema, Body(description="角色授权参数")], ) -> JSONResponse: - await RoleService(auth, db).set_permission(data=data) + await RoleService(auth, db).set_permission(data=data, redis=redis) return SuccessResponse(msg="授权角色成功") diff --git a/backend/app/api/v1/module_system/role/service.py b/backend/app/api/v1/module_system/role/service.py index ab969c7..2b26093 100644 --- a/backend/app/api/v1/module_system/role/service.py +++ b/backend/app/api/v1/module_system/role/service.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.exceptions import CustomException +from app.core.logger import logger from app.utils.common_util import search_to_dict from app.utils.excel_util import ExcelUtil @@ -110,12 +111,13 @@ class RoleService: new_role = await RoleCRUD(self.auth, self.db).create(data=data) return await self.detail(id=new_role.id) - async def update(self, id: int, data: RoleUpdateSchema) -> RoleOutSchema: + async def update(self, id: int, data: RoleUpdateSchema, redis: Any | None = None) -> RoleOutSchema: """更新角色 参数: - id (int): 角色ID - data (RoleUpdateSchema): 更新角色模型 + - redis (Any | None): Redis 客户端(非 None 时踢下线绑定该角色的用户) 返回: - RoleOutSchema: 更新后的角色响应模型 @@ -128,13 +130,16 @@ class RoleService: if exist_code and exist_code.id != id: raise CustomException(msg="更新失败,角色编码已存在") await RoleCRUD(self.auth, self.db).update(id=id, data=data) + if redis is not None: + await self._kick_users(redis, [id]) return await self.detail(id=id) - async def delete(self, ids: list[int]) -> None: + async def delete(self, ids: list[int], redis: Any | None = None) -> None: """删除角色 参数: - ids (list[int]): 角色ID列表 + - redis (Any | None): Redis 客户端(非 None 时踢下线绑定该角色的用户) 返回: - None @@ -148,12 +153,15 @@ class RoleService: raise CustomException(msg="删除失败,部分ID不存在") await RoleCRUD(self.auth, self.db).delete(ids=ids) + if redis is not None: + await self._kick_users(redis, ids) - async def set_permission(self, data: RolePermissionSettingSchema) -> None: + async def set_permission(self, data: RolePermissionSettingSchema, redis: Any | None = None) -> None: """设置角色权限 参数: - data (RolePermissionSettingSchema): 角色权限设置模型 + - redis (Any | None): Redis 客户端(非 None 时踢下线绑定角色的用户) 返回: - None @@ -164,12 +172,15 @@ class RoleService: # 设置数据权限范围(自定义部门关联已废弃,直接清空) await RoleCRUD(self.auth, self.db).set(ids=data.role_ids, data_scope=data.data_scope) await RoleCRUD(self.auth, self.db).set_role_depts_crud(role_ids=data.role_ids, dept_ids=[]) + if redis is not None: + await self._kick_users(redis, data.role_ids) - async def set_available(self, data: BatchSetAvailable) -> None: + async def set_available(self, data: BatchSetAvailable, redis: Any | None = None) -> None: """设置角色可用状态 参数: - data (BatchSetAvailable): 批量设置可用状态模型 + - redis (Any | None): Redis 客户端(非 None 时踢下线绑定该角色的用户) 返回: - None @@ -180,6 +191,23 @@ class RoleService: if rid not in role_map: raise CustomException(msg="该数据不存在") await RoleCRUD(self.auth, self.db).set(ids=data.ids, status=data.status) + if redis is not None: + await self._kick_users(redis, data.ids) + + async def _kick_users(self, redis: Any, role_ids: list[int]) -> int: + """踢下线绑定指定角色的全部用户的会话(角色权限/状态/删除后调用)。""" + roles = await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)}, preload=["users"]) + user_ids = sorted({u.id for r in roles for u in r.users}) + if not user_ids: + return 0 + + from app.api.v1.module_monitor.online.service import OnlineService + + try: + return await OnlineService.kick_user_sessions(redis=redis, user_ids=user_ids) + except Exception as e: + logger.error(f"角色变更自动踢下线失败: role_ids={role_ids}, err={e!s}") + return 0 @staticmethod def export_list(role_list: list[dict[str, Any]]) -> bytes: diff --git a/backend/app/api/v1/module_system/user/controller.py b/backend/app/api/v1/module_system/user/controller.py index c8fcdf1..fdafee0 100644 --- a/backend/app/api/v1/module_system/user/controller.py +++ b/backend/app/api/v1/module_system/user/controller.py @@ -56,9 +56,10 @@ async def update_current_user_info_controller( async def change_current_user_password_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], data: Annotated[UserChangePasswordSchema, Body(description="修改用户密码参数")], ) -> JSONResponse: - result_dict = await UserService(auth, db).change_password(data=data) + result_dict = await UserService(auth, db).change_password(data=data, redis=redis) return SuccessResponse(data=result_dict, msg="修改密码成功, 请重新登录") @@ -66,11 +67,12 @@ async def change_current_user_password_controller( async def reset_password_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:update"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], id: Annotated[int, Path(description="用户ID", ge=1)], data: Annotated[ResetPasswordSchema, Body(description="重置用户密码参数")], ) -> JSONResponse: data.id = id - result_dict = await UserService(auth, db).reset_password(data=data) + result_dict = await UserService(auth, db).reset_password(data=data, redis=redis) return SuccessResponse(data=result_dict, msg="重置密码成功") @@ -159,10 +161,11 @@ async def create_user_controller( async def update_user_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:update"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], id: Annotated[int, Path(description="用户ID")], data: Annotated[UserUpdateSchema, Body(description="修改用户参数")], ) -> JSONResponse: - result_dict = await UserService(auth, db).update(id=id, data=data) + result_dict = await UserService(auth, db).update(id=id, data=data, redis=redis) return SuccessResponse(data=result_dict, msg="修改用户成功") @@ -170,9 +173,10 @@ async def update_user_controller( async def delete_user_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:delete"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], ids: Annotated[list[int], Body(description="ID列表")], ) -> JSONResponse: - await UserService(auth, db).delete(ids=ids) + await UserService(auth, db).delete(ids=ids, redis=redis) return SuccessResponse(msg="删除用户成功") @@ -180,9 +184,10 @@ async def delete_user_controller( async def batch_set_available_user_controller( auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:patch"]))], db: Annotated[AsyncSession, Depends(db_getter)], + redis: Annotated[Redis, Depends(redis_getter)], data: Annotated[BatchSetAvailable, Body(description="状态设置")], ) -> JSONResponse: - await UserService(auth, db).set_available(data=data) + await UserService(auth, db).set_available(data=data, redis=redis) return SuccessResponse(msg="批量修改用户状态成功") diff --git a/backend/app/api/v1/module_system/user/service.py b/backend/app/api/v1/module_system/user/service.py index 2b4aa0d..a2984de 100644 --- a/backend/app/api/v1/module_system/user/service.py +++ b/backend/app/api/v1/module_system/user/service.py @@ -11,12 +11,12 @@ from app.api.v1.module_system.menu.crud import MenuCRUD from app.api.v1.module_system.menu.schema import MenuOutSchema, MenuTreeOutSchema from app.api.v1.module_system.position.crud import PositionCRUD from app.api.v1.module_system.role.crud import RoleCRUD +from app.common.enums import RedisInitKeyConfig from app.config.setting import settings from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.email import send_reset_code_email from app.core.exceptions import CustomException from app.core.logger import logger -from app.common.enums import RedisInitKeyConfig from app.core.redis_crud import RedisCURD from app.utils.common_util import search_to_dict, traversal_to_tree from app.utils.excel_util import ExcelUtil @@ -133,7 +133,7 @@ class UserService: await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[new_user.id], position_ids=data.position_ids) return await self.detail(id=new_user.id) - async def update(self, id: int, data: UserUpdateSchema) -> UserOutSchema: + async def update(self, id: int, data: UserUpdateSchema, redis: Redis | None = None) -> UserOutSchema: if data.username: if exist_user := await UserCRUD(self.auth, self.db).get(username=data.username): if exist_user.id != id: @@ -174,9 +174,13 @@ class UserService: raise CustomException(msg="更新失败,部分岗位已被禁用") await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[id], position_ids=data.position_ids) + # 角色/状态变更使登录时固化的权限快照失效,自动踢下线让其重登生效 + if redis is not None and (data.role_ids is not None or data.status is not None): + await self._kick_users(redis, [id]) + return await self.detail(id=id) - async def delete(self, ids: list[int]) -> None: + async def delete(self, ids: list[int], redis: Redis | None = None) -> None: if not ids: raise CustomException(msg="删除失败,删除对象不能为空") users = await UserCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)}) @@ -199,6 +203,8 @@ class UserService: await UserCRUD(self.auth, self.db).set_user_roles(user_ids=ids, role_ids=[]) await UserCRUD(self.auth, self.db).set_user_positions(user_ids=ids, position_ids=[]) await UserCRUD(self.auth, self.db).delete(ids=ids) + if redis is not None: + await self._kick_users(redis, ids) async def current_info(self, check_data_scope: bool = True) -> CurrentUserOutSchema: user_id = self.auth.user.id @@ -262,14 +268,16 @@ class UserService: await UserCRUD(self.auth, self.db).update(id=user_id, data=user_update_data) return await self.detail(id=user_id) - async def set_available(self, data: BatchSetAvailable) -> None: + async def set_available(self, data: BatchSetAvailable, redis: Redis | None = None) -> None: users = await UserCRUD(self.auth, self.db).get_list(search={"id": ("in", data.ids)}) for user in users: if user.is_superuser: raise CustomException(msg="超级管理员状态不能修改") await UserCRUD(self.auth, self.db).set(ids=data.ids, status=data.status) + if redis is not None: + await self._kick_users(redis, data.ids) - async def change_password(self, data: UserChangePasswordSchema) -> UserOutSchema: + async def change_password(self, data: UserChangePasswordSchema, redis: Redis | None = None) -> UserOutSchema: user_id = self.auth.user.id if not user_id: raise CustomException(msg="该数据不存在") @@ -280,17 +288,32 @@ class UserService: new_password_hash = PwdUtil.hash_password(password=data.new_password) await UserCRUD(self.auth, self.db).change_password(id=user_id, password_hash=new_password_hash) + if redis is not None: + await self._kick_users(redis, [user_id]) return await self.detail(id=user_id) - async def reset_password(self, data: ResetPasswordSchema) -> UserOutSchema: + async def reset_password(self, data: ResetPasswordSchema, redis: Redis | None = None) -> UserOutSchema: user = await UserCRUD(self.auth, self.db).get_or_404(id=data.id) if user.is_superuser: raise CustomException(msg="超级管理员密码不能重置") new_password_hash = PwdUtil.hash_password(password=data.password) await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash) + if redis is not None: + await self._kick_users(redis, [data.id]) return await self.detail(id=data.id) + @staticmethod + async def _kick_users(redis: Redis, user_ids: list[int]) -> int: + """踢下线指定用户的全部会话(权限/状态/密码变更后调用)。""" + from app.api.v1.module_monitor.online.service import OnlineService + + try: + return await OnlineService.kick_user_sessions(redis=redis, user_ids=user_ids) + except Exception as e: + logger.error(f"自动踢下线失败: user_ids={user_ids}, err={e!s}") + return 0 + async def forget_password(self, data: UserForgetPasswordSchema) -> str: """忘记密码前置校验:返回绑定邮箱(脱敏),供前端确认;不存在或未绑定邮箱则异常。""" user = await UserCRUD(self.auth, self.db).get_or_404(username=data.username) @@ -330,6 +353,7 @@ class UserService: new_password_hash = PwdUtil.hash_password(password=data.new_password) await UserCRUD(self.auth, self.db).change_password(id=user.id, password_hash=new_password_hash) await RedisCURD(redis).delete(redis_key) + await self._kick_users(redis, [user.id]) async def register(self, data: UserRegisterSchema) -> UserOutSchema: """用户注册""" diff --git a/backend/app/core/permission.py b/backend/app/core/permission.py index 16f90cf..cd6d438 100644 --- a/backend/app/core/permission.py +++ b/backend/app/core/permission.py @@ -48,6 +48,10 @@ class Permission: roles = result.scalars().all() if not roles: + if self.model.__name__ == "UserModel": + id_attr = getattr(self.model, "id", None) + if id_attr is not None and self.auth.user and self.auth.user.id: + return id_attr == self.auth.user.id created_id_attr = getattr(self.model, "created_id", None) if created_id_attr is not None and self.auth.user and self.auth.user.id: return created_id_attr == self.auth.user.id