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:
iven
2026-04-11 23:25:43 +08:00
parent 1fec5e2cf2
commit 5d6e1dc394
32 changed files with 549 additions and 184 deletions

View File

@@ -38,6 +38,7 @@ pub struct UserResp {
pub avatar_url: Option<String>,
pub status: String,
pub roles: Vec<RoleResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -58,6 +59,7 @@ pub struct UpdateUserReq {
pub phone: Option<String>,
pub display_name: Option<String>,
pub status: Option<String>,
pub version: i32,
}
// --- Role DTOs ---
@@ -69,6 +71,7 @@ pub struct RoleResp {
pub code: String,
pub description: Option<String>,
pub is_system: bool,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -84,6 +87,7 @@ pub struct CreateRoleReq {
pub struct UpdateRoleReq {
pub name: Option<String>,
pub description: Option<String>,
pub version: i32,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -120,6 +124,7 @@ pub struct OrganizationResp {
pub level: i32,
pub sort_order: i32,
pub children: Vec<OrganizationResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -136,6 +141,7 @@ pub struct UpdateOrganizationReq {
pub name: Option<String>,
pub code: Option<String>,
pub sort_order: Option<i32>,
pub version: i32,
}
// --- Department DTOs ---
@@ -151,6 +157,7 @@ pub struct DepartmentResp {
pub path: Option<String>,
pub sort_order: i32,
pub children: Vec<DepartmentResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -169,6 +176,7 @@ pub struct UpdateDepartmentReq {
pub code: Option<String>,
pub manager_id: Option<Uuid>,
pub sort_order: Option<i32>,
pub version: i32,
}
// --- Position DTOs ---
@@ -181,6 +189,7 @@ pub struct PositionResp {
pub code: Option<String>,
pub level: i32,
pub sort_order: i32,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -198,4 +207,5 @@ pub struct UpdatePositionReq {
pub code: Option<String>,
pub level: Option<i32>,
pub sort_order: Option<i32>,
pub version: i32,
}

View File

@@ -23,6 +23,9 @@ pub enum AuthError {
#[error("{0}")]
Validation(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
}
impl From<AuthError> for AppError {
@@ -35,6 +38,16 @@ impl From<AuthError> for AppError {
AuthError::Validation(s) => AppError::Validation(s),
AuthError::HashError(_) => AppError::Internal(err.to_string()),
AuthError::JwtError(_) => AppError::Unauthorized,
AuthError::VersionMismatch => AppError::VersionMismatch,
}
}
}
impl From<AppError> for AuthError {
fn from(err: AppError) -> Self {
match err {
AppError::VersionMismatch => AuthError::VersionMismatch,
other => AuthError::Validation(other.to_string()),
}
}
}

View File

@@ -116,6 +116,7 @@ where
ctx.user_id,
&req.name,
&req.description,
req.version,
&state.db,
)
.await?;

View File

@@ -122,6 +122,7 @@ impl AuthService {
avatar_url: user_model.avatar_url,
status: user_model.status,
roles: role_resps,
version: user_model.version,
};
// 9. Publish event
@@ -193,6 +194,7 @@ impl AuthService {
avatar_url: user_model.avatar_url,
status: user_model.status,
roles: role_resps,
version: user_model.version,
};
Ok(LoginResp {
@@ -245,6 +247,7 @@ impl AuthService {
code: r.code.clone(),
description: r.description.clone(),
is_system: r.is_system,
version: r.version,
})
.collect())
}

View File

@@ -8,6 +8,7 @@ use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
use crate::entity::department;
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Department CRUD service -- create, read, update, soft-delete departments
@@ -133,6 +134,7 @@ impl DeptService {
path: None,
sort_order: req.sort_order.unwrap_or(0),
children: vec![],
version: 1,
})
}
@@ -167,6 +169,8 @@ impl DeptService {
}
}
let next_ver = check_version(req.version, model.version)?;
let mut active: department::ActiveModel = model.into();
if let Some(n) = &req.name {
@@ -184,6 +188,7 @@ impl DeptService {
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
@@ -200,6 +205,7 @@ impl DeptService {
path: updated.path.clone(),
sort_order: updated.sort_order,
children: vec![],
version: updated.version,
})
}
@@ -234,10 +240,12 @@ impl DeptService {
));
}
let current_version = model.version;
let mut active: department::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(current_version + 1);
active
.update(db)
.await
@@ -277,6 +285,7 @@ fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
path: item.path.clone(),
sort_order: item.sort_order,
children,
version: item.version,
}
}

View File

@@ -7,6 +7,7 @@ use uuid::Uuid;
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Organization CRUD service -- create, read, update, soft-delete organizations
@@ -118,6 +119,7 @@ impl OrgService {
level,
sort_order: req.sort_order.unwrap_or(0),
children: vec![],
version: 1,
})
}
@@ -152,6 +154,8 @@ impl OrgService {
}
}
let next_ver = check_version(req.version, model.version)?;
let mut active: organization::ActiveModel = model.into();
if let Some(ref name) = req.name {
@@ -166,6 +170,7 @@ impl OrgService {
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
@@ -181,6 +186,7 @@ impl OrgService {
level: updated.level,
sort_order: updated.sort_order,
children: vec![],
version: updated.version,
})
}
@@ -213,10 +219,12 @@ impl OrgService {
));
}
let current_version = model.version;
let mut active: organization::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(current_version + 1);
active
.update(db)
.await
@@ -258,6 +266,7 @@ fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
level: item.level,
sort_order: item.sort_order,
children,
version: item.version,
}
}

View File

@@ -6,6 +6,7 @@ use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
use crate::entity::department;
use crate::entity::position;
use crate::error::{AuthError, AuthResult};
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Position CRUD service -- create, read, update, soft-delete positions
@@ -44,6 +45,7 @@ impl PositionService {
code: p.code.clone(),
level: p.level,
sort_order: p.sort_order,
version: p.version,
})
.collect())
}
@@ -114,6 +116,7 @@ impl PositionService {
code: req.code.clone(),
level: req.level.unwrap_or(1),
sort_order: req.sort_order.unwrap_or(0),
version: 1,
})
}
@@ -148,6 +151,8 @@ impl PositionService {
}
}
let next_ver = check_version(req.version, model.version)?;
let mut active: position::ActiveModel = model.into();
if let Some(n) = &req.name {
@@ -165,6 +170,7 @@ impl PositionService {
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
@@ -178,6 +184,7 @@ impl PositionService {
code: updated.code.clone(),
level: updated.level,
sort_order: updated.sort_order,
version: updated.version,
})
}
@@ -196,10 +203,12 @@ impl PositionService {
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
let current_version = model.version;
let mut active: position::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(current_version + 1);
active
.update(db)
.await

View File

@@ -8,6 +8,7 @@ use crate::dto::{PermissionResp, RoleResp};
use crate::entity::{permission, role, role_permission};
use crate::error::AuthError;
use crate::error::AuthResult;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -48,6 +49,7 @@ impl RoleService {
code: m.code.clone(),
description: m.description.clone(),
is_system: m.is_system,
version: m.version,
})
.collect();
@@ -73,6 +75,7 @@ impl RoleService {
code: model.code.clone(),
description: model.description.clone(),
is_system: model.is_system,
version: model.version,
})
}
@@ -134,6 +137,7 @@ impl RoleService {
code: code.to_string(),
description: description.clone(),
is_system: false,
version: 1,
})
}
@@ -146,6 +150,7 @@ impl RoleService {
operator_id: Uuid,
name: &Option<String>,
description: &Option<String>,
version: i32,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<RoleResp> {
let model = role::Entity::find_by_id(id)
@@ -155,6 +160,8 @@ impl RoleService {
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
let next_ver = check_version(version, model.version)?;
let mut active: role::ActiveModel = model.into();
if let Some(name) = name {
@@ -166,6 +173,7 @@ impl RoleService {
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
@@ -178,6 +186,7 @@ impl RoleService {
code: updated.code.clone(),
description: updated.description.clone(),
is_system: updated.is_system,
version: updated.version,
})
}
@@ -202,10 +211,12 @@ impl RoleService {
return Err(AuthError::Validation("系统角色不可删除".to_string()));
}
let current_version = model.version;
let mut active: role::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(current_version + 1);
active
.update(db)
.await

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
use crate::entity::{role, user, user_credential, user_role};
use crate::error::AuthError;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -102,6 +103,7 @@ impl UserService {
avatar_url: None,
status: "active".to_string(),
roles: vec![],
version: 1,
})
}
@@ -185,6 +187,8 @@ impl UserService {
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
let next_ver = check_version(req.version, user_model.version)?;
let mut active: user::ActiveModel = user_model.into();
if let Some(email) = &req.email {
@@ -205,6 +209,7 @@ impl UserService {
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
@@ -229,10 +234,12 @@ impl UserService {
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
let current_version = user_model.version;
let mut active: user::ActiveModel = user_model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
@@ -279,6 +286,7 @@ impl UserService {
code: r.code.clone(),
description: r.description.clone(),
is_system: r.is_system,
version: r.version,
})
.collect())
}
@@ -295,5 +303,6 @@ fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
avatar_url: m.avatar_url.clone(),
status: m.status.clone(),
roles,
version: m.version,
}
}