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
@@ -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:
"""用户注册"""