Files
hms/crates/erp-config/src/service/menu_service.rs
iven 212c08b7ae feat(health,ai): 后端服务优化 + 媒体文件处理
- erp-health: article/banner/consultation/media 服务层优化
- erp-ai: analysis/insight/prompt 服务增强
- erp-auth: auth/role/token 服务改进
- erp-workflow: executor 执行引擎修复
- erp-plugin: 服务层改进
- 新增媒体上传文件样例
2026-05-13 23:28:57 +08:00

590 lines
20 KiB
Rust
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.
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<Vec<Uuid>> {
if role_codes.is_empty() {
return Ok(vec![]);
}
let codes_csv: String = role_codes
.iter()
.map(|c| format!("'{}'", c.replace('\'', "''")))
.collect::<Vec<_>>()
.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<Vec<MenuResp>> {
// 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<Vec<Uuid>> = 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<Uuid> = 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<Option<Uuid>, 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<Vec<MenuResp>> {
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<MenuResp> {
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<MenuResp> {
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<Option<Uuid>, Vec<&'a menu::Model>>,
) -> Vec<MenuResp> {
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<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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);
}
}