feat(config): 角色权限控制菜单可见性 + 医疗业务角色
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled

- 修复 menu_service 角色过滤 bug: ctx.roles 存的是角色 code 而非 UUID,
  新增 resolve_role_ids() 方法通过 code 查找数据库中的角色 ID
- 创建 4 个医疗业务角色: 医生/护士/健康管理师/运营人员
- 重组菜单目录结构: 基础模块→工作台、业务模块→系统管理、健康管理→健康业务
- 菜单排序按功能域分组(患者医护/随访咨询/积分运营/内容运营/AI分析)
- 为各角色分配对应的菜单可见性和操作权限
This commit is contained in:
iven
2026-05-06 12:35:45 +08:00
parent 5fd8e88825
commit 570377a31f
5 changed files with 457 additions and 19 deletions

View File

@@ -28,6 +28,12 @@ impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
}
}
impl From<sea_orm::DbErr> for ConfigError {
fn from(err: sea_orm::DbErr) -> Self {
ConfigError::Validation(err.to_string())
}
}
impl From<ConfigError> for AppError {
fn from(err: ConfigError) -> Self {
match err {

View File

@@ -36,13 +36,7 @@ where
{
require_permission(&ctx, "menu.list")?;
let role_ids: Vec<Uuid> = ctx
.roles
.iter()
.filter_map(|r| Uuid::parse_str(r).ok())
.collect();
let menus = MenuService::get_menu_tree(ctx.tenant_id, &role_ids, &state.db).await?;
let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?;
Ok(JsonResponse(ApiResponse::ok(menus)))
}
@@ -257,14 +251,7 @@ where
ConfigState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let role_ids: Vec<Uuid> = ctx
.roles
.iter()
.filter_map(|r| Uuid::parse_str(r).ok())
.collect();
// 如果用户有角色关联菜单按角色过滤否则返回全部admin 兜底)
let menus = MenuService::get_menu_tree(ctx.tenant_id, &role_ids, &state.db).await?;
let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?;
Ok(JsonResponse(ApiResponse::ok(menus)))
}

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use uuid::Uuid;
use crate::dto::{CreateMenuReq, MenuResp};
@@ -17,15 +17,49 @@ use erp_core::events::EventBus;
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_ids` 非空,仅返回这些角色关联的菜单;
/// 否则返回租户全部菜单。结果按 `sort_order` 排列并组装为树形结构
/// `role_codes` 为当前用户的角色 code 列表(如 ["admin"]、["doctor"])。
/// 方法内部将 code 转换为 ID再通过 menu_roles 表过滤
/// 如果角色没有任何菜单关联返回全部菜单admin 兜底)。
pub async fn get_menu_tree(
tenant_id: Uuid,
role_ids: &[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))

View File

@@ -124,6 +124,7 @@ mod m20260505_000121_create_ai_knowledge_references;
mod m20260505_000122_create_ai_knowledge_guides;
mod m20260505_000123_update_ai_prompts_system_instruction;
mod m20260505_000124_freeze_deferred_menus;
mod m20260506_000125_restructure_menus_and_roles;
pub struct Migrator;
@@ -255,6 +256,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260505_000122_create_ai_knowledge_guides::Migration),
Box::new(m20260505_000123_update_ai_prompts_system_instruction::Migration),
Box::new(m20260505_000124_freeze_deferred_menus::Migration),
Box::new(m20260506_000125_restructure_menus_and_roles::Migration),
]
}
}

View File

@@ -0,0 +1,409 @@
//! 重组菜单结构 + 创建医疗业务角色 + 菜单-角色关联
//!
//! 1. 重命名顶级目录,使其更贴合健康管理机构使用习惯
//! 2. 调整菜单项排序,按功能域分组
//! 3. 创建 4 个医疗业务角色: doctor / nurse / health_manager / operator
//! 4. 为各角色分配菜单可见性和操作权限
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// ================================================================
// Part 1: 重命名顶级目录
// ================================================================
let dir_renames: &[(&str, &str)] = &[
("基础模块", "工作台"),
("业务模块", "系统管理"),
("健康管理", "健康业务"),
("系统", "配置"),
];
for &(old, new) in dir_renames {
db.execute_unprepared(&format!(
"UPDATE menus SET title = '{new}' WHERE title = '{old}' AND menu_type = 'directory' AND deleted_at IS NULL"
)).await?;
}
// ================================================================
// Part 2: 调整菜单排序和归属
// ================================================================
// 将"统计报表"移到"工作台"目录下
db.execute_unprepared(
"UPDATE menus SET parent_id = (SELECT id FROM menus WHERE title = '工作台' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1), sort_order = 1 WHERE path = '/health/statistics' AND deleted_at IS NULL"
).await?;
// 将"工作流"和"消息中心"移到"系统管理"目录下
// (它们已经是"业务模块"的子项,目录重命名后自动归到"系统管理"
// 将"系统设置"和"插件管理"移到"系统管理"目录下
db.execute_unprepared(
"UPDATE menus SET parent_id = (SELECT id FROM menus WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1) WHERE path IN ('/settings', '/plugins/admin') AND deleted_at IS NULL"
).await?;
// 删除空的"配置"目录(原"系统"目录,其子项已移走)
db.execute_unprepared(
"UPDATE menus SET deleted_at = NOW() WHERE title = '配置' AND menu_type = 'directory' AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM menus c WHERE c.parent_id = menus.id AND c.deleted_at IS NULL)"
).await?;
// 调整"系统管理"目录下的排序
let sys_sort: &[(&str, i32)] = &[
("/users", 0),
("/roles", 1),
("/organizations", 2),
("/workflow", 3),
("/messages", 4),
("/settings", 5),
("/plugins/admin", 6),
];
for &(path, sort) in sys_sort {
db.execute_unprepared(&format!(
"UPDATE menus SET sort_order = {sort} WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 调整"健康业务"目录下的排序 — 按功能域分组
let health_sort: &[(&str, i32)] = &[
// 患者与医护
("/health/patients", 0),
("/health/doctors", 1),
("/health/tags", 2),
("/health/diagnoses", 3),
// 随访与咨询
("/health/follow-up-tasks", 10),
("/health/consultations", 11),
("/health/action-inbox", 12),
("/health/follow-up-templates", 13),
// 监测与知情同意
("/health/daily-monitoring", 20),
("/health/consents", 21),
("/health/realtime-monitor", 22),
// 告警与设备
("/health/alert-dashboard", 30),
("/health/alerts", 31),
("/health/alert-rules", 32),
("/health/devices", 33),
("/health/ble-gateways", 34),
("/health/critical-value-thresholds", 35),
// 运营
("/health/articles", 40),
("/health/points-rules", 41),
("/health/points-products", 42),
("/health/points-orders", 43),
("/health/offline-events", 44),
// AI
("/health/ai-prompts", 50),
("/health/ai-analysis", 51),
("/health/ai-usage", 52),
// 其他
("/health/oauth-clients", 60),
];
for &(path, sort) in health_sort {
db.execute_unprepared(&format!(
"UPDATE menus SET sort_order = {sort} WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// ================================================================
// Part 3: 创建/更新医疗业务角色(幂等)
// ================================================================
let sys = "00000000-0000-0000-0000-000000000000";
let roles: &[(&str, &str, &str)] = &[
("doctor", "医生", "负责患者诊疗、随访管理、AI辅助诊断"),
("nurse", "护士", "负责患者护理、体征监测、用药管理"),
("health_manager", "健康管理师", "负责健康管理计划、随访协调、运营统计"),
("operator", "运营人员", "负责内容运营、积分商城、活动管理"),
];
for &(code, name, desc) in roles {
// 对已存在的角色:更新名称和描述;对新租户:创建角色
db.execute_unprepared(&format!(
"INSERT INTO roles (id, tenant_id, name, code, description, is_system, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), t.id, '{name}', '{code}', '{desc}', false, NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \
FROM tenant t \
WHERE NOT EXISTS (SELECT 1 FROM roles r WHERE r.tenant_id = t.id AND r.code = '{code}' AND r.deleted_at IS NULL)"
)).await?;
// 更新已存在的角色的名称和描述
db.execute_unprepared(&format!(
"UPDATE roles SET name = '{name}', description = '{desc}' WHERE code = '{code}' AND deleted_at IS NULL AND name != '{name}'"
)).await?;
}
// ================================================================
// Part 4: 为新角色分配权限
// ================================================================
// doctor 权限: 患者 + 医护 + 随访 + 咨询 + AI + 告警 + 日常监测 + 诊断 + 知情同意 + 行动收件箱 + 消息(只读)
assign_perms_by_codes(db, "doctor", &[
"health.patient.list", "health.patient.manage",
"health.doctor.list", "health.doctor.manage",
"health.follow-up.list", "health.follow-up.manage",
"health.consultation.list", "health.consultation.manage",
"health.action-inbox.list", "health.action-inbox.manage",
"health.daily-monitoring.list", "health.daily-monitoring.manage",
"health.alerts.list", "health.alerts.manage",
"health.alert-rules.list",
"health.critical-alerts.list",
"health.diagnosis.list", "health.diagnosis.manage",
"health.consent.list", "health.consent.manage",
"health.health-data.list",
"ai.analysis.list", "ai.suggestion.list",
"message.list",
"workflow.list", "workflow.read",
]).await?;
// nurse 权限: 患者 + 随访 + 咨询 + 告警 + 日常监测 + 诊断 + 知情同意 + 行动收件箱 + 消息(只读)
assign_perms_by_codes(db, "nurse", &[
"health.patient.list", "health.patient.manage",
"health.follow-up.list", "health.follow-up.manage",
"health.consultation.list",
"health.action-inbox.list", "health.action-inbox.manage",
"health.daily-monitoring.list", "health.daily-monitoring.manage",
"health.alerts.list",
"health.critical-alerts.list",
"health.diagnosis.list",
"health.consent.list", "health.consent.manage",
"health.health-data.list",
"health.device-readings.list",
"message.list",
]).await?;
// health_manager 权限: 统计 + 患者 + 医护 + 随访 + 咨询 + AI + 告警 + 设备 + 日常监测 + 标签 + 诊断 + 知情同意 + 行动收件箱 + 随访模板
assign_perms_by_codes(db, "health_manager", &[
"health.patient.list", "health.patient.manage",
"health.doctor.list",
"health.follow-up.list", "health.follow-up.manage",
"health.consultation.list", "health.consultation.manage",
"health.action-inbox.list", "health.action-inbox.manage", "health.action-inbox.team",
"health.daily-monitoring.list", "health.daily-monitoring.manage",
"health.alerts.list", "health.alerts.manage",
"health.alert-rules.list", "health.alert-rules.manage",
"health.critical-alerts.list",
"health.critical-value-thresholds.list",
"health.devices.list",
"health.tags.list", "health.tags.manage",
"health.diagnosis.list", "health.diagnosis.manage",
"health.consent.list", "health.consent.manage",
"health.health-data.list", "health.health-data.manage",
"health.follow-up-templates.list", "health.follow-up-templates.manage",
"health.dashboard.manage",
"ai.analysis.list", "ai.analysis.manage",
"ai.prompt.list",
"ai.suggestion.list", "ai.suggestion.manage",
"ai.usage.list",
"message.list",
"workflow.list", "workflow.read", "workflow.start",
]).await?;
// operator 权限: 统计 + 标签 + 内容 + 积分 + 活动 + 设备 + 告警(只读)
assign_perms_by_codes(db, "operator", &[
"health.patient.list",
"health.tags.list", "health.tags.manage",
"health.articles.list", "health.articles.manage",
"health.articles.review",
"health.points.list", "health.points.manage",
"health.offline-events.list", "health.offline-events.manage",
"health.devices.list",
"health.alerts.list",
"health.dashboard.manage",
"ai.usage.list",
"message.list",
]).await?;
// ================================================================
// Part 5: 菜单-角色关联menu_roles
// ================================================================
// admin 角色自动看到所有 visible=true 菜单service 层 fallback 逻辑)
// 只需为新角色关联菜单
// doctor 可见菜单路径
let doctor_paths: &[&str] = &[
"/", "/health/statistics",
"/health/patients", "/health/doctors",
"/health/follow-up-tasks", "/health/consultations",
"/health/action-inbox", "/health/follow-up-templates",
"/health/daily-monitoring", "/health/consents", "/health/diagnoses",
"/health/alert-dashboard", "/health/alerts",
"/health/ai-analysis", "/health/ai-usage",
"/messages",
];
assign_menus_for_role(db, "doctor", doctor_paths).await?;
// nurse 可见菜单路径
let nurse_paths: &[&str] = &[
"/", "/health/statistics",
"/health/patients",
"/health/follow-up-tasks", "/health/consultations",
"/health/action-inbox",
"/health/daily-monitoring", "/health/consents", "/health/diagnoses",
"/health/alert-dashboard", "/health/alerts",
"/messages",
];
assign_menus_for_role(db, "nurse", nurse_paths).await?;
// health_manager 可见菜单路径
let hm_paths: &[&str] = &[
"/", "/health/statistics",
"/health/patients", "/health/doctors", "/health/tags",
"/health/follow-up-tasks", "/health/consultations",
"/health/action-inbox", "/health/follow-up-templates",
"/health/daily-monitoring", "/health/consents", "/health/diagnoses",
"/health/alert-dashboard", "/health/alerts", "/health/alert-rules",
"/health/devices", "/health/critical-value-thresholds",
"/health/ai-prompts", "/health/ai-analysis", "/health/ai-usage",
"/health/realtime-monitor",
"/messages",
];
assign_menus_for_role(db, "health_manager", hm_paths).await?;
// operator 可见菜单路径
let op_paths: &[&str] = &[
"/", "/health/statistics",
"/health/patients", "/health/tags",
"/health/articles",
"/health/points-rules", "/health/points-products", "/health/points-orders",
"/health/offline-events",
"/health/devices",
"/health/alert-dashboard", "/health/alerts",
"/health/ai-usage",
"/messages",
];
assign_menus_for_role(db, "operator", op_paths).await?;
// 也要为 admin 角色显式关联所有可见菜单
assign_admin_all_menus(db).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 删除新创建的角色及其关联
for code in &["doctor", "nurse", "health_manager", "operator"] {
// 删除 menu_roles 关联
db.execute_unprepared(&format!(
"UPDATE menu_roles SET deleted_at = NOW() WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}')"
)).await?;
// 删除 role_permissions 关联
db.execute_unprepared(&format!(
"UPDATE role_permissions SET deleted_at = NOW() WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}')"
)).await?;
// 删除 user_roles 关联
db.execute_unprepared(&format!(
"UPDATE user_roles SET deleted_at = NOW() WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}')"
)).await?;
// 软删除角色
db.execute_unprepared(&format!(
"UPDATE roles SET deleted_at = NOW() WHERE code = '{code}' AND is_system = false"
)).await?;
}
// 恢复目录名
let dir_renames: &[(&str, &str)] = &[
("工作台", "基础模块"),
("系统管理", "业务模块"),
("健康业务", "健康管理"),
];
for &(old, new) in dir_renames {
db.execute_unprepared(&format!(
"UPDATE menus SET title = '{new}' WHERE title = '{old}' AND menu_type = 'directory'"
)).await?;
}
// 恢复"系统"目录
db.execute_unprepared(
"UPDATE menus SET deleted_at = NULL WHERE title = '配置' AND menu_type = 'directory'"
).await?;
// 恢复"系统"名称
db.execute_unprepared(
"UPDATE menus SET title = '系统' WHERE title = '配置' AND menu_type = 'directory'"
).await?;
Ok(())
}
}
/// 为指定角色分配权限码
async fn assign_perms_by_codes(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
role_code: &str,
perm_codes: &[&str],
) -> Result<(), DbErr> {
let codes_csv: String = perm_codes
.iter()
.map(|c| format!("'{}'", c))
.collect::<Vec<_>>()
.join(",");
// 为所有租户的该角色分配这些权限
// 使用 ON CONFLICT 处理已软删除的行(恢复它们而非报错)
db.execute_unprepared(&format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) DO UPDATE SET \
data_scope = 'all', deleted_at = NULL, updated_at = NOW(), version = role_permissions.version + 1"
)).await?;
Ok(())
}
/// 为指定角色关联菜单
async fn assign_menus_for_role(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
role_code: &str,
menu_paths: &[&str],
) -> Result<(), DbErr> {
let paths_csv: String = menu_paths
.iter()
.map(|p| format!("'{}'", p))
.collect::<Vec<_>>()
.join(",");
// 为所有租户的该角色关联菜单
db.execute_unprepared(&format!(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.path IN ({paths_csv}) AND m.deleted_at IS NULL AND m.visible = true \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
)).await?;
// 同时关联所有顶级 directory菜单组标题确保目录可见
db.execute_unprepared(&format!(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.menu_type = 'directory' AND m.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
)).await?;
Ok(())
}
/// 为 admin 角色关联所有可见菜单
async fn assign_admin_all_menus(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
) -> Result<(), DbErr> {
db.execute_unprepared(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.deleted_at IS NULL AND m.visible = true \
WHERE r.code = 'admin' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
).await?;
Ok(())
}