- erp-health: article/banner/consultation/media 服务层优化 - erp-ai: analysis/insight/prompt 服务增强 - erp-auth: auth/role/token 服务改进 - erp-workflow: executor 执行引擎修复 - erp-plugin: 服务层改进 - 新增媒体上传文件样例
590 lines
20 KiB
Rust
590 lines
20 KiB
Rust
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);
|
||
}
|
||
}
|