新增服务端菜单校验与归一化逻辑,完善菜单相关Schema;前端实现条件渲染的菜单编辑器,支持搜索式图标选择器与父级树过滤。新增媒体URL统一处理工具修复管理端本地静态资源路径映射问题,更新全部相关文档、技术决策记录与测试用例。本次变更不影响WonderQ-MiniAPP端。
474 lines
20 KiB
Python
474 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..auth import get_actor_id, hash_password, require_admin
|
|
from ..database import get_db
|
|
from ..menu_rules import MENU_FIELDS, normalize_menu_values
|
|
from ..redis_session import AdminSessionStore, RedisUnavailableError, get_admin_session_store
|
|
from ..models import (
|
|
AdminDepartment,
|
|
AdminMenu,
|
|
AdminRole,
|
|
AdminRoleDepartment,
|
|
AdminRoleMenu,
|
|
AdminUser,
|
|
AdminUserDepartment,
|
|
AdminUserRole,
|
|
AuditLog,
|
|
new_id,
|
|
utc_now,
|
|
)
|
|
from ..rbac import build_admin_permission_context, build_menu_tree, user_has_permission
|
|
from ..schemas import (
|
|
AdminDepartmentCreate,
|
|
AdminDepartmentPatch,
|
|
AdminMenuCreate,
|
|
AdminMenuPatch,
|
|
AdminRoleCreate,
|
|
AdminRolePatch,
|
|
AdminUserCreate,
|
|
AdminUserPatch,
|
|
)
|
|
from ..serializers import model_dict
|
|
|
|
|
|
router = APIRouter(prefix="/api/admin/system", tags=["admin-system"])
|
|
|
|
|
|
def require_permission(permission: str):
|
|
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 _audit(db: Session, request: Request, action: str, entity: str, entity_id: str | None, before=None, after=None) -> None:
|
|
db.add(
|
|
AuditLog(
|
|
actorId=get_actor_id(request),
|
|
action=action,
|
|
entity=entity,
|
|
entityId=entity_id,
|
|
before=before,
|
|
after=after,
|
|
)
|
|
)
|
|
|
|
|
|
def _role_dict(role: AdminRole, menu_ids: list[str] | None = None, dept_ids: list[str] | None = None) -> dict:
|
|
return {
|
|
**model_dict(role),
|
|
"menuIds": menu_ids or [],
|
|
"deptIds": dept_ids or [],
|
|
}
|
|
|
|
|
|
def _menu_dict(menu: AdminMenu) -> dict:
|
|
data = model_dict(menu)
|
|
data["component"] = data.pop("componentKey")
|
|
data["permission"] = data.pop("permissionCode")
|
|
return data
|
|
|
|
|
|
def _dept_dict(dept: AdminDepartment) -> dict:
|
|
return model_dict(dept)
|
|
|
|
|
|
def _ensure_ids_exist(db: Session, model, ids: list[str], label: str) -> None:
|
|
if not ids:
|
|
return
|
|
existing = set(db.scalars(select(model.id).where(model.id.in_(ids))).all())
|
|
missing = sorted(set(ids) - existing)
|
|
if missing:
|
|
raise HTTPException(status_code=400, detail={"msg": f"{label}不存在", "errorCode": "REFERENCE_NOT_FOUND", "details": {"ids": missing}})
|
|
|
|
|
|
def _ensure_menu_parent(db: Session, menu_id: str | None, parent_id: str | None) -> None:
|
|
if not parent_id:
|
|
return
|
|
parent = db.get(AdminMenu, parent_id)
|
|
if not parent:
|
|
raise HTTPException(status_code=400, detail="父级菜单不存在")
|
|
if parent.type == "button":
|
|
raise HTTPException(status_code=400, detail="按钮菜单不能作为父级菜单")
|
|
|
|
seen: set[str] = set()
|
|
current: AdminMenu | None = parent
|
|
while current:
|
|
if current.id == menu_id:
|
|
raise HTTPException(status_code=400, detail="菜单不能移动到自己的子菜单下")
|
|
if current.id in seen:
|
|
break
|
|
seen.add(current.id)
|
|
current = db.get(AdminMenu, current.parentId) if current.parentId else None
|
|
|
|
|
|
def _normalized_menu_or_422(values: dict) -> dict:
|
|
try:
|
|
return normalize_menu_values(values)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
|
|
def _ensure_permission_available(db: Session, permission_code: str | None, menu_id: str | None = None) -> None:
|
|
if not permission_code:
|
|
return
|
|
stmt = select(AdminMenu.id).where(AdminMenu.permissionCode == permission_code)
|
|
if menu_id:
|
|
stmt = stmt.where(AdminMenu.id != menu_id)
|
|
if db.scalar(stmt):
|
|
raise HTTPException(status_code=409, detail="权限标识已存在")
|
|
|
|
|
|
def _invalidate_permissions(store: AdminSessionStore, user_ids: list[str] | None = None) -> None:
|
|
try:
|
|
store.invalidate_permission_cache(user_ids)
|
|
except RedisUnavailableError as exc:
|
|
raise HTTPException(status_code=503, detail="后台权限缓存暂时不可用") from exc
|
|
|
|
|
|
@router.get("/profile")
|
|
def profile(
|
|
user: AdminUser = Depends(require_admin),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
context = build_admin_permission_context(user, db, store)
|
|
return {
|
|
"code": 200,
|
|
"msg": "success",
|
|
"data": {
|
|
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
|
|
**context,
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/routers")
|
|
def routers(
|
|
user: AdminUser = Depends(require_admin),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
"""Return the authenticated user's visible menu tree for dynamic routing."""
|
|
context = build_admin_permission_context(user, db, store)
|
|
return {"code": 200, "msg": "success", "data": context["menus"]}
|
|
|
|
|
|
@router.get("/users")
|
|
def list_users(_user: AdminUser = Depends(require_permission("system:user:read")), db: Session = Depends(get_db)):
|
|
users = list(db.scalars(select(AdminUser).order_by(AdminUser.createdAt.desc())).all())
|
|
role_links = db.scalars(select(AdminUserRole)).all()
|
|
dept_links = db.scalars(select(AdminUserDepartment)).all()
|
|
role_map: dict[str, list[str]] = {}
|
|
dept_map: dict[str, list[str]] = {}
|
|
for link in role_links:
|
|
role_map.setdefault(link.userId, []).append(link.roleId)
|
|
for link in dept_links:
|
|
dept_map.setdefault(link.userId, []).append(link.deptId)
|
|
items = [
|
|
{
|
|
"id": user.id,
|
|
"email": user.email,
|
|
"name": user.name,
|
|
"role": user.role,
|
|
"isActive": user.isActive,
|
|
"roleIds": role_map.get(user.id, []),
|
|
"deptIds": dept_map.get(user.id, []),
|
|
"createdAt": user.createdAt,
|
|
"updatedAt": user.updatedAt,
|
|
}
|
|
for user in users
|
|
]
|
|
return {"code": 200, "msg": "success", "data": {"items": items}}
|
|
|
|
|
|
@router.post("/users", status_code=status.HTTP_201_CREATED)
|
|
def create_user(
|
|
body: AdminUserCreate,
|
|
request: Request,
|
|
_user: AdminUser = Depends(require_permission("system:user:create")),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
if db.scalar(select(AdminUser).where(AdminUser.email == body.email)):
|
|
raise HTTPException(status_code=409, detail="邮箱已存在")
|
|
_ensure_ids_exist(db, AdminRole, body.roleIds, "角色")
|
|
_ensure_ids_exist(db, AdminDepartment, body.deptIds, "部门")
|
|
user = AdminUser(email=body.email, name=body.name.strip(), passwordHash=hash_password(body.password), isActive=body.isActive)
|
|
db.add(user)
|
|
db.flush()
|
|
for role_id in body.roleIds:
|
|
db.add(AdminUserRole(userId=user.id, roleId=role_id))
|
|
for dept_id in body.deptIds:
|
|
db.add(AdminUserDepartment(userId=user.id, deptId=dept_id))
|
|
_audit(db, request, "create", "admin_user", user.id, after={"email": user.email, "name": user.name})
|
|
db.commit()
|
|
_invalidate_permissions(store, [user.id])
|
|
return {"code": 201, "msg": "success", "data": {"id": user.id}}
|
|
|
|
|
|
@router.patch("/users/{user_id}")
|
|
def update_user(
|
|
user_id: str,
|
|
body: AdminUserPatch,
|
|
request: Request,
|
|
current_user: AdminUser = Depends(require_permission("system:user:update")),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
user = db.get(AdminUser, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="管理员不存在")
|
|
if user.id == current_user.id and body.isActive is False:
|
|
raise HTTPException(status_code=400, detail="不能停用当前登录账号")
|
|
_ensure_ids_exist(db, AdminRole, body.roleIds or [], "角色")
|
|
_ensure_ids_exist(db, AdminDepartment, body.deptIds or [], "部门")
|
|
before = model_dict(user)
|
|
if body.name is not None:
|
|
user.name = body.name.strip()
|
|
if body.password is not None:
|
|
user.passwordHash = hash_password(body.password)
|
|
if body.isActive is not None:
|
|
user.isActive = body.isActive
|
|
if body.roleIds is not None:
|
|
db.execute(delete(AdminUserRole).where(AdminUserRole.userId == user.id))
|
|
for role_id in body.roleIds:
|
|
db.add(AdminUserRole(userId=user.id, roleId=role_id))
|
|
if body.deptIds is not None:
|
|
db.execute(delete(AdminUserDepartment).where(AdminUserDepartment.userId == user.id))
|
|
for dept_id in body.deptIds:
|
|
db.add(AdminUserDepartment(userId=user.id, deptId=dept_id))
|
|
db.flush()
|
|
_audit(db, request, "update", "admin_user", user.id, before=before, after=model_dict(user))
|
|
db.commit()
|
|
_invalidate_permissions(store, [user.id])
|
|
return {"code": 200, "msg": "success", "data": {"id": user.id}}
|
|
|
|
|
|
@router.delete("/users/{user_id}")
|
|
def delete_user(
|
|
user_id: str,
|
|
request: Request,
|
|
current_user: AdminUser = Depends(require_permission("system:user:delete")),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
user = db.get(AdminUser, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="管理员不存在")
|
|
if user.id == current_user.id:
|
|
raise HTTPException(status_code=400, detail="不能删除当前登录账号")
|
|
before = model_dict(user)
|
|
db.delete(user)
|
|
_audit(db, request, "delete", "admin_user", user.id, before=before, after={"id": user.id})
|
|
db.commit()
|
|
_invalidate_permissions(store, [user.id])
|
|
return {"code": 200, "msg": "success", "data": {"id": user.id}}
|
|
|
|
|
|
@router.get("/roles")
|
|
def list_roles(_user: AdminUser = Depends(require_permission("system:role:read")), db: Session = Depends(get_db)):
|
|
roles = list(db.scalars(select(AdminRole).order_by(AdminRole.createdAt.asc())).all())
|
|
menu_links = db.scalars(select(AdminRoleMenu)).all()
|
|
dept_links = db.scalars(select(AdminRoleDepartment)).all()
|
|
menu_map: dict[str, list[str]] = {}
|
|
dept_map: dict[str, list[str]] = {}
|
|
for link in menu_links:
|
|
menu_map.setdefault(link.roleId, []).append(link.menuId)
|
|
for link in dept_links:
|
|
dept_map.setdefault(link.roleId, []).append(link.deptId)
|
|
return {"code": 200, "msg": "success", "data": {"items": [_role_dict(role, menu_map.get(role.id, []), dept_map.get(role.id, [])) for role in roles]}}
|
|
|
|
|
|
@router.get("/menus")
|
|
def list_menus(_user: AdminUser = Depends(require_permission("system:menu:read")), db: Session = Depends(get_db)):
|
|
menus = list(db.scalars(select(AdminMenu).order_by(AdminMenu.sortOrder.asc(), AdminMenu.id.asc())).all())
|
|
return {"code": 200, "msg": "success", "data": {"items": [_menu_dict(menu) for menu in menus], "tree": build_menu_tree(menus, visible_only=False)}}
|
|
|
|
|
|
@router.get("/depts")
|
|
def list_depts(_user: AdminUser = Depends(require_permission("system:dept:read")), db: Session = Depends(get_db)):
|
|
depts = list(db.scalars(select(AdminDepartment).order_by(AdminDepartment.sortOrder.asc(), AdminDepartment.id.asc())).all())
|
|
return {"code": 200, "msg": "success", "data": {"items": [_dept_dict(dept) for dept in depts]}}
|
|
|
|
|
|
@router.post("/roles", status_code=status.HTTP_201_CREATED)
|
|
def create_role(body: AdminRoleCreate, request: Request, _user: AdminUser = Depends(require_permission("system:role:create")), db: Session = Depends(get_db)):
|
|
if db.scalar(select(AdminRole).where(AdminRole.code == body.code)):
|
|
raise HTTPException(status_code=409, detail="角色编码已存在")
|
|
_ensure_ids_exist(db, AdminMenu, body.menuIds, "菜单")
|
|
_ensure_ids_exist(db, AdminDepartment, body.deptIds, "部门")
|
|
role = AdminRole(name=body.name.strip(), code=body.code, dataScope=body.dataScope, description=body.description, isActive=body.isActive)
|
|
db.add(role)
|
|
db.flush()
|
|
for menu_id in body.menuIds:
|
|
db.add(AdminRoleMenu(roleId=role.id, menuId=menu_id))
|
|
for dept_id in body.deptIds:
|
|
db.add(AdminRoleDepartment(roleId=role.id, deptId=dept_id))
|
|
_audit(db, request, "create", "admin_role", role.id, after={"code": role.code})
|
|
db.commit()
|
|
return {"code": 201, "msg": "success", "data": {"id": role.id}}
|
|
|
|
|
|
@router.patch("/roles/{role_id}")
|
|
def update_role(role_id: str, body: AdminRolePatch, request: Request, _user: AdminUser = Depends(require_permission("system:role:update")), db: Session = Depends(get_db), store: AdminSessionStore = Depends(get_admin_session_store)):
|
|
role = db.get(AdminRole, role_id)
|
|
if not role:
|
|
raise HTTPException(status_code=404, detail="角色不存在")
|
|
_ensure_ids_exist(db, AdminMenu, body.menuIds or [], "菜单")
|
|
_ensure_ids_exist(db, AdminDepartment, body.deptIds or [], "部门")
|
|
if body.name is not None:
|
|
role.name = body.name.strip()
|
|
if body.code is not None:
|
|
role.code = body.code
|
|
if body.dataScope is not None:
|
|
role.dataScope = body.dataScope
|
|
if body.description is not None:
|
|
role.description = body.description
|
|
if body.isActive is not None:
|
|
role.isActive = body.isActive
|
|
if body.menuIds is not None:
|
|
db.execute(delete(AdminRoleMenu).where(AdminRoleMenu.roleId == role.id))
|
|
for menu_id in body.menuIds:
|
|
db.add(AdminRoleMenu(roleId=role.id, menuId=menu_id))
|
|
if body.deptIds is not None:
|
|
db.execute(delete(AdminRoleDepartment).where(AdminRoleDepartment.roleId == role.id))
|
|
for dept_id in body.deptIds:
|
|
db.add(AdminRoleDepartment(roleId=role.id, deptId=dept_id))
|
|
db.flush()
|
|
_audit(db, request, "update", "admin_role", role.id, after=_role_dict(role))
|
|
affected_user_ids = list(db.scalars(select(AdminUserRole.userId).where(AdminUserRole.roleId == role.id)).all())
|
|
db.commit()
|
|
_invalidate_permissions(store, affected_user_ids)
|
|
return {"code": 200, "msg": "success", "data": {"id": role.id}}
|
|
|
|
|
|
@router.delete("/roles/{role_id}")
|
|
def delete_role(
|
|
role_id: str,
|
|
request: Request,
|
|
_user: AdminUser = Depends(require_permission("system:role:delete")),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
role = db.get(AdminRole, role_id)
|
|
if not role:
|
|
raise HTTPException(status_code=404, detail="角色不存在")
|
|
if role.code == "super_admin":
|
|
raise HTTPException(status_code=400, detail="系统内置角色不能删除")
|
|
before = _role_dict(role)
|
|
affected_user_ids = list(db.scalars(select(AdminUserRole.userId).where(AdminUserRole.roleId == role.id)).all())
|
|
db.delete(role)
|
|
_audit(db, request, "delete", "admin_role", role.id, before=before, after={"id": role.id})
|
|
db.commit()
|
|
_invalidate_permissions(store, affected_user_ids)
|
|
return {"code": 200, "msg": "success", "data": {"id": role.id}}
|
|
|
|
|
|
@router.post("/menus", status_code=status.HTTP_201_CREATED)
|
|
def create_menu(body: AdminMenuCreate, request: Request, _user: AdminUser = Depends(require_permission("system:menu:create")), db: Session = Depends(get_db)):
|
|
values = _normalized_menu_or_422(body.model_dump())
|
|
_ensure_menu_parent(db, None, values["parentId"])
|
|
_ensure_permission_available(db, values["permissionCode"])
|
|
menu = AdminMenu(**values)
|
|
db.add(menu)
|
|
db.flush()
|
|
_audit(db, request, "create", "admin_menu", menu.id, after=_menu_dict(menu))
|
|
db.commit()
|
|
return {"code": 201, "msg": "success", "data": {"id": menu.id}}
|
|
|
|
|
|
@router.patch("/menus/{menu_id}")
|
|
def update_menu(menu_id: str, body: AdminMenuPatch, request: Request, _user: AdminUser = Depends(require_permission("system:menu:update")), db: Session = Depends(get_db), store: AdminSessionStore = Depends(get_admin_session_store)):
|
|
menu = db.get(AdminMenu, menu_id)
|
|
if not menu:
|
|
raise HTTPException(status_code=404, detail="菜单不存在")
|
|
values = {field: getattr(menu, field) for field in MENU_FIELDS}
|
|
values.update(body.model_dump(exclude_unset=True))
|
|
values = _normalized_menu_or_422(values)
|
|
_ensure_menu_parent(db, menu.id, values["parentId"])
|
|
_ensure_permission_available(db, values["permissionCode"], menu.id)
|
|
for field in MENU_FIELDS:
|
|
setattr(menu, field, values[field])
|
|
db.flush()
|
|
_audit(db, request, "update", "admin_menu", menu.id, after=_menu_dict(menu))
|
|
db.commit()
|
|
_invalidate_permissions(store)
|
|
return {"code": 200, "msg": "success", "data": {"id": menu.id}}
|
|
|
|
|
|
@router.delete("/menus/{menu_id}")
|
|
def delete_menu(
|
|
menu_id: str,
|
|
request: Request,
|
|
_user: AdminUser = Depends(require_permission("system:menu:delete")),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
menu = db.get(AdminMenu, menu_id)
|
|
if not menu:
|
|
raise HTTPException(status_code=404, detail="菜单不存在")
|
|
if db.scalar(select(AdminMenu.id).where(AdminMenu.parentId == menu.id).limit(1)):
|
|
raise HTTPException(status_code=409, detail="该菜单存在子菜单,请先删除或移动子菜单")
|
|
before = _menu_dict(menu)
|
|
db.delete(menu)
|
|
_audit(db, request, "delete", "admin_menu", menu.id, before=before, after={"id": menu.id})
|
|
db.commit()
|
|
_invalidate_permissions(store)
|
|
return {"code": 200, "msg": "success", "data": {"id": menu.id}}
|
|
|
|
|
|
@router.post("/depts", status_code=status.HTTP_201_CREATED)
|
|
def create_dept(body: AdminDepartmentCreate, request: Request, _user: AdminUser = Depends(require_permission("system:dept:create")), db: Session = Depends(get_db)):
|
|
if body.parentId and not db.get(AdminDepartment, body.parentId):
|
|
raise HTTPException(status_code=400, detail="父级部门不存在")
|
|
dept = AdminDepartment(**body.model_dump())
|
|
db.add(dept)
|
|
db.flush()
|
|
_audit(db, request, "create", "admin_department", dept.id, after=_dept_dict(dept))
|
|
db.commit()
|
|
return {"code": 201, "msg": "success", "data": {"id": dept.id}}
|
|
|
|
|
|
@router.patch("/depts/{dept_id}")
|
|
def update_dept(dept_id: str, body: AdminDepartmentPatch, request: Request, _user: AdminUser = Depends(require_permission("system:dept:update")), db: Session = Depends(get_db), store: AdminSessionStore = Depends(get_admin_session_store)):
|
|
dept = db.get(AdminDepartment, dept_id)
|
|
if not dept:
|
|
raise HTTPException(status_code=404, detail="部门不存在")
|
|
if body.parentId == dept.id:
|
|
raise HTTPException(status_code=400, detail="部门不能将自身设置为父级")
|
|
for field, value in body.model_dump(exclude_unset=True).items():
|
|
setattr(dept, field, value)
|
|
db.flush()
|
|
_audit(db, request, "update", "admin_department", dept.id, after=_dept_dict(dept))
|
|
db.commit()
|
|
_invalidate_permissions(store)
|
|
return {"code": 200, "msg": "success", "data": {"id": dept.id}}
|
|
|
|
|
|
@router.delete("/depts/{dept_id}")
|
|
def delete_dept(
|
|
dept_id: str,
|
|
request: Request,
|
|
_user: AdminUser = Depends(require_permission("system:dept:delete")),
|
|
db: Session = Depends(get_db),
|
|
store: AdminSessionStore = Depends(get_admin_session_store),
|
|
):
|
|
dept = db.get(AdminDepartment, dept_id)
|
|
if not dept:
|
|
raise HTTPException(status_code=404, detail="部门不存在")
|
|
if db.scalar(select(AdminDepartment.id).where(AdminDepartment.parentId == dept.id).limit(1)):
|
|
raise HTTPException(status_code=409, detail="该部门存在子部门,请先删除或移动子部门")
|
|
before = _dept_dict(dept)
|
|
db.delete(dept)
|
|
_audit(db, request, "delete", "admin_department", dept.id, before=before, after={"id": dept.id})
|
|
db.commit()
|
|
_invalidate_permissions(store)
|
|
return {"code": 200, "msg": "success", "data": {"id": dept.id}}
|