Files
hms/crates/erp-config/src/service/menu_service.rs
iven ee65b6e3c9 test: add 149 unit tests across core, auth, config, message crates
Test coverage increased from ~34 to 183 tests (zero failures):

- erp-core (21): version check, pagination, API response, error mapping
- erp-auth (39): org tree building, DTO validation, error conversion,
  password hashing, user model mapping
- erp-config (57): DTO validation, numbering reset logic, menu tree
  building, error conversion. Fixed BatchSaveMenusReq nested validation
- erp-message (50): DTO validation, template rendering, query defaults,
  error conversion
- erp-workflow (16): unchanged (parser + expression tests)

All tests are pure unit tests requiring no database.
2026-04-15 01:06:34 +08:00

550 lines
19 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, 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 {
/// 获取当前租户下指定角色可见的菜单树。
///
/// 如果 `role_ids` 非空,仅返回这些角色关联的菜单;
/// 否则返回租户全部菜单。结果按 `sort_order` 排列并组装为树形结构。
pub async fn get_menu_tree(
tenant_id: Uuid,
role_ids: &[Uuid],
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<MenuResp>> {
// 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() {
return Ok(vec![]);
}
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
.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);
}
}