Files
WonderQ-Project/WonderQ-Admin/app/rbac.py
duanshuwen 4c245f0d1f feat(admin, admin-ui): 实现RuoYi风格后台菜单管理,优化媒体资源处理
新增服务端菜单校验与归一化逻辑,完善菜单相关Schema;前端实现条件渲染的菜单编辑器,支持搜索式图标选择器与父级树过滤。新增媒体URL统一处理工具修复管理端本地静态资源路径映射问题,更新全部相关文档、技术决策记录与测试用例。本次变更不影响WonderQ-MiniAPP端。
2026-08-27 09:08:05 +08:00

201 lines
7.9 KiB
Python

from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from sqlalchemy import false, or_, select
from sqlalchemy.orm import Session
from .models import AdminDepartment, AdminMenu, AdminRole, AdminRoleDepartment, AdminRoleMenu, AdminUser, AdminUserDepartment, AdminUserRole
from .redis_session import AdminSessionStore, RedisUnavailableError
DATA_SCOPE_VALUES = {"all", "dept", "dept_and_children", "custom_dept", "self"}
def build_menu_tree(menus: Iterable[AdminMenu], visible_only: bool = True) -> list[dict[str, Any]]:
active_menus = [
menu
for menu in menus
if not visible_only or (menu.isActive and (menu.type == "button" or menu.isVisible))
]
nodes: dict[str, dict[str, Any]] = {
menu.id: {
"id": menu.id,
"parentId": menu.parentId,
"name": menu.name,
"type": menu.type,
"path": menu.path,
"component": menu.componentKey,
"permission": menu.permissionCode,
"icon": menu.icon,
"children": [],
"sortOrder": menu.sortOrder,
"isVisible": menu.isVisible,
"isActive": menu.isActive,
}
for menu in active_menus
}
roots: list[dict[str, Any]] = []
for menu in sorted(active_menus, key=lambda item: (item.sortOrder, item.id)):
node = nodes[menu.id]
if menu.parentId and menu.parentId in nodes:
nodes[menu.parentId]["children"].append(node)
else:
roots.append(node)
return roots
def flatten_permission_codes(menus: Iterable[AdminMenu]) -> set[str]:
return {menu.permissionCode for menu in menus if menu.isActive and menu.permissionCode}
def get_admin_roles(user: AdminUser, db: Session) -> list[AdminRole]:
if user.role in {"admin", "super_admin"}:
return list(db.scalars(select(AdminRole).where(AdminRole.isActive).order_by(AdminRole.code.asc())).all())
role_ids = db.scalars(select(AdminUserRole.roleId).where(AdminUserRole.userId == user.id)).all()
if not role_ids:
return []
return list(db.scalars(select(AdminRole).where(AdminRole.id.in_(role_ids), AdminRole.isActive)).all())
def get_admin_menus(user: AdminUser, db: Session) -> list[AdminMenu]:
if user.role in {"admin", "super_admin"}:
return list(db.scalars(select(AdminMenu).where(AdminMenu.isActive).order_by(AdminMenu.sortOrder.asc(), AdminMenu.id.asc())).all())
role_ids = db.scalars(select(AdminUserRole.roleId).where(AdminUserRole.userId == user.id)).all()
if not role_ids:
return []
return list(
db.scalars(
select(AdminMenu)
.join(AdminRoleMenu, AdminRoleMenu.menuId == AdminMenu.id)
.where(AdminRoleMenu.roleId.in_(role_ids), AdminMenu.isActive)
.distinct()
.order_by(AdminMenu.sortOrder.asc(), AdminMenu.id.asc())
).all()
)
def _build_permission_context(user: AdminUser, db: Session) -> dict[str, Any]:
roles = get_admin_roles(user, db)
menus = get_admin_menus(user, db)
permissions = flatten_permission_codes(menus)
if user.role in {"admin", "super_admin"}:
permissions.add("*")
user_dept_ids = set(db.scalars(select(AdminUserDepartment.deptId).where(AdminUserDepartment.userId == user.id)).all())
return {
"roles": [{"id": role.id, "name": role.name, "code": role.code, "dataScope": role.dataScope} for role in roles],
"permissions": sorted(permissions),
"menus": build_menu_tree(menus),
"dataScopes": sorted({role.dataScope for role in roles if role.dataScope in DATA_SCOPE_VALUES} or {"all" if user.role in {"admin", "super_admin"} else "self"}),
"deptIds": sorted(user_dept_ids),
}
def build_admin_permission_context(user: AdminUser, db: Session, store: AdminSessionStore | None = None) -> dict[str, Any]:
if store:
try:
cached = store.get_permission_context(user.id)
if cached is not None:
return cached
except RedisUnavailableError:
# 权限缓存不可用时重新读取数据库;绝不将缓存故障降级为放行权限。
pass
context = _build_permission_context(user, db)
if store:
try:
from .config import get_settings
store.set_permission_context(user.id, context, get_settings().admin_permission_cache_seconds)
except RedisUnavailableError:
pass
return context
def user_has_permission(user: AdminUser, db: Session, permission: str) -> bool:
if user.role in {"admin", "super_admin"}:
return True
return permission in set(build_admin_permission_context(user, db)["permissions"])
def require_admin_permission(permission: str):
from fastapi import Depends, HTTPException
from .auth import require_admin
from .database import get_db
def checker(user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)) -> AdminUser:
if not user_has_permission(user, db, permission):
raise HTTPException(status_code=403, detail="没有执行该操作的权限")
return user
return checker
def _descendant_department_ids(db: Session, roots: set[str]) -> set[str]:
if not roots:
return set()
rows = db.execute(select(AdminDepartment.id, AdminDepartment.parentId)).all()
children: dict[str | None, set[str]] = {}
for department_id, parent_id in rows:
children.setdefault(parent_id, set()).add(department_id)
result = set(roots)
pending = list(roots)
while pending:
parent_id = pending.pop()
for child_id in children.get(parent_id, set()):
if child_id not in result:
result.add(child_id)
pending.append(child_id)
return result
def accessible_scope(user: AdminUser, db: Session) -> tuple[set[str] | None, bool]:
"""Return (department ids, include self-created rows). None department ids means all."""
if user.role in {"admin", "super_admin"}:
return None, True
roles = get_admin_roles(user, db)
if any(role.dataScope == "all" for role in roles):
return None, True
user_dept_ids = set(db.scalars(select(AdminUserDepartment.deptId).where(AdminUserDepartment.userId == user.id)).all())
role_ids = {role.id for role in roles}
custom_dept_ids = set(
db.scalars(select(AdminRoleDepartment.deptId).where(AdminRoleDepartment.roleId.in_(role_ids))).all()
) if role_ids else set()
dept_ids: set[str] = set()
include_self = False
for role in roles:
if role.dataScope == "dept":
dept_ids.update(user_dept_ids)
elif role.dataScope == "dept_and_children":
dept_ids.update(_descendant_department_ids(db, user_dept_ids))
elif role.dataScope == "custom_dept":
dept_ids.update(custom_dept_ids)
elif role.dataScope == "self":
include_self = True
return dept_ids, include_self
def apply_data_scope(stmt, model, user: AdminUser, db: Session):
"""Apply the five RBAC data scopes to an owned business model query."""
dept_ids, include_self = accessible_scope(user, db)
if dept_ids is None:
return stmt
clauses = []
dept_column = getattr(model, "deptId", None)
creator_column = getattr(model, "createdById", None)
if dept_column is not None and dept_ids:
clauses.append(dept_column.in_(dept_ids))
if include_self and creator_column is not None:
clauses.append(creator_column == user.id)
return stmt.where(or_(*clauses) if clauses else false())
def is_within_data_scope(item, user: AdminUser, db: Session) -> bool:
dept_ids, include_self = accessible_scope(user, db)
if dept_ids is None:
return True
item_dept_id = getattr(item, "deptId", None)
item_creator_id = getattr(item, "createdById", None)
return (item_dept_id is not None and item_dept_id in dept_ids) or (include_self and item_creator_id == user.id)