feat(config): add system configuration module (Phase 3)
Implement the complete erp-config crate with: - Data dictionaries (CRUD + items management) - Dynamic menus (tree structure with role filtering) - System settings (hierarchical: platform > tenant > org > user) - Numbering rules (concurrency-safe via PostgreSQL advisory_lock) - Theme and language configuration (via settings store) - 6 database migrations (dictionaries, menus, settings, numbering_rules) - Frontend Settings page with 5 tabs (dictionary, menu, numbering, settings, theme) Refactor: move RBAC functions (require_permission) from erp-auth to erp-core to avoid cross-module dependencies. Add 20 new seed permissions for config module operations.
This commit is contained in:
355
crates/erp-config/src/service/menu_service.rs
Normal file
355
crates/erp-config/src/service/menu_service.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
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::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![],
|
||||
})
|
||||
.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 {
|
||||
if !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 }),
|
||||
));
|
||||
|
||||
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![],
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新菜单字段,并可选地重新关联角色。
|
||||
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 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);
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
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![],
|
||||
})
|
||||
}
|
||||
|
||||
/// 软删除菜单。
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
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 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
|
||||
.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 }),
|
||||
));
|
||||
|
||||
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),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user