Files
erp/crates/erp-config/src/service/setting_service.rs
iven 9568dd7875 chore: apply cargo fmt across workspace and update docs
- Run cargo fmt on all Rust crates for consistent formatting
- Update CLAUDE.md with WASM plugin commands and dev.ps1 instructions
- Update wiki: add WASM plugin architecture, rewrite dev environment docs
- Minor frontend cleanup (unused imports)
2026-04-15 00:49:20 +08:00

353 lines
12 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 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::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
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(
params: crate::dto::SetSettingParams,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<SettingResp> {
// Look for an existing non-deleted record
let mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(&params.scope))
.filter(setting::Column::SettingKey.eq(&params.key))
.filter(setting::Column::DeletedAt.is_null());
query = match params.scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let existing = query
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if let Some(model) = existing {
// Update existing record — 乐观锁校验
let next_version = match params.version {
Some(v) => {
check_version(v, model.version).map_err(|_| ConfigError::VersionMismatch)?
}
None => model.version + 1,
};
let mut active: setting::ActiveModel = model.into();
active.setting_value = Set(params.value.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()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"setting.updated",
tenant_id,
serde_json::json!({
"setting_id": updated.id,
"key": params.key,
"scope": params.scope,
}),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
.with_resource_id(updated.id),
db,
)
.await;
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(params.scope.clone()),
scope_id: Set(params.scope_id),
setting_key: Set(params.key.clone()),
setting_value: Set(params.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": params.key,
"scope": params.scope,
}),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
.with_resource_id(id),
db,
)
.await;
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 mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::DeletedAt.is_null());
query = match scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let paginator = query.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);
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.
/// Performs optimistic locking via version check.
pub async fn delete(
key: &str,
scope: &str,
scope_id: &Option<Uuid>,
tenant_id: Uuid,
operator_id: Uuid,
version: i32,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<()> {
let mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::SettingKey.eq(key))
.filter(setting::Column::DeletedAt.is_null());
query = match scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let model = query
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.ok_or_else(|| {
ConfigError::NotFound(format!("设置 '{}' 在 '{}' 作用域下不存在", key, scope))
})?;
let next_version =
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let setting_id = model.id;
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.version = Set(next_version);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "setting.delete", "setting")
.with_resource_id(setting_id),
db,
)
.await;
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 mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::SettingKey.eq(key))
.filter(setting::Column::DeletedAt.is_null());
// SQL 中 `= NULL` 永远返回 false必须用 IS NULL 匹配 NULL 值
query = match scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let model = query
.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(),
version: model.version,
}
}
}