- 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)
353 lines
12 KiB
Rust
353 lines
12 KiB
Rust
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(¶ms.scope))
|
||
.filter(setting::Column::SettingKey.eq(¶ms.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,
|
||
}
|
||
}
|
||
}
|