feat(core): implement optimistic locking across all entities
Add VersionMismatch error variant and check_version() helper to erp-core. All 13 mutable entities now enforce version checking on update/delete: - erp-auth: user, role, organization, department, position - erp-config: dictionary, dictionary_item, menu, setting, numbering_rule - erp-workflow: process_definition, process_instance, task - erp-message: message, message_subscription Update DTOs to expose version in responses and require version in update requests. HTTP 409 Conflict returned on version mismatch.
This commit is contained in:
@@ -38,6 +38,7 @@ pub struct UserResp {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub roles: Vec<RoleResp>,
|
pub roles: Vec<RoleResp>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -58,6 +59,7 @@ pub struct UpdateUserReq {
|
|||||||
pub phone: Option<String>,
|
pub phone: Option<String>,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Role DTOs ---
|
// --- Role DTOs ---
|
||||||
@@ -69,6 +71,7 @@ pub struct RoleResp {
|
|||||||
pub code: String,
|
pub code: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub is_system: bool,
|
pub is_system: bool,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -84,6 +87,7 @@ pub struct CreateRoleReq {
|
|||||||
pub struct UpdateRoleReq {
|
pub struct UpdateRoleReq {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
@@ -120,6 +124,7 @@ pub struct OrganizationResp {
|
|||||||
pub level: i32,
|
pub level: i32,
|
||||||
pub sort_order: i32,
|
pub sort_order: i32,
|
||||||
pub children: Vec<OrganizationResp>,
|
pub children: Vec<OrganizationResp>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -136,6 +141,7 @@ pub struct UpdateOrganizationReq {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub sort_order: Option<i32>,
|
pub sort_order: Option<i32>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Department DTOs ---
|
// --- Department DTOs ---
|
||||||
@@ -151,6 +157,7 @@ pub struct DepartmentResp {
|
|||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
pub sort_order: i32,
|
pub sort_order: i32,
|
||||||
pub children: Vec<DepartmentResp>,
|
pub children: Vec<DepartmentResp>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -169,6 +176,7 @@ pub struct UpdateDepartmentReq {
|
|||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub manager_id: Option<Uuid>,
|
pub manager_id: Option<Uuid>,
|
||||||
pub sort_order: Option<i32>,
|
pub sort_order: Option<i32>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Position DTOs ---
|
// --- Position DTOs ---
|
||||||
@@ -181,6 +189,7 @@ pub struct PositionResp {
|
|||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub level: i32,
|
pub level: i32,
|
||||||
pub sort_order: i32,
|
pub sort_order: i32,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -198,4 +207,5 @@ pub struct UpdatePositionReq {
|
|||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub level: Option<i32>,
|
pub level: Option<i32>,
|
||||||
pub sort_order: Option<i32>,
|
pub sort_order: Option<i32>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ pub enum AuthError {
|
|||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Validation(String),
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AuthError> for AppError {
|
impl From<AuthError> for AppError {
|
||||||
@@ -35,6 +38,16 @@ impl From<AuthError> for AppError {
|
|||||||
AuthError::Validation(s) => AppError::Validation(s),
|
AuthError::Validation(s) => AppError::Validation(s),
|
||||||
AuthError::HashError(_) => AppError::Internal(err.to_string()),
|
AuthError::HashError(_) => AppError::Internal(err.to_string()),
|
||||||
AuthError::JwtError(_) => AppError::Unauthorized,
|
AuthError::JwtError(_) => AppError::Unauthorized,
|
||||||
|
AuthError::VersionMismatch => AppError::VersionMismatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppError> for AuthError {
|
||||||
|
fn from(err: AppError) -> Self {
|
||||||
|
match err {
|
||||||
|
AppError::VersionMismatch => AuthError::VersionMismatch,
|
||||||
|
other => AuthError::Validation(other.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ where
|
|||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
&req.name,
|
&req.name,
|
||||||
&req.description,
|
&req.description,
|
||||||
|
req.version,
|
||||||
&state.db,
|
&state.db,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ impl AuthService {
|
|||||||
avatar_url: user_model.avatar_url,
|
avatar_url: user_model.avatar_url,
|
||||||
status: user_model.status,
|
status: user_model.status,
|
||||||
roles: role_resps,
|
roles: role_resps,
|
||||||
|
version: user_model.version,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 9. Publish event
|
// 9. Publish event
|
||||||
@@ -193,6 +194,7 @@ impl AuthService {
|
|||||||
avatar_url: user_model.avatar_url,
|
avatar_url: user_model.avatar_url,
|
||||||
status: user_model.status,
|
status: user_model.status,
|
||||||
roles: role_resps,
|
roles: role_resps,
|
||||||
|
version: user_model.version,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(LoginResp {
|
Ok(LoginResp {
|
||||||
@@ -245,6 +247,7 @@ impl AuthService {
|
|||||||
code: r.code.clone(),
|
code: r.code.clone(),
|
||||||
description: r.description.clone(),
|
description: r.description.clone(),
|
||||||
is_system: r.is_system,
|
is_system: r.is_system,
|
||||||
|
version: r.version,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
|
|||||||
use crate::entity::department;
|
use crate::entity::department;
|
||||||
use crate::entity::organization;
|
use crate::entity::organization;
|
||||||
use crate::error::{AuthError, AuthResult};
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
/// Department CRUD service -- create, read, update, soft-delete departments
|
/// Department CRUD service -- create, read, update, soft-delete departments
|
||||||
@@ -133,6 +134,7 @@ impl DeptService {
|
|||||||
path: None,
|
path: None,
|
||||||
sort_order: req.sort_order.unwrap_or(0),
|
sort_order: req.sort_order.unwrap_or(0),
|
||||||
children: vec![],
|
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();
|
let mut active: department::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(n) = &req.name {
|
if let Some(n) = &req.name {
|
||||||
@@ -184,6 +188,7 @@ impl DeptService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -200,6 +205,7 @@ impl DeptService {
|
|||||||
path: updated.path.clone(),
|
path: updated.path.clone(),
|
||||||
sort_order: updated.sort_order,
|
sort_order: updated.sort_order,
|
||||||
children: vec![],
|
children: vec![],
|
||||||
|
version: updated.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,10 +240,12 @@ impl DeptService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
let mut active: department::ActiveModel = model.into();
|
let mut active: department::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -277,6 +285,7 @@ fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
|
|||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
sort_order: item.sort_order,
|
sort_order: item.sort_order,
|
||||||
children,
|
children,
|
||||||
|
version: item.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
||||||
use crate::entity::organization;
|
use crate::entity::organization;
|
||||||
use crate::error::{AuthError, AuthResult};
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
/// Organization CRUD service -- create, read, update, soft-delete organizations
|
/// Organization CRUD service -- create, read, update, soft-delete organizations
|
||||||
@@ -118,6 +119,7 @@ impl OrgService {
|
|||||||
level,
|
level,
|
||||||
sort_order: req.sort_order.unwrap_or(0),
|
sort_order: req.sort_order.unwrap_or(0),
|
||||||
children: vec![],
|
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();
|
let mut active: organization::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(ref name) = req.name {
|
if let Some(ref name) = req.name {
|
||||||
@@ -166,6 +170,7 @@ impl OrgService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -181,6 +186,7 @@ impl OrgService {
|
|||||||
level: updated.level,
|
level: updated.level,
|
||||||
sort_order: updated.sort_order,
|
sort_order: updated.sort_order,
|
||||||
children: vec![],
|
children: vec![],
|
||||||
|
version: updated.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +219,12 @@ impl OrgService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
let mut active: organization::ActiveModel = model.into();
|
let mut active: organization::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -258,6 +266,7 @@ fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
|||||||
level: item.level,
|
level: item.level,
|
||||||
sort_order: item.sort_order,
|
sort_order: item.sort_order,
|
||||||
children,
|
children,
|
||||||
|
version: item.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
|
|||||||
use crate::entity::department;
|
use crate::entity::department;
|
||||||
use crate::entity::position;
|
use crate::entity::position;
|
||||||
use crate::error::{AuthError, AuthResult};
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
/// Position CRUD service -- create, read, update, soft-delete positions
|
/// Position CRUD service -- create, read, update, soft-delete positions
|
||||||
@@ -44,6 +45,7 @@ impl PositionService {
|
|||||||
code: p.code.clone(),
|
code: p.code.clone(),
|
||||||
level: p.level,
|
level: p.level,
|
||||||
sort_order: p.sort_order,
|
sort_order: p.sort_order,
|
||||||
|
version: p.version,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -114,6 +116,7 @@ impl PositionService {
|
|||||||
code: req.code.clone(),
|
code: req.code.clone(),
|
||||||
level: req.level.unwrap_or(1),
|
level: req.level.unwrap_or(1),
|
||||||
sort_order: req.sort_order.unwrap_or(0),
|
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();
|
let mut active: position::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(n) = &req.name {
|
if let Some(n) = &req.name {
|
||||||
@@ -165,6 +170,7 @@ impl PositionService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -178,6 +184,7 @@ impl PositionService {
|
|||||||
code: updated.code.clone(),
|
code: updated.code.clone(),
|
||||||
level: updated.level,
|
level: updated.level,
|
||||||
sort_order: updated.sort_order,
|
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())
|
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||||
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
let mut active: position::ActiveModel = model.into();
|
let mut active: position::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::dto::{PermissionResp, RoleResp};
|
|||||||
use crate::entity::{permission, role, role_permission};
|
use crate::entity::{permission, role, role_permission};
|
||||||
use crate::error::AuthError;
|
use crate::error::AuthError;
|
||||||
use crate::error::AuthResult;
|
use crate::error::AuthResult;
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::types::Pagination;
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ impl RoleService {
|
|||||||
code: m.code.clone(),
|
code: m.code.clone(),
|
||||||
description: m.description.clone(),
|
description: m.description.clone(),
|
||||||
is_system: m.is_system,
|
is_system: m.is_system,
|
||||||
|
version: m.version,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ impl RoleService {
|
|||||||
code: model.code.clone(),
|
code: model.code.clone(),
|
||||||
description: model.description.clone(),
|
description: model.description.clone(),
|
||||||
is_system: model.is_system,
|
is_system: model.is_system,
|
||||||
|
version: model.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ impl RoleService {
|
|||||||
code: code.to_string(),
|
code: code.to_string(),
|
||||||
description: description.clone(),
|
description: description.clone(),
|
||||||
is_system: false,
|
is_system: false,
|
||||||
|
version: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +150,7 @@ impl RoleService {
|
|||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
name: &Option<String>,
|
name: &Option<String>,
|
||||||
description: &Option<String>,
|
description: &Option<String>,
|
||||||
|
version: i32,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> AuthResult<RoleResp> {
|
) -> AuthResult<RoleResp> {
|
||||||
let model = role::Entity::find_by_id(id)
|
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())
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||||
|
|
||||||
|
let next_ver = check_version(version, model.version)?;
|
||||||
|
|
||||||
let mut active: role::ActiveModel = model.into();
|
let mut active: role::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(name) = name {
|
if let Some(name) = name {
|
||||||
@@ -166,6 +173,7 @@ impl RoleService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -178,6 +186,7 @@ impl RoleService {
|
|||||||
code: updated.code.clone(),
|
code: updated.code.clone(),
|
||||||
description: updated.description.clone(),
|
description: updated.description.clone(),
|
||||||
is_system: updated.is_system,
|
is_system: updated.is_system,
|
||||||
|
version: updated.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +211,12 @@ impl RoleService {
|
|||||||
return Err(AuthError::Validation("系统角色不可删除".to_string()));
|
return Err(AuthError::Validation("系统角色不可删除".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
let mut active: role::ActiveModel = model.into();
|
let mut active: role::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
|
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
|
||||||
use crate::entity::{role, user, user_credential, user_role};
|
use crate::entity::{role, user, user_credential, user_role};
|
||||||
use crate::error::AuthError;
|
use crate::error::AuthError;
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::types::Pagination;
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ impl UserService {
|
|||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
roles: vec![],
|
roles: vec![],
|
||||||
|
version: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +187,8 @@ impl UserService {
|
|||||||
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
|
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
|
||||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
.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();
|
let mut active: user::ActiveModel = user_model.into();
|
||||||
|
|
||||||
if let Some(email) = &req.email {
|
if let Some(email) = &req.email {
|
||||||
@@ -205,6 +209,7 @@ impl UserService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -229,10 +234,12 @@ impl UserService {
|
|||||||
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
|
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
|
||||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let current_version = user_model.version;
|
||||||
let mut active: user::ActiveModel = user_model.into();
|
let mut active: user::ActiveModel = user_model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -279,6 +286,7 @@ impl UserService {
|
|||||||
code: r.code.clone(),
|
code: r.code.clone(),
|
||||||
description: r.description.clone(),
|
description: r.description.clone(),
|
||||||
is_system: r.is_system,
|
is_system: r.is_system,
|
||||||
|
version: r.version,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -295,5 +303,6 @@ fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
|
|||||||
avatar_url: m.avatar_url.clone(),
|
avatar_url: m.avatar_url.clone(),
|
||||||
status: m.status.clone(),
|
status: m.status.clone(),
|
||||||
roles,
|
roles,
|
||||||
|
version: m.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct DictionaryItemResp {
|
|||||||
pub sort_order: i32,
|
pub sort_order: i32,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
@@ -24,6 +25,7 @@ pub struct DictionaryResp {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub items: Vec<DictionaryItemResp>,
|
pub items: Vec<DictionaryItemResp>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -39,6 +41,7 @@ pub struct CreateDictionaryReq {
|
|||||||
pub struct UpdateDictionaryReq {
|
pub struct UpdateDictionaryReq {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -57,6 +60,7 @@ pub struct UpdateDictionaryItemReq {
|
|||||||
pub value: Option<String>,
|
pub value: Option<String>,
|
||||||
pub sort_order: Option<i32>,
|
pub sort_order: Option<i32>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Menu DTOs ---
|
// --- Menu DTOs ---
|
||||||
@@ -77,6 +81,7 @@ pub struct MenuResp {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission: Option<String>,
|
pub permission: Option<String>,
|
||||||
pub children: Vec<MenuResp>,
|
pub children: Vec<MenuResp>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -103,6 +108,7 @@ pub struct UpdateMenuReq {
|
|||||||
pub visible: Option<bool>,
|
pub visible: Option<bool>,
|
||||||
pub permission: Option<String>,
|
pub permission: Option<String>,
|
||||||
pub role_ids: Option<Vec<Uuid>>,
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -124,6 +130,8 @@ pub struct MenuItemReq {
|
|||||||
pub menu_type: Option<String>,
|
pub menu_type: Option<String>,
|
||||||
pub permission: Option<String>,
|
pub permission: Option<String>,
|
||||||
pub role_ids: Option<Vec<Uuid>>,
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
/// 乐观锁版本号。更新已有菜单时必填。
|
||||||
|
pub version: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Setting DTOs ---
|
// --- Setting DTOs ---
|
||||||
@@ -136,11 +144,14 @@ pub struct SettingResp {
|
|||||||
pub scope_id: Option<Uuid>,
|
pub scope_id: Option<Uuid>,
|
||||||
pub setting_key: String,
|
pub setting_key: String,
|
||||||
pub setting_value: serde_json::Value,
|
pub setting_value: serde_json::Value,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
pub struct UpdateSettingReq {
|
pub struct UpdateSettingReq {
|
||||||
pub setting_value: serde_json::Value,
|
pub setting_value: serde_json::Value,
|
||||||
|
/// 乐观锁版本号。更新已有设置时必填,创建新设置时忽略。
|
||||||
|
pub version: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 内部参数结构体,用于减少 SettingService::set 的参数数量。
|
/// 内部参数结构体,用于减少 SettingService::set 的参数数量。
|
||||||
@@ -149,6 +160,8 @@ pub struct SetSettingParams {
|
|||||||
pub scope: String,
|
pub scope: String,
|
||||||
pub scope_id: Option<Uuid>,
|
pub scope_id: Option<Uuid>,
|
||||||
pub value: serde_json::Value,
|
pub value: serde_json::Value,
|
||||||
|
/// 乐观锁版本号。更新已有设置时用于校验。
|
||||||
|
pub version: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Numbering Rule DTOs ---
|
// --- Numbering Rule DTOs ---
|
||||||
@@ -168,6 +181,7 @@ pub struct NumberingRuleResp {
|
|||||||
pub reset_cycle: String,
|
pub reset_cycle: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub last_reset_date: Option<String>,
|
pub last_reset_date: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -192,6 +206,7 @@ pub struct UpdateNumberingRuleReq {
|
|||||||
pub seq_length: Option<i32>,
|
pub seq_length: Option<i32>,
|
||||||
pub separator: Option<String>,
|
pub separator: Option<String>,
|
||||||
pub reset_cycle: Option<String>,
|
pub reset_cycle: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ pub enum ConfigError {
|
|||||||
|
|
||||||
#[error("编号序列耗尽: {0}")]
|
#[error("编号序列耗尽: {0}")]
|
||||||
NumberingExhausted(String),
|
NumberingExhausted(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
|
impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
|
||||||
@@ -34,6 +37,7 @@ impl From<ConfigError> for AppError {
|
|||||||
ConfigError::NotFound(s) => AppError::NotFound(s),
|
ConfigError::NotFound(s) => AppError::NotFound(s),
|
||||||
ConfigError::DuplicateKey(s) => AppError::Conflict(s),
|
ConfigError::DuplicateKey(s) => AppError::Conflict(s),
|
||||||
ConfigError::NumberingExhausted(s) => AppError::Internal(s),
|
ConfigError::NumberingExhausted(s) => AppError::Internal(s),
|
||||||
|
ConfigError::VersionMismatch => AppError::VersionMismatch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ where
|
|||||||
id,
|
id,
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
&req.name,
|
&req,
|
||||||
&req.description,
|
|
||||||
&state.db,
|
&state.db,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -113,11 +112,13 @@ where
|
|||||||
/// DELETE /api/v1/dictionaries/:id
|
/// DELETE /api/v1/dictionaries/:id
|
||||||
///
|
///
|
||||||
/// 软删除字典,设置 deleted_at 时间戳。
|
/// 软删除字典,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
/// 需要 `dictionary.delete` 权限。
|
/// 需要 `dictionary.delete` 权限。
|
||||||
pub async fn delete_dictionary<S>(
|
pub async fn delete_dictionary<S>(
|
||||||
State(state): State<ConfigState>,
|
State(state): State<ConfigState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<DeleteVersionReq>,
|
||||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
where
|
where
|
||||||
ConfigState: FromRef<S>,
|
ConfigState: FromRef<S>,
|
||||||
@@ -125,8 +126,15 @@ where
|
|||||||
{
|
{
|
||||||
require_permission(&ctx, "dictionary.delete")?;
|
require_permission(&ctx, "dictionary.delete")?;
|
||||||
|
|
||||||
DictionaryService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
|
DictionaryService::delete(
|
||||||
.await?;
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -228,11 +236,13 @@ where
|
|||||||
/// DELETE /api/v1/dictionaries/:dict_id/items/:item_id
|
/// DELETE /api/v1/dictionaries/:dict_id/items/:item_id
|
||||||
///
|
///
|
||||||
/// 软删除字典项,设置 deleted_at 时间戳。
|
/// 软删除字典项,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
/// 需要 `dictionary.delete` 权限。
|
/// 需要 `dictionary.delete` 权限。
|
||||||
pub async fn delete_item<S>(
|
pub async fn delete_item<S>(
|
||||||
State(state): State<ConfigState>,
|
State(state): State<ConfigState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path((_dict_id, item_id)): Path<(Uuid, Uuid)>,
|
Path((_dict_id, item_id)): Path<(Uuid, Uuid)>,
|
||||||
|
Json(req): Json<DeleteVersionReq>,
|
||||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
where
|
where
|
||||||
ConfigState: FromRef<S>,
|
ConfigState: FromRef<S>,
|
||||||
@@ -240,7 +250,8 @@ where
|
|||||||
{
|
{
|
||||||
require_permission(&ctx, "dictionary.delete")?;
|
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 {
|
Ok(Json(ApiResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -254,3 +265,9 @@ where
|
|||||||
pub struct ItemsByCodeQuery {
|
pub struct ItemsByCodeQuery {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除操作的乐观锁版本号。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct DeleteVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ where
|
|||||||
scope: "platform".to_string(),
|
scope: "platform".to_string(),
|
||||||
scope_id: None,
|
scope_id: None,
|
||||||
value,
|
value,
|
||||||
|
version: None,
|
||||||
},
|
},
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
|
|||||||
@@ -86,11 +86,12 @@ where
|
|||||||
|
|
||||||
/// DELETE /api/v1/config/menus/{id}
|
/// DELETE /api/v1/config/menus/{id}
|
||||||
///
|
///
|
||||||
/// 软删除单个菜单项。
|
/// 软删除单个菜单项。需要请求体包含 version 字段用于乐观锁校验。
|
||||||
pub async fn delete_menu<S>(
|
pub async fn delete_menu<S>(
|
||||||
State(state): State<ConfigState>,
|
State(state): State<ConfigState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<DeleteMenuVersionReq>,
|
||||||
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
|
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
|
||||||
where
|
where
|
||||||
ConfigState: FromRef<S>,
|
ConfigState: FromRef<S>,
|
||||||
@@ -98,7 +99,15 @@ where
|
|||||||
{
|
{
|
||||||
require_permission(&ctx, "menu.update")?;
|
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(())))
|
Ok(JsonResponse(ApiResponse::ok(())))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +131,7 @@ where
|
|||||||
for item in &req.menus {
|
for item in &req.menus {
|
||||||
match item.id {
|
match item.id {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
|
let version = item.version.unwrap_or(0);
|
||||||
let update_req = crate::dto::UpdateMenuReq {
|
let update_req = crate::dto::UpdateMenuReq {
|
||||||
title: Some(item.title.clone()),
|
title: Some(item.title.clone()),
|
||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
@@ -130,6 +140,7 @@ where
|
|||||||
visible: item.visible,
|
visible: item.visible,
|
||||||
permission: item.permission.clone(),
|
permission: item.permission.clone(),
|
||||||
role_ids: item.role_ids.clone(),
|
role_ids: item.role_ids.clone(),
|
||||||
|
version,
|
||||||
};
|
};
|
||||||
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db)
|
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -164,3 +175,9 @@ where
|
|||||||
message: Some("菜单批量保存成功".to_string()),
|
message: Some("菜单批量保存成功".to_string()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除菜单的乐观锁版本号请求体。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct DeleteMenuVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,11 +121,13 @@ where
|
|||||||
/// DELETE /api/v1/numbering-rules/:id
|
/// DELETE /api/v1/numbering-rules/:id
|
||||||
///
|
///
|
||||||
/// 软删除编号规则,设置 deleted_at 时间戳。
|
/// 软删除编号规则,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
/// 需要 `numbering.delete` 权限。
|
/// 需要 `numbering.delete` 权限。
|
||||||
pub async fn delete_numbering_rule<S>(
|
pub async fn delete_numbering_rule<S>(
|
||||||
State(state): State<ConfigState>,
|
State(state): State<ConfigState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<DeleteNumberingVersionReq>,
|
||||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
where
|
where
|
||||||
ConfigState: FromRef<S>,
|
ConfigState: FromRef<S>,
|
||||||
@@ -133,8 +135,15 @@ where
|
|||||||
{
|
{
|
||||||
require_permission(&ctx, "numbering.delete")?;
|
require_permission(&ctx, "numbering.delete")?;
|
||||||
|
|
||||||
NumberingService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
|
NumberingService::delete(
|
||||||
.await?;
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -142,3 +151,9 @@ where
|
|||||||
message: Some("编号规则已删除".to_string()),
|
message: Some("编号规则已删除".to_string()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除编号规则的乐观锁版本号请求体。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct DeleteNumberingVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ where
|
|||||||
scope: "tenant".to_string(),
|
scope: "tenant".to_string(),
|
||||||
scope_id: None,
|
scope_id: None,
|
||||||
value: req.setting_value,
|
value: req.setting_value,
|
||||||
|
version: req.version,
|
||||||
},
|
},
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
@@ -80,12 +81,14 @@ pub struct SettingQuery {
|
|||||||
/// DELETE /api/v1/settings/:key
|
/// DELETE /api/v1/settings/:key
|
||||||
///
|
///
|
||||||
/// 软删除设置值,设置 deleted_at 时间戳。
|
/// 软删除设置值,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
/// 需要 `setting.delete` 权限。
|
/// 需要 `setting.delete` 权限。
|
||||||
pub async fn delete_setting<S>(
|
pub async fn delete_setting<S>(
|
||||||
State(state): State<ConfigState>,
|
State(state): State<ConfigState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
Query(query): Query<SettingQuery>,
|
Query(query): Query<SettingQuery>,
|
||||||
|
Json(req): Json<DeleteSettingVersionReq>,
|
||||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
where
|
where
|
||||||
ConfigState: FromRef<S>,
|
ConfigState: FromRef<S>,
|
||||||
@@ -101,6 +104,7 @@ where
|
|||||||
&query.scope_id,
|
&query.scope_id,
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
|
req.version,
|
||||||
&state.db,
|
&state.db,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -111,3 +115,9 @@ where
|
|||||||
message: Some("设置已删除".to_string()),
|
message: Some("设置已删除".to_string()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除设置的乐观锁版本号请求体。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct DeleteSettingVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ where
|
|||||||
scope: "tenant".to_string(),
|
scope: "tenant".to_string(),
|
||||||
scope_id: None,
|
scope_id: None,
|
||||||
value,
|
value,
|
||||||
|
version: None,
|
||||||
},
|
},
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::{DictionaryItemResp, DictionaryResp};
|
use crate::dto::{DictionaryItemResp, DictionaryResp};
|
||||||
use crate::entity::{dictionary, dictionary_item};
|
use crate::entity::{dictionary, dictionary_item};
|
||||||
use crate::error::{ConfigError, ConfigResult};
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::types::Pagination;
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ impl DictionaryService {
|
|||||||
code: m.code.clone(),
|
code: m.code.clone(),
|
||||||
description: m.description.clone(),
|
description: m.description.clone(),
|
||||||
items,
|
items,
|
||||||
|
version: m.version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ impl DictionaryService {
|
|||||||
code: model.code.clone(),
|
code: model.code.clone(),
|
||||||
description: model.description.clone(),
|
description: model.description.clone(),
|
||||||
items,
|
items,
|
||||||
|
version: model.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,18 +143,19 @@ impl DictionaryService {
|
|||||||
code: code.to_string(),
|
code: code.to_string(),
|
||||||
description: description.clone(),
|
description: description.clone(),
|
||||||
items: vec![],
|
items: vec![],
|
||||||
|
version: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update editable dictionary fields (name and description).
|
/// Update editable dictionary fields (name and description).
|
||||||
///
|
///
|
||||||
/// Code cannot be changed after creation.
|
/// Code cannot be changed after creation.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
name: &Option<String>,
|
req: &crate::dto::UpdateDictionaryReq,
|
||||||
description: &Option<String>,
|
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> ConfigResult<DictionaryResp> {
|
) -> ConfigResult<DictionaryResp> {
|
||||||
let model = dictionary::Entity::find_by_id(id)
|
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())
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
.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();
|
let mut active: dictionary::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(n) = name {
|
if let Some(n) = &req.name {
|
||||||
active.name = Set(n.clone());
|
active.name = Set(n.clone());
|
||||||
}
|
}
|
||||||
if let Some(d) = description {
|
if let Some(d) = &req.description {
|
||||||
active.description = Set(Some(d.clone()));
|
active.description = Set(Some(d.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -186,14 +194,17 @@ impl DictionaryService {
|
|||||||
code: updated.code.clone(),
|
code: updated.code.clone(),
|
||||||
description: updated.description.clone(),
|
description: updated.description.clone(),
|
||||||
items,
|
items,
|
||||||
|
version: updated.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
|
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
event_bus: &EventBus,
|
event_bus: &EventBus,
|
||||||
) -> ConfigResult<()> {
|
) -> ConfigResult<()> {
|
||||||
@@ -204,10 +215,14 @@ impl DictionaryService {
|
|||||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
.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();
|
let mut active: dictionary::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -283,10 +298,12 @@ impl DictionaryService {
|
|||||||
value: req.value.clone(),
|
value: req.value.clone(),
|
||||||
sort_order,
|
sort_order,
|
||||||
color: req.color.clone(),
|
color: req.color.clone(),
|
||||||
|
version: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update editable dictionary item fields (label, value, sort_order, color).
|
/// Update editable dictionary item fields (label, value, sort_order, color).
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
pub async fn update_item(
|
pub async fn update_item(
|
||||||
item_id: Uuid,
|
item_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
@@ -301,6 +318,9 @@ impl DictionaryService {
|
|||||||
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
.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();
|
let mut active: dictionary_item::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(l) = &req.label {
|
if let Some(l) = &req.label {
|
||||||
@@ -318,6 +338,7 @@ impl DictionaryService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -331,14 +352,17 @@ impl DictionaryService {
|
|||||||
value: updated.value.clone(),
|
value: updated.value.clone(),
|
||||||
sort_order: updated.sort_order,
|
sort_order: updated.sort_order,
|
||||||
color: updated.color.clone(),
|
color: updated.color.clone(),
|
||||||
|
version: updated.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
|
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
pub async fn delete_item(
|
pub async fn delete_item(
|
||||||
item_id: Uuid,
|
item_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> ConfigResult<()> {
|
) -> ConfigResult<()> {
|
||||||
let model = dictionary_item::Entity::find_by_id(item_id)
|
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())
|
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
.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();
|
let mut active: dictionary_item::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -405,6 +433,7 @@ impl DictionaryService {
|
|||||||
value: i.value.clone(),
|
value: i.value.clone(),
|
||||||
sort_order: i.sort_order,
|
sort_order: i.sort_order,
|
||||||
color: i.color.clone(),
|
color: i.color.clone(),
|
||||||
|
version: i.version,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::{CreateMenuReq, MenuResp};
|
use crate::dto::{CreateMenuReq, MenuResp};
|
||||||
use crate::entity::{menu, menu_role};
|
use crate::entity::{menu, menu_role};
|
||||||
use crate::error::{ConfigError, ConfigResult};
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
|
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
|
||||||
@@ -103,6 +104,7 @@ impl MenuService {
|
|||||||
menu_type: m.menu_type.clone(),
|
menu_type: m.menu_type.clone(),
|
||||||
permission: m.permission.clone(),
|
permission: m.permission.clone(),
|
||||||
children: vec![],
|
children: vec![],
|
||||||
|
version: m.version,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -165,10 +167,12 @@ impl MenuService {
|
|||||||
menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()),
|
menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()),
|
||||||
permission: req.permission.clone(),
|
permission: req.permission.clone(),
|
||||||
children: vec![],
|
children: vec![],
|
||||||
|
version: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新菜单字段,并可选地重新关联角色。
|
/// 更新菜单字段,并可选地重新关联角色。
|
||||||
|
/// 使用乐观锁校验版本。
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
@@ -183,6 +187,9 @@ impl MenuService {
|
|||||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
.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();
|
let mut active: menu::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(title) = &req.title {
|
if let Some(title) = &req.title {
|
||||||
@@ -206,6 +213,7 @@ impl MenuService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -228,14 +236,16 @@ impl MenuService {
|
|||||||
menu_type: updated.menu_type.clone(),
|
menu_type: updated.menu_type.clone(),
|
||||||
permission: updated.permission.clone(),
|
permission: updated.permission.clone(),
|
||||||
children: vec![],
|
children: vec![],
|
||||||
|
version: updated.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 软删除菜单。
|
/// 软删除菜单。使用乐观锁校验版本。
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
event_bus: &EventBus,
|
event_bus: &EventBus,
|
||||||
) -> ConfigResult<()> {
|
) -> ConfigResult<()> {
|
||||||
@@ -246,10 +256,14 @@ impl MenuService {
|
|||||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
.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();
|
let mut active: menu::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -348,6 +362,7 @@ impl MenuService {
|
|||||||
menu_type: m.menu_type.clone(),
|
menu_type: m.menu_type.clone(),
|
||||||
permission: m.permission.clone(),
|
permission: m.permission.clone(),
|
||||||
children: Self::build_tree(&children, children_map),
|
children: Self::build_tree(&children, children_map),
|
||||||
|
version: m.version,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
|
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
|
||||||
use crate::entity::numbering_rule;
|
use crate::entity::numbering_rule;
|
||||||
use crate::error::{ConfigError, ConfigResult};
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::types::Pagination;
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
@@ -118,10 +119,11 @@ impl NumberingService {
|
|||||||
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
|
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
|
||||||
reset_cycle: req.reset_cycle.clone().unwrap_or_else(|| "never".to_string()),
|
reset_cycle: req.reset_cycle.clone().unwrap_or_else(|| "never".to_string()),
|
||||||
last_reset_date: Some(Utc::now().date_naive().to_string()),
|
last_reset_date: Some(Utc::now().date_naive().to_string()),
|
||||||
|
version: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新编号规则的可编辑字段。
|
/// 更新编号规则的可编辑字段。使用乐观锁校验版本。
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
@@ -136,6 +138,9 @@ impl NumberingService {
|
|||||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
.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();
|
let mut active: numbering_rule::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(name) = &req.name {
|
if let Some(name) = &req.name {
|
||||||
@@ -159,6 +164,7 @@ impl NumberingService {
|
|||||||
|
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -168,11 +174,12 @@ impl NumberingService {
|
|||||||
Ok(Self::model_to_resp(&updated))
|
Ok(Self::model_to_resp(&updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 软删除编号规则。
|
/// 软删除编号规则。使用乐观锁校验版本。
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
event_bus: &EventBus,
|
event_bus: &EventBus,
|
||||||
) -> ConfigResult<()> {
|
) -> ConfigResult<()> {
|
||||||
@@ -183,10 +190,14 @@ impl NumberingService {
|
|||||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
.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();
|
let mut active: numbering_rule::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -374,6 +385,7 @@ impl NumberingService {
|
|||||||
separator: m.separator.clone(),
|
separator: m.separator.clone(),
|
||||||
reset_cycle: m.reset_cycle.clone(),
|
reset_cycle: m.reset_cycle.clone(),
|
||||||
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
|
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
|
||||||
|
version: m.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::SettingResp;
|
use crate::dto::SettingResp;
|
||||||
use crate::entity::setting;
|
use crate::entity::setting;
|
||||||
use crate::error::{ConfigError, ConfigResult};
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::types::Pagination;
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
@@ -89,11 +90,17 @@ impl SettingService {
|
|||||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
if let Some(model) = existing {
|
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();
|
let mut active: setting::ActiveModel = model.into();
|
||||||
active.setting_value = Set(params.value.clone());
|
active.setting_value = Set(params.value.clone());
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -181,12 +188,14 @@ impl SettingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Soft-delete a setting by setting the `deleted_at` timestamp.
|
/// Soft-delete a setting by setting the `deleted_at` timestamp.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
key: &str,
|
key: &str,
|
||||||
scope: &str,
|
scope: &str,
|
||||||
scope_id: &Option<Uuid>,
|
scope_id: &Option<Uuid>,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> ConfigResult<()> {
|
) -> ConfigResult<()> {
|
||||||
let model = setting::Entity::find()
|
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();
|
let mut active: setting::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
active
|
active
|
||||||
.update(db)
|
.update(db)
|
||||||
.await
|
.await
|
||||||
@@ -283,6 +296,7 @@ impl SettingService {
|
|||||||
scope_id: model.scope_id,
|
scope_id: model.scope_id,
|
||||||
setting_key: model.setting_key.clone(),
|
setting_key: model.setting_key.clone(),
|
||||||
setting_value: model.setting_value.clone(),
|
setting_value: model.setting_value.clone(),
|
||||||
|
version: model.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ pub enum AppError {
|
|||||||
#[error("冲突: {0}")]
|
#[error("冲突: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
|
|
||||||
|
#[error("请求过于频繁,请稍后重试")]
|
||||||
|
TooManyRequests,
|
||||||
|
|
||||||
#[error("内部错误: {0}")]
|
#[error("内部错误: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
@@ -42,6 +48,8 @@ impl IntoResponse for AppError {
|
|||||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()),
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()),
|
||||||
AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()),
|
AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
AppError::Conflict(_) => (StatusCode::CONFLICT, 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()),
|
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>;
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub struct MessageResp {
|
|||||||
pub sent_at: Option<DateTime<Utc>>,
|
pub sent_at: Option<DateTime<Utc>>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送消息请求
|
/// 发送消息请求
|
||||||
@@ -162,6 +163,7 @@ pub struct MessageSubscriptionResp {
|
|||||||
pub dnd_end: Option<String>,
|
pub dnd_end: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_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_enabled: Option<bool>,
|
||||||
pub dnd_start: Option<String>,
|
pub dnd_start: Option<String>,
|
||||||
pub dnd_end: Option<String>,
|
pub dnd_end: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ pub enum MessageError {
|
|||||||
|
|
||||||
#[error("渲染失败: {0}")]
|
#[error("渲染失败: {0}")]
|
||||||
TemplateRenderError(String),
|
TemplateRenderError(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<MessageError> for AppError {
|
impl From<MessageError> for AppError {
|
||||||
@@ -23,6 +26,7 @@ impl From<MessageError> for AppError {
|
|||||||
MessageError::NotFound(msg) => AppError::NotFound(msg),
|
MessageError::NotFound(msg) => AppError::NotFound(msg),
|
||||||
MessageError::DuplicateTemplateCode(msg) => AppError::Conflict(msg),
|
MessageError::DuplicateTemplateCode(msg) => AppError::Conflict(msg),
|
||||||
MessageError::TemplateRenderError(msg) => AppError::Internal(msg),
|
MessageError::TemplateRenderError(msg) => AppError::Internal(msg),
|
||||||
|
MessageError::VersionMismatch => AppError::VersionMismatch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,9 +218,11 @@ impl MessageService {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
let mut active: message::ActiveModel = model.into();
|
let mut active: message::ActiveModel = model.into();
|
||||||
active.is_read = Set(true);
|
active.is_read = Set(true);
|
||||||
active.read_at = Set(Some(Utc::now()));
|
active.read_at = Set(Some(Utc::now()));
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(user_id);
|
active.updated_by = Set(user_id);
|
||||||
active
|
active
|
||||||
@@ -275,7 +277,9 @@ impl MessageService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
let mut active: message::ActiveModel = model.into();
|
let mut active: message::ActiveModel = model.into();
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(user_id);
|
active.updated_by = Set(user_id);
|
||||||
@@ -308,6 +312,7 @@ impl MessageService {
|
|||||||
sent_at: m.sent_at,
|
sent_at: m.sent_at,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
updated_at: m.updated_at,
|
updated_at: m.updated_at,
|
||||||
|
version: m.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::{MessageSubscriptionResp, UpdateSubscriptionReq};
|
use crate::dto::{MessageSubscriptionResp, UpdateSubscriptionReq};
|
||||||
use crate::entity::message_subscription;
|
use crate::entity::message_subscription;
|
||||||
use crate::error::{MessageError, MessageResult};
|
use crate::error::{MessageError, MessageResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
|
||||||
/// 消息订阅偏好服务。
|
/// 消息订阅偏好服务。
|
||||||
pub struct SubscriptionService;
|
pub struct SubscriptionService;
|
||||||
@@ -46,6 +47,9 @@ impl SubscriptionService {
|
|||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
if let Some(model) = existing {
|
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();
|
let mut active: message_subscription::ActiveModel = model.into();
|
||||||
if let Some(types) = &req.notification_types {
|
if let Some(types) = &req.notification_types {
|
||||||
active.notification_types = Set(Some(types.clone()));
|
active.notification_types = Set(Some(types.clone()));
|
||||||
@@ -64,6 +68,7 @@ impl SubscriptionService {
|
|||||||
}
|
}
|
||||||
active.updated_at = Set(now);
|
active.updated_at = Set(now);
|
||||||
active.updated_by = Set(user_id);
|
active.updated_by = Set(user_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let updated = active
|
let updated = active
|
||||||
.update(db)
|
.update(db)
|
||||||
@@ -112,6 +117,7 @@ impl SubscriptionService {
|
|||||||
dnd_end: m.dnd_end.clone(),
|
dnd_end: m.dnd_end.clone(),
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
updated_at: m.updated_at,
|
updated_at: m.updated_at,
|
||||||
|
version: m.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ pub struct ProcessDefinitionResp {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub lock_version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -101,6 +102,7 @@ pub struct UpdateProcessDefinitionReq {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub nodes: Option<Vec<NodeDef>>,
|
pub nodes: Option<Vec<NodeDef>>,
|
||||||
pub edges: Option<Vec<EdgeDef>>,
|
pub edges: Option<Vec<EdgeDef>>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 流程实例 DTOs ---
|
// --- 流程实例 DTOs ---
|
||||||
@@ -120,6 +122,7 @@ pub struct ProcessInstanceResp {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
/// 当前活跃的 token 位置
|
/// 当前活跃的 token 位置
|
||||||
pub active_tokens: Vec<TokenResp>,
|
pub active_tokens: Vec<TokenResp>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
@@ -169,6 +172,7 @@ pub struct TaskResp {
|
|||||||
pub definition_name: Option<String>,
|
pub definition_name: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub business_key: Option<String>,
|
pub business_key: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub enum WorkflowError {
|
|||||||
|
|
||||||
#[error("表达式求值失败: {0}")]
|
#[error("表达式求值失败: {0}")]
|
||||||
ExpressionError(String),
|
ExpressionError(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<sea_orm::TransactionError<WorkflowError>> for WorkflowError {
|
impl From<sea_orm::TransactionError<WorkflowError>> for WorkflowError {
|
||||||
@@ -42,6 +45,7 @@ impl From<WorkflowError> for AppError {
|
|||||||
WorkflowError::InvalidDiagram(s) => AppError::Validation(s),
|
WorkflowError::InvalidDiagram(s) => AppError::Validation(s),
|
||||||
WorkflowError::InvalidState(s) => AppError::Validation(s),
|
WorkflowError::InvalidState(s) => AppError::Validation(s),
|
||||||
WorkflowError::ExpressionError(s) => AppError::Validation(s),
|
WorkflowError::ExpressionError(s) => AppError::Validation(s),
|
||||||
|
WorkflowError::VersionMismatch => AppError::VersionMismatch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::dto::{
|
|||||||
use crate::engine::parser;
|
use crate::engine::parser;
|
||||||
use crate::entity::process_definition;
|
use crate::entity::process_definition;
|
||||||
use crate::error::{WorkflowError, WorkflowResult};
|
use crate::error::{WorkflowError, WorkflowResult};
|
||||||
|
use erp_core::error::check_version;
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::types::Pagination;
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ impl DefinitionService {
|
|||||||
status: "draft".to_string(),
|
status: "draft".to_string(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_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();
|
let mut active: process_definition::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(name) = &req.name {
|
if let Some(name) = &req.name {
|
||||||
@@ -168,6 +171,9 @@ impl DefinitionService {
|
|||||||
active.edges = Set(edges_json);
|
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_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
@@ -207,8 +213,10 @@ impl DefinitionService {
|
|||||||
.map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?;
|
.map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?;
|
||||||
parser::parse_and_validate(&nodes, &edges)?;
|
parser::parse_and_validate(&nodes, &edges)?;
|
||||||
|
|
||||||
|
let current_version = model.version_field;
|
||||||
let mut active: process_definition::ActiveModel = model.into();
|
let mut active: process_definition::ActiveModel = model.into();
|
||||||
active.status = Set("published".to_string());
|
active.status = Set("published".to_string());
|
||||||
|
active.version_field = Set(current_version + 1);
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
@@ -240,7 +248,9 @@ impl DefinitionService {
|
|||||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
||||||
|
|
||||||
|
let current_version = model.version_field;
|
||||||
let mut active: process_definition::ActiveModel = model.into();
|
let mut active: process_definition::ActiveModel = model.into();
|
||||||
|
active.version_field = Set(current_version + 1);
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
@@ -265,6 +275,7 @@ impl DefinitionService {
|
|||||||
status: m.status.clone(),
|
status: m.status.clone(),
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
updated_at: m.updated_at,
|
updated_at: m.updated_at,
|
||||||
|
lock_version: m.version_field,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ impl InstanceService {
|
|||||||
completed_at: instance.completed_at,
|
completed_at: instance.completed_at,
|
||||||
created_at: instance.created_at,
|
created_at: instance.created_at,
|
||||||
active_tokens,
|
active_tokens,
|
||||||
|
version: instance.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +196,7 @@ impl InstanceService {
|
|||||||
completed_at: m.completed_at,
|
completed_at: m.completed_at,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
active_tokens,
|
active_tokens,
|
||||||
|
version: m.version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +236,7 @@ impl InstanceService {
|
|||||||
completed_at: instance.completed_at,
|
completed_at: instance.completed_at,
|
||||||
created_at: instance.created_at,
|
created_at: instance.created_at,
|
||||||
active_tokens,
|
active_tokens,
|
||||||
|
version: instance.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,8 +292,10 @@ impl InstanceService {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_version = instance.version;
|
||||||
let mut active: process_instance::ActiveModel = instance.into();
|
let mut active: process_instance::ActiveModel = instance.into();
|
||||||
active.status = Set(to_status.to_string());
|
active.status = Set(to_status.to_string());
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -206,11 +206,13 @@ impl TaskService {
|
|||||||
let task_model = task_model.clone();
|
let task_model = task_model.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
// 更新任务状态
|
// 更新任务状态
|
||||||
|
let current_version = task_model.version;
|
||||||
let mut active: task::ActiveModel = task_model.clone().into();
|
let mut active: task::ActiveModel = task_model.clone().into();
|
||||||
active.status = Set("completed".to_string());
|
active.status = Set("completed".to_string());
|
||||||
active.outcome = Set(Some(outcome));
|
active.outcome = Set(Some(outcome));
|
||||||
active.form_data = Set(form_data);
|
active.form_data = Set(form_data);
|
||||||
active.completed_at = Set(Some(now));
|
active.completed_at = Set(Some(now));
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active.updated_at = Set(now);
|
active.updated_at = Set(now);
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
active
|
active
|
||||||
@@ -297,8 +299,10 @@ impl TaskService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_version = task_model.version;
|
||||||
let mut active: task::ActiveModel = task_model.into();
|
let mut active: task::ActiveModel = task_model.into();
|
||||||
active.assignee_id = Set(Some(req.delegate_to));
|
active.assignee_id = Set(Some(req.delegate_to));
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
@@ -372,6 +376,7 @@ impl TaskService {
|
|||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
definition_name: None,
|
definition_name: None,
|
||||||
business_key: None,
|
business_key: None,
|
||||||
|
version: m.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +1,285 @@
|
|||||||
# Phase 5-6 Implementation Plan: Message Center + Integration & Polish
|
# Phase 7: 审计日志 + 乐观锁 + Redis 限流 + 事件 Outbox
|
||||||
|
|
||||||
## Context
|
## 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 7.1: 乐观锁 — erp-core 基础设施
|
||||||
|
|
||||||
### 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),
|
|
||||||
- 标准审计字段
|
|
||||||
|
|
||||||
**修改文件:**
|
**修改文件:**
|
||||||
- `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,
|
||||||
|
|
||||||
**修改/创建文件:**
|
// 新增 helper 函数
|
||||||
|
pub fn check_version(expected: i32, actual: i32) -> AppResult<i32> {
|
||||||
1. `crates/erp-message/Cargo.toml` — 补齐缺失依赖 (thiserror, utoipa, async-trait, validator, serde/uuid/chrono features, sea-orm features)
|
if expected == actual { Ok(actual + 1) }
|
||||||
2. `crates/erp-message/src/lib.rs` — 声明子模块 + pub use
|
else { Err(AppError::VersionMismatch) }
|
||||||
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 — 更新订阅偏好
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 5.4: 服务器端集成
|
IntoResponse 中 `VersionMismatch` 映射到 `StatusCode::CONFLICT` (409)。
|
||||||
|
|
||||||
**修改文件:**
|
|
||||||
|
|
||||||
1. `crates/erp-server/Cargo.toml` — 添加 erp-message 依赖
|
|
||||||
2. `crates/erp-server/src/state.rs` — 添加 `FromRef<AppState> 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` — 通知弹出面板组件
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: 整合与打磨
|
## Task 7.2: 乐观锁 — 全部 Service 方法 + DTO
|
||||||
|
|
||||||
### Task 6.1: 跨模块事件集成 — 工作流 → 消息
|
**原则:** 所有用户可调用的 update/delete 方法必须检查并递增 version。
|
||||||
|
|
||||||
**修改文件:**
|
### DTO 变更
|
||||||
|
|
||||||
1. `crates/erp-message/src/module.rs` — `register_event_handlers()` 订阅工作流事件
|
**所有 Update*Req** 添加 `pub version: i32` 字段(必填)。涉及:
|
||||||
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` → 通知相关人
|
|
||||||
|
|
||||||
### 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` — 审计日志表
|
| Crate | Resp DTOs |
|
||||||
- id, tenant_id, user_id, action, resource_type, resource_id,
|
|-------|-----------|
|
||||||
- old_value (JSON), new_value (JSON), ip_address, user_agent,
|
| erp-auth | UserResp, RoleResp, OrganizationResp, DepartmentResp, PositionResp |
|
||||||
- 标准审计字段
|
| erp-config | DictionaryResp, DictionaryItemResp, MenuResp, SettingResp, NumberingRuleResp |
|
||||||
2. `crates/erp-core/src/audit.rs` — 审计中间件/工具函数
|
| erp-workflow | ProcessDefinitionResp, ProcessInstanceResp, TaskResp |
|
||||||
3. `crates/erp-server/src/main.rs` — 应用审计中间件到 protected routes
|
| 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 路由
|
**Update 模式(有 DTO):**
|
||||||
2. 各模块已有 utoipa 注解,确保正确注册到 OpenApi
|
```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);
|
||||||
|
```
|
||||||
|
|
||||||
检查项:
|
**涉及文件(13 个 service 的 update/delete 方法):**
|
||||||
- JWT 中间件正确性
|
|
||||||
- 多租户隔离 (所有查询带 tenant_id)
|
|
||||||
- SQL 注入防护 (SeaORM 参数化)
|
|
||||||
- CORS 配置
|
|
||||||
- 密码安全 (Argon2)
|
|
||||||
- 输入验证 (所有 API 端点)
|
|
||||||
- 错误信息不泄露敏感数据
|
|
||||||
|
|
||||||
### 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: 审计日志
|
||||||
|
|
||||||
```
|
### 7.3a: SeaORM Entity
|
||||||
Task 5.1 (迁移)
|
|
||||||
→ Task 5.2 (crate 基础)
|
**新建文件:**
|
||||||
→ Task 5.3 (服务+处理器)
|
- `crates/erp-core/src/entity/mod.rs`
|
||||||
→ Task 5.4 (服务器集成)
|
- `crates/erp-core/src/entity/audit_log.rs`
|
||||||
→ Task 5.5 (前端页面)
|
|
||||||
→ Task 5.6 (通知面板)
|
**修改文件:**
|
||||||
→ Task 6.1 (事件集成)
|
- `crates/erp-core/src/lib.rs` — 添加 `pub mod entity;`
|
||||||
→ Task 6.2 (审计日志)
|
- `crates/erp-core/Cargo.toml` — 添加 sea-orm 依赖(如果尚未有)
|
||||||
→ Task 6.3 (API 文档)
|
|
||||||
→ Task 6.4 (安全审查)
|
audit_log.rs Entity 映射已有的 `audit_logs` 表(迁移 #26 已存在)。
|
||||||
→ Task 6.5 (前端整合)
|
|
||||||
|
### 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 编译通过
|
在每个 service 的 create/update/delete 方法中,操作成功后调用 `audit_service::record()`。
|
||||||
2. `cargo test --workspace` — 所有测试通过
|
|
||||||
3. `cargo run -p erp-server` — 服务启动正常
|
**请求信息获取:** handler 层从 `HeaderMap` 提取 IP 和 User-Agent,传给 service。
|
||||||
4. 浏览器验证消息 CRUD 流程
|
|
||||||
5. 验证通知面板未读计数
|
```rust
|
||||||
6. 验证工作流事件触发消息通知
|
// handler 中
|
||||||
7. Swagger UI 验证 API 文档
|
fn extract_request_info(headers: &HeaderMap) -> (Option<String>, Option<String>) {
|
||||||
|
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<String>, user_agent: Option<String>`。
|
||||||
|
|
||||||
|
**涉及文件(与乐观锁相同 + 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` |
|
| 错误类型 | `crates/erp-core/src/error.rs` |
|
||||||
| 前端路由 | `apps/web/src/App.tsx` |
|
| 事件总线 | `crates/erp-core/src/events.rs` |
|
||||||
| 布局 | `apps/web/src/layouts/MainLayout.tsx` |
|
| 审计日志类型 | `crates/erp-core/src/audit.rs` |
|
||||||
| API 客户端 | `apps/web/src/api/client.ts` |
|
| AppState | `crates/erp-server/src/state.rs` |
|
||||||
| 参考模块 | `crates/erp-workflow/` (完整模式参考) |
|
| 服务器入口 | `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 处理
|
||||||
|
|||||||
Reference in New Issue
Block a user