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:
291
crates/erp-config/src/service/setting_service.rs
Normal file
291
crates/erp-config/src/service/setting_service.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::SettingResp;
|
||||
use crate::entity::setting;
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// Setting scope hierarchy constants.
|
||||
const SCOPE_PLATFORM: &str = "platform";
|
||||
const SCOPE_TENANT: &str = "tenant";
|
||||
const SCOPE_ORG: &str = "org";
|
||||
const SCOPE_USER: &str = "user";
|
||||
|
||||
/// Setting CRUD service — manage hierarchical configuration values.
|
||||
///
|
||||
/// Settings support a 4-level inheritance hierarchy:
|
||||
/// `user -> org -> tenant -> platform`
|
||||
///
|
||||
/// When reading a setting, if the exact scope+scope_id match is not found,
|
||||
/// the service walks up the hierarchy to find the nearest ancestor value.
|
||||
pub struct SettingService;
|
||||
|
||||
impl SettingService {
|
||||
/// Get a setting value with hierarchical fallback.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. Exact match at (scope, scope_id)
|
||||
/// 2. Walk up the hierarchy based on scope:
|
||||
/// - `user` -> org -> tenant -> platform
|
||||
/// - `org` -> tenant -> platform
|
||||
/// - `tenant` -> platform
|
||||
/// - `platform` -> NotFound
|
||||
pub async fn get(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<SettingResp> {
|
||||
// 1. Try exact match
|
||||
if let Some(resp) =
|
||||
Self::find_exact(key, scope, scope_id, tenant_id, db).await?
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// 2. Walk up the hierarchy based on scope
|
||||
let fallback_chain = Self::fallback_chain(scope, scope_id, tenant_id)?;
|
||||
|
||||
for (fb_scope, fb_scope_id) in fallback_chain {
|
||||
if let Some(resp) =
|
||||
Self::find_exact(key, &fb_scope, &fb_scope_id, tenant_id, db).await?
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
||||
|
||||
Err(ConfigError::NotFound(format!(
|
||||
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||
key, scope
|
||||
)))
|
||||
}
|
||||
|
||||
/// Set a setting value. Creates or updates.
|
||||
///
|
||||
/// If a record with the same (scope, scope_id, key) exists and is not
|
||||
/// soft-deleted, it will be updated. Otherwise a new record is inserted.
|
||||
pub async fn set(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
value: serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<SettingResp> {
|
||||
// Look for an existing non-deleted record
|
||||
let existing = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
if let Some(model) = existing {
|
||||
// Update existing record
|
||||
let mut active: setting::ActiveModel = model.into();
|
||||
active.setting_value = Set(value.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()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"setting.updated",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": updated.id,
|
||||
"key": key,
|
||||
"scope": scope,
|
||||
}),
|
||||
));
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
} else {
|
||||
// Insert new record
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = setting::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
scope: Set(scope.to_string()),
|
||||
scope_id: Set(*scope_id),
|
||||
setting_key: Set(key.to_string()),
|
||||
setting_value: Set(value),
|
||||
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),
|
||||
};
|
||||
let inserted = model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"setting.created",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": id,
|
||||
"key": key,
|
||||
"scope": scope,
|
||||
}),
|
||||
));
|
||||
|
||||
Ok(Self::model_to_resp(&inserted))
|
||||
}
|
||||
}
|
||||
|
||||
/// List all settings for a specific scope and scope_id, with pagination.
|
||||
pub async fn list_by_scope(
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<(Vec<SettingResp>, u64)> {
|
||||
let paginator = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
let resps: Vec<SettingResp> =
|
||||
models.iter().map(Self::model_to_resp).collect();
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// Soft-delete a setting by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<()> {
|
||||
let model = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
ConfigError::NotFound(format!(
|
||||
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||
key, scope
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut active: setting::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()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- 内部辅助方法 ----
|
||||
|
||||
/// Find an exact setting match by key, scope, and scope_id.
|
||||
async fn find_exact(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<Option<SettingResp>> {
|
||||
let model = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(model.as_ref().map(Self::model_to_resp))
|
||||
}
|
||||
|
||||
/// Build the fallback chain for hierarchical lookup.
|
||||
///
|
||||
/// Returns a list of (scope, scope_id) tuples to try in order.
|
||||
fn fallback_chain(
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
) -> ConfigResult<Vec<(String, Option<Uuid>)>> {
|
||||
match scope {
|
||||
SCOPE_USER => {
|
||||
// user -> org -> tenant -> platform
|
||||
// Note: We cannot resolve the actual org_id from user scope here
|
||||
// without a dependency on auth module. The caller should handle
|
||||
// org-level resolution externally if needed. We skip org fallback
|
||||
// and go directly to tenant.
|
||||
Ok(vec![
|
||||
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||
(SCOPE_PLATFORM.to_string(), None),
|
||||
])
|
||||
}
|
||||
SCOPE_ORG => Ok(vec![
|
||||
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||
(SCOPE_PLATFORM.to_string(), None),
|
||||
]),
|
||||
SCOPE_TENANT => {
|
||||
Ok(vec![(SCOPE_PLATFORM.to_string(), None)])
|
||||
}
|
||||
SCOPE_PLATFORM => Ok(vec![]),
|
||||
_ => Err(ConfigError::Validation(format!(
|
||||
"不支持的作用域类型: '{}'",
|
||||
scope
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a SeaORM model to a response DTO.
|
||||
fn model_to_resp(model: &setting::Model) -> SettingResp {
|
||||
SettingResp {
|
||||
id: model.id,
|
||||
scope: model.scope.clone(),
|
||||
scope_id: model.scope_id,
|
||||
setting_key: model.setting_key.clone(),
|
||||
setting_value: model.setting_value.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user