checkpoint: 权限即时生效——角色/用户变更自动踢下线 + roleless 用户自读修复

- online/service: kick_user_sessions 扫描 USER_SESSION 匹配目标用户,删三键使登录时固化的权限快照即时失效
- role service+controller: 更新/删除/改状态/改权限后踢下线绑定该角色的用户
- user service+controller: 停用/改密等变更后踢下线(对应用户即时 401 重登)
- permission: 无角色用户读自身记录时按 id 匹配放行(修复 409 真 bug)
This commit is contained in:
34047007@qq.com
2026-08-07 21:47:51 +08:00
parent c1fa752d9b
commit 20b7184b44
6 changed files with 132 additions and 20 deletions
@@ -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="授权角色成功")
@@ -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: