feat(core): add audit logging to all mutation operations

Create audit_log SeaORM entity and audit_service::record() helper.
Integrate audit recording into 35 mutation endpoints across all modules:
- erp-auth: user/role/organization/department/position CRUD (15 actions)
- erp-config: dictionary/menu/setting/numbering_rule CRUD (15 actions)
- erp-workflow: definition/instance/task operations (8 actions)
- erp-message: send/system/mark_read/delete (5 actions)

Uses fire-and-forget pattern — audit failures logged but non-blocking.
This commit is contained in:
iven
2026-04-11 23:48:45 +08:00
parent 5d6e1dc394
commit db2cd24259
17 changed files with 388 additions and 0 deletions

View File

@@ -8,6 +8,8 @@ use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
use crate::entity::department;
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
@@ -124,6 +126,13 @@ impl DeptService {
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "department.create", "department")
.with_resource_id(id),
db,
)
.await;
Ok(DepartmentResp {
id,
org_id,
@@ -195,6 +204,13 @@ impl DeptService {
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "department.update", "department")
.with_resource_id(id),
db,
)
.await;
Ok(DepartmentResp {
id: updated.id,
org_id: updated.org_id,
@@ -256,6 +272,14 @@ impl DeptService {
tenant_id,
serde_json::json!({ "dept_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "department.delete", "department")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}

View File

@@ -7,6 +7,8 @@ use uuid::Uuid;
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
@@ -110,6 +112,13 @@ impl OrgService {
serde_json::json!({ "org_id": id, "name": req.name }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "organization.create", "organization")
.with_resource_id(id),
db,
)
.await;
Ok(OrganizationResp {
id,
name: req.name.clone(),
@@ -177,6 +186,13 @@ impl OrgService {
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "organization.update", "organization")
.with_resource_id(id),
db,
)
.await;
Ok(OrganizationResp {
id: updated.id,
name: updated.name.clone(),
@@ -235,6 +251,14 @@ impl OrgService {
tenant_id,
serde_json::json!({ "org_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "organization.delete", "organization")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}

View File

@@ -6,6 +6,8 @@ use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
use crate::entity::department;
use crate::entity::position;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
@@ -109,6 +111,13 @@ impl PositionService {
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "position.create", "position")
.with_resource_id(id),
db,
)
.await;
Ok(PositionResp {
id,
dept_id,
@@ -177,6 +186,13 @@ impl PositionService {
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "position.update", "position")
.with_resource_id(id),
db,
)
.await;
Ok(PositionResp {
id: updated.id,
dept_id: updated.dept_id,
@@ -219,6 +235,14 @@ impl PositionService {
tenant_id,
serde_json::json!({ "position_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "position.delete", "position")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}

View File

@@ -8,6 +8,8 @@ use crate::dto::{PermissionResp, RoleResp};
use crate::entity::{permission, role, role_permission};
use crate::error::AuthError;
use crate::error::AuthResult;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -131,6 +133,13 @@ impl RoleService {
serde_json::json!({ "role_id": id, "code": code }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "role.create", "role")
.with_resource_id(id),
db,
)
.await;
Ok(RoleResp {
id,
name: name.to_string(),
@@ -180,6 +189,13 @@ impl RoleService {
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "role.update", "role")
.with_resource_id(id),
db,
)
.await;
Ok(RoleResp {
id: updated.id,
name: updated.name.clone(),
@@ -227,6 +243,14 @@ impl RoleService {
tenant_id,
serde_json::json!({ "role_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "role.delete", "role")
.with_resource_id(id),
db,
)
.await;
Ok(())
}

View File

@@ -5,6 +5,8 @@ use uuid::Uuid;
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
use crate::entity::{role, user, user_credential, user_role};
use crate::error::AuthError;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -94,6 +96,13 @@ impl UserService {
serde_json::json!({ "user_id": user_id, "username": req.username }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.create", "user")
.with_resource_id(user_id),
db,
)
.await;
Ok(UserResp {
id: user_id,
username: req.username.clone(),
@@ -215,6 +224,13 @@ impl UserService {
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
.with_resource_id(id),
db,
)
.await;
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
Ok(model_to_resp(&updated, roles))
}
@@ -250,6 +266,14 @@ impl UserService {
tenant_id,
serde_json::json!({ "user_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.delete", "user")
.with_resource_id(id),
db,
)
.await;
Ok(())
}

View File

@@ -7,6 +7,8 @@ use uuid::Uuid;
use crate::dto::{DictionaryItemResp, DictionaryResp};
use crate::entity::{dictionary, dictionary_item};
use crate::error::{ConfigError, ConfigResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -137,6 +139,13 @@ impl DictionaryService {
serde_json::json!({ "dictionary_id": id, "code": code }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "dictionary.create", "dictionary")
.with_resource_id(id),
db,
)
.await;
Ok(DictionaryResp {
id,
name: name.to_string(),
@@ -188,6 +197,13 @@ impl DictionaryService {
let items = Self::fetch_items(updated.id, tenant_id, db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "dictionary.update", "dictionary")
.with_resource_id(id),
db,
)
.await;
Ok(DictionaryResp {
id: updated.id,
name: updated.name.clone(),
@@ -234,6 +250,13 @@ impl DictionaryService {
serde_json::json!({ "dictionary_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "dictionary.delete", "dictionary")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
@@ -291,6 +314,13 @@ impl DictionaryService {
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "dictionary_item.create", "dictionary_item")
.with_resource_id(id),
db,
)
.await;
Ok(DictionaryItemResp {
id,
dictionary_id,
@@ -345,6 +375,13 @@ impl DictionaryService {
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "dictionary_item.update", "dictionary_item")
.with_resource_id(item_id),
db,
)
.await;
Ok(DictionaryItemResp {
id: updated.id,
dictionary_id: updated.dictionary_id,
@@ -385,6 +422,13 @@ impl DictionaryService {
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "dictionary_item.delete", "dictionary_item")
.with_resource_id(item_id),
db,
)
.await;
Ok(())
}

View File

@@ -9,6 +9,8 @@ use uuid::Uuid;
use crate::dto::{CreateMenuReq, MenuResp};
use crate::entity::{menu, menu_role};
use crate::error::{ConfigError, ConfigResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
@@ -156,6 +158,13 @@ impl MenuService {
serde_json::json!({ "menu_id": id, "title": req.title }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "menu.create", "menu")
.with_resource_id(id),
db,
)
.await;
Ok(MenuResp {
id,
parent_id: req.parent_id,
@@ -225,6 +234,13 @@ impl MenuService {
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
}
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "menu.update", "menu")
.with_resource_id(id),
db,
)
.await;
Ok(MenuResp {
id: updated.id,
parent_id: updated.parent_id,
@@ -275,6 +291,13 @@ impl MenuService {
serde_json::json!({ "menu_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "menu.delete", "menu")
.with_resource_id(id),
db,
)
.await;
Ok(())
}

View File

@@ -8,6 +8,8 @@ use uuid::Uuid;
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
use crate::entity::numbering_rule;
use crate::error::{ConfigError, ConfigResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -107,6 +109,13 @@ impl NumberingService {
serde_json::json!({ "rule_id": id, "code": req.code }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "numbering_rule.create", "numbering_rule")
.with_resource_id(id),
db,
)
.await;
Ok(NumberingRuleResp {
id,
name: req.name.clone(),
@@ -171,6 +180,13 @@ impl NumberingService {
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "numbering_rule.update", "numbering_rule")
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&updated))
}
@@ -209,6 +225,13 @@ impl NumberingService {
serde_json::json!({ "rule_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "numbering_rule.delete", "numbering_rule")
.with_resource_id(id),
db,
)
.await;
Ok(())
}

View File

@@ -7,6 +7,8 @@ use uuid::Uuid;
use crate::dto::SettingResp;
use crate::entity::setting;
use crate::error::{ConfigError, ConfigResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -117,6 +119,13 @@ impl SettingService {
}),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
.with_resource_id(updated.id),
db,
)
.await;
Ok(Self::model_to_resp(&updated))
} else {
// Insert new record
@@ -151,6 +160,13 @@ impl SettingService {
}),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&inserted))
}
}
@@ -217,6 +233,7 @@ impl SettingService {
let next_version =
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let setting_id = model.id;
let mut active: setting::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
@@ -227,6 +244,13 @@ impl SettingService {
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "setting.delete", "setting")
.with_resource_id(setting_id),
db,
)
.await;
Ok(())
}

View File

@@ -0,0 +1,27 @@
use crate::audit::AuditLog;
use crate::entity::audit_log;
use sea_orm::{ActiveModelTrait, Set};
use tracing;
/// 持久化审计日志到 audit_logs 表。
///
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
pub async fn record(log: AuditLog, db: &sea_orm::DatabaseConnection) {
let model = audit_log::ActiveModel {
id: Set(log.id),
tenant_id: Set(log.tenant_id),
user_id: Set(log.user_id),
action: Set(log.action),
resource_type: Set(log.resource_type),
resource_id: Set(log.resource_id),
old_value: Set(log.old_value),
new_value: Set(log.new_value),
ip_address: Set(log.ip_address),
user_agent: Set(log.user_agent),
created_at: Set(log.created_at),
};
if let Err(e) = model.insert(db).await {
tracing::warn!(error = %e, "审计日志写入失败");
}
}

View File

@@ -0,0 +1,25 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
/// 审计日志实体 — 映射 audit_logs 表。
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "audit_logs")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Option<Uuid>,
pub action: String,
pub resource_type: String,
pub resource_id: Option<Uuid>,
pub old_value: Option<serde_json::Value>,
pub new_value: Option<serde_json::Value>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1 @@
pub mod audit_log;

View File

@@ -1,4 +1,6 @@
pub mod audit;
pub mod audit_service;
pub mod entity;
pub mod error;
pub mod events;
pub mod module;

View File

@@ -8,6 +8,8 @@ use uuid::Uuid;
use crate::dto::{MessageQuery, MessageResp, SendMessageReq, UnreadCountResp};
use crate::entity::message;
use crate::error::{MessageError, MessageResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::EventBus;
/// 消息服务。
@@ -130,6 +132,13 @@ impl MessageService {
}),
));
audit_service::record(
AuditLog::new(tenant_id, Some(sender_id), "message.send", "message")
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&inserted))
}
@@ -191,6 +200,13 @@ impl MessageService {
}),
));
audit_service::record(
AuditLog::new(tenant_id, Some(system_user), "message.send_system", "message")
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&inserted))
}
@@ -230,6 +246,13 @@ impl MessageService {
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "message.mark_read", "message")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
@@ -254,6 +277,12 @@ impl MessageService {
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "message.mark_all_read", "message"),
db,
)
.await;
Ok(())
}
@@ -288,6 +317,13 @@ impl MessageService {
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "message.delete", "message")
.with_resource_id(id),
db,
)
.await;
Ok(())
}

View File

@@ -10,6 +10,8 @@ use crate::dto::{
use crate::engine::parser;
use crate::entity::process_definition;
use crate::error::{WorkflowError, WorkflowResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -107,6 +109,13 @@ impl DefinitionService {
serde_json::json!({ "definition_id": id, "key": req.key }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "process_definition.create", "process_definition")
.with_resource_id(id),
db,
)
.await;
Ok(ProcessDefinitionResp {
id,
name: req.name.clone(),
@@ -182,6 +191,13 @@ impl DefinitionService {
.await
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "process_definition.update", "process_definition")
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&updated))
}
@@ -231,6 +247,13 @@ impl DefinitionService {
serde_json::json!({ "definition_id": id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "process_definition.publish", "process_definition")
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&updated))
}
@@ -259,6 +282,13 @@ impl DefinitionService {
.await
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "process_definition.delete", "process_definition")
.with_resource_id(id),
db,
)
.await;
Ok(())
}

View File

@@ -12,6 +12,8 @@ use crate::engine::executor::FlowExecutor;
use crate::engine::parser;
use crate::entity::{process_definition, process_instance, process_variable, token};
use crate::error::{WorkflowError, WorkflowResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -130,6 +132,13 @@ impl InstanceService {
serde_json::json!({ "instance_id": instance_id, "definition_id": definition.id, "started_by": operator_id }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "process_instance.start", "process_instance")
.with_resource_id(instance_id),
db,
)
.await;
// 查询创建后的实例(包含 token
let instance = process_instance::Entity::find_by_id(instance_id)
.one(db)
@@ -303,6 +312,14 @@ impl InstanceService {
.await
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
let action = format!("process_instance.{}", to_status);
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), action, "process_instance")
.with_resource_id(id),
db,
)
.await;
Ok(())
}

View File

@@ -12,6 +12,8 @@ use crate::engine::executor::FlowExecutor;
use crate::engine::parser;
use crate::entity::{process_definition, process_instance, task};
use crate::error::{WorkflowError, WorkflowResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
@@ -242,6 +244,13 @@ impl TaskService {
serde_json::json!({ "task_id": id, "outcome": req.outcome }),
));
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "task.complete", "task")
.with_resource_id(id),
db,
)
.await;
// 重新查询任务
let updated = task::Entity::find_by_id(id)
.one(db)
@@ -311,6 +320,13 @@ impl TaskService {
.await
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "task.delegate", "task")
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&updated))
}