From 5d6e1dc394ac6fbb8587402dc7b6a7c7dc051c8b Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 23:25:43 +0800 Subject: [PATCH] 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. --- crates/erp-auth/src/dto.rs | 10 + crates/erp-auth/src/error.rs | 13 + crates/erp-auth/src/handler/role_handler.rs | 1 + crates/erp-auth/src/service/auth_service.rs | 3 + crates/erp-auth/src/service/dept_service.rs | 9 + crates/erp-auth/src/service/org_service.rs | 9 + .../erp-auth/src/service/position_service.rs | 9 + crates/erp-auth/src/service/role_service.rs | 11 + crates/erp-auth/src/service/user_service.rs | 9 + crates/erp-config/src/dto.rs | 15 + crates/erp-config/src/error.rs | 4 + .../src/handler/dictionary_handler.rs | 27 +- .../src/handler/language_handler.rs | 1 + crates/erp-config/src/handler/menu_handler.rs | 21 +- .../src/handler/numbering_handler.rs | 19 +- .../erp-config/src/handler/setting_handler.rs | 10 + .../erp-config/src/handler/theme_handler.rs | 1 + .../src/service/dictionary_service.rs | 37 +- crates/erp-config/src/service/menu_service.rs | 17 +- .../src/service/numbering_service.rs | 16 +- .../erp-config/src/service/setting_service.rs | 16 +- crates/erp-core/src/error.rs | 19 + crates/erp-message/src/dto.rs | 3 + crates/erp-message/src/error.rs | 4 + .../src/service/message_service.rs | 5 + .../src/service/subscription_service.rs | 6 + crates/erp-workflow/src/dto.rs | 4 + crates/erp-workflow/src/error.rs | 4 + .../src/service/definition_service.rs | 11 + .../src/service/instance_service.rs | 5 + .../erp-workflow/src/service/task_service.rs | 5 + plans/bubbly-squishing-lerdorf.md | 409 +++++++++++------- 32 files changed, 549 insertions(+), 184 deletions(-) diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs index eb216ef..ff4d7cf 100644 --- a/crates/erp-auth/src/dto.rs +++ b/crates/erp-auth/src/dto.rs @@ -38,6 +38,7 @@ pub struct UserResp { pub avatar_url: Option, pub status: String, pub roles: Vec, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -58,6 +59,7 @@ pub struct UpdateUserReq { pub phone: Option, pub display_name: Option, pub status: Option, + pub version: i32, } // --- Role DTOs --- @@ -69,6 +71,7 @@ pub struct RoleResp { pub code: String, pub description: Option, 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, pub description: Option, + pub version: i32, } #[derive(Debug, Deserialize, ToSchema)] @@ -120,6 +124,7 @@ pub struct OrganizationResp { pub level: i32, pub sort_order: i32, pub children: Vec, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -136,6 +141,7 @@ pub struct UpdateOrganizationReq { pub name: Option, pub code: Option, pub sort_order: Option, + pub version: i32, } // --- Department DTOs --- @@ -151,6 +157,7 @@ pub struct DepartmentResp { pub path: Option, pub sort_order: i32, pub children: Vec, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -169,6 +176,7 @@ pub struct UpdateDepartmentReq { pub code: Option, pub manager_id: Option, pub sort_order: Option, + pub version: i32, } // --- Position DTOs --- @@ -181,6 +189,7 @@ pub struct PositionResp { pub code: Option, 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, pub level: Option, pub sort_order: Option, + pub version: i32, } diff --git a/crates/erp-auth/src/error.rs b/crates/erp-auth/src/error.rs index 2f1a6a3..f79e215 100644 --- a/crates/erp-auth/src/error.rs +++ b/crates/erp-auth/src/error.rs @@ -23,6 +23,9 @@ pub enum AuthError { #[error("{0}")] Validation(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, } impl From for AppError { @@ -35,6 +38,16 @@ impl From 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 for AuthError { + fn from(err: AppError) -> Self { + match err { + AppError::VersionMismatch => AuthError::VersionMismatch, + other => AuthError::Validation(other.to_string()), } } } diff --git a/crates/erp-auth/src/handler/role_handler.rs b/crates/erp-auth/src/handler/role_handler.rs index 802bceb..ca7dda0 100644 --- a/crates/erp-auth/src/handler/role_handler.rs +++ b/crates/erp-auth/src/handler/role_handler.rs @@ -116,6 +116,7 @@ where ctx.user_id, &req.name, &req.description, + req.version, &state.db, ) .await?; diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index 61a9aae..df13fa6 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -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()) } diff --git a/crates/erp-auth/src/service/dept_service.rs b/crates/erp-auth/src/service/dept_service.rs index 9743bdb..50e8f8a 100644 --- a/crates/erp-auth/src/service/dept_service.rs +++ b/crates/erp-auth/src/service/dept_service.rs @@ -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 { path: item.path.clone(), sort_order: item.sort_order, children, + version: item.version, } } diff --git a/crates/erp-auth/src/service/org_service.rs b/crates/erp-auth/src/service/org_service.rs index 3891457..2097a3e 100644 --- a/crates/erp-auth/src/service/org_service.rs +++ b/crates/erp-auth/src/service/org_service.rs @@ -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 { level: item.level, sort_order: item.sort_order, children, + version: item.version, } } diff --git a/crates/erp-auth/src/service/position_service.rs b/crates/erp-auth/src/service/position_service.rs index c92bb0e..3f1da85 100644 --- a/crates/erp-auth/src/service/position_service.rs +++ b/crates/erp-auth/src/service/position_service.rs @@ -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 diff --git a/crates/erp-auth/src/service/role_service.rs b/crates/erp-auth/src/service/role_service.rs index 5c47fb4..4cbe072 100644 --- a/crates/erp-auth/src/service/role_service.rs +++ b/crates/erp-auth/src/service/role_service.rs @@ -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, description: &Option, + version: i32, db: &sea_orm::DatabaseConnection, ) -> AuthResult { 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 diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index 3ec66ad..528b597 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -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) -> UserResp { avatar_url: m.avatar_url.clone(), status: m.status.clone(), roles, + version: m.version, } } diff --git a/crates/erp-config/src/dto.rs b/crates/erp-config/src/dto.rs index 6a0cc8e..a95d433 100644 --- a/crates/erp-config/src/dto.rs +++ b/crates/erp-config/src/dto.rs @@ -14,6 +14,7 @@ pub struct DictionaryItemResp { pub sort_order: i32, #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, + pub version: i32, } #[derive(Debug, Serialize, ToSchema)] @@ -24,6 +25,7 @@ pub struct DictionaryResp { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub items: Vec, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -39,6 +41,7 @@ pub struct CreateDictionaryReq { pub struct UpdateDictionaryReq { pub name: Option, pub description: Option, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -57,6 +60,7 @@ pub struct UpdateDictionaryItemReq { pub value: Option, pub sort_order: Option, pub color: Option, + pub version: i32, } // --- Menu DTOs --- @@ -77,6 +81,7 @@ pub struct MenuResp { #[serde(skip_serializing_if = "Option::is_none")] pub permission: Option, pub children: Vec, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -103,6 +108,7 @@ pub struct UpdateMenuReq { pub visible: Option, pub permission: Option, pub role_ids: Option>, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -124,6 +130,8 @@ pub struct MenuItemReq { pub menu_type: Option, pub permission: Option, pub role_ids: Option>, + /// 乐观锁版本号。更新已有菜单时必填。 + pub version: Option, } // --- Setting DTOs --- @@ -136,11 +144,14 @@ pub struct SettingResp { pub scope_id: Option, 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, } /// 内部参数结构体,用于减少 SettingService::set 的参数数量。 @@ -149,6 +160,8 @@ pub struct SetSettingParams { pub scope: String, pub scope_id: Option, pub value: serde_json::Value, + /// 乐观锁版本号。更新已有设置时用于校验。 + pub version: Option, } // --- 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, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -192,6 +206,7 @@ pub struct UpdateNumberingRuleReq { pub seq_length: Option, pub separator: Option, pub reset_cycle: Option, + pub version: i32, } #[derive(Debug, Serialize, ToSchema)] diff --git a/crates/erp-config/src/error.rs b/crates/erp-config/src/error.rs index d5f589f..9176e73 100644 --- a/crates/erp-config/src/error.rs +++ b/crates/erp-config/src/error.rs @@ -14,6 +14,9 @@ pub enum ConfigError { #[error("编号序列耗尽: {0}")] NumberingExhausted(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, } impl From> for ConfigError { @@ -34,6 +37,7 @@ impl From for AppError { ConfigError::NotFound(s) => AppError::NotFound(s), ConfigError::DuplicateKey(s) => AppError::Conflict(s), ConfigError::NumberingExhausted(s) => AppError::Internal(s), + ConfigError::VersionMismatch => AppError::VersionMismatch, } } } diff --git a/crates/erp-config/src/handler/dictionary_handler.rs b/crates/erp-config/src/handler/dictionary_handler.rs index 2185eda..1ace124 100644 --- a/crates/erp-config/src/handler/dictionary_handler.rs +++ b/crates/erp-config/src/handler/dictionary_handler.rs @@ -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( State(state): State, Extension(ctx): Extension, Path(id): Path, + Json(req): Json, ) -> Result>, AppError> where ConfigState: FromRef, @@ -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( State(state): State, Extension(ctx): Extension, Path((_dict_id, item_id)): Path<(Uuid, Uuid)>, + Json(req): Json, ) -> Result>, AppError> where ConfigState: FromRef, @@ -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, +} diff --git a/crates/erp-config/src/handler/language_handler.rs b/crates/erp-config/src/handler/language_handler.rs index 0efa9d8..8af8ed4 100644 --- a/crates/erp-config/src/handler/language_handler.rs +++ b/crates/erp-config/src/handler/language_handler.rs @@ -87,6 +87,7 @@ where scope: "platform".to_string(), scope_id: None, value, + version: None, }, ctx.tenant_id, ctx.user_id, diff --git a/crates/erp-config/src/handler/menu_handler.rs b/crates/erp-config/src/handler/menu_handler.rs index 9048799..3c58322 100644 --- a/crates/erp-config/src/handler/menu_handler.rs +++ b/crates/erp-config/src/handler/menu_handler.rs @@ -86,11 +86,12 @@ where /// DELETE /api/v1/config/menus/{id} /// -/// 软删除单个菜单项。 +/// 软删除单个菜单项。需要请求体包含 version 字段用于乐观锁校验。 pub async fn delete_menu( State(state): State, Extension(ctx): Extension, Path(id): Path, + Json(req): Json, ) -> Result>, AppError> where ConfigState: FromRef, @@ -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, +} diff --git a/crates/erp-config/src/handler/numbering_handler.rs b/crates/erp-config/src/handler/numbering_handler.rs index a3068e6..684083e 100644 --- a/crates/erp-config/src/handler/numbering_handler.rs +++ b/crates/erp-config/src/handler/numbering_handler.rs @@ -121,11 +121,13 @@ where /// DELETE /api/v1/numbering-rules/:id /// /// 软删除编号规则,设置 deleted_at 时间戳。 +/// 需要请求体包含 version 字段用于乐观锁校验。 /// 需要 `numbering.delete` 权限。 pub async fn delete_numbering_rule( State(state): State, Extension(ctx): Extension, Path(id): Path, + Json(req): Json, ) -> Result>, AppError> where ConfigState: FromRef, @@ -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, +} diff --git a/crates/erp-config/src/handler/setting_handler.rs b/crates/erp-config/src/handler/setting_handler.rs index 4f6fffd..f004af3 100644 --- a/crates/erp-config/src/handler/setting_handler.rs +++ b/crates/erp-config/src/handler/setting_handler.rs @@ -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( State(state): State, Extension(ctx): Extension, Path(key): Path, Query(query): Query, + Json(req): Json, ) -> Result>, AppError> where ConfigState: FromRef, @@ -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, +} diff --git a/crates/erp-config/src/handler/theme_handler.rs b/crates/erp-config/src/handler/theme_handler.rs index f3ba202..06bb2e4 100644 --- a/crates/erp-config/src/handler/theme_handler.rs +++ b/crates/erp-config/src/handler/theme_handler.rs @@ -59,6 +59,7 @@ where scope: "tenant".to_string(), scope_id: None, value, + version: None, }, ctx.tenant_id, ctx.user_id, diff --git a/crates/erp-config/src/service/dictionary_service.rs b/crates/erp-config/src/service/dictionary_service.rs index c86c79e..e856d1d 100644 --- a/crates/erp-config/src/service/dictionary_service.rs +++ b/crates/erp-config/src/service/dictionary_service.rs @@ -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, - description: &Option, + req: &crate::dto::UpdateDictionaryReq, db: &sea_orm::DatabaseConnection, ) -> ConfigResult { 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()) } diff --git a/crates/erp-config/src/service/menu_service.rs b/crates/erp-config/src/service/menu_service.rs index f2d4f57..b6a6db0 100644 --- a/crates/erp-config/src/service/menu_service.rs +++ b/crates/erp-config/src/service/menu_service.rs @@ -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() diff --git a/crates/erp-config/src/service/numbering_service.rs b/crates/erp-config/src/service/numbering_service.rs index 33b1758..881cf95 100644 --- a/crates/erp-config/src/service/numbering_service.rs +++ b/crates/erp-config/src/service/numbering_service.rs @@ -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, } } } diff --git a/crates/erp-config/src/service/setting_service.rs b/crates/erp-config/src/service/setting_service.rs index e3e5e67..8f7e045 100644 --- a/crates/erp-config/src/service/setting_service.rs +++ b/crates/erp-config/src/service/setting_service.rs @@ -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, 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, } } } diff --git a/crates/erp-core/src/error.rs b/crates/erp-core/src/error.rs index 18617ed..c4a71ba 100644 --- a/crates/erp-core/src/error.rs +++ b/crates/erp-core/src/error.rs @@ -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 for AppError { } pub type AppResult = Result; + +/// 检查乐观锁版本是否匹配。 +/// +/// 返回下一个版本号(actual + 1),或 VersionMismatch 错误。 +pub fn check_version(expected: i32, actual: i32) -> AppResult { + if expected == actual { + Ok(actual + 1) + } else { + Err(AppError::VersionMismatch) + } +} diff --git a/crates/erp-message/src/dto.rs b/crates/erp-message/src/dto.rs index 7df7ca5..9aebcc0 100644 --- a/crates/erp-message/src/dto.rs +++ b/crates/erp-message/src/dto.rs @@ -30,6 +30,7 @@ pub struct MessageResp { pub sent_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, + pub version: i32, } /// 发送消息请求 @@ -162,6 +163,7 @@ pub struct MessageSubscriptionResp { pub dnd_end: Option, pub created_at: DateTime, pub updated_at: DateTime, + pub version: i32, } /// 更新消息订阅偏好请求 @@ -172,4 +174,5 @@ pub struct UpdateSubscriptionReq { pub dnd_enabled: Option, pub dnd_start: Option, pub dnd_end: Option, + pub version: i32, } diff --git a/crates/erp-message/src/error.rs b/crates/erp-message/src/error.rs index 378b8c1..92ab58c 100644 --- a/crates/erp-message/src/error.rs +++ b/crates/erp-message/src/error.rs @@ -14,6 +14,9 @@ pub enum MessageError { #[error("渲染失败: {0}")] TemplateRenderError(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, } impl From for AppError { @@ -23,6 +26,7 @@ impl From for AppError { MessageError::NotFound(msg) => AppError::NotFound(msg), MessageError::DuplicateTemplateCode(msg) => AppError::Conflict(msg), MessageError::TemplateRenderError(msg) => AppError::Internal(msg), + MessageError::VersionMismatch => AppError::VersionMismatch, } } } diff --git a/crates/erp-message/src/service/message_service.rs b/crates/erp-message/src/service/message_service.rs index a10acad..c8d290d 100644 --- a/crates/erp-message/src/service/message_service.rs +++ b/crates/erp-message/src/service/message_service.rs @@ -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, } } } diff --git a/crates/erp-message/src/service/subscription_service.rs b/crates/erp-message/src/service/subscription_service.rs index 443532d..e2194de 100644 --- a/crates/erp-message/src/service/subscription_service.rs +++ b/crates/erp-message/src/service/subscription_service.rs @@ -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, } } } diff --git a/crates/erp-workflow/src/dto.rs b/crates/erp-workflow/src/dto.rs index 1d4e4a8..7f98e5e 100644 --- a/crates/erp-workflow/src/dto.rs +++ b/crates/erp-workflow/src/dto.rs @@ -79,6 +79,7 @@ pub struct ProcessDefinitionResp { pub status: String, pub created_at: DateTime, pub updated_at: DateTime, + pub lock_version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -101,6 +102,7 @@ pub struct UpdateProcessDefinitionReq { pub description: Option, pub nodes: Option>, pub edges: Option>, + pub version: i32, } // --- 流程实例 DTOs --- @@ -120,6 +122,7 @@ pub struct ProcessInstanceResp { pub created_at: DateTime, /// 当前活跃的 token 位置 pub active_tokens: Vec, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] @@ -169,6 +172,7 @@ pub struct TaskResp { pub definition_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub business_key: Option, + pub version: i32, } #[derive(Debug, Deserialize, Validate, ToSchema)] diff --git a/crates/erp-workflow/src/error.rs b/crates/erp-workflow/src/error.rs index 0665433..109cfa4 100644 --- a/crates/erp-workflow/src/error.rs +++ b/crates/erp-workflow/src/error.rs @@ -20,6 +20,9 @@ pub enum WorkflowError { #[error("表达式求值失败: {0}")] ExpressionError(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, } impl From> for WorkflowError { @@ -42,6 +45,7 @@ impl From for AppError { WorkflowError::InvalidDiagram(s) => AppError::Validation(s), WorkflowError::InvalidState(s) => AppError::Validation(s), WorkflowError::ExpressionError(s) => AppError::Validation(s), + WorkflowError::VersionMismatch => AppError::VersionMismatch, } } } diff --git a/crates/erp-workflow/src/service/definition_service.rs b/crates/erp-workflow/src/service/definition_service.rs index 4948a54..2f71d40 100644 --- a/crates/erp-workflow/src/service/definition_service.rs +++ b/crates/erp-workflow/src/service/definition_service.rs @@ -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, } } } diff --git a/crates/erp-workflow/src/service/instance_service.rs b/crates/erp-workflow/src/service/instance_service.rs index 07c22c7..ebfa8be 100644 --- a/crates/erp-workflow/src/service/instance_service.rs +++ b/crates/erp-workflow/src/service/instance_service.rs @@ -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 diff --git a/crates/erp-workflow/src/service/task_service.rs b/crates/erp-workflow/src/service/task_service.rs index c9e195b..c9f17ef 100644 --- a/crates/erp-workflow/src/service/task_service.rs +++ b/crates/erp-workflow/src/service/task_service.rs @@ -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, } } } diff --git a/plans/bubbly-squishing-lerdorf.md b/plans/bubbly-squishing-lerdorf.md index e6bd117..64029bd 100644 --- a/plans/bubbly-squishing-lerdorf.md +++ b/plans/bubbly-squishing-lerdorf.md @@ -1,210 +1,285 @@ -# Phase 5-6 Implementation Plan: Message Center + Integration & Polish +# Phase 7: 审计日志 + 乐观锁 + Redis 限流 + 事件 Outbox ## Context -Phase 1-4 已完成(core, auth, config, workflow)。现在需要实现 Phase 5(消息中心)和 Phase 6(整合与打磨)。`erp-message` crate 当前是空壳,需要完整实现。 +Phase 1-6 已完成。对比设计规格发现 4 项核心基础设施缺失: +1. **审计日志** — AuditLog 类型存在但从未使用,audit_logs 表存在但无 Entity/Service +2. **乐观锁** — 所有实体有 version 字段但更新时不检查/递增,DTO 不暴露 version +3. **Redis 限流** — 客户端创建后立即丢弃(`_redis_client`),未存入 AppState +4. **事件 Outbox** — EventBus 纯内存 broadcast,重启即丢失,无持久化 + +## 实施顺序与依赖 + +``` +Task 7.1 乐观锁 (erp-core error helper) + → Task 7.2 乐观锁 (全部 service 方法 + DTO) + → Task 7.3 审计日志 (Entity + Service + 集成) + → Task 7.4 Redis 限流 (AppState + 中间件) + → Task 7.5 事件 Outbox (迁移 + Entity + EventBus 改造) +``` --- -## Phase 5: 消息中心 - -### Task 5.1: 数据库迁移 — 消息相关表 - -**新增 3 个迁移文件:** - -1. `m20260413_000023_create_message_templates.rs` — 消息模板表 - - id (UUID PK), tenant_id, name, code (唯一编码), channel (in_app/email/sms/wechat), - - title_template, body_template (支持 `{{variable}}` 插值), language (zh-CN/en-US), - - 标准审计字段 (created_at, updated_at, created_by, updated_by, deleted_at) - -2. `m20260413_000024_create_messages.rs` — 消息表 - - id (UUID PK), tenant_id, template_id (FK, nullable), - - sender_id (UUID, nullable=系统消息), sender_type (system/user), - - recipient_id (UUID, not null), recipient_type (user/role/department/all), - - title, body, priority (normal/important/urgent), - - business_type (workflow_task/system_notice/...), business_id (deep link ref), - - is_read (bool), read_at (nullable), - - is_archived (bool), archived_at (nullable), - - sent_at (nullable, for scheduled), status (pending/sent/recalled), - - 标准审计字段 - -3. `m20260413_000025_create_message_subscriptions.rs` — 消息订阅偏好 - - id (UUID PK), tenant_id, user_id, - - notification_types (JSON: 订阅的通知类型列表), - - channel_preferences (JSON: 各类型偏好的通道), - - dnd_enabled (bool), dnd_start (time), dnd_end (time), - - 标准审计字段 +## Task 7.1: 乐观锁 — erp-core 基础设施 **修改文件:** -- `crates/erp-server/migration/src/lib.rs` — 注册 3 个新迁移 +- `crates/erp-core/src/error.rs` — 添加 `VersionMismatch` 变体 + `check_version()` helper -### Task 5.2: erp-message crate 基础结构 +```rust +// 新增变体 +#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] +VersionMismatch, -**修改/创建文件:** - -1. `crates/erp-message/Cargo.toml` — 补齐缺失依赖 (thiserror, utoipa, async-trait, validator, serde/uuid/chrono features, sea-orm features) -2. `crates/erp-message/src/lib.rs` — 声明子模块 + pub use -3. `crates/erp-message/src/message_state.rs` — MessageState { db, event_bus } -4. `crates/erp-message/src/error.rs` — MessageError 枚举 + From impls -5. `crates/erp-message/src/dto.rs` — 请求/响应 DTOs -6. `crates/erp-message/src/entity/mod.rs` — 实体子模块声明 -7. `crates/erp-message/src/entity/message_template.rs` -8. `crates/erp-message/src/entity/message.rs` -9. `crates/erp-message/src/entity/message_subscription.rs` -10. `crates/erp-message/src/module.rs` — MessageModule 实现 ErpModule - -### Task 5.3: 消息 CRUD 服务与处理器 - -**创建文件:** - -1. `crates/erp-message/src/service/mod.rs` -2. `crates/erp-message/src/service/message_service.rs` — 消息 CRUD + 发送 + 已读/未读 -3. `crates/erp-message/src/service/template_service.rs` — 模板 CRUD + 变量插值渲染 -4. `crates/erp-message/src/service/subscription_service.rs` — 订阅偏好 CRUD -5. `crates/erp-message/src/handler/mod.rs` -6. `crates/erp-message/src/handler/message_handler.rs` — 消息 API handlers -7. `crates/erp-message/src/handler/template_handler.rs` — 模板 API handlers -8. `crates/erp-message/src/handler/subscription_handler.rs` — 订阅 API handlers - -**路由设计:** -``` -GET /messages — 消息列表 (分页, 支持 status/priority/is_read 过滤) -GET /messages/unread-count — 未读消息数 -PUT /messages/{id}/read — 标记已读 -PUT /messages/read-all — 全部标记已读 -DELETE /messages/{id} — 删除消息 (软删除) -POST /messages/send — 发送消息 - -GET /message-templates — 模板列表 -POST /message-templates — 创建模板 - -PUT /message-subscriptions — 更新订阅偏好 +// 新增 helper 函数 +pub fn check_version(expected: i32, actual: i32) -> AppResult { + if expected == actual { Ok(actual + 1) } + else { Err(AppError::VersionMismatch) } +} ``` -### Task 5.4: 服务器端集成 - -**修改文件:** - -1. `crates/erp-server/Cargo.toml` — 添加 erp-message 依赖 -2. `crates/erp-server/src/state.rs` — 添加 `FromRef for MessageState` -3. `crates/erp-server/src/main.rs` — 初始化并注册 MessageModule,合并路由 - -### Task 5.5: 前端 — 消息 API 与页面 - -**创建/修改文件:** - -1. `apps/web/src/api/messages.ts` — 消息 API 客户端 -2. `apps/web/src/api/messageTemplates.ts` — 模板 API 客户端 -3. `apps/web/src/pages/Messages.tsx` — 消息中心主页面 (Tabs: 通知列表/已归档) -4. `apps/web/src/pages/messages/NotificationList.tsx` — 通知列表子组件 -5. `apps/web/src/pages/messages/MessageTemplates.tsx` — 模板管理子组件 -6. `apps/web/src/pages/messages/NotificationPreferences.tsx` — 通知偏好设置 -7. `apps/web/src/App.tsx` — 添加 `/messages` 路由 -8. `apps/web/src/layouts/MainLayout.tsx` — 添加消息菜单项 + Bell 点击弹出通知面板 - -### Task 5.6: 通知面板与未读计数 - -**修改文件:** - -1. `apps/web/src/layouts/MainLayout.tsx` — Bell 图标添加 Badge (未读数) + Popover 通知面板 -2. `apps/web/src/stores/message.ts` — Zustand store: unreadCount, fetchUnread, recentMessages -3. `apps/web/src/components/NotificationPanel.tsx` — 通知弹出面板组件 +IntoResponse 中 `VersionMismatch` 映射到 `StatusCode::CONFLICT` (409)。 --- -## Phase 6: 整合与打磨 +## Task 7.2: 乐观锁 — 全部 Service 方法 + DTO -### Task 6.1: 跨模块事件集成 — 工作流 → 消息 +**原则:** 所有用户可调用的 update/delete 方法必须检查并递增 version。 -**修改文件:** +### DTO 变更 -1. `crates/erp-message/src/module.rs` — `register_event_handlers()` 订阅工作流事件 -2. `crates/erp-message/src/service/message_service.rs` — 添加事件处理方法 -3. 订阅的事件: - - `workflow.instance.started` → 通知发起人 - - `workflow.task.created` → 通知待办人 - - `workflow.task.completed` → 通知发起人 - - `workflow.instance.completed` → 通知发起人 - - `workflow.instance.terminated` → 通知相关人 +**所有 Update*Req** 添加 `pub version: i32` 字段(必填)。涉及: -### Task 6.2: 审计日志 +| Crate | DTO 文件 | DTOs | +|-------|---------|------| +| erp-auth | `dto.rs` | UpdateUserReq, UpdateRoleReq, UpdateOrganizationReq, UpdateDepartmentReq, UpdatePositionReq | +| erp-config | `dto.rs` | UpdateDictionaryReq, UpdateDictionaryItemReq, UpdateMenuReq, UpdateNumberingRuleReq | +| erp-workflow | `dto.rs` | UpdateProcessDefinitionReq | +| erp-message | `dto.rs` | UpdateSubscriptionReq (如果存在) | -**创建/修改文件:** +**所有 *Resp** 添加 `pub version: i32` 字段。涉及: -1. 迁移 `m20260413_000026_create_audit_logs.rs` — 审计日志表 - - id, tenant_id, user_id, action, resource_type, resource_id, - - old_value (JSON), new_value (JSON), ip_address, user_agent, - - 标准审计字段 -2. `crates/erp-core/src/audit.rs` — 审计中间件/工具函数 -3. `crates/erp-server/src/main.rs` — 应用审计中间件到 protected routes +| Crate | Resp DTOs | +|-------|-----------| +| erp-auth | UserResp, RoleResp, OrganizationResp, DepartmentResp, PositionResp | +| erp-config | DictionaryResp, DictionaryItemResp, MenuResp, SettingResp, NumberingRuleResp | +| erp-workflow | ProcessDefinitionResp, ProcessInstanceResp, TaskResp | +| erp-message | MessageResp, MessageSubscriptionResp | -### Task 6.3: API 文档完善 +每个 `model_to_resp` 函数添加 `version: m.version`。 -**修改文件:** +### Service 方法变更 -1. `crates/erp-server/src/main.rs` — 添加 utoipa Swagger UI 路由 -2. 各模块已有 utoipa 注解,确保正确注册到 OpenApi +**Update 模式(有 DTO):** +```rust +// 在 update 方法中,读取 model 后: +let next_ver = erp_core::error::check_version(req.version, model.version)?; +// ... 设置字段 ... +active.version = Set(next_ver); +active.update(db).await?; +``` -### Task 6.4: 安全审查 +**Delete 模式(无 DTO version):** +```rust +// delete 方法中,读取 model 后: +active.version = Set(model.version + 1); +``` -检查项: -- JWT 中间件正确性 -- 多租户隔离 (所有查询带 tenant_id) -- SQL 注入防护 (SeaORM 参数化) -- CORS 配置 -- 密码安全 (Argon2) -- 输入验证 (所有 API 端点) -- 错误信息不泄露敏感数据 +**涉及文件(13 个 service 的 update/delete 方法):** -### Task 6.5: 前端整合完善 +| Crate | 文件 | 方法 | +|-------|------|------| +| erp-auth | `user_service.rs` | update, delete | +| erp-auth | `role_service.rs` | update, delete | +| erp-auth | `org_service.rs` | update, delete | +| erp-auth | `dept_service.rs` | update, delete | +| erp-auth | `position_service.rs` | update, delete | +| erp-config | `dictionary_service.rs` | update, delete, update_item, delete_item | +| erp-config | `menu_service.rs` | update, delete | +| erp-config | `setting_service.rs` | set (update 分支), delete | +| erp-config | `numbering_service.rs` | update, delete | +| erp-workflow | `definition_service.rs` | update, publish, delete | +| erp-workflow | `instance_service.rs` | 状态变更方法 (suspend/resume/terminate) | +| erp-workflow | `task_service.rs` | complete, delegate | +| erp-message | `message_service.rs` | mark_read, delete | +| erp-message | `subscription_service.rs` | upsert (update 分支) | -**修改文件:** +**注意:** `numbering_service::generate_number` 使用 advisory lock,不需要 version 检查。 -1. `apps/web/src/layouts/MainLayout.tsx` — 完善通知面板交互 -2. 工作流页面集成消息通知反馈 -3. 整体 UI 打磨和一致性检查 +### 前端适配 + +前端所有编辑表单需要在请求时传递 version 字段。涉及: +- `apps/web/src/pages/` 下所有调用 PUT API 的页面 --- -## 实施顺序 +## Task 7.3: 审计日志 -``` -Task 5.1 (迁移) - → Task 5.2 (crate 基础) - → Task 5.3 (服务+处理器) - → Task 5.4 (服务器集成) - → Task 5.5 (前端页面) - → Task 5.6 (通知面板) - → Task 6.1 (事件集成) - → Task 6.2 (审计日志) - → Task 6.3 (API 文档) - → Task 6.4 (安全审查) - → Task 6.5 (前端整合) +### 7.3a: SeaORM Entity + +**新建文件:** +- `crates/erp-core/src/entity/mod.rs` +- `crates/erp-core/src/entity/audit_log.rs` + +**修改文件:** +- `crates/erp-core/src/lib.rs` — 添加 `pub mod entity;` +- `crates/erp-core/Cargo.toml` — 添加 sea-orm 依赖(如果尚未有) + +audit_log.rs Entity 映射已有的 `audit_logs` 表(迁移 #26 已存在)。 + +### 7.3b: 审计记录服务 + +**新建文件:** `crates/erp-core/src/audit_service.rs` + +```rust +/// 持久化审计日志到 audit_logs 表。 +/// 使用 fire-and-forget 模式:失败仅记录日志,不影响业务操作。 +pub async fn record(log: AuditLog, db: &DatabaseConnection) { + // AuditLog → audit_log::ActiveModel → insert + // 失败时 tracing::warn! +} ``` -每个 Task 完成后立即提交。每个 Task 预计产生 1 个 commit。 +**修改文件:** `crates/erp-core/src/lib.rs` — 添加 `pub mod audit_service;` -## 验证方式 +### 7.3c: 集成到所有 mutation service -1. `cargo check` — 全 workspace 编译通过 -2. `cargo test --workspace` — 所有测试通过 -3. `cargo run -p erp-server` — 服务启动正常 -4. 浏览器验证消息 CRUD 流程 -5. 验证通知面板未读计数 -6. 验证工作流事件触发消息通知 -7. Swagger UI 验证 API 文档 +在每个 service 的 create/update/delete 方法中,操作成功后调用 `audit_service::record()`。 + +**请求信息获取:** handler 层从 `HeaderMap` 提取 IP 和 User-Agent,传给 service。 + +```rust +// handler 中 +fn extract_request_info(headers: &HeaderMap) -> (Option, Option) { + let ip = headers.get("x-forwarded-for").or_else(|| headers.get("x-real-ip")) + .and_then(|v| v.to_str().ok()).map(|s| s.to_string()); + let ua = headers.get("user-agent").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); + (ip, ua) +} +``` + +Handler 签名增加 `headers: HeaderMap` 参数,service 方法签名增加 `ip: Option, user_agent: Option`。 + +**涉及文件(与乐观锁相同 + handler 层):** + +| Crate | Handler 文件 | +|-------|-------------| +| erp-auth | `user_handler.rs`, `role_handler.rs`, `org_handler.rs` | +| erp-config | `dictionary_handler.rs`, `menu_handler.rs`, `setting_handler.rs`, `numbering_handler.rs` | +| erp-workflow | `definition_handler.rs`, `instance_handler.rs`, `task_handler.rs` | +| erp-message | `message_handler.rs`, `subscription_handler.rs` | + +--- + +## Task 7.4: Redis 限流 + +### 7.4a: Redis 存入 AppState + +**修改文件:** +- `crates/erp-server/src/state.rs` — `AppState` 添加 `pub redis: redis::Client` +- `crates/erp-server/src/main.rs` — `_redis_client` → `redis_client`,传入 AppState + +### 7.4b: 限流中间件 + +**新建文件:** +- `crates/erp-server/src/middleware/mod.rs` +- `crates/erp-server/src/middleware/rate_limit.rs` + +使用 Redis INCR + EXPIRE 实现滑动窗口: +- Key: `rate_limit:{prefix}:{identifier}` +- 登录: 5 次/分钟/IP +- 写操作: 100 次/分钟/user_id + +### 7.4c: 应用限流层 + +**修改文件:** `crates/erp-server/src/main.rs` +- 登录路由添加 IP 限流层 +- protected routes 添加 user_id 限流层 +- 超限返回 HTTP 429 Too Many Requests + +**修改文件:** `crates/erp-core/src/error.rs` +- 添加 `TooManyRequests` 变体(可选,中间件可直接返回 429) + +--- + +## Task 7.5: 事件 Outbox 持久化 + +### 7.5a: 数据库迁移 + +**新建文件:** `crates/erp-server/migration/src/m20260416_000031_create_domain_events.rs` + +```sql +CREATE TABLE IF NOT EXISTS domain_events ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + event_type VARCHAR(200) NOT NULL, + payload JSONB, + correlation_id UUID, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL, + published_at TIMESTAMPTZ +); +CREATE INDEX idx_domain_events_status ON domain_events (status, created_at); +CREATE INDEX idx_domain_events_tenant ON domain_events (tenant_id); +``` + +**修改文件:** `crates/erp-server/migration/src/lib.rs` — 注册新迁移 + +### 7.5b: SeaORM Entity + +**新建文件:** `crates/erp-core/src/entity/domain_event.rs` + +**修改文件:** `crates/erp-core/src/entity/mod.rs` — 添加 `pub mod domain_event;` + +### 7.5c: EventBus 改造 + +**修改文件:** `crates/erp-core/src/events.rs` + +- 现有 `publish()` 重命名为 `broadcast()`(内部使用) +- 新增 `publish_with_persist(event, db)` — 先 INSERT domain_events,再 broadcast +- INSERT 失败时仅 log warning,仍然 broadcast(best-effort) + +### 7.5d: 更新所有 publish 调用点 + +全部 25 个 `event_bus.publish(...)` 调用改为 `event_bus.publish_with_persist(event, db).await`。 + +**涉及文件:** +- `erp-auth/src/service/` — 5 个文件 (user, role, org, dept, position) +- `erp-config/src/service/` — 4 个文件 (dictionary, menu, setting, numbering) +- `erp-workflow/src/service/` — 3 个文件 (definition, instance, task) +- `erp-message/src/service/` — 1 个文件 (message_service) + +### 7.5e: Outbox Relay 后台任务 + +**新建文件:** `crates/erp-server/src/outbox.rs` + +后台 tokio task 每 5 秒扫描 `domain_events WHERE status = 'pending'`,重新 broadcast 并标记为 published。 + +**修改文件:** `crates/erp-server/src/main.rs` — 启动 outbox relay + +--- ## 关键文件索引 | 用途 | 文件路径 | |------|---------| -| 迁移注册 | `crates/erp-server/migration/src/lib.rs` | -| 服务器入口 | `crates/erp-server/src/main.rs` | -| 状态桥接 | `crates/erp-server/src/state.rs` | -| 模块 trait | `crates/erp-core/src/module.rs` | -| 事件总线 | `crates/erp-core/src/events.rs` | | 错误类型 | `crates/erp-core/src/error.rs` | -| 前端路由 | `apps/web/src/App.tsx` | -| 布局 | `apps/web/src/layouts/MainLayout.tsx` | -| API 客户端 | `apps/web/src/api/client.ts` | -| 参考模块 | `crates/erp-workflow/` (完整模式参考) | +| 事件总线 | `crates/erp-core/src/events.rs` | +| 审计日志类型 | `crates/erp-core/src/audit.rs` | +| AppState | `crates/erp-server/src/state.rs` | +| 服务器入口 | `crates/erp-server/src/main.rs` | +| 迁移注册 | `crates/erp-server/migration/src/lib.rs` | +| Auth DTO | `crates/erp-auth/src/dto.rs` | +| Auth Service 参考 | `crates/erp-auth/src/service/user_service.rs` | +| Auth Handler 参考 | `crates/erp-auth/src/handler/user_handler.rs` | + +## 验证方式 + +1. `cargo check` — 全 workspace 编译通过 +2. `cargo test --workspace` — 所有测试通过 +3. 手动测试:更新用户两次(第二次用旧 version)→ 409 Conflict +4. 手动测试:登录限流 → 第 6 次返回 429 +5. 查询 `SELECT * FROM audit_logs` → 验证审计记录 +6. 查询 `SELECT * FROM domain_events` → 验证事件持久化 +7. 重启服务后验证 pending 事件被 relay 处理