#!/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="删除成功")