use std::collections::HashMap; use chrono::Utc; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use crate::dto::{CreateMenuReq, MenuResp}; use crate::entity::{menu, menu_role}; use crate::error::{ConfigError, ConfigResult}; use erp_core::audit::AuditLog; use erp_core::audit_service; use erp_core::error::check_version; use erp_core::events::EventBus; /// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单, /// 以及管理菜单-角色关联。 pub struct MenuService; impl MenuService { /// 通过角色 code 列表查找对应的角色 ID 列表。 async fn resolve_role_ids( tenant_id: Uuid, role_codes: &[String], db: &sea_orm::DatabaseConnection, ) -> ConfigResult> { if role_codes.is_empty() { return Ok(vec![]); } let codes_csv: String = role_codes .iter() .map(|c| format!("'{}'", c.replace('\'', "''"))) .collect::>() .join(","); let sql = format!( "SELECT id FROM roles WHERE tenant_id = '{}' AND code IN ({}) AND deleted_at IS NULL", tenant_id, codes_csv ); let stmt = sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql); let rows = db.query_all(stmt).await?; Ok(rows .into_iter() .filter_map(|row| { let id: Uuid = row.try_get_by_index(0).ok()?; Some(id) }) .collect()) } /// 获取当前租户下指定角色可见的菜单树。 /// /// `role_codes` 为当前用户的角色 code 列表(如 ["admin"]、["doctor"])。 /// 方法内部将 code 转换为 ID,再通过 menu_roles 表过滤。 /// 如果角色没有任何菜单关联,返回全部菜单(admin 兜底)。 pub async fn get_menu_tree( tenant_id: Uuid, role_codes: &[String], db: &sea_orm::DatabaseConnection, ) -> ConfigResult> { // 0. 将角色 code 转换为 UUID let role_ids = Self::resolve_role_ids(tenant_id, role_codes, db).await?; // 1. 查询租户下所有未删除的菜单,按 sort_order 排序 let all_menus = menu::Entity::find() .filter(menu::Column::TenantId.eq(tenant_id)) .filter(menu::Column::DeletedAt.is_null()) .order_by_asc(menu::Column::SortOrder) .all(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; // 2. 如果 role_ids 非空,通过 menu_roles 表过滤 let visible_menu_ids: Option> = if !role_ids.is_empty() { let mr_rows = menu_role::Entity::find() .filter(menu_role::Column::TenantId.eq(tenant_id)) .filter(menu_role::Column::RoleId.is_in(role_ids.iter().copied())) .filter(menu_role::Column::DeletedAt.is_null()) .all(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; let ids: Vec = mr_rows.iter().map(|mr| mr.menu_id).collect(); if ids.is_empty() { // 角色未关联菜单时回退到显示全部菜单, // 避免种子数据阶段 menu_roles 为空导致所有有角色用户看不到菜单 None } else { Some(ids) } } else { None }; // 3. 按 parent_id 分组构建 HashMap let filtered: Vec<&menu::Model> = match &visible_menu_ids { Some(ids) => all_menus.iter().filter(|m| ids.contains(&m.id)).collect(), None => all_menus.iter().collect(), }; let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); for m in &filtered { children_map.entry(m.parent_id).or_default().push(*m); } // 4. 递归构建树形结构(从 parent_id == None 的根节点开始) let roots = children_map.get(&None).cloned().unwrap_or_default(); let tree = Self::build_tree(&roots, &children_map); Ok(tree) } /// 获取当前租户下所有菜单的平铺列表(无角色过滤)。 pub async fn get_flat_list( tenant_id: Uuid, db: &sea_orm::DatabaseConnection, ) -> ConfigResult> { let menus = menu::Entity::find() .filter(menu::Column::TenantId.eq(tenant_id)) .filter(menu::Column::DeletedAt.is_null()) .order_by_asc(menu::Column::SortOrder) .all(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; Ok(menus .iter() .map(|m| MenuResp { id: m.id, parent_id: m.parent_id, title: m.title.clone(), path: m.path.clone(), icon: m.icon.clone(), sort_order: m.sort_order, visible: m.visible, menu_type: m.menu_type.clone(), permission: m.permission.clone(), children: vec![], version: m.version, }) .collect()) } /// 创建菜单并可选地关联角色。 pub async fn create( tenant_id: Uuid, operator_id: Uuid, req: &CreateMenuReq, db: &sea_orm::DatabaseConnection, event_bus: &EventBus, ) -> ConfigResult { let now = Utc::now(); let id = Uuid::now_v7(); let model = menu::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), parent_id: Set(req.parent_id), title: Set(req.title.clone()), path: Set(req.path.clone()), icon: Set(req.icon.clone()), sort_order: Set(req.sort_order.unwrap_or(0)), visible: Set(req.visible.unwrap_or(true)), menu_type: Set(req.menu_type.clone().unwrap_or_else(|| "menu".to_string())), permission: Set(req.permission.clone()), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), updated_by: Set(operator_id), deleted_at: Set(None), version: Set(1), }; model .insert(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; // 关联角色(如果提供了 role_ids) if let Some(role_ids) = &req.role_ids && !role_ids.is_empty() { Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?; } event_bus .publish( erp_core::events::DomainEvent::new( "menu.created", tenant_id, serde_json::json!({ "menu_id": id, "title": req.title }), ), db, ) .await; audit_service::record( AuditLog::new(tenant_id, Some(operator_id), "menu.create", "menu").with_resource_id(id), db, ) .await; Ok(MenuResp { id, parent_id: req.parent_id, title: req.title.clone(), path: req.path.clone(), icon: req.icon.clone(), sort_order: req.sort_order.unwrap_or(0), visible: req.visible.unwrap_or(true), menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()), permission: req.permission.clone(), children: vec![], version: 1, }) } /// 更新菜单字段,并可选地重新关联角色。 /// 使用乐观锁校验版本。 pub async fn update( id: Uuid, tenant_id: Uuid, operator_id: Uuid, req: &crate::dto::UpdateMenuReq, db: &sea_orm::DatabaseConnection, ) -> ConfigResult { let model = menu::Entity::find_by_id(id) .one(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))? .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) .ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?; let next_version = check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?; let mut active: menu::ActiveModel = model.into(); if let Some(title) = &req.title { active.title = Set(title.clone()); } if let Some(path) = &req.path { active.path = Set(Some(path.clone())); } if let Some(icon) = &req.icon { active.icon = Set(Some(icon.clone())); } if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); } if let Some(visible) = req.visible { active.visible = Set(visible); } if let Some(permission) = &req.permission { active.permission = Set(Some(permission.clone())); } active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_version); let updated = active .update(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; // 如果提供了 role_ids,重新关联角色 if let Some(role_ids) = &req.role_ids { Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?; } audit_service::record( AuditLog::new(tenant_id, Some(operator_id), "menu.update", "menu").with_resource_id(id), db, ) .await; Ok(MenuResp { id: updated.id, parent_id: updated.parent_id, title: updated.title.clone(), path: updated.path.clone(), icon: updated.icon.clone(), sort_order: updated.sort_order, visible: updated.visible, menu_type: updated.menu_type.clone(), permission: updated.permission.clone(), children: vec![], version: updated.version, }) } /// 软删除菜单。使用乐观锁校验版本。 pub async fn delete( id: Uuid, tenant_id: Uuid, operator_id: Uuid, version: i32, db: &sea_orm::DatabaseConnection, event_bus: &EventBus, ) -> ConfigResult<()> { let model = menu::Entity::find_by_id(id) .one(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))? .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) .ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?; let next_version = check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?; let mut active: menu::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_version); active .update(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; event_bus .publish( erp_core::events::DomainEvent::new( "menu.deleted", tenant_id, serde_json::json!({ "menu_id": id }), ), db, ) .await; audit_service::record( AuditLog::new(tenant_id, Some(operator_id), "menu.delete", "menu").with_resource_id(id), db, ) .await; Ok(()) } /// 替换菜单的角色关联。 /// /// 软删除现有关联行,然后插入新关联(参考 RoleService::assign_permissions 模式)。 pub async fn assign_roles( menu_id: Uuid, role_ids: &[Uuid], tenant_id: Uuid, operator_id: Uuid, db: &sea_orm::DatabaseConnection, ) -> ConfigResult<()> { // 验证菜单存在且属于当前租户 let _menu = menu::Entity::find_by_id(menu_id) .one(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))? .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) .ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {menu_id}")))?; // 软删除现有关联 let existing = menu_role::Entity::find() .filter(menu_role::Column::MenuId.eq(menu_id)) .filter(menu_role::Column::TenantId.eq(tenant_id)) .filter(menu_role::Column::DeletedAt.is_null()) .all(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; let now = Utc::now(); for mr in existing { let mut active: menu_role::ActiveModel = mr.into(); active.deleted_at = Set(Some(now)); active.updated_at = Set(now); active.updated_by = Set(operator_id); active.version = Set(active.version.unwrap() + 1); active .update(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; } // 插入新关联 for role_id in role_ids { let mr = menu_role::ActiveModel { id: Set(Uuid::now_v7()), menu_id: Set(menu_id), role_id: Set(*role_id), tenant_id: Set(tenant_id), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), updated_by: Set(operator_id), deleted_at: Set(None), version: Set(1), }; mr.insert(db) .await .map_err(|e| ConfigError::Validation(e.to_string()))?; } Ok(()) } /// 递归构建菜单树。 fn build_tree<'a>( nodes: &[&'a menu::Model], children_map: &HashMap, Vec<&'a menu::Model>>, ) -> Vec { nodes .iter() .map(|m| { let children = children_map.get(&Some(m.id)).cloned().unwrap_or_default(); MenuResp { id: m.id, parent_id: m.parent_id, title: m.title.clone(), path: m.path.clone(), icon: m.icon.clone(), sort_order: m.sort_order, visible: m.visible, menu_type: m.menu_type.clone(), permission: m.permission.clone(), children: Self::build_tree(&children, children_map), version: m.version, } }) .collect() } } #[cfg(test)] mod tests { use super::*; use chrono::Utc; /// 辅助:构造 menu::Model fn make_menu(id: Uuid, parent_id: Option, title: &str, sort_order: i32) -> menu::Model { let now = Utc::now(); let tenant_id = Uuid::now_v7(); menu::Model { id, tenant_id, parent_id, title: title.to_string(), path: Some(format!("/{}", title.to_lowercase())), icon: None, sort_order, visible: true, menu_type: "menu".to_string(), permission: None, created_at: now, updated_at: now, created_by: tenant_id, updated_by: tenant_id, deleted_at: None, version: 1, } } #[test] fn build_tree_empty_input() { let nodes: Vec<&menu::Model> = vec![]; let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); let tree = MenuService::build_tree(&nodes, &children_map); assert!(tree.is_empty()); } #[test] fn build_tree_single_root() { let root_id = Uuid::now_v7(); let root = make_menu(root_id, None, "首页", 0); let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); let roots: Vec<&menu::Model> = vec![&root]; let tree = MenuService::build_tree(&roots, &children_map); assert_eq!(tree.len(), 1); assert_eq!(tree[0].id, root_id); assert_eq!(tree[0].title, "首页"); assert!(tree[0].children.is_empty()); } #[test] fn build_tree_two_levels() { // 根节点 -> 子节点1, 子节点2 let root_id = Uuid::now_v7(); let child1_id = Uuid::now_v7(); let child2_id = Uuid::now_v7(); let root = make_menu(root_id, None, "系统管理", 0); let child1 = make_menu(child1_id, Some(root_id), "用户管理", 1); let child2 = make_menu(child2_id, Some(root_id), "角色管理", 2); let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); children_map.insert(Some(root_id), vec![&child1, &child2]); let roots: Vec<&menu::Model> = vec![&root]; let tree = MenuService::build_tree(&roots, &children_map); assert_eq!(tree.len(), 1); assert_eq!(tree[0].children.len(), 2); assert_eq!(tree[0].children[0].title, "用户管理"); assert_eq!(tree[0].children[1].title, "角色管理"); } #[test] fn build_tree_three_levels() { // 根 -> 子 -> 孙 let root_id = Uuid::now_v7(); let child_id = Uuid::now_v7(); let grandchild_id = Uuid::now_v7(); let root = make_menu(root_id, None, "系统管理", 0); let child = make_menu(child_id, Some(root_id), "用户管理", 1); let grandchild = make_menu(grandchild_id, Some(child_id), "用户详情", 0); let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); children_map.insert(Some(root_id), vec![&child]); children_map.insert(Some(child_id), vec![&grandchild]); let roots: Vec<&menu::Model> = vec![&root]; let tree = MenuService::build_tree(&roots, &children_map); assert_eq!(tree.len(), 1); assert_eq!(tree[0].children.len(), 1); assert_eq!(tree[0].children[0].children.len(), 1); assert_eq!(tree[0].children[0].children[0].title, "用户详情"); } #[test] fn build_tree_multiple_roots() { // 两个独立的根节点 let root1_id = Uuid::now_v7(); let root2_id = Uuid::now_v7(); let root1 = make_menu(root1_id, None, "首页", 0); let root2 = make_menu(root2_id, None, "系统管理", 1); let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); let roots: Vec<&menu::Model> = vec![&root1, &root2]; let tree = MenuService::build_tree(&roots, &children_map); assert_eq!(tree.len(), 2); assert_eq!(tree[0].title, "首页"); assert_eq!(tree[1].title, "系统管理"); } #[test] fn build_tree_preserves_model_fields() { let root_id = Uuid::now_v7(); let now = Utc::now(); let tenant_id = Uuid::now_v7(); let root = menu::Model { id: root_id, tenant_id, parent_id: None, title: "设置".to_string(), path: Some("/settings".to_string()), icon: Some("SettingOutlined".to_string()), sort_order: 5, visible: false, menu_type: "directory".to_string(), permission: Some("settings:view".to_string()), created_at: now, updated_at: now, created_by: tenant_id, updated_by: tenant_id, deleted_at: None, version: 3, }; let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); let roots: Vec<&menu::Model> = vec![&root]; let tree = MenuService::build_tree(&roots, &children_map); assert_eq!(tree.len(), 1); let node = &tree[0]; assert_eq!(node.id, root_id); assert_eq!(node.title, "设置"); assert_eq!(node.path, Some("/settings".to_string())); assert_eq!(node.icon, Some("SettingOutlined".to_string())); assert_eq!(node.sort_order, 5); assert!(!node.visible); assert_eq!(node.menu_type, "directory"); assert_eq!(node.permission, Some("settings:view".to_string())); assert_eq!(node.version, 3); } }