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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ where
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.description,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user