feat: initialize Nuanji (Warm Notes) project

- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin)
- Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs)
- Integrated erp-diary into workspace and erp-server
- Added DiaryModule registration in main.rs
- Added DiaryState FromRef in state.rs
- Diary routes mounted (empty routes, ready for implementation)
- Product design spec v1.2 preserved in docs/
- Implementation plan preserved in plans/

Cargo check: OK
Cargo test: OK (78+ base tests passing)
This commit is contained in:
iven
2026-05-31 20:52:19 +08:00
commit c539e6fd83
285 changed files with 59156 additions and 0 deletions

View File

@@ -0,0 +1,628 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
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;
/// Dictionary CRUD service — manage dictionaries and their items within a tenant.
///
/// Dictionaries provide enumerated value sets (e.g. status codes, categories)
/// that can be referenced throughout the system by their unique `code`.
pub struct DictionaryService;
impl DictionaryService {
/// List dictionaries within a tenant with pagination.
///
/// Each dictionary includes its associated items.
/// Returns `(dictionaries, total_count)`.
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<(Vec<DictionaryResp>, u64)> {
let paginator = dictionary::Entity::find()
.filter(dictionary::Column::TenantId.eq(tenant_id))
.filter(dictionary::Column::DeletedAt.is_null())
.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let mut resps = Vec::with_capacity(models.len());
for m in &models {
let items = Self::fetch_items(m.id, tenant_id, db).await?;
resps.push(dict_model_to_resp(m, items));
}
Ok((resps, total))
}
/// Fetch a single dictionary by ID, scoped to the given tenant.
///
/// Includes all associated items.
pub async fn get_by_id(
id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryResp> {
let model = dictionary::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
let items = Self::fetch_items(model.id, tenant_id, db).await?;
Ok(dict_model_to_resp(&model, items))
}
/// Create a new dictionary within the current tenant.
///
/// Validates code uniqueness, then inserts the record and publishes
/// a `dictionary.created` domain event.
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
name: &str,
code: &str,
description: &Option<String>,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<DictionaryResp> {
// Check code uniqueness within tenant
let existing = dictionary::Entity::find()
.filter(dictionary::Column::TenantId.eq(tenant_id))
.filter(dictionary::Column::Code.eq(code))
.filter(dictionary::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(ConfigError::Validation("字典编码已存在".to_string()));
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = dictionary::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(name.to_string()),
code: Set(code.to_string()),
description: Set(description.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"dictionary.created",
tenant_id,
serde_json::json!({ "dictionary_id": id, "code": code }),
),
db,
)
.await;
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(),
code: code.to_string(),
description: description.clone(),
items: vec![],
version: 1,
})
}
/// Update editable dictionary fields (name and description).
///
/// Code cannot be changed after creation.
/// Performs optimistic locking via version check.
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &crate::dto::UpdateDictionaryReq,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryResp> {
let model = dictionary::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
let next_version =
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: dictionary::ActiveModel = model.into();
if let Some(n) = &req.name {
active.name = Set(n.clone());
}
if let Some(d) = &req.description {
active.description = Set(Some(d.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
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(dict_model_to_resp(&updated, items))
}
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
/// Performs optimistic locking via version check.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
version: i32,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<()> {
let model = dictionary::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
let next_version =
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: dictionary::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"dictionary.deleted",
tenant_id,
serde_json::json!({ "dictionary_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"dictionary.delete",
"dictionary",
)
.with_resource_id(id),
db,
)
.await;
Ok(())
}
/// Add a new item to a dictionary.
///
/// Validates that the item `value` is unique within the dictionary.
pub async fn add_item(
dictionary_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &crate::dto::CreateDictionaryItemReq,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryItemResp> {
// Verify the dictionary exists and belongs to this tenant
let _dict = dictionary::Entity::find_by_id(dictionary_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
// Check value uniqueness within dictionary
let existing = dictionary_item::Entity::find()
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
.filter(dictionary_item::Column::Value.eq(&req.value))
.filter(dictionary_item::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(ConfigError::Validation("字典项值已存在".to_string()));
}
let now = Utc::now();
let id = Uuid::now_v7();
let sort_order = req.sort_order.unwrap_or(0);
let model = dictionary_item::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
dictionary_id: Set(dictionary_id),
label: Set(req.label.clone()),
value: Set(req.value.clone()),
sort_order: Set(sort_order),
color: Set(req.color.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.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,
label: req.label.clone(),
value: req.value.clone(),
sort_order,
color: req.color.clone(),
version: 1,
})
}
/// Update editable dictionary item fields (label, value, sort_order, color).
/// Performs optimistic locking via version check.
pub async fn update_item(
item_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &crate::dto::UpdateDictionaryItemReq,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryItemResp> {
let model = dictionary_item::Entity::find_by_id(item_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
let next_version =
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: dictionary_item::ActiveModel = model.into();
if let Some(l) = &req.label {
active.label = Set(l.clone());
}
if let Some(v) = &req.value {
active.value = Set(v.clone());
}
if let Some(s) = req.sort_order {
active.sort_order = Set(s);
}
if let Some(c) = &req.color {
active.color = Set(Some(c.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
let updated = active
.update(db)
.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(item_model_to_resp(&updated))
}
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
/// Performs optimistic locking via version check.
pub async fn delete_item(
item_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
version: i32,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<()> {
let model = dictionary_item::Entity::find_by_id(item_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
let next_version =
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: dictionary_item::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
active
.update(db)
.await
.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(())
}
/// Look up a dictionary by its `code` and return all items.
///
/// Useful for frontend dropdowns and enum-like lookups.
pub async fn list_items_by_code(
code: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<DictionaryItemResp>> {
let dict = dictionary::Entity::find()
.filter(dictionary::Column::TenantId.eq(tenant_id))
.filter(dictionary::Column::Code.eq(code))
.filter(dictionary::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.ok_or_else(|| ConfigError::NotFound(format!("字典编码 '{}' 不存在", code)))?;
Self::fetch_items(dict.id, tenant_id, db).await
}
// ---- 内部辅助方法 ----
/// Fetch all non-deleted items for a given dictionary.
async fn fetch_items(
dictionary_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<DictionaryItemResp>> {
let items = dictionary_item::Entity::find()
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
.filter(dictionary_item::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(items.iter().map(item_model_to_resp).collect())
}
}
/// Free function wrapping the private helper so the mapping logic is reusable
/// in both async methods and synchronous unit tests without a database.
fn item_model_to_resp(m: &dictionary_item::Model) -> DictionaryItemResp {
DictionaryItemResp {
id: m.id,
dictionary_id: m.dictionary_id,
label: m.label.clone(),
value: m.value.clone(),
sort_order: m.sort_order,
color: m.color.clone(),
version: m.version,
}
}
/// Free function for dictionary model -> response DTO mapping.
fn dict_model_to_resp(m: &dictionary::Model, items: Vec<DictionaryItemResp>) -> DictionaryResp {
DictionaryResp {
id: m.id,
name: m.name.clone(),
code: m.code.clone(),
description: m.description.clone(),
items,
version: m.version,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use uuid::Uuid;
fn sample_dict_model() -> dictionary::Model {
dictionary::Model {
id: Uuid::now_v7(),
tenant_id: Uuid::now_v7(),
name: "测试字典".to_string(),
code: "test_dict".to_string(),
description: Some("描述".to_string()),
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: Uuid::now_v7(),
updated_by: Uuid::now_v7(),
deleted_at: None,
version: 1,
}
}
fn sample_item_model() -> dictionary_item::Model {
dictionary_item::Model {
id: Uuid::now_v7(),
tenant_id: Uuid::now_v7(),
dictionary_id: Uuid::now_v7(),
label: "选项A".to_string(),
value: "option_a".to_string(),
sort_order: 1,
color: Some("#FF0000".to_string()),
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: Uuid::now_v7(),
updated_by: Uuid::now_v7(),
deleted_at: None,
version: 1,
}
}
// ---- dict_model_to_resp ----
#[test]
fn dict_model_to_resp_with_items() {
let m = sample_dict_model();
let item = item_model_to_resp(&sample_item_model());
let resp = dict_model_to_resp(&m, vec![item]);
assert_eq!(resp.id, m.id);
assert_eq!(resp.name, "测试字典");
assert_eq!(resp.code, "test_dict");
assert_eq!(resp.description, Some("描述".to_string()));
assert_eq!(resp.version, 1);
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.items[0].label, "选项A");
}
#[test]
fn dict_model_to_resp_without_description() {
let mut m = sample_dict_model();
m.description = None;
let resp = dict_model_to_resp(&m, vec![]);
assert_eq!(resp.description, None);
assert!(resp.items.is_empty());
}
#[test]
fn dict_model_to_resp_preserves_version() {
let mut m = sample_dict_model();
m.version = 42;
let resp = dict_model_to_resp(&m, vec![]);
assert_eq!(resp.version, 42);
}
// ---- item_model_to_resp ----
#[test]
fn item_model_to_resp_all_fields() {
let m = sample_item_model();
let resp = item_model_to_resp(&m);
assert_eq!(resp.id, m.id);
assert_eq!(resp.dictionary_id, m.dictionary_id);
assert_eq!(resp.label, "选项A");
assert_eq!(resp.value, "option_a");
assert_eq!(resp.sort_order, 1);
assert_eq!(resp.color, Some("#FF0000".to_string()));
assert_eq!(resp.version, 1);
}
#[test]
fn item_model_to_resp_without_color() {
let mut m = sample_item_model();
m.color = None;
let resp = item_model_to_resp(&m);
assert_eq!(resp.color, None);
}
#[test]
fn item_model_to_resp_default_sort_order() {
let mut m = sample_item_model();
m.sort_order = 0;
let resp = item_model_to_resp(&m);
assert_eq!(resp.sort_order, 0);
}
#[test]
fn item_model_to_resp_preserves_version() {
let mut m = sample_item_model();
m.version = 7;
let resp = item_model_to_resp(&m);
assert_eq!(resp.version, 7);
}
}

View File

@@ -0,0 +1,600 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set,
};
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;
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
/// 以及管理菜单-角色关联。
pub struct MenuService;
impl MenuService {
/// 通过角色 code 列表查找对应的角色 ID 列表。
async fn resolve_role_ids(
tenant_id: Uuid,
role_codes: &[String],
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<Uuid>> {
if role_codes.is_empty() {
return Ok(vec![]);
}
let codes_csv: String = role_codes
.iter()
.map(|c| format!("'{}'", c.replace('\'', "''")))
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT id FROM roles WHERE tenant_id = '{}' AND code IN ({}) AND deleted_at IS NULL",
tenant_id, codes_csv
);
let stmt = sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql);
let rows = db.query_all(stmt).await?;
Ok(rows
.into_iter()
.filter_map(|row| {
let id: Uuid = row.try_get_by_index(0).ok()?;
Some(id)
})
.collect())
}
pub async fn get_menu_tree(
tenant_id: Uuid,
role_codes: &[String],
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<MenuResp>> {
// 0. admin 角色直接返回全部菜单,跳过 menu_roles 过滤
if role_codes.iter().any(|c| c == "admin") {
let all_menus = menu::Entity::find()
.filter(menu::Column::TenantId.eq(tenant_id))
.filter(menu::Column::DeletedAt.is_null())
.order_by_asc(menu::Column::SortOrder)
.all(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
for m in &all_menus {
children_map.entry(m.parent_id).or_default().push(m);
}
let roots = children_map.get(&None).cloned().unwrap_or_default();
return Ok(Self::build_tree(&roots, &children_map));
}
// 1. 将角色 code 转换为 UUID
let role_ids = Self::resolve_role_ids(tenant_id, role_codes, db).await?;
// 2. 查询租户下所有未删除的菜单,按 sort_order 排序
let all_menus = menu::Entity::find()
.filter(menu::Column::TenantId.eq(tenant_id))
.filter(menu::Column::DeletedAt.is_null())
.order_by_asc(menu::Column::SortOrder)
.all(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
// 3. 通过 menu_roles 表过滤
let visible_menu_ids: Option<Vec<Uuid>> = if !role_ids.is_empty() {
let mr_rows = menu_role::Entity::find()
.filter(menu_role::Column::TenantId.eq(tenant_id))
.filter(menu_role::Column::RoleId.is_in(role_ids.iter().copied()))
.filter(menu_role::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let ids: Vec<Uuid> = mr_rows.iter().map(|mr| mr.menu_id).collect();
if ids.is_empty() {
Some(vec![]) // 无菜单关联 = 不显示
} else {
Some(ids)
}
} else {
Some(vec![]) // 无角色 = 不显示任何菜单
};
// 3. 按 parent_id 分组构建 HashMap
let filtered: Vec<&menu::Model> = match &visible_menu_ids {
Some(ids) => all_menus.iter().filter(|m| ids.contains(&m.id)).collect(),
None => all_menus.iter().collect(),
};
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
for m in &filtered {
children_map.entry(m.parent_id).or_default().push(*m);
}
// 4. 递归构建树形结构(从 parent_id == None 的根节点开始)
let roots = children_map.get(&None).cloned().unwrap_or_default();
let tree = Self::build_tree(&roots, &children_map);
Ok(tree)
}
/// 获取当前租户下所有菜单的平铺列表(无角色过滤)。
pub async fn get_flat_list(
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<MenuResp>> {
let menus = menu::Entity::find()
.filter(menu::Column::TenantId.eq(tenant_id))
.filter(menu::Column::DeletedAt.is_null())
.order_by_asc(menu::Column::SortOrder)
.all(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(menus
.iter()
.map(|m| MenuResp {
id: m.id,
parent_id: m.parent_id,
title: m.title.clone(),
path: m.path.clone(),
icon: m.icon.clone(),
sort_order: m.sort_order,
visible: m.visible,
menu_type: m.menu_type.clone(),
permission: m.permission.clone(),
children: vec![],
version: m.version,
})
.collect())
}
/// 创建菜单并可选地关联角色。
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateMenuReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<MenuResp> {
let now = Utc::now();
let id = Uuid::now_v7();
let model = menu::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
parent_id: Set(req.parent_id),
title: Set(req.title.clone()),
path: Set(req.path.clone()),
icon: Set(req.icon.clone()),
sort_order: Set(req.sort_order.unwrap_or(0)),
visible: Set(req.visible.unwrap_or(true)),
menu_type: Set(req.menu_type.clone().unwrap_or_else(|| "menu".to_string())),
permission: Set(req.permission.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
// 关联角色(如果提供了 role_ids
if let Some(role_ids) = &req.role_ids
&& !role_ids.is_empty()
{
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
}
event_bus
.publish(
erp_core::events::DomainEvent::new(
"menu.created",
tenant_id,
serde_json::json!({ "menu_id": id, "title": req.title }),
),
db,
)
.await;
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,
title: req.title.clone(),
path: req.path.clone(),
icon: req.icon.clone(),
sort_order: req.sort_order.unwrap_or(0),
visible: req.visible.unwrap_or(true),
menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()),
permission: req.permission.clone(),
children: vec![],
version: 1,
})
}
/// 更新菜单字段,并可选地重新关联角色。
/// 使用乐观锁校验版本。
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &crate::dto::UpdateMenuReq,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<MenuResp> {
let model = menu::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
let next_version =
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: menu::ActiveModel = model.into();
if let Some(title) = &req.title {
active.title = Set(title.clone());
}
if let Some(path) = &req.path {
active.path = Set(Some(path.clone()));
}
if let Some(icon) = &req.icon {
active.icon = Set(Some(icon.clone()));
}
if let Some(sort_order) = req.sort_order {
active.sort_order = Set(sort_order);
}
if let Some(visible) = req.visible {
active.visible = Set(visible);
}
if let Some(permission) = &req.permission {
active.permission = Set(Some(permission.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
// 如果提供了 role_ids重新关联角色
if let Some(role_ids) = &req.role_ids {
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,
title: updated.title.clone(),
path: updated.path.clone(),
icon: updated.icon.clone(),
sort_order: updated.sort_order,
visible: updated.visible,
menu_type: updated.menu_type.clone(),
permission: updated.permission.clone(),
children: vec![],
version: updated.version,
})
}
/// 软删除菜单。使用乐观锁校验版本。
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
version: i32,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<()> {
let model = menu::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
let next_version =
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: menu::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"menu.deleted",
tenant_id,
serde_json::json!({ "menu_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "menu.delete", "menu").with_resource_id(id),
db,
)
.await;
Ok(())
}
/// 替换菜单的角色关联。
///
/// 软删除现有关联行,然后插入新关联(参考 RoleService::assign_permissions 模式)。
pub async fn assign_roles(
menu_id: Uuid,
role_ids: &[Uuid],
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<()> {
// 验证菜单存在且属于当前租户
let _menu = menu::Entity::find_by_id(menu_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {menu_id}")))?;
// 软删除现有关联
let existing = menu_role::Entity::find()
.filter(menu_role::Column::MenuId.eq(menu_id))
.filter(menu_role::Column::TenantId.eq(tenant_id))
.filter(menu_role::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let now = Utc::now();
for mr in existing {
let mut active: menu_role::ActiveModel = mr.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.take().unwrap_or(0) + 1);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
}
// 插入新关联
for role_id in role_ids {
let mr = menu_role::ActiveModel {
id: Set(Uuid::now_v7()),
menu_id: Set(menu_id),
role_id: Set(*role_id),
tenant_id: Set(tenant_id),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
mr.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
}
Ok(())
}
/// 递归构建菜单树。
fn build_tree<'a>(
nodes: &[&'a menu::Model],
children_map: &HashMap<Option<Uuid>, Vec<&'a menu::Model>>,
) -> Vec<MenuResp> {
nodes
.iter()
.map(|m| {
let children = children_map.get(&Some(m.id)).cloned().unwrap_or_default();
MenuResp {
id: m.id,
parent_id: m.parent_id,
title: m.title.clone(),
path: m.path.clone(),
icon: m.icon.clone(),
sort_order: m.sort_order,
visible: m.visible,
menu_type: m.menu_type.clone(),
permission: m.permission.clone(),
children: Self::build_tree(&children, children_map),
version: m.version,
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
/// 辅助:构造 menu::Model
fn make_menu(id: Uuid, parent_id: Option<Uuid>, title: &str, sort_order: i32) -> menu::Model {
let now = Utc::now();
let tenant_id = Uuid::now_v7();
menu::Model {
id,
tenant_id,
parent_id,
title: title.to_string(),
path: Some(format!("/{}", title.to_lowercase())),
icon: None,
sort_order,
visible: true,
menu_type: "menu".to_string(),
permission: None,
created_at: now,
updated_at: now,
created_by: tenant_id,
updated_by: tenant_id,
deleted_at: None,
version: 1,
}
}
#[test]
fn build_tree_empty_input() {
let nodes: Vec<&menu::Model> = vec![];
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
let tree = MenuService::build_tree(&nodes, &children_map);
assert!(tree.is_empty());
}
#[test]
fn build_tree_single_root() {
let root_id = Uuid::now_v7();
let root = make_menu(root_id, None, "首页", 0);
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
let roots: Vec<&menu::Model> = vec![&root];
let tree = MenuService::build_tree(&roots, &children_map);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].id, root_id);
assert_eq!(tree[0].title, "首页");
assert!(tree[0].children.is_empty());
}
#[test]
fn build_tree_two_levels() {
// 根节点 -> 子节点1, 子节点2
let root_id = Uuid::now_v7();
let child1_id = Uuid::now_v7();
let child2_id = Uuid::now_v7();
let root = make_menu(root_id, None, "系统管理", 0);
let child1 = make_menu(child1_id, Some(root_id), "用户管理", 1);
let child2 = make_menu(child2_id, Some(root_id), "角色管理", 2);
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
children_map.insert(Some(root_id), vec![&child1, &child2]);
let roots: Vec<&menu::Model> = vec![&root];
let tree = MenuService::build_tree(&roots, &children_map);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), 2);
assert_eq!(tree[0].children[0].title, "用户管理");
assert_eq!(tree[0].children[1].title, "角色管理");
}
#[test]
fn build_tree_three_levels() {
// 根 -> 子 -> 孙
let root_id = Uuid::now_v7();
let child_id = Uuid::now_v7();
let grandchild_id = Uuid::now_v7();
let root = make_menu(root_id, None, "系统管理", 0);
let child = make_menu(child_id, Some(root_id), "用户管理", 1);
let grandchild = make_menu(grandchild_id, Some(child_id), "用户详情", 0);
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
children_map.insert(Some(root_id), vec![&child]);
children_map.insert(Some(child_id), vec![&grandchild]);
let roots: Vec<&menu::Model> = vec![&root];
let tree = MenuService::build_tree(&roots, &children_map);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].children.len(), 1);
assert_eq!(tree[0].children[0].children[0].title, "用户详情");
}
#[test]
fn build_tree_multiple_roots() {
// 两个独立的根节点
let root1_id = Uuid::now_v7();
let root2_id = Uuid::now_v7();
let root1 = make_menu(root1_id, None, "首页", 0);
let root2 = make_menu(root2_id, None, "系统管理", 1);
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
let roots: Vec<&menu::Model> = vec![&root1, &root2];
let tree = MenuService::build_tree(&roots, &children_map);
assert_eq!(tree.len(), 2);
assert_eq!(tree[0].title, "首页");
assert_eq!(tree[1].title, "系统管理");
}
#[test]
fn build_tree_preserves_model_fields() {
let root_id = Uuid::now_v7();
let now = Utc::now();
let tenant_id = Uuid::now_v7();
let root = menu::Model {
id: root_id,
tenant_id,
parent_id: None,
title: "设置".to_string(),
path: Some("/settings".to_string()),
icon: Some("SettingOutlined".to_string()),
sort_order: 5,
visible: false,
menu_type: "directory".to_string(),
permission: Some("settings:view".to_string()),
created_at: now,
updated_at: now,
created_by: tenant_id,
updated_by: tenant_id,
deleted_at: None,
version: 3,
};
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
let roots: Vec<&menu::Model> = vec![&root];
let tree = MenuService::build_tree(&roots, &children_map);
assert_eq!(tree.len(), 1);
let node = &tree[0];
assert_eq!(node.id, root_id);
assert_eq!(node.title, "设置");
assert_eq!(node.path, Some("/settings".to_string()));
assert_eq!(node.icon, Some("SettingOutlined".to_string()));
assert_eq!(node.sort_order, 5);
assert!(!node.visible);
assert_eq!(node.menu_type, "directory");
assert_eq!(node.permission, Some("settings:view".to_string()));
assert_eq!(node.version, 3);
}
}

View File

@@ -0,0 +1,4 @@
pub mod dictionary_service;
pub mod menu_service;
pub mod numbering_service;
pub mod setting_service;

View File

@@ -0,0 +1,747 @@
use chrono::{Datelike, NaiveDate, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait,
QueryFilter, Set, Statement, TransactionTrait,
};
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;
/// 格式化编号字符串。
///
/// 拼接规则:
/// 1. 以 `prefix` 开头
/// 2. 若 `prefix` 非空,追加 `separator`
/// 3. 若 `date_part` 为 `Some` 且非空,追加 `date_part` + `separator`
/// 4. 追加零填充的 `seq_current`(填充到 `seq_length` 位,最少 1 位)
pub(crate) fn format_number(
prefix: &str,
separator: &str,
date_part: Option<&str>,
seq_current: i64,
seq_length: i32,
) -> String {
let mut result = String::with_capacity(32);
result.push_str(prefix);
if !prefix.is_empty() {
result.push_str(separator);
}
if let Some(dp) = date_part
&& !dp.is_empty()
{
result.push_str(dp);
result.push_str(separator);
}
let width = (seq_length.max(1)) as usize;
let seq_padded = format!("{:0>width$}", seq_current, width = width);
result.push_str(&seq_padded);
result
}
/// 编号规则 CRUD 服务 -- 创建、查询、更新、软删除编号规则,
/// 以及线程安全地生成编号序列。
pub struct NumberingService;
impl NumberingService {
/// 分页查询编号规则列表。
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<(Vec<NumberingRuleResp>, u64)> {
let paginator = numbering_rule::Entity::find()
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
.filter(numbering_rule::Column::DeletedAt.is_null())
.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let resps: Vec<NumberingRuleResp> = models.iter().map(Self::model_to_resp).collect();
Ok((resps, total))
}
/// 创建编号规则。
///
/// 检查 code 在租户内唯一后插入。
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateNumberingRuleReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<NumberingRuleResp> {
// 检查 code 唯一性
let existing = numbering_rule::Entity::find()
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
.filter(numbering_rule::Column::Code.eq(&req.code))
.filter(numbering_rule::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(ConfigError::DuplicateKey(format!(
"编号规则编码已存在: {}",
req.code
)));
}
let now = Utc::now();
let id = Uuid::now_v7();
let seq_start = req.seq_start.unwrap_or(1);
let model = numbering_rule::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
prefix: Set(req.prefix.clone().unwrap_or_default()),
date_format: Set(req.date_format.clone()),
seq_length: Set(req.seq_length.unwrap_or(4)),
seq_start: Set(seq_start),
seq_current: Set(seq_start as i64),
separator: Set(req.separator.clone().unwrap_or_else(|| "-".to_string())),
reset_cycle: Set(req
.reset_cycle
.clone()
.unwrap_or_else(|| "never".to_string())),
last_reset_date: Set(Some(Utc::now().date_naive())),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"numbering_rule.created",
tenant_id,
serde_json::json!({ "rule_id": id, "code": req.code }),
),
db,
)
.await;
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(),
code: req.code.clone(),
prefix: req.prefix.clone().unwrap_or_default(),
date_format: req.date_format.clone(),
seq_length: req.seq_length.unwrap_or(4),
seq_start,
seq_current: seq_start as i64,
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
reset_cycle: req
.reset_cycle
.clone()
.unwrap_or_else(|| "never".to_string()),
last_reset_date: Some(Utc::now().date_naive().to_string()),
version: 1,
})
}
/// 更新编号规则的可编辑字段。使用乐观锁校验版本。
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &crate::dto::UpdateNumberingRuleReq,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<NumberingRuleResp> {
let model = numbering_rule::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
let next_version =
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: numbering_rule::ActiveModel = model.into();
if let Some(name) = &req.name {
active.name = Set(name.clone());
}
if let Some(prefix) = &req.prefix {
active.prefix = Set(prefix.clone());
}
if let Some(date_format) = &req.date_format {
active.date_format = Set(Some(date_format.clone()));
}
if let Some(seq_length) = req.seq_length {
active.seq_length = Set(seq_length);
}
if let Some(separator) = &req.separator {
active.separator = Set(separator.clone());
}
if let Some(reset_cycle) = &req.reset_cycle {
active.reset_cycle = Set(reset_cycle.clone());
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
let updated = active
.update(db)
.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))
}
/// 软删除编号规则。使用乐观锁校验版本。
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
version: i32,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<()> {
let model = numbering_rule::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
let next_version =
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: numbering_rule::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"numbering_rule.deleted",
tenant_id,
serde_json::json!({ "rule_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"numbering_rule.delete",
"numbering_rule",
)
.with_resource_id(id),
db,
)
.await;
Ok(())
}
/// 线程安全地生成编号。
///
/// 使用 PostgreSQL advisory lock 保证并发安全:
/// 1. 在事务内获取 pg_advisory_xact_lock
/// 2. 在同一事务内读取规则、检查重置周期、递增序列、更新数据库
/// 3. 拼接编号字符串返回
pub async fn generate_number(
rule_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<GenerateNumberResp> {
// 先读取规则获取 code用于 advisory lock
let rule = numbering_rule::Entity::find_by_id(rule_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
let rule_code = rule.code.clone();
let tenant_id_str = tenant_id.to_string();
// 在同一个事务内获取 advisory lock 并执行编号生成
// pg_advisory_xact_lock 是事务级别的,锁会在事务结束时自动释放
let number = db
.transaction(|txn| {
let rule_code = rule_code.clone();
let tenant_id_str = tenant_id_str.clone();
Box::pin(async move {
// 在事务内获取 advisory lock
txn.execute(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT pg_advisory_xact_lock(abs(hashtext($1)), abs(hashtext($2))::int)",
[rule_code.into(), tenant_id_str.into()],
))
.await
.map_err(|e| ConfigError::Validation(format!("获取编号锁失败: {e}")))?;
// 在同一个事务内执行编号生成
Self::generate_number_in_txn(rule_id, tenant_id, txn).await
})
})
.await?;
Ok(GenerateNumberResp { number })
}
/// 事务内执行编号生成逻辑。
///
/// 检查重置周期,必要时重置序列,然后递增并拼接编号。
async fn generate_number_in_txn<C>(
rule_id: Uuid,
tenant_id: Uuid,
txn: &C,
) -> ConfigResult<String>
where
C: ConnectionTrait,
{
let rule = numbering_rule::Entity::find_by_id(rule_id)
.one(txn)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
let today = Utc::now().date_naive();
let mut seq_current = rule.seq_current;
// 检查是否需要重置序列
seq_current = Self::maybe_reset_sequence(
seq_current,
rule.seq_start as i64,
&rule.reset_cycle,
rule.last_reset_date,
today,
);
// 递增序列
let next_seq = seq_current + 1;
// 检查序列是否超出 seq_length 能表示的最大值
let max_val = 10i64.pow(rule.seq_length as u32) - 1;
if next_seq > max_val {
return Err(ConfigError::NumberingExhausted(format!(
"编号序列已耗尽,当前序列号 {next_seq} 超出长度 {} 的最大值",
rule.seq_length
)));
}
// 更新数据库中的 seq_current 和 last_reset_date
let mut active: numbering_rule::ActiveModel = rule.clone().into();
active.seq_current = Set(next_seq);
active.last_reset_date = Set(Some(today));
active.updated_at = Set(Utc::now());
active.version = Set(active.version.take().unwrap_or(0) + 1);
active
.update(txn)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
let date_part = rule
.date_format
.as_ref()
.map(|fmt| Utc::now().format(fmt).to_string());
let number = format_number(
&rule.prefix,
&rule.separator,
date_part.as_deref(),
seq_current,
rule.seq_length,
);
Ok(number)
}
/// 根据重置周期判断是否需要重置序列号。
///
/// 如果需要重置,返回 `seq_start`;否则返回原值。
fn maybe_reset_sequence(
seq_current: i64,
seq_start: i64,
reset_cycle: &str,
last_reset_date: Option<NaiveDate>,
today: NaiveDate,
) -> i64 {
let last_reset = match last_reset_date {
Some(d) => d,
None => return seq_start, // 从未重置过,使用 seq_start
};
match reset_cycle {
"daily" => {
if last_reset != today {
seq_start
} else {
seq_current
}
}
"monthly" => {
if last_reset.month() != today.month() || last_reset.year() != today.year() {
seq_start
} else {
seq_current
}
}
"yearly" => {
if last_reset.year() != today.year() {
seq_start
} else {
seq_current
}
}
_ => seq_current, // "never" 或其他值不重置
}
}
/// 将数据库模型转换为响应 DTO。
fn model_to_resp(m: &numbering_rule::Model) -> NumberingRuleResp {
NumberingRuleResp {
id: m.id,
name: m.name.clone(),
code: m.code.clone(),
prefix: m.prefix.clone(),
date_format: m.date_format.clone(),
seq_length: m.seq_length,
seq_start: m.seq_start,
seq_current: m.seq_current,
separator: m.separator.clone(),
reset_cycle: m.reset_cycle.clone(),
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
version: m.version,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
/// 辅助:构造 NaiveDate
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
// ---- maybe_reset_sequence 测试 ----
#[test]
fn reset_never_keeps_current() {
// "never" 周期:永远不重置,保持 seq_current
let result = NumberingService::maybe_reset_sequence(
100,
1,
"never",
Some(date(2025, 1, 1)),
date(2026, 4, 15),
);
assert_eq!(result, 100);
}
#[test]
fn reset_unknown_cycle_keeps_current() {
// 未知周期值等同于不重置
let result = NumberingService::maybe_reset_sequence(
50,
1,
"weekly",
Some(date(2025, 1, 1)),
date(2026, 4, 15),
);
assert_eq!(result, 50);
}
#[test]
fn reset_daily_same_day_keeps_current() {
// 同一天内不重置
let today = date(2026, 4, 15);
let result = NumberingService::maybe_reset_sequence(42, 1, "daily", Some(today), today);
assert_eq!(result, 42);
}
#[test]
fn reset_daily_different_day_resets() {
// 不同天重置为 seq_start
let result = NumberingService::maybe_reset_sequence(
42,
1,
"daily",
Some(date(2026, 4, 14)),
date(2026, 4, 15),
);
assert_eq!(result, 1);
}
#[test]
fn reset_daily_resets_with_custom_start() {
// 重置时使用自定义 seq_start
let result = NumberingService::maybe_reset_sequence(
99,
10,
"daily",
Some(date(2026, 4, 10)),
date(2026, 4, 15),
);
assert_eq!(result, 10);
}
#[test]
fn reset_monthly_same_month_keeps_current() {
// 同月不重置
let result = NumberingService::maybe_reset_sequence(
30,
1,
"monthly",
Some(date(2026, 4, 1)),
date(2026, 4, 15),
);
assert_eq!(result, 30);
}
#[test]
fn reset_monthly_different_month_resets() {
// 不同月份重置
let result = NumberingService::maybe_reset_sequence(
30,
1,
"monthly",
Some(date(2026, 3, 31)),
date(2026, 4, 1),
);
assert_eq!(result, 1);
}
#[test]
fn reset_monthly_same_month_different_year_resets() {
// 不同年份但相同月份数字,仍然重置
let result = NumberingService::maybe_reset_sequence(
20,
5,
"monthly",
Some(date(2025, 4, 15)),
date(2026, 4, 15),
);
assert_eq!(result, 5);
}
#[test]
fn reset_yearly_same_year_keeps_current() {
// 同年不重置
let result = NumberingService::maybe_reset_sequence(
50,
1,
"yearly",
Some(date(2026, 1, 1)),
date(2026, 12, 31),
);
assert_eq!(result, 50);
}
#[test]
fn reset_yearly_different_year_resets() {
// 不同年份重置
let result = NumberingService::maybe_reset_sequence(
50,
1,
"yearly",
Some(date(2025, 12, 31)),
date(2026, 1, 1),
);
assert_eq!(result, 1);
}
#[test]
fn reset_no_last_reset_date_returns_seq_start() {
// 从未重置过,使用 seq_start
let result =
NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15));
assert_eq!(result, 1);
}
#[test]
fn reset_no_last_reset_date_uses_custom_start() {
// 从未重置过,使用自定义 seq_start
let result =
NumberingService::maybe_reset_sequence(999, 42, "monthly", None, date(2026, 4, 15));
assert_eq!(result, 42);
}
// ---- model_to_resp 测试 ----
#[test]
fn model_to_resp_maps_fields_correctly() {
let id = Uuid::now_v7();
let tenant_id = Uuid::now_v7();
let now = Utc::now();
let today = now.date_naive();
let model = numbering_rule::Model {
id,
tenant_id,
name: "订单编号".to_string(),
code: "ORDER".to_string(),
prefix: "ORD".to_string(),
date_format: Some("%Y%m%d".to_string()),
seq_length: 6,
seq_start: 1,
seq_current: 42,
separator: "-".to_string(),
reset_cycle: "daily".to_string(),
last_reset_date: Some(today),
created_at: now,
updated_at: now,
created_by: tenant_id,
updated_by: tenant_id,
deleted_at: None,
version: 3,
};
let resp = NumberingService::model_to_resp(&model);
assert_eq!(resp.id, id);
assert_eq!(resp.name, "订单编号");
assert_eq!(resp.code, "ORDER");
assert_eq!(resp.prefix, "ORD");
assert_eq!(resp.date_format, Some("%Y%m%d".to_string()));
assert_eq!(resp.seq_length, 6);
assert_eq!(resp.seq_start, 1);
assert_eq!(resp.seq_current, 42);
assert_eq!(resp.separator, "-");
assert_eq!(resp.reset_cycle, "daily");
assert_eq!(resp.last_reset_date, Some(today.to_string()));
assert_eq!(resp.version, 3);
}
#[test]
fn model_to_resp_none_fields() {
let id = Uuid::now_v7();
let tenant_id = Uuid::now_v7();
let now = Utc::now();
let model = numbering_rule::Model {
id,
tenant_id,
name: "简单编号".to_string(),
code: "SIMPLE".to_string(),
prefix: "".to_string(),
date_format: None,
seq_length: 4,
seq_start: 1,
seq_current: 1,
separator: "-".to_string(),
reset_cycle: "never".to_string(),
last_reset_date: None,
created_at: now,
updated_at: now,
created_by: tenant_id,
updated_by: tenant_id,
deleted_at: None,
version: 1,
};
let resp = NumberingService::model_to_resp(&model);
assert_eq!(resp.date_format, None);
assert_eq!(resp.last_reset_date, None);
assert_eq!(resp.prefix, "");
}
// ---- format_number 测试 ----
#[test]
fn format_basic_prefix_no_date() {
// 基础:前缀 + 序列号
let result = format_number("ORD", "/", None, 1, 5);
assert_eq!(result, "ORD/00001");
}
#[test]
fn format_with_date_part() {
// 前缀 + 日期 + 序列号
let result = format_number("INV", "-", Some("20260430"), 42, 4);
assert_eq!(result, "INV-20260430-0042");
}
#[test]
fn format_no_prefix() {
// 无前缀,直接输出序列号
let result = format_number("", "/", None, 7, 3);
assert_eq!(result, "007");
}
#[test]
fn format_no_prefix_no_date() {
// 无前缀无日期,仅序列号
let result = format_number("", "-", None, 99, 6);
assert_eq!(result, "000099");
}
#[test]
fn format_seq_length_zero_pads_to_one() {
// seq_length=0 时仍至少填充 1 位
let result = format_number("", "", None, 5, 0);
assert_eq!(result, "5");
}
}

View File

@@ -0,0 +1,447 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
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;
/// Setting scope hierarchy constants.
const SCOPE_PLATFORM: &str = "platform";
const SCOPE_TENANT: &str = "tenant";
const SCOPE_ORG: &str = "org";
const SCOPE_USER: &str = "user";
/// Setting CRUD service — manage hierarchical configuration values.
///
/// Settings support a 4-level inheritance hierarchy:
/// `user -> org -> tenant -> platform`
///
/// When reading a setting, if the exact scope+scope_id match is not found,
/// the service walks up the hierarchy to find the nearest ancestor value.
pub struct SettingService;
impl SettingService {
/// Get a setting value with hierarchical fallback.
///
/// Resolution order:
/// 1. Exact match at (scope, scope_id)
/// 2. Walk up the hierarchy based on scope:
/// - `user` -> org -> tenant -> platform
/// - `org` -> tenant -> platform
/// - `tenant` -> platform
/// - `platform` -> NotFound
pub async fn get(
key: &str,
scope: &str,
scope_id: &Option<Uuid>,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<SettingResp> {
// 1. Try exact match
if let Some(resp) = Self::find_exact(key, scope, scope_id, tenant_id, db).await? {
return Ok(resp);
}
// 2. Walk up the hierarchy based on scope
let fallback_chain = Self::fallback_chain(scope, scope_id, tenant_id)?;
for (fb_scope, fb_scope_id) in fallback_chain {
if let Some(resp) =
Self::find_exact(key, &fb_scope, &fb_scope_id, tenant_id, db).await?
{
return Ok(resp);
}
}
Err(ConfigError::NotFound(format!(
"设置 '{}' 在 '{}' 作用域下不存在",
key, scope
)))
}
/// Set a setting value. Creates or updates.
///
/// If a record with the same (scope, scope_id, key) exists and is not
/// soft-deleted, it will be updated. Otherwise a new record is inserted.
pub async fn set(
params: crate::dto::SetSettingParams,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<SettingResp> {
// Look for an existing non-deleted record
let mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(&params.scope))
.filter(setting::Column::SettingKey.eq(&params.key))
.filter(setting::Column::DeletedAt.is_null());
query = match params.scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let existing = query
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if let Some(model) = existing {
// Update existing record — 乐观锁校验
let next_version = match params.version {
Some(v) => {
check_version(v, model.version).map_err(|_| ConfigError::VersionMismatch)?
}
None => model.version + 1,
};
let mut active: setting::ActiveModel = model.into();
active.setting_value = Set(params.value.clone());
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"setting.updated",
tenant_id,
serde_json::json!({
"setting_id": updated.id,
"key": params.key,
"scope": params.scope,
}),
),
db,
)
.await;
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
let now = Utc::now();
let id = Uuid::now_v7();
let model = setting::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
scope: Set(params.scope.clone()),
scope_id: Set(params.scope_id),
setting_key: Set(params.key.clone()),
setting_value: Set(params.value),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let inserted = model
.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"setting.created",
tenant_id,
serde_json::json!({
"setting_id": id,
"key": params.key,
"scope": params.scope,
}),
),
db,
)
.await;
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))
}
}
/// List all settings for a specific scope and scope_id, with pagination.
pub async fn list_by_scope(
scope: &str,
scope_id: &Option<Uuid>,
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<(Vec<SettingResp>, u64)> {
let mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::DeletedAt.is_null());
query = match scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let paginator = query.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let resps: Vec<SettingResp> = models.iter().map(Self::model_to_resp).collect();
Ok((resps, total))
}
/// Soft-delete a setting by setting the `deleted_at` timestamp.
/// Performs optimistic locking via version check.
pub async fn delete(
key: &str,
scope: &str,
scope_id: &Option<Uuid>,
tenant_id: Uuid,
operator_id: Uuid,
version: i32,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<()> {
let mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::SettingKey.eq(key))
.filter(setting::Column::DeletedAt.is_null());
query = match scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let model = query
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.ok_or_else(|| {
ConfigError::NotFound(format!("设置 '{}' 在 '{}' 作用域下不存在", key, scope))
})?;
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());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
active
.update(db)
.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(())
}
// ---- 内部辅助方法 ----
/// Find an exact setting match by key, scope, and scope_id.
async fn find_exact(
key: &str,
scope: &str,
scope_id: &Option<Uuid>,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Option<SettingResp>> {
let mut query = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::SettingKey.eq(key))
.filter(setting::Column::DeletedAt.is_null());
// SQL 中 `= NULL` 永远返回 false必须用 IS NULL 匹配 NULL 值
query = match scope_id {
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
None => query.filter(setting::Column::ScopeId.is_null()),
};
let model = query
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(model.as_ref().map(Self::model_to_resp))
}
/// Build the fallback chain for hierarchical lookup.
///
/// Returns a list of (scope, scope_id) tuples to try in order.
pub(crate) fn fallback_chain(
scope: &str,
_scope_id: &Option<Uuid>,
tenant_id: Uuid,
) -> ConfigResult<Vec<(String, Option<Uuid>)>> {
match scope {
SCOPE_USER => {
// user -> org -> tenant -> platform
// Note: We cannot resolve the actual org_id from user scope here
// without a dependency on auth module. The caller should handle
// org-level resolution externally if needed. We skip org fallback
// and go directly to tenant.
Ok(vec![
(SCOPE_TENANT.to_string(), Some(tenant_id)),
(SCOPE_PLATFORM.to_string(), None),
])
}
SCOPE_ORG => Ok(vec![
(SCOPE_TENANT.to_string(), Some(tenant_id)),
(SCOPE_PLATFORM.to_string(), None),
]),
SCOPE_TENANT => Ok(vec![(SCOPE_PLATFORM.to_string(), None)]),
SCOPE_PLATFORM => Ok(vec![]),
_ => Err(ConfigError::Validation(format!(
"不支持的作用域类型: '{}'",
scope
))),
}
}
/// Convert a SeaORM model to a response DTO.
pub(crate) fn model_to_resp(model: &setting::Model) -> SettingResp {
SettingResp {
id: model.id,
scope: model.scope.clone(),
scope_id: model.scope_id,
setting_key: model.setting_key.clone(),
setting_value: model.setting_value.clone(),
version: model.version,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tid() -> Uuid {
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
}
// ---- fallback_chain ----
#[test]
fn fallback_user_scope_returns_tenant_then_platform() {
let chain = SettingService::fallback_chain("user", &None, tid()).unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(chain[0], ("tenant".to_string(), Some(tid())));
assert_eq!(chain[1], ("platform".to_string(), None));
}
#[test]
fn fallback_org_scope_returns_tenant_then_platform() {
let chain = SettingService::fallback_chain("org", &None, tid()).unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(chain[0], ("tenant".to_string(), Some(tid())));
assert_eq!(chain[1], ("platform".to_string(), None));
}
#[test]
fn fallback_tenant_scope_returns_platform() {
let chain = SettingService::fallback_chain("tenant", &None, tid()).unwrap();
assert_eq!(chain.len(), 1);
assert_eq!(chain[0], ("platform".to_string(), None));
}
#[test]
fn fallback_platform_scope_returns_empty() {
let chain = SettingService::fallback_chain("platform", &None, tid()).unwrap();
assert!(chain.is_empty());
}
#[test]
fn fallback_invalid_scope_returns_error() {
let result = SettingService::fallback_chain("invalid", &None, tid());
assert!(result.is_err());
match result.unwrap_err() {
ConfigError::Validation(msg) => assert!(msg.contains("不支持的作用域")),
other => panic!("期望 Validation得到 {:?}", other),
}
}
// ---- model_to_resp ----
#[test]
fn model_to_resp_maps_all_fields() {
let m = setting::Model {
id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(),
tenant_id: tid(),
scope: "tenant".to_string(),
scope_id: Some(tid()),
setting_key: "theme.primary_color".to_string(),
setting_value: serde_json::json!("#1890ff"),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
created_by: tid(),
updated_by: tid(),
deleted_at: None,
version: 3,
};
let resp = SettingService::model_to_resp(&m);
assert_eq!(resp.scope, "tenant");
assert_eq!(resp.setting_key, "theme.primary_color");
assert_eq!(resp.setting_value, serde_json::json!("#1890ff"));
assert_eq!(resp.version, 3);
}
#[test]
fn model_to_resp_null_scope_id() {
let m = setting::Model {
id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(),
tenant_id: tid(),
scope: "platform".to_string(),
scope_id: None,
setting_key: "language.default".to_string(),
setting_value: serde_json::json!("zh-CN"),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
created_by: tid(),
updated_by: tid(),
deleted_at: None,
version: 1,
};
let resp = SettingService::model_to_resp(&m);
assert_eq!(resp.scope_id, None);
}
}