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,
}
}

View File

@@ -14,6 +14,7 @@ pub struct DictionaryItemResp {
pub sort_order: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
pub version: i32,
}
#[derive(Debug, Serialize, ToSchema)]
@@ -24,6 +25,7 @@ pub struct DictionaryResp {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub items: Vec<DictionaryItemResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -39,6 +41,7 @@ pub struct CreateDictionaryReq {
pub struct UpdateDictionaryReq {
pub name: Option<String>,
pub description: Option<String>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -57,6 +60,7 @@ pub struct UpdateDictionaryItemReq {
pub value: Option<String>,
pub sort_order: Option<i32>,
pub color: Option<String>,
pub version: i32,
}
// --- Menu DTOs ---
@@ -77,6 +81,7 @@ pub struct MenuResp {
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<String>,
pub children: Vec<MenuResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -103,6 +108,7 @@ pub struct UpdateMenuReq {
pub visible: Option<bool>,
pub permission: Option<String>,
pub role_ids: Option<Vec<Uuid>>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -124,6 +130,8 @@ pub struct MenuItemReq {
pub menu_type: Option<String>,
pub permission: Option<String>,
pub role_ids: Option<Vec<Uuid>>,
/// 乐观锁版本号。更新已有菜单时必填。
pub version: Option<i32>,
}
// --- Setting DTOs ---
@@ -136,11 +144,14 @@ pub struct SettingResp {
pub scope_id: Option<Uuid>,
pub setting_key: String,
pub setting_value: serde_json::Value,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateSettingReq {
pub setting_value: serde_json::Value,
/// 乐观锁版本号。更新已有设置时必填,创建新设置时忽略。
pub version: Option<i32>,
}
/// 内部参数结构体,用于减少 SettingService::set 的参数数量。
@@ -149,6 +160,8 @@ pub struct SetSettingParams {
pub scope: String,
pub scope_id: Option<Uuid>,
pub value: serde_json::Value,
/// 乐观锁版本号。更新已有设置时用于校验。
pub version: Option<i32>,
}
// --- Numbering Rule DTOs ---
@@ -168,6 +181,7 @@ pub struct NumberingRuleResp {
pub reset_cycle: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_reset_date: Option<String>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -192,6 +206,7 @@ pub struct UpdateNumberingRuleReq {
pub seq_length: Option<i32>,
pub separator: Option<String>,
pub reset_cycle: Option<String>,
pub version: i32,
}
#[derive(Debug, Serialize, ToSchema)]

View File

@@ -14,6 +14,9 @@ pub enum ConfigError {
#[error("编号序列耗尽: {0}")]
NumberingExhausted(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
}
impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
@@ -34,6 +37,7 @@ impl From<ConfigError> for AppError {
ConfigError::NotFound(s) => AppError::NotFound(s),
ConfigError::DuplicateKey(s) => AppError::Conflict(s),
ConfigError::NumberingExhausted(s) => AppError::Internal(s),
ConfigError::VersionMismatch => AppError::VersionMismatch,
}
}
}

View File

@@ -101,8 +101,7 @@ where
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.description,
&req,
&state.db,
)
.await?;
@@ -113,11 +112,13 @@ where
/// DELETE /api/v1/dictionaries/:id
///
/// 软删除字典,设置 deleted_at 时间戳。
/// 需要请求体包含 version 字段用于乐观锁校验。
/// 需要 `dictionary.delete` 权限。
pub async fn delete_dictionary<S>(
State(state): State<ConfigState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteVersionReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
ConfigState: FromRef<S>,
@@ -125,8 +126,15 @@ where
{
require_permission(&ctx, "dictionary.delete")?;
DictionaryService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
.await?;
DictionaryService::delete(
id,
ctx.tenant_id,
ctx.user_id,
req.version,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse {
success: true,
@@ -228,11 +236,13 @@ where
/// DELETE /api/v1/dictionaries/:dict_id/items/:item_id
///
/// 软删除字典项,设置 deleted_at 时间戳。
/// 需要请求体包含 version 字段用于乐观锁校验。
/// 需要 `dictionary.delete` 权限。
pub async fn delete_item<S>(
State(state): State<ConfigState>,
Extension(ctx): Extension<TenantContext>,
Path((_dict_id, item_id)): Path<(Uuid, Uuid)>,
Json(req): Json<DeleteVersionReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
ConfigState: FromRef<S>,
@@ -240,7 +250,8 @@ where
{
require_permission(&ctx, "dictionary.delete")?;
DictionaryService::delete_item(item_id, ctx.tenant_id, ctx.user_id, &state.db).await?;
DictionaryService::delete_item(item_id, ctx.tenant_id, ctx.user_id, req.version, &state.db)
.await?;
Ok(Json(ApiResponse {
success: true,
@@ -254,3 +265,9 @@ where
pub struct ItemsByCodeQuery {
pub code: String,
}
/// 删除操作的乐观锁版本号。
#[derive(Debug, serde::Deserialize)]
pub struct DeleteVersionReq {
pub version: i32,
}

View File

@@ -87,6 +87,7 @@ where
scope: "platform".to_string(),
scope_id: None,
value,
version: None,
},
ctx.tenant_id,
ctx.user_id,

View File

@@ -86,11 +86,12 @@ where
/// DELETE /api/v1/config/menus/{id}
///
/// 软删除单个菜单项。
/// 软删除单个菜单项。需要请求体包含 version 字段用于乐观锁校验。
pub async fn delete_menu<S>(
State(state): State<ConfigState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteMenuVersionReq>,
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
where
ConfigState: FromRef<S>,
@@ -98,7 +99,15 @@ where
{
require_permission(&ctx, "menu.update")?;
MenuService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
MenuService::delete(
id,
ctx.tenant_id,
ctx.user_id,
req.version,
&state.db,
&state.event_bus,
)
.await?;
Ok(JsonResponse(ApiResponse::ok(())))
}
@@ -122,6 +131,7 @@ where
for item in &req.menus {
match item.id {
Some(id) => {
let version = item.version.unwrap_or(0);
let update_req = crate::dto::UpdateMenuReq {
title: Some(item.title.clone()),
path: item.path.clone(),
@@ -130,6 +140,7 @@ where
visible: item.visible,
permission: item.permission.clone(),
role_ids: item.role_ids.clone(),
version,
};
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db)
.await?;
@@ -164,3 +175,9 @@ where
message: Some("菜单批量保存成功".to_string()),
}))
}
/// 删除菜单的乐观锁版本号请求体。
#[derive(Debug, serde::Deserialize)]
pub struct DeleteMenuVersionReq {
pub version: i32,
}

View File

@@ -121,11 +121,13 @@ where
/// DELETE /api/v1/numbering-rules/:id
///
/// 软删除编号规则,设置 deleted_at 时间戳。
/// 需要请求体包含 version 字段用于乐观锁校验。
/// 需要 `numbering.delete` 权限。
pub async fn delete_numbering_rule<S>(
State(state): State<ConfigState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteNumberingVersionReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
ConfigState: FromRef<S>,
@@ -133,8 +135,15 @@ where
{
require_permission(&ctx, "numbering.delete")?;
NumberingService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
.await?;
NumberingService::delete(
id,
ctx.tenant_id,
ctx.user_id,
req.version,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse {
success: true,
@@ -142,3 +151,9 @@ where
message: Some("编号规则已删除".to_string()),
}))
}
/// 删除编号规则的乐观锁版本号请求体。
#[derive(Debug, serde::Deserialize)]
pub struct DeleteNumberingVersionReq {
pub version: i32,
}

View File

@@ -59,6 +59,7 @@ where
scope: "tenant".to_string(),
scope_id: None,
value: req.setting_value,
version: req.version,
},
ctx.tenant_id,
ctx.user_id,
@@ -80,12 +81,14 @@ pub struct SettingQuery {
/// DELETE /api/v1/settings/:key
///
/// 软删除设置值,设置 deleted_at 时间戳。
/// 需要请求体包含 version 字段用于乐观锁校验。
/// 需要 `setting.delete` 权限。
pub async fn delete_setting<S>(
State(state): State<ConfigState>,
Extension(ctx): Extension<TenantContext>,
Path(key): Path<String>,
Query(query): Query<SettingQuery>,
Json(req): Json<DeleteSettingVersionReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
ConfigState: FromRef<S>,
@@ -101,6 +104,7 @@ where
&query.scope_id,
ctx.tenant_id,
ctx.user_id,
req.version,
&state.db,
)
.await?;
@@ -111,3 +115,9 @@ where
message: Some("设置已删除".to_string()),
}))
}
/// 删除设置的乐观锁版本号请求体。
#[derive(Debug, serde::Deserialize)]
pub struct DeleteSettingVersionReq {
pub version: i32,
}

View File

@@ -59,6 +59,7 @@ where
scope: "tenant".to_string(),
scope_id: None,
value,
version: None,
},
ctx.tenant_id,
ctx.user_id,

View File

@@ -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())
}

View File

@@ -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()

View File

@@ -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,
}
}
}

View File

@@ -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,
}
}
}

View File

@@ -30,6 +30,12 @@ pub enum AppError {
#[error("冲突: {0}")]
Conflict(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
#[error("请求过于频繁,请稍后重试")]
TooManyRequests,
#[error("内部错误: {0}")]
Internal(String),
}
@@ -42,6 +48,8 @@ impl IntoResponse for AppError {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()),
AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()),
AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
AppError::VersionMismatch => (StatusCode::CONFLICT, self.to_string()),
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string()),
};
@@ -76,3 +84,14 @@ impl From<sea_orm::DbErr> for AppError {
}
pub type AppResult<T> = Result<T, AppError>;
/// 检查乐观锁版本是否匹配。
///
/// 返回下一个版本号actual + 1或 VersionMismatch 错误。
pub fn check_version(expected: i32, actual: i32) -> AppResult<i32> {
if expected == actual {
Ok(actual + 1)
} else {
Err(AppError::VersionMismatch)
}
}

View File

@@ -30,6 +30,7 @@ pub struct MessageResp {
pub sent_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
/// 发送消息请求
@@ -162,6 +163,7 @@ pub struct MessageSubscriptionResp {
pub dnd_end: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
/// 更新消息订阅偏好请求
@@ -172,4 +174,5 @@ pub struct UpdateSubscriptionReq {
pub dnd_enabled: Option<bool>,
pub dnd_start: Option<String>,
pub dnd_end: Option<String>,
pub version: i32,
}

View File

@@ -14,6 +14,9 @@ pub enum MessageError {
#[error("渲染失败: {0}")]
TemplateRenderError(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
}
impl From<MessageError> for AppError {
@@ -23,6 +26,7 @@ impl From<MessageError> for AppError {
MessageError::NotFound(msg) => AppError::NotFound(msg),
MessageError::DuplicateTemplateCode(msg) => AppError::Conflict(msg),
MessageError::TemplateRenderError(msg) => AppError::Internal(msg),
MessageError::VersionMismatch => AppError::VersionMismatch,
}
}
}

View File

@@ -218,9 +218,11 @@ impl MessageService {
return Ok(());
}
let current_version = model.version;
let mut active: message::ActiveModel = model.into();
active.is_read = Set(true);
active.read_at = Set(Some(Utc::now()));
active.version = Set(current_version + 1);
active.updated_at = Set(Utc::now());
active.updated_by = Set(user_id);
active
@@ -275,7 +277,9 @@ impl MessageService {
));
}
let current_version = model.version;
let mut active: message::ActiveModel = model.into();
active.version = Set(current_version + 1);
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(user_id);
@@ -308,6 +312,7 @@ impl MessageService {
sent_at: m.sent_at,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
}

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use crate::dto::{MessageSubscriptionResp, UpdateSubscriptionReq};
use crate::entity::message_subscription;
use crate::error::{MessageError, MessageResult};
use erp_core::error::check_version;
/// 消息订阅偏好服务。
pub struct SubscriptionService;
@@ -46,6 +47,9 @@ impl SubscriptionService {
let now = Utc::now();
if let Some(model) = existing {
let current_version = model.version;
let next_ver = check_version(req.version, current_version)
.map_err(|_| MessageError::VersionMismatch)?;
let mut active: message_subscription::ActiveModel = model.into();
if let Some(types) = &req.notification_types {
active.notification_types = Set(Some(types.clone()));
@@ -64,6 +68,7 @@ impl SubscriptionService {
}
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(next_ver);
let updated = active
.update(db)
@@ -112,6 +117,7 @@ impl SubscriptionService {
dnd_end: m.dnd_end.clone(),
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
}

View File

@@ -79,6 +79,7 @@ pub struct ProcessDefinitionResp {
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub lock_version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -101,6 +102,7 @@ pub struct UpdateProcessDefinitionReq {
pub description: Option<String>,
pub nodes: Option<Vec<NodeDef>>,
pub edges: Option<Vec<EdgeDef>>,
pub version: i32,
}
// --- 流程实例 DTOs ---
@@ -120,6 +122,7 @@ pub struct ProcessInstanceResp {
pub created_at: DateTime<Utc>,
/// 当前活跃的 token 位置
pub active_tokens: Vec<TokenResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
@@ -169,6 +172,7 @@ pub struct TaskResp {
pub definition_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub business_key: Option<String>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]

View File

@@ -20,6 +20,9 @@ pub enum WorkflowError {
#[error("表达式求值失败: {0}")]
ExpressionError(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
}
impl From<sea_orm::TransactionError<WorkflowError>> for WorkflowError {
@@ -42,6 +45,7 @@ impl From<WorkflowError> for AppError {
WorkflowError::InvalidDiagram(s) => AppError::Validation(s),
WorkflowError::InvalidState(s) => AppError::Validation(s),
WorkflowError::ExpressionError(s) => AppError::Validation(s),
WorkflowError::VersionMismatch => AppError::VersionMismatch,
}
}
}

View File

@@ -10,6 +10,7 @@ use crate::dto::{
use crate::engine::parser;
use crate::entity::process_definition;
use crate::error::{WorkflowError, WorkflowResult};
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -118,6 +119,7 @@ impl DefinitionService {
status: "draft".to_string(),
created_at: now,
updated_at: now,
lock_version: 1,
})
}
@@ -142,6 +144,7 @@ impl DefinitionService {
));
}
let current_version = model.version_field;
let mut active: process_definition::ActiveModel = model.into();
if let Some(name) = &req.name {
@@ -168,6 +171,9 @@ impl DefinitionService {
active.edges = Set(edges_json);
}
let next_ver = check_version(req.version, current_version)
.map_err(|_| WorkflowError::VersionMismatch)?;
active.version_field = Set(next_ver);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
@@ -207,8 +213,10 @@ impl DefinitionService {
.map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?;
parser::parse_and_validate(&nodes, &edges)?;
let current_version = model.version_field;
let mut active: process_definition::ActiveModel = model.into();
active.status = Set("published".to_string());
active.version_field = Set(current_version + 1);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
@@ -240,7 +248,9 @@ impl DefinitionService {
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
let current_version = model.version_field;
let mut active: process_definition::ActiveModel = model.into();
active.version_field = Set(current_version + 1);
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
@@ -265,6 +275,7 @@ impl DefinitionService {
status: m.status.clone(),
created_at: m.created_at,
updated_at: m.updated_at,
lock_version: m.version_field,
}
}
}

View File

@@ -150,6 +150,7 @@ impl InstanceService {
completed_at: instance.completed_at,
created_at: instance.created_at,
active_tokens,
version: instance.version,
})
}
@@ -195,6 +196,7 @@ impl InstanceService {
completed_at: m.completed_at,
created_at: m.created_at,
active_tokens,
version: m.version,
});
}
@@ -234,6 +236,7 @@ impl InstanceService {
completed_at: instance.completed_at,
created_at: instance.created_at,
active_tokens,
version: instance.version,
})
}
@@ -289,8 +292,10 @@ impl InstanceService {
)));
}
let current_version = instance.version;
let mut active: process_instance::ActiveModel = instance.into();
active.status = Set(to_status.to_string());
active.version = Set(current_version + 1);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active

View File

@@ -206,11 +206,13 @@ impl TaskService {
let task_model = task_model.clone();
Box::pin(async move {
// 更新任务状态
let current_version = task_model.version;
let mut active: task::ActiveModel = task_model.clone().into();
active.status = Set("completed".to_string());
active.outcome = Set(Some(outcome));
active.form_data = Set(form_data);
active.completed_at = Set(Some(now));
active.version = Set(current_version + 1);
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active
@@ -297,8 +299,10 @@ impl TaskService {
));
}
let current_version = task_model.version;
let mut active: task::ActiveModel = task_model.into();
active.assignee_id = Set(Some(req.delegate_to));
active.version = Set(current_version + 1);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
@@ -372,6 +376,7 @@ impl TaskService {
created_at: m.created_at,
definition_name: None,
business_key: None,
version: m.version,
}
}
}