Files
ai-agent-admin/backend-fastapi/core/menu/api.py
T
2026-06-08 18:14:59 +08:00

478 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Menu API - 菜单管理接口
提供菜单的 CRUD 操作和路由树生成
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from core.menu.model import Menu
from core.menu.schema import (
MenuCreate,
MenuUpdate,
MenuResponse,
MenuTreeNode,
MenuSimple,
MenuBatchDeleteRequest,
MenuBatchDeleteResponse,
MenuPathResponse,
MenuStatsResponse,
MenuMoveRequest,
MenuCheckNameRequest,
MenuCheckPathRequest,
)
from core.menu.service import MenuService
from utils.security import get_current_user, get_current_user_id
router = APIRouter(prefix="/menu", tags=["菜单管理"])
def _build_menu_response(menu: Menu, level: int = 0, child_count: int = 0) -> MenuResponse:
"""构建菜单响应"""
return MenuResponse(
id=menu.id,
parent_id=menu.parent_id,
application_id=menu.application_id,
is_system=menu.is_system,
name=menu.name,
title=menu.title,
authCode=menu.authCode,
path=menu.path,
type=menu.type,
component=menu.component,
redirect=menu.redirect,
activePath=menu.activePath,
query=menu.query,
noBasicLayout=menu.noBasicLayout,
icon=menu.icon,
activeIcon=menu.activeIcon,
order=menu.order,
hideInMenu=menu.hideInMenu,
hideChildrenInMenu=menu.hideChildrenInMenu,
hideInBreadcrumb=menu.hideInBreadcrumb,
hideInTab=menu.hideInTab,
affixTab=menu.affixTab,
affixTabOrder=menu.affixTabOrder,
keepAlive=menu.keepAlive,
maxNumOfOpenTab=menu.maxNumOfOpenTab,
fullPathKey=menu.fullPathKey if hasattr(menu, 'fullPathKey') else True,
link=menu.link,
iframeSrc=menu.iframeSrc,
openInNewWindow=menu.openInNewWindow,
badge=menu.badge,
badgeType=menu.badgeType,
badgeVariants=menu.badgeVariants,
sort=menu.sort,
is_deleted=menu.is_deleted,
sys_create_datetime=menu.sys_create_datetime,
sys_update_datetime=menu.sys_update_datetime,
level=level,
childCount=child_count,
)
@router.post("", response_model=MenuResponse, summary="创建菜单")
async def create_menu(
data: MenuCreate,
db: AsyncSession = Depends(get_db)
):
"""
创建新菜单
- 检查菜单名称唯一性
- 检查路由路径唯一性
- 检查父菜单存在性
"""
# 检查菜单名称是否已存在
if await MenuService.check_name_exists(db, data.name):
raise HTTPException(status_code=400, detail=f"菜单名称已存在: {data.name}")
# 检查路由路径是否已存在
if await MenuService.check_path_exists(db, data.path):
raise HTTPException(status_code=400, detail=f"路由路径已存在: {data.path}")
# 检查父菜单是否存在
if data.parent_id:
parent = await MenuService.get_by_id(db, data.parent_id)
if not parent:
raise HTTPException(status_code=400, detail="父菜单不存在")
menu = await MenuService.create(db, data)
await MenuService.invalidate_cache()
level = await MenuService.get_level(db, menu)
child_count = await MenuService.get_child_count(db, menu.id)
return _build_menu_response(menu, level, child_count)
@router.get("/get/tree", response_model=List[dict], summary="获取菜单树")
async def get_menu_tree(
application_id: str = Query(default=None, alias="applicationId", description="应用ID,用于过滤应用菜单"),
include_system: bool = Query(default=True, alias="includeSystem", description="是否包含系统菜单"),
db: AsyncSession = Depends(get_db)
):
"""获取菜单树形结构(带缓存)"""
return await MenuService.get_menu_tree_cached(db, application_id=application_id, include_system=include_system)
@router.get("/route/tree", response_model=List[dict], summary="获取用户路由树")
async def get_route_tree(
application_code: str = Query(default=None, description="应用编码,用于过滤应用专属菜单"),
dev_mode: bool = Query(default=False, alias="devMode", description="开发模式:true只返回系统菜单,false只返回应用菜单"),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
获取当前用户的路由树
- 超级管理员获取所有菜单
- 普通用户获取其角色关联的菜单
- 如果提供 application_code
- dev_mode=true(开发模式):只返回系统菜单(is_system=true
- dev_mode=false(正常模式):只返回该应用的专属菜单(application_id=app.id
"""
# 获取用户所有角色关联的菜单ID列表(多对多)
role_menu_ids = None
if not current_user.is_superuser:
from core.user.service import UserService
from core.role.service import RoleService
# 获取用户的所有角色ID
role_ids = await UserService.get_user_role_ids(db, current_user.id)
if role_ids:
# 遍历所有角色,收集菜单ID并去重
menu_id_set = set()
for role_id in role_ids:
role = await RoleService.get_by_id_with_relations(db, role_id)
if role and role.menus:
for menu in role.menus:
menu_id_set.add(menu.id)
if menu_id_set:
role_menu_ids = list(menu_id_set)
return await MenuService.get_user_route_tree(
db,
user_id=current_user.id,
is_superuser=current_user.is_superuser,
role_menu_ids=role_menu_ids,
application_code=application_code,
dev_mode=dev_mode
)
@router.get("/list", response_model=PaginatedResponse[MenuResponse], summary="获取菜单列表")
async def get_menu_list(
page: int = Query(default=1, ge=1, description="页码"),
pageSize: int = Query(default=20, ge=1, le=100, description="每页数量"),
name: str = Query(default=None, description="菜单名称"),
title: str = Query(default=None, description="菜单标题"),
type: str = Query(default=None, description="菜单类型"),
parent_id: str = Query(default=None, description="父菜单ID"),
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
db: AsyncSession = Depends(get_db)
):
"""获取菜单列表(分页)"""
# 构建过滤条件
filters = {}
if name:
filters["name"] = name
if title:
filters["title"] = title
if type:
filters["type"] = type
if parent_id:
filters["parent_id"] = parent_id
if application_id:
filters["application_id"] = application_id
items, total = await MenuService.get_list(db, page=page, page_size=pageSize, filters=filters)
# 构建响应
result_items = []
for menu in items:
level = await MenuService.get_level(db, menu)
child_count = await MenuService.get_child_count(db, menu.id)
result_items.append(_build_menu_response(menu, level, child_count))
return PaginatedResponse(items=result_items, total=total)
@router.get("/all", response_model=List[MenuSimple], summary="获取所有菜单")
async def get_all_menus(
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
db: AsyncSession = Depends(get_db)
):
"""获取所有菜单(不分页,简化版,用于选择器)"""
menus = await MenuService.get_all_menus(db, application_id=application_id)
result = []
for menu in menus:
level = await MenuService.get_level(db, menu)
result.append(MenuSimple(
id=menu.id,
name=menu.name,
title=menu.title,
path=menu.path,
type=menu.type,
parent_id=menu.parent_id,
level=level,
))
return result
@router.get("/stats", response_model=MenuStatsResponse, summary="获取菜单统计")
async def get_menu_stats(
db: AsyncSession = Depends(get_db)
):
"""获取菜单统计信息"""
stats = await MenuService.get_menu_stats(db)
return MenuStatsResponse(**stats)
@router.get("/by-parent/{parent_id}", response_model=List[dict], summary="根据父菜单获取子菜单")
async def get_menus_by_parent(
parent_id: str,
db: AsyncSession = Depends(get_db)
):
"""
根据父菜单ID获取直接子菜单
- parent_id="null" 获取根菜单
"""
if parent_id == "null":
parent_id = None
children = await MenuService.get_children(db, parent_id)
result = []
for menu in children:
level = await MenuService.get_level(db, menu)
child_count = await MenuService.get_child_count(db, menu.id)
result.append({
"id": menu.id,
"parent_id": menu.parent_id,
"name": menu.name,
"title": menu.title,
"path": menu.path,
"type": menu.type,
"icon": menu.icon,
"order": menu.order,
"level": level,
"childCount": child_count,
})
return result
@router.get("/path/{menu_id}", response_model=MenuPathResponse, summary="获取菜单路径")
async def get_menu_path(
menu_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取菜单的完整路径(从根到当前菜单)"""
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
# 获取所有祖先
ancestors = await MenuService.get_ancestors(db, menu)
path = []
for ancestor in reversed(ancestors):
level = await MenuService.get_level(db, ancestor)
path.append(MenuSimple(
id=ancestor.id,
name=ancestor.name,
title=ancestor.title,
path=ancestor.path,
type=ancestor.type,
parent_id=ancestor.parent_id,
level=level,
))
# 添加当前菜单
level = await MenuService.get_level(db, menu)
path.append(MenuSimple(
id=menu.id,
name=menu.name,
title=menu.title,
path=menu.path,
type=menu.type,
parent_id=menu.parent_id,
level=level,
))
return MenuPathResponse(
menuId=menu.id,
menuName=menu.title or menu.name,
path=path,
)
@router.post("/check/name", response_model=ResponseModel, summary="检查菜单名称")
async def check_menu_name(
data: MenuCheckNameRequest,
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
db: AsyncSession = Depends(get_db)
):
"""检查菜单名称是否已存在"""
exists = await MenuService.check_name_exists(db, data.name, data.exclude_id, application_id=application_id)
return ResponseModel(
message=f"菜单名称 '{data.name}' 已存在" if exists else "菜单名称可用",
data={"exists": exists}
)
@router.post("/check/path", response_model=ResponseModel, summary="检查路由路径")
async def check_menu_path(
data: MenuCheckPathRequest,
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
db: AsyncSession = Depends(get_db)
):
"""检查路由路径是否已存在"""
exists = await MenuService.check_path_exists(db, data.path, data.exclude_id, application_id=application_id)
return ResponseModel(
message=f"路由路径 '{data.path}' 已存在" if exists else "路由路径可用",
data={"exists": exists}
)
@router.post("/move", response_model=ResponseModel, summary="移动菜单")
async def move_menu(
data: MenuMoveRequest,
db: AsyncSession = Depends(get_db)
):
"""移动菜单到新的父菜单下"""
success, message = await MenuService.move_menu(db, data.menuId, data.newParentId)
if not success:
raise HTTPException(status_code=400, detail=message)
return ResponseModel(message=message)
@router.post("/batch/delete", response_model=MenuBatchDeleteResponse, summary="批量删除菜单")
async def batch_delete_menus(
data: MenuBatchDeleteRequest,
db: AsyncSession = Depends(get_db)
):
"""
批量删除菜单
- 跳过有子菜单的菜单
- 返回删除失败的ID列表
"""
failed_ids = []
success_count = 0
for menu_id in data.ids:
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
failed_ids.append(menu_id)
continue
if not await MenuService.can_delete(db, menu_id):
failed_ids.append(menu_id)
continue
if await MenuService.delete(db, menu_id):
success_count += 1
else:
failed_ids.append(menu_id)
await MenuService.invalidate_cache()
return MenuBatchDeleteResponse(count=success_count, failedIds=failed_ids)
@router.get("/{menu_id}", response_model=MenuResponse, summary="获取菜单详情")
async def get_menu(
menu_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取单个菜单的详细信息"""
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
level = await MenuService.get_level(db, menu)
child_count = await MenuService.get_child_count(db, menu.id)
return _build_menu_response(menu, level, child_count)
@router.put("/{menu_id}", response_model=MenuResponse, summary="更新菜单")
async def update_menu(
menu_id: str,
data: MenuUpdate,
db: AsyncSession = Depends(get_db)
):
"""
更新菜单信息
- 检查菜单名称唯一性(排除自身)
- 检查路由路径唯一性(排除自身)
- 防止设置自己为父菜单
- 防止形成循环引用
"""
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
# 检查菜单名称是否已存在(排除自身)
if data.name and await MenuService.check_name_exists(db, data.name, menu_id):
raise HTTPException(status_code=400, detail=f"菜单名称已存在: {data.name}")
# 检查路由路径是否已存在(排除自身)
if data.path and await MenuService.check_path_exists(db, data.path, menu_id):
raise HTTPException(status_code=400, detail=f"路由路径已存在: {data.path}")
# 检查父菜单
if data.parent_id:
if data.parent_id == menu_id:
raise HTTPException(status_code=400, detail="不能将自己设置为父菜单")
parent = await MenuService.get_by_id(db, data.parent_id)
if not parent:
raise HTTPException(status_code=400, detail="父菜单不存在")
# 检查是否会形成循环引用
ancestors = await MenuService.get_ancestors(db, parent)
ancestor_ids = [a.id for a in ancestors]
if menu.id in ancestor_ids:
raise HTTPException(status_code=400, detail="不能将子菜单设置为父菜单,会形成循环引用")
updated_menu = await MenuService.update(db, menu_id, data)
await MenuService.invalidate_cache()
level = await MenuService.get_level(db, updated_menu)
child_count = await MenuService.get_child_count(db, updated_menu.id)
return _build_menu_response(updated_menu, level, child_count)
@router.delete("/{menu_id}", response_model=ResponseModel, summary="删除菜单")
async def delete_menu(
menu_id: str,
hard: bool = Query(default=False, description="是否物理删除"),
db: AsyncSession = Depends(get_db)
):
"""
删除菜单
- 检查是否有子菜单
"""
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
if not await MenuService.can_delete(db, menu_id):
raise HTTPException(status_code=400, detail="该菜单下还有子菜单,无法删除")
success = await MenuService.delete(db, menu_id, hard=hard)
if not success:
raise HTTPException(status_code=500, detail="删除失败")
await MenuService.invalidate_cache()
return ResponseModel(message="删除成功")