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
@@ -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}:*")
@@ -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:
@@ -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="批量修改用户状态成功")
@@ -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:
"""用户注册"""