feat(core): implement optimistic locking across all entities
Add VersionMismatch error variant and check_version() helper to erp-core. All 13 mutable entities now enforce version checking on update/delete: - erp-auth: user, role, organization, department, position - erp-config: dictionary, dictionary_item, menu, setting, numbering_rule - erp-workflow: process_definition, process_instance, task - erp-message: message, message_subscription Update DTOs to expose version in responses and require version in update requests. HTTP 409 Conflict returned on version mismatch.
This commit is contained in:
@@ -7,6 +7,7 @@ use uuid::Uuid;
|
||||
use crate::dto::{DictionaryItemResp, DictionaryResp};
|
||||
use crate::entity::{dictionary, dictionary_item};
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
@@ -51,6 +52,7 @@ impl DictionaryService {
|
||||
code: m.code.clone(),
|
||||
description: m.description.clone(),
|
||||
items,
|
||||
version: m.version,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,6 +82,7 @@ impl DictionaryService {
|
||||
code: model.code.clone(),
|
||||
description: model.description.clone(),
|
||||
items,
|
||||
version: model.version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,18 +143,19 @@ impl DictionaryService {
|
||||
code: code.to_string(),
|
||||
description: description.clone(),
|
||||
items: vec![],
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable dictionary fields (name and description).
|
||||
///
|
||||
/// Code cannot be changed after creation.
|
||||
/// Performs optimistic locking via version check.
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
name: &Option<String>,
|
||||
description: &Option<String>,
|
||||
req: &crate::dto::UpdateDictionaryReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<DictionaryResp> {
|
||||
let model = dictionary::Entity::find_by_id(id)
|
||||
@@ -161,17 +165,21 @@ impl DictionaryService {
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||
|
||||
let next_version =
|
||||
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||
|
||||
let mut active: dictionary::ActiveModel = model.into();
|
||||
|
||||
if let Some(n) = name {
|
||||
if let Some(n) = &req.name {
|
||||
active.name = Set(n.clone());
|
||||
}
|
||||
if let Some(d) = description {
|
||||
if let Some(d) = &req.description {
|
||||
active.description = Set(Some(d.clone()));
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_version);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
@@ -186,14 +194,17 @@ impl DictionaryService {
|
||||
code: updated.code.clone(),
|
||||
description: updated.description.clone(),
|
||||
items,
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
|
||||
/// Performs optimistic locking via version check.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<()> {
|
||||
@@ -204,10 +215,14 @@ impl DictionaryService {
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||
|
||||
let next_version =
|
||||
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||
|
||||
let mut active: dictionary::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
|
||||
@@ -283,10 +298,12 @@ impl DictionaryService {
|
||||
value: req.value.clone(),
|
||||
sort_order,
|
||||
color: req.color.clone(),
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable dictionary item fields (label, value, sort_order, color).
|
||||
/// Performs optimistic locking via version check.
|
||||
pub async fn update_item(
|
||||
item_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
@@ -301,6 +318,9 @@ impl DictionaryService {
|
||||
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
||||
|
||||
let next_version =
|
||||
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||
|
||||
let mut active: dictionary_item::ActiveModel = model.into();
|
||||
|
||||
if let Some(l) = &req.label {
|
||||
@@ -318,6 +338,7 @@ impl DictionaryService {
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_version);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
@@ -331,14 +352,17 @@ impl DictionaryService {
|
||||
value: updated.value.clone(),
|
||||
sort_order: updated.sort_order,
|
||||
color: updated.color.clone(),
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
|
||||
/// Performs optimistic locking via version check.
|
||||
pub async fn delete_item(
|
||||
item_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<()> {
|
||||
let model = dictionary_item::Entity::find_by_id(item_id)
|
||||
@@ -348,10 +372,14 @@ impl DictionaryService {
|
||||
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
||||
|
||||
let next_version =
|
||||
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||
|
||||
let mut active: dictionary_item::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
|
||||
@@ -405,6 +433,7 @@ impl DictionaryService {
|
||||
value: i.value.clone(),
|
||||
sort_order: i.sort_order,
|
||||
color: i.color.clone(),
|
||||
version: i.version,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
||||
use crate::dto::{CreateMenuReq, MenuResp};
|
||||
use crate::entity::{menu, menu_role};
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
|
||||
@@ -103,6 +104,7 @@ impl MenuService {
|
||||
menu_type: m.menu_type.clone(),
|
||||
permission: m.permission.clone(),
|
||||
children: vec![],
|
||||
version: m.version,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -165,10 +167,12 @@ impl MenuService {
|
||||
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,
|
||||
@@ -183,6 +187,9 @@ impl MenuService {
|
||||
.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 {
|
||||
@@ -206,6 +213,7 @@ impl MenuService {
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_version);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
@@ -228,14 +236,16 @@ impl MenuService {
|
||||
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<()> {
|
||||
@@ -246,10 +256,14 @@ impl MenuService {
|
||||
.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
|
||||
@@ -348,6 +362,7 @@ impl MenuService {
|
||||
menu_type: m.menu_type.clone(),
|
||||
permission: m.permission.clone(),
|
||||
children: Self::build_tree(&children, children_map),
|
||||
version: m.version,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -8,6 +8,7 @@ use uuid::Uuid;
|
||||
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
|
||||
use crate::entity::numbering_rule;
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
@@ -118,10 +119,11 @@ impl NumberingService {
|
||||
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
|
||||
reset_cycle: req.reset_cycle.clone().unwrap_or_else(|| "never".to_string()),
|
||||
last_reset_date: Some(Utc::now().date_naive().to_string()),
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新编号规则的可编辑字段。
|
||||
/// 更新编号规则的可编辑字段。使用乐观锁校验版本。
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
@@ -136,6 +138,9 @@ impl NumberingService {
|
||||
.filter(|r| r.tenant_id == tenant_id && r.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: numbering_rule::ActiveModel = model.into();
|
||||
|
||||
if let Some(name) = &req.name {
|
||||
@@ -159,6 +164,7 @@ impl NumberingService {
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_version);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
@@ -168,11 +174,12 @@ impl NumberingService {
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 软删除编号规则。
|
||||
/// 软删除编号规则。使用乐观锁校验版本。
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<()> {
|
||||
@@ -183,10 +190,14 @@ impl NumberingService {
|
||||
.filter(|r| r.tenant_id == tenant_id && r.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: numbering_rule::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
|
||||
@@ -374,6 +385,7 @@ impl NumberingService {
|
||||
separator: m.separator.clone(),
|
||||
reset_cycle: m.reset_cycle.clone(),
|
||||
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use uuid::Uuid;
|
||||
use crate::dto::SettingResp;
|
||||
use crate::entity::setting;
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
@@ -89,11 +90,17 @@ impl SettingService {
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
if let Some(model) = existing {
|
||||
// Update existing record
|
||||
// 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)
|
||||
@@ -181,12 +188,14 @@ impl SettingService {
|
||||
}
|
||||
|
||||
/// 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 model = setting::Entity::find()
|
||||
@@ -205,10 +214,14 @@ impl SettingService {
|
||||
))
|
||||
})?;
|
||||
|
||||
let next_version =
|
||||
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||
|
||||
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
|
||||
@@ -283,6 +296,7 @@ impl SettingService {
|
||||
scope_id: model.scope_id,
|
||||
setting_key: model.setting_key.clone(),
|
||||
setting_value: model.setting_value.clone(),
|
||||
version: model.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user