chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
This commit is contained in:
20
crates/erp-config/Cargo.toml
Normal file
20
crates/erp-config/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "erp-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
validator.workspace = true
|
||||
utoipa.workspace = true
|
||||
async-trait.workspace = true
|
||||
11
crates/erp-config/src/config_state.rs
Normal file
11
crates/erp-config/src/config_state.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use erp_core::events::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
/// Config-specific state extracted from the server's AppState via `FromRef`.
|
||||
///
|
||||
/// Contains the database connection and event bus needed by config handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
}
|
||||
693
crates/erp-config/src/dto.rs
Normal file
693
crates/erp-config/src/dto.rs
Normal file
@@ -0,0 +1,693 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
// --- Dictionary DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DictionaryItemResp {
|
||||
pub id: Uuid,
|
||||
pub dictionary_id: Uuid,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub sort_order: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DictionaryResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub items: Vec<DictionaryItemResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateDictionaryReq {
|
||||
#[validate(length(min = 1, max = 100, message = "字典名称不能为空"))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 50, message = "字典编码不能为空"))]
|
||||
pub code: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateDictionaryReq {
|
||||
#[validate(length(min = 1, max = 100, message = "字典名称不能为空且不超过100字符"))]
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateDictionaryItemReq {
|
||||
#[validate(length(min = 1, max = 100, message = "标签不能为空"))]
|
||||
pub label: String,
|
||||
#[validate(length(min = 1, max = 100, message = "值不能为空"))]
|
||||
pub value: String,
|
||||
pub sort_order: Option<i32>,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateDictionaryItemReq {
|
||||
#[validate(length(min = 1, max = 100, message = "标签不能为空且不超过100字符"))]
|
||||
pub label: Option<String>,
|
||||
#[validate(length(min = 1, max = 100, message = "值不能为空且不超过100字符"))]
|
||||
pub value: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub color: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Menu DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema, Clone)]
|
||||
pub struct MenuResp {
|
||||
pub id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub visible: bool,
|
||||
pub menu_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission: Option<String>,
|
||||
pub children: Vec<MenuResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateMenuReq {
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
||||
pub title: String,
|
||||
pub path: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub visible: Option<bool>,
|
||||
#[validate(length(min = 1, message = "菜单类型不能为空"))]
|
||||
pub menu_type: Option<String>,
|
||||
pub permission: Option<String>,
|
||||
pub role_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateMenuReq {
|
||||
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空且不超过100字符"))]
|
||||
pub title: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub visible: Option<bool>,
|
||||
pub permission: Option<String>,
|
||||
pub role_ids: Option<Vec<Uuid>>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct BatchSaveMenusReq {
|
||||
#[validate(length(min = 1, message = "菜单列表不能为空"), nested)]
|
||||
pub menus: Vec<MenuItemReq>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
|
||||
pub struct MenuItemReq {
|
||||
pub id: Option<Uuid>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
||||
pub title: String,
|
||||
pub path: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub visible: Option<bool>,
|
||||
pub menu_type: Option<String>,
|
||||
pub permission: Option<String>,
|
||||
pub role_ids: Option<Vec<Uuid>>,
|
||||
/// 乐观锁版本号。更新已有菜单时必填。
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
// --- Setting DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SettingResp {
|
||||
pub id: Uuid,
|
||||
pub scope: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope_id: Option<Uuid>,
|
||||
pub setting_key: String,
|
||||
pub setting_value: serde_json::Value,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateSettingReq {
|
||||
pub setting_value: serde_json::Value,
|
||||
/// 乐观锁版本号。更新已有设置时必填,创建新设置时忽略。
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// 内部参数结构体,用于减少 SettingService::set 的参数数量。
|
||||
pub struct SetSettingParams {
|
||||
pub key: String,
|
||||
pub scope: String,
|
||||
pub scope_id: Option<Uuid>,
|
||||
pub value: serde_json::Value,
|
||||
/// 乐观锁版本号。更新已有设置时用于校验。
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
// --- Numbering Rule DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct NumberingRuleResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
pub prefix: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_format: Option<String>,
|
||||
pub seq_length: i32,
|
||||
pub seq_start: i32,
|
||||
pub seq_current: i64,
|
||||
pub separator: String,
|
||||
pub reset_cycle: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_reset_date: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateNumberingRuleReq {
|
||||
#[validate(length(min = 1, max = 100, message = "规则名称不能为空"))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 50, message = "规则编码不能为空"))]
|
||||
pub code: String,
|
||||
pub prefix: Option<String>,
|
||||
pub date_format: Option<String>,
|
||||
pub seq_length: Option<i32>,
|
||||
pub seq_start: Option<i32>,
|
||||
pub separator: Option<String>,
|
||||
pub reset_cycle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateNumberingRuleReq {
|
||||
#[validate(length(min = 1, max = 100, message = "规则名称不能为空且不超过100字符"))]
|
||||
pub name: Option<String>,
|
||||
pub prefix: Option<String>,
|
||||
pub date_format: Option<String>,
|
||||
pub seq_length: Option<i32>,
|
||||
pub separator: Option<String>,
|
||||
pub reset_cycle: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct GenerateNumberResp {
|
||||
pub number: String,
|
||||
}
|
||||
|
||||
// --- Theme DTOs (stored via settings) ---
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct ThemeResp {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logo_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sidebar_style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brand_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brand_slogan: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brand_features: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brand_copyright: Option<String>,
|
||||
}
|
||||
|
||||
/// 品牌信息公开响应(不含内部配置)
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct PublicBrandResp {
|
||||
pub brand_name: String,
|
||||
pub brand_slogan: String,
|
||||
pub brand_features: String,
|
||||
pub brand_copyright: String,
|
||||
}
|
||||
|
||||
// --- Language DTOs (stored via settings) ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LanguageResp {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateLanguageReq {
|
||||
pub is_active: bool,
|
||||
#[validate(length(min = 1, max = 100, message = "语言名称不能为空且不超过100字符"))]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use validator::Validate;
|
||||
|
||||
// ---- CreateDictionaryReq 验证 ----
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_req_valid() {
|
||||
let req = CreateDictionaryReq {
|
||||
name: "状态字典".to_string(),
|
||||
code: "status".to_string(),
|
||||
description: Some("通用状态".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_req_empty_name_fails() {
|
||||
let req = CreateDictionaryReq {
|
||||
name: "".to_string(),
|
||||
code: "status".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_req_empty_code_fails() {
|
||||
let req = CreateDictionaryReq {
|
||||
name: "状态字典".to_string(),
|
||||
code: "".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_req_name_too_long_fails() {
|
||||
let req = CreateDictionaryReq {
|
||||
name: "x".repeat(101),
|
||||
code: "status".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_req_code_too_long_fails() {
|
||||
let req = CreateDictionaryReq {
|
||||
name: "状态字典".to_string(),
|
||||
code: "x".repeat(51),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_req_max_boundary_ok() {
|
||||
let req = CreateDictionaryReq {
|
||||
name: "x".repeat(100),
|
||||
code: "x".repeat(50),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
// ---- CreateDictionaryItemReq 验证 ----
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_item_req_valid() {
|
||||
let req = CreateDictionaryItemReq {
|
||||
label: "启用".to_string(),
|
||||
value: "active".to_string(),
|
||||
sort_order: Some(1),
|
||||
color: Some("#00FF00".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_item_req_empty_label_fails() {
|
||||
let req = CreateDictionaryItemReq {
|
||||
label: "".to_string(),
|
||||
value: "active".to_string(),
|
||||
sort_order: None,
|
||||
color: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_item_req_empty_value_fails() {
|
||||
let req = CreateDictionaryItemReq {
|
||||
label: "启用".to_string(),
|
||||
value: "".to_string(),
|
||||
sort_order: None,
|
||||
color: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_item_req_label_too_long_fails() {
|
||||
let req = CreateDictionaryItemReq {
|
||||
label: "x".repeat(101),
|
||||
value: "active".to_string(),
|
||||
sort_order: None,
|
||||
color: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_item_req_value_too_long_fails() {
|
||||
let req = CreateDictionaryItemReq {
|
||||
label: "启用".to_string(),
|
||||
value: "x".repeat(101),
|
||||
sort_order: None,
|
||||
color: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_item_req_min_boundary_ok() {
|
||||
let req = CreateDictionaryItemReq {
|
||||
label: "x".to_string(),
|
||||
value: "x".to_string(),
|
||||
sort_order: None,
|
||||
color: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dictionary_item_req_max_boundary_ok() {
|
||||
let req = CreateDictionaryItemReq {
|
||||
label: "x".repeat(100),
|
||||
value: "x".repeat(100),
|
||||
sort_order: Some(99),
|
||||
color: Some("#FFFFFF".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
// ---- CreateMenuReq 验证 ----
|
||||
|
||||
#[test]
|
||||
fn create_menu_req_valid() {
|
||||
let req = CreateMenuReq {
|
||||
parent_id: None,
|
||||
title: "系统设置".to_string(),
|
||||
path: Some("/settings".to_string()),
|
||||
icon: Some("SettingOutlined".to_string()),
|
||||
sort_order: Some(1),
|
||||
visible: Some(true),
|
||||
menu_type: Some("menu".to_string()),
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_menu_req_empty_title_fails() {
|
||||
let req = CreateMenuReq {
|
||||
parent_id: None,
|
||||
title: "".to_string(),
|
||||
path: None,
|
||||
icon: None,
|
||||
sort_order: None,
|
||||
visible: None,
|
||||
menu_type: None,
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_menu_req_title_too_long_fails() {
|
||||
let req = CreateMenuReq {
|
||||
parent_id: None,
|
||||
title: "x".repeat(101),
|
||||
path: None,
|
||||
icon: None,
|
||||
sort_order: None,
|
||||
visible: None,
|
||||
menu_type: None,
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_menu_req_title_max_boundary_ok() {
|
||||
let req = CreateMenuReq {
|
||||
parent_id: None,
|
||||
title: "x".repeat(100),
|
||||
path: None,
|
||||
icon: None,
|
||||
sort_order: None,
|
||||
visible: None,
|
||||
menu_type: None,
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
// ---- BatchSaveMenusReq 验证 ----
|
||||
|
||||
#[test]
|
||||
fn batch_save_menus_req_valid() {
|
||||
let req = BatchSaveMenusReq {
|
||||
menus: vec![MenuItemReq {
|
||||
id: None,
|
||||
parent_id: None,
|
||||
title: "首页".to_string(),
|
||||
path: Some("/home".to_string()),
|
||||
icon: None,
|
||||
sort_order: Some(0),
|
||||
visible: Some(true),
|
||||
menu_type: Some("menu".to_string()),
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
version: None,
|
||||
}],
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_save_menus_req_empty_list_fails() {
|
||||
let req = BatchSaveMenusReq { menus: vec![] };
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_save_menus_req_item_empty_title_fails() {
|
||||
let req = BatchSaveMenusReq {
|
||||
menus: vec![MenuItemReq {
|
||||
id: None,
|
||||
parent_id: None,
|
||||
title: "".to_string(),
|
||||
path: None,
|
||||
icon: None,
|
||||
sort_order: None,
|
||||
visible: None,
|
||||
menu_type: None,
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
version: None,
|
||||
}],
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_save_menus_req_item_title_too_long_fails() {
|
||||
let req = BatchSaveMenusReq {
|
||||
menus: vec![MenuItemReq {
|
||||
id: None,
|
||||
parent_id: None,
|
||||
title: "x".repeat(101),
|
||||
path: None,
|
||||
icon: None,
|
||||
sort_order: None,
|
||||
visible: None,
|
||||
menu_type: None,
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
version: None,
|
||||
}],
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_save_menus_req_multiple_items_ok() {
|
||||
let req = BatchSaveMenusReq {
|
||||
menus: vec![
|
||||
MenuItemReq {
|
||||
id: None,
|
||||
parent_id: None,
|
||||
title: "菜单A".to_string(),
|
||||
path: Some("/a".to_string()),
|
||||
icon: None,
|
||||
sort_order: Some(0),
|
||||
visible: Some(true),
|
||||
menu_type: Some("menu".to_string()),
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
version: None,
|
||||
},
|
||||
MenuItemReq {
|
||||
id: None,
|
||||
parent_id: None,
|
||||
title: "菜单B".to_string(),
|
||||
path: Some("/b".to_string()),
|
||||
icon: None,
|
||||
sort_order: Some(1),
|
||||
visible: Some(true),
|
||||
menu_type: Some("menu".to_string()),
|
||||
permission: None,
|
||||
role_ids: None,
|
||||
version: Some(1),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
// ---- CreateNumberingRuleReq 验证 ----
|
||||
|
||||
#[test]
|
||||
fn create_numbering_rule_req_valid() {
|
||||
let req = CreateNumberingRuleReq {
|
||||
name: "订单编号".to_string(),
|
||||
code: "ORDER".to_string(),
|
||||
prefix: Some("ORD".to_string()),
|
||||
date_format: Some("%Y%m%d".to_string()),
|
||||
seq_length: Some(4),
|
||||
seq_start: Some(1),
|
||||
separator: Some("-".to_string()),
|
||||
reset_cycle: Some("daily".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_numbering_rule_req_empty_name_fails() {
|
||||
let req = CreateNumberingRuleReq {
|
||||
name: "".to_string(),
|
||||
code: "ORDER".to_string(),
|
||||
prefix: None,
|
||||
date_format: None,
|
||||
seq_length: None,
|
||||
seq_start: None,
|
||||
separator: None,
|
||||
reset_cycle: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_numbering_rule_req_empty_code_fails() {
|
||||
let req = CreateNumberingRuleReq {
|
||||
name: "订单编号".to_string(),
|
||||
code: "".to_string(),
|
||||
prefix: None,
|
||||
date_format: None,
|
||||
seq_length: None,
|
||||
seq_start: None,
|
||||
separator: None,
|
||||
reset_cycle: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_numbering_rule_req_name_too_long_fails() {
|
||||
let req = CreateNumberingRuleReq {
|
||||
name: "x".repeat(101),
|
||||
code: "ORDER".to_string(),
|
||||
prefix: None,
|
||||
date_format: None,
|
||||
seq_length: None,
|
||||
seq_start: None,
|
||||
separator: None,
|
||||
reset_cycle: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_numbering_rule_req_code_too_long_fails() {
|
||||
let req = CreateNumberingRuleReq {
|
||||
name: "订单编号".to_string(),
|
||||
code: "x".repeat(51),
|
||||
prefix: None,
|
||||
date_format: None,
|
||||
seq_length: None,
|
||||
seq_start: None,
|
||||
separator: None,
|
||||
reset_cycle: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_numbering_rule_req_max_boundary_ok() {
|
||||
let req = CreateNumberingRuleReq {
|
||||
name: "x".repeat(100),
|
||||
code: "x".repeat(50),
|
||||
prefix: None,
|
||||
date_format: None,
|
||||
seq_length: None,
|
||||
seq_start: None,
|
||||
separator: None,
|
||||
reset_cycle: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
// ---- UpdateSettingReq 验证 ----
|
||||
|
||||
#[test]
|
||||
fn update_setting_req_valid() {
|
||||
let req = UpdateSettingReq {
|
||||
setting_value: serde_json::json!({"key": "value"}),
|
||||
version: Some(1),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_setting_req_without_version_ok() {
|
||||
let req = UpdateSettingReq {
|
||||
setting_value: serde_json::json!("hello"),
|
||||
version: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
}
|
||||
35
crates/erp-config/src/entity/dictionary.rs
Normal file
35
crates/erp-config/src/entity/dictionary.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "dictionaries")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::dictionary_item::Entity")]
|
||||
DictionaryItem,
|
||||
}
|
||||
|
||||
impl Related<super::dictionary_item::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::DictionaryItem.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
42
crates/erp-config/src/entity/dictionary_item.rs
Normal file
42
crates/erp-config/src/entity/dictionary_item.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "dictionary_items")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub dictionary_id: Uuid,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub sort_order: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::dictionary::Entity",
|
||||
from = "Column::DictionaryId",
|
||||
to = "super::dictionary::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Dictionary,
|
||||
}
|
||||
|
||||
impl Related<super::dictionary::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Dictionary.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
43
crates/erp-config/src/entity/menu.rs
Normal file
43
crates/erp-config/src/entity/menu.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "menus")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub visible: bool,
|
||||
pub menu_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::menu_role::Entity")]
|
||||
MenuRole,
|
||||
}
|
||||
|
||||
impl Related<super::menu_role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::MenuRole.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
38
crates/erp-config/src/entity/menu_role.rs
Normal file
38
crates/erp-config/src/entity/menu_role.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "menu_roles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub menu_id: Uuid,
|
||||
pub role_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::menu::Entity",
|
||||
from = "Column::MenuId",
|
||||
to = "super::menu::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Menu,
|
||||
}
|
||||
|
||||
impl Related<super::menu::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Menu.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
6
crates/erp-config/src/entity/mod.rs
Normal file
6
crates/erp-config/src/entity/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod dictionary;
|
||||
pub mod dictionary_item;
|
||||
pub mod menu;
|
||||
pub mod menu_role;
|
||||
pub mod numbering_rule;
|
||||
pub mod setting;
|
||||
34
crates/erp-config/src/entity/numbering_rule.rs
Normal file
34
crates/erp-config/src/entity/numbering_rule.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "numbering_rules")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
pub prefix: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_format: Option<String>,
|
||||
pub seq_length: i32,
|
||||
pub seq_start: i32,
|
||||
pub seq_current: i64,
|
||||
pub separator: String,
|
||||
pub reset_cycle: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_reset_date: Option<chrono::NaiveDate>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
27
crates/erp-config/src/entity/setting.rs
Normal file
27
crates/erp-config/src/entity/setting.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "settings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub scope: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope_id: Option<Uuid>,
|
||||
pub setting_key: String,
|
||||
pub setting_value: serde_json::Value,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
143
crates/erp-config/src/error.rs
Normal file
143
crates/erp-config/src/error.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
/// Config module error types.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("验证失败: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("资源未找到: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("键已存在: {0}")]
|
||||
DuplicateKey(String),
|
||||
|
||||
#[error("编号序列耗尽: {0}")]
|
||||
NumberingExhausted(String),
|
||||
|
||||
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||
VersionMismatch,
|
||||
}
|
||||
|
||||
impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
|
||||
fn from(err: sea_orm::TransactionError<ConfigError>) -> Self {
|
||||
match err {
|
||||
sea_orm::TransactionError::Connection(err) => ConfigError::Validation(err.to_string()),
|
||||
sea_orm::TransactionError::Transaction(inner) => inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sea_orm::DbErr> for ConfigError {
|
||||
fn from(err: sea_orm::DbErr) -> Self {
|
||||
ConfigError::Validation(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigError> for AppError {
|
||||
fn from(err: ConfigError) -> Self {
|
||||
match err {
|
||||
ConfigError::Validation(s) => AppError::Validation(s),
|
||||
ConfigError::NotFound(s) => AppError::NotFound(s),
|
||||
ConfigError::DuplicateKey(s) => AppError::Conflict(s),
|
||||
ConfigError::NumberingExhausted(s) => AppError::Internal(s),
|
||||
ConfigError::VersionMismatch => AppError::VersionMismatch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ConfigResult<T> = Result<T, ConfigError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use erp_core::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn config_error_validation_maps_to_app_validation() {
|
||||
let app: AppError = ConfigError::Validation("字段不能为空".to_string()).into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert_eq!(msg, "字段不能为空"),
|
||||
other => panic!("期望 Validation,实际得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_error_not_found_maps_to_app_not_found() {
|
||||
let app: AppError = ConfigError::NotFound("字典不存在".to_string()).into();
|
||||
match app {
|
||||
AppError::NotFound(msg) => assert_eq!(msg, "字典不存在"),
|
||||
other => panic!("期望 NotFound,实际得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_error_duplicate_key_maps_to_app_conflict() {
|
||||
let app: AppError = ConfigError::DuplicateKey("编码已存在".to_string()).into();
|
||||
match app {
|
||||
AppError::Conflict(msg) => assert_eq!(msg, "编码已存在"),
|
||||
other => panic!("期望 Conflict,实际得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_error_numbering_exhausted_maps_to_app_internal() {
|
||||
let app: AppError = ConfigError::NumberingExhausted("序列已耗尽".to_string()).into();
|
||||
match app {
|
||||
AppError::Internal(msg) => assert!(msg.contains("序列已耗尽")),
|
||||
other => panic!("期望 Internal,实际得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_error_version_mismatch_maps_to_app_version_mismatch() {
|
||||
let app: AppError = ConfigError::VersionMismatch.into();
|
||||
match app {
|
||||
AppError::VersionMismatch => {}
|
||||
other => panic!("期望 VersionMismatch,实际得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_error_display_messages() {
|
||||
// 验证各变体的 Display 输出包含中文描述
|
||||
assert!(
|
||||
ConfigError::Validation("test".into())
|
||||
.to_string()
|
||||
.contains("验证失败")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::NotFound("test".into())
|
||||
.to_string()
|
||||
.contains("资源未找到")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::DuplicateKey("test".into())
|
||||
.to_string()
|
||||
.contains("键已存在")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::NumberingExhausted("test".into())
|
||||
.to_string()
|
||||
.contains("编号序列耗尽")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::VersionMismatch
|
||||
.to_string()
|
||||
.contains("版本冲突")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_error_connection_maps_to_validation() {
|
||||
// TransactionError::Connection 应该转换为 ConfigError::Validation
|
||||
let config_err: ConfigError = sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn(
|
||||
sea_orm::RuntimeErr::Internal("连接失败".to_string()),
|
||||
))
|
||||
.into();
|
||||
match config_err {
|
||||
ConfigError::Validation(msg) => assert!(msg.contains("连接失败")),
|
||||
other => panic!("期望 Validation,实际得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
360
crates/erp-config/src/handler/dictionary_handler.rs
Normal file
360
crates/erp-config/src/handler/dictionary_handler.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_state::ConfigState;
|
||||
use crate::dto::{
|
||||
CreateDictionaryItemReq, CreateDictionaryReq, DictionaryItemResp, DictionaryResp,
|
||||
UpdateDictionaryItemReq, UpdateDictionaryReq,
|
||||
};
|
||||
use crate::service::dictionary_service::DictionaryService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/dictionaries",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<DictionaryResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// GET /api/v1/dictionaries
|
||||
///
|
||||
/// 分页查询当前租户下的字典列表。
|
||||
/// 每个字典包含其关联的字典项。
|
||||
/// 需要 `dictionary.list` 权限。
|
||||
pub async fn list_dictionaries<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<DictionaryResp>>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.list")?;
|
||||
|
||||
let (dictionaries, total) =
|
||||
DictionaryService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: dictionaries,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/dictionaries",
|
||||
request_body = CreateDictionaryReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<DictionaryResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// POST /api/v1/dictionaries
|
||||
///
|
||||
/// 在当前租户下创建新字典。
|
||||
/// 字典编码在租户内必须唯一。
|
||||
/// 需要 `dictionary.create` 权限。
|
||||
pub async fn create_dictionary<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateDictionaryReq>,
|
||||
) -> Result<Json<ApiResponse<DictionaryResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let dictionary = DictionaryService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.code,
|
||||
&req.description,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(dictionary)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/dictionaries/{id}",
|
||||
params(("id" = Uuid, Path, description = "字典ID")),
|
||||
request_body = UpdateDictionaryReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<DictionaryResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// PUT /api/v1/dictionaries/:id
|
||||
///
|
||||
/// 更新字典的可编辑字段(名称、描述)。
|
||||
/// 编码创建后不可更改。
|
||||
/// 需要 `dictionary.update` 权限。
|
||||
pub async fn update_dictionary<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateDictionaryReq>,
|
||||
) -> Result<Json<ApiResponse<DictionaryResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.update")?;
|
||||
|
||||
let dictionary =
|
||||
DictionaryService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(dictionary)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/dictionaries/{id}",
|
||||
params(("id" = Uuid, Path, description = "字典ID")),
|
||||
request_body = DeleteVersionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// DELETE /api/v1/dictionaries/:id
|
||||
///
|
||||
/// 软删除字典,设置 deleted_at 时间戳。
|
||||
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||
/// 需要 `dictionary.delete` 权限。
|
||||
pub async fn delete_dictionary<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<DeleteVersionReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.delete")?;
|
||||
|
||||
DictionaryService::delete(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.version,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("字典已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/dictionaries/items-by-code",
|
||||
params(("code" = String, Query, description = "字典编码")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<DictionaryItemResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// GET /api/v1/dictionaries/items-by-code?code=xxx
|
||||
///
|
||||
/// 根据字典编码查询所有字典项。
|
||||
/// 用于前端下拉框和枚举值查找。
|
||||
/// 需要 `dictionary.list` 权限。
|
||||
pub async fn list_items_by_code<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<ItemsByCodeQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<DictionaryItemResp>>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.list")?;
|
||||
|
||||
let items =
|
||||
DictionaryService::list_items_by_code(&query.code, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/dictionaries/{dict_id}/items",
|
||||
params(("dict_id" = Uuid, Path, description = "字典ID")),
|
||||
request_body = CreateDictionaryItemReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<DictionaryItemResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// POST /api/v1/dictionaries/:dict_id/items
|
||||
///
|
||||
/// 向指定字典添加新的字典项。
|
||||
/// 字典项的 value 在同一字典内必须唯一。
|
||||
/// 需要 `dictionary.create` 权限。
|
||||
pub async fn create_item<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(dict_id): Path<Uuid>,
|
||||
Json(req): Json<CreateDictionaryItemReq>,
|
||||
) -> Result<Json<ApiResponse<DictionaryItemResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let item =
|
||||
DictionaryService::add_item(dict_id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(item)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/dictionaries/{dict_id}/items/{item_id}",
|
||||
params(
|
||||
("dict_id" = Uuid, Path, description = "字典ID"),
|
||||
("item_id" = Uuid, Path, description = "字典项ID"),
|
||||
),
|
||||
request_body = UpdateDictionaryItemReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<DictionaryItemResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// PUT /api/v1/dictionaries/:dict_id/items/:item_id
|
||||
///
|
||||
/// 更新字典项的可编辑字段(label、value、sort_order、color)。
|
||||
/// 需要 `dictionary.update` 权限。
|
||||
pub async fn update_item<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((dict_id, item_id)): Path<(Uuid, Uuid)>,
|
||||
Json(req): Json<UpdateDictionaryItemReq>,
|
||||
) -> Result<Json<ApiResponse<DictionaryItemResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.update")?;
|
||||
|
||||
// 验证 item_id 属于 dict_id
|
||||
let item = DictionaryService::update_item(item_id, ctx.tenant_id, ctx.user_id, &req, &state.db)
|
||||
.await?;
|
||||
|
||||
// 确保 item 属于指定的 dictionary
|
||||
if item.dictionary_id != dict_id {
|
||||
return Err(AppError::Validation("字典项不属于指定的字典".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse::ok(item)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/dictionaries/{dict_id}/items/{item_id}",
|
||||
params(
|
||||
("dict_id" = Uuid, Path, description = "字典ID"),
|
||||
("item_id" = Uuid, Path, description = "字典项ID"),
|
||||
),
|
||||
request_body = DeleteVersionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "字典管理"
|
||||
)]
|
||||
/// DELETE /api/v1/dictionaries/:dict_id/items/:item_id
|
||||
///
|
||||
/// 软删除字典项,设置 deleted_at 时间戳。
|
||||
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||
/// 需要 `dictionary.delete` 权限。
|
||||
pub async fn delete_item<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((_dict_id, item_id)): Path<(Uuid, Uuid)>,
|
||||
Json(req): Json<DeleteVersionReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "dictionary.delete")?;
|
||||
|
||||
DictionaryService::delete_item(item_id, ctx.tenant_id, ctx.user_id, req.version, &state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("字典项已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 按编码查询字典项的查询参数。
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ItemsByCodeQuery {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// 删除操作的乐观锁版本号。
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteVersionReq {
|
||||
pub version: i32,
|
||||
}
|
||||
142
crates/erp-config/src/handler/language_handler.rs
Normal file
142
crates/erp-config/src/handler/language_handler.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, State};
|
||||
use axum::response::Json as JsonResponse;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, Pagination, TenantContext};
|
||||
|
||||
use crate::config_state::ConfigState;
|
||||
use crate::dto::{LanguageResp, SetSettingParams, UpdateLanguageReq};
|
||||
use crate::service::setting_service::SettingService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/languages",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<LanguageResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "语言管理"
|
||||
)]
|
||||
/// GET /api/v1/languages
|
||||
///
|
||||
/// 获取当前租户的语言配置列表。
|
||||
/// 查询 scope 为 "platform" 的设置,过滤 key 以 "language." 开头的记录。
|
||||
/// 需要 `language.list` 权限。
|
||||
pub async fn list_languages<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<JsonResponse<ApiResponse<Vec<LanguageResp>>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "language.list")?;
|
||||
|
||||
let pagination = Pagination {
|
||||
page: Some(1),
|
||||
page_size: Some(100),
|
||||
};
|
||||
|
||||
let (settings, _total) =
|
||||
SettingService::list_by_scope("platform", &None, ctx.tenant_id, &pagination, &state.db)
|
||||
.await?;
|
||||
|
||||
let languages: Vec<LanguageResp> = settings
|
||||
.into_iter()
|
||||
.filter(|s| s.setting_key.starts_with("language."))
|
||||
.filter_map(|s| {
|
||||
let code = s.setting_key.strip_prefix("language.")?.to_string();
|
||||
let name = s
|
||||
.setting_value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&code)
|
||||
.to_string();
|
||||
let is_active = s
|
||||
.setting_value
|
||||
.get("is_active")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
Some(LanguageResp {
|
||||
code,
|
||||
name,
|
||||
is_active,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(languages)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/languages/{code}",
|
||||
params(("code" = String, Path, description = "语言编码")),
|
||||
request_body = UpdateLanguageReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<LanguageResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "语言管理"
|
||||
)]
|
||||
/// PUT /api/v1/languages/:code
|
||||
///
|
||||
/// 更新指定语言配置的激活状态。
|
||||
/// 语言配置存储在 settings 表中,key 为 "language.{code}",scope 为 "platform"。
|
||||
/// 需要 `language.update` 权限。
|
||||
pub async fn update_language<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(code): Path<String>,
|
||||
Json(req): Json<UpdateLanguageReq>,
|
||||
) -> Result<JsonResponse<ApiResponse<LanguageResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "language.update")?;
|
||||
|
||||
let key = format!("language.{}", code);
|
||||
let mut value = serde_json::json!({"is_active": req.is_active});
|
||||
if let Some(ref name) = req.name {
|
||||
value["name"] = serde_json::Value::String(name.clone());
|
||||
}
|
||||
|
||||
SettingService::set(
|
||||
SetSettingParams {
|
||||
key: key.clone(),
|
||||
scope: "platform".to_string(),
|
||||
scope_id: None,
|
||||
value,
|
||||
version: None,
|
||||
},
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 从返回的 SettingResp 中读取实际值
|
||||
let updated = SettingService::get(&key, "platform", &None, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
// 尝试从 value 中提取 name,否则用 code 作为默认名称
|
||||
let name = updated
|
||||
.setting_value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&code)
|
||||
.to_string();
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(LanguageResp {
|
||||
code,
|
||||
name,
|
||||
is_active: req.is_active,
|
||||
})))
|
||||
}
|
||||
263
crates/erp-config/src/handler/menu_handler.rs
Normal file
263
crates/erp-config/src/handler/menu_handler.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, State};
|
||||
use axum::response::Json as JsonResponse;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_state::ConfigState;
|
||||
use crate::dto::{BatchSaveMenusReq, CreateMenuReq, MenuResp, UpdateMenuReq};
|
||||
use crate::service::menu_service::MenuService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/config/menus",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<MenuResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "菜单管理"
|
||||
)]
|
||||
/// GET /api/v1/config/menus
|
||||
///
|
||||
/// 获取当前租户下当前用户角色可见的菜单树。
|
||||
pub async fn get_menus<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<JsonResponse<ApiResponse<Vec<MenuResp>>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "menu.list")?;
|
||||
|
||||
let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?;
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(menus)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/config/menus",
|
||||
request_body = CreateMenuReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<MenuResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "菜单管理"
|
||||
)]
|
||||
/// POST /api/v1/config/menus
|
||||
///
|
||||
/// 创建单个菜单项。
|
||||
pub async fn create_menu<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateMenuReq>,
|
||||
) -> Result<JsonResponse<ApiResponse<MenuResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "menu.update")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = MenuService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/config/menus/{id}",
|
||||
params(("id" = Uuid, Path, description = "菜单ID")),
|
||||
request_body = UpdateMenuReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<MenuResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "菜单管理"
|
||||
)]
|
||||
/// PUT /api/v1/config/menus/{id}
|
||||
///
|
||||
/// 更新单个菜单项。
|
||||
pub async fn update_menu<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateMenuReq>,
|
||||
) -> Result<JsonResponse<ApiResponse<MenuResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "menu.update")?;
|
||||
|
||||
let resp = MenuService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(JsonResponse(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/config/menus/{id}",
|
||||
params(("id" = Uuid, Path, description = "菜单ID")),
|
||||
request_body = DeleteMenuVersionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "菜单管理"
|
||||
)]
|
||||
/// DELETE /api/v1/config/menus/{id}
|
||||
///
|
||||
/// 软删除单个菜单项。需要请求体包含 version 字段用于乐观锁校验。
|
||||
pub async fn delete_menu<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<DeleteMenuVersionReq>,
|
||||
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "menu.update")?;
|
||||
|
||||
MenuService::delete(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.version,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
Ok(JsonResponse(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/config/menus/batch",
|
||||
request_body = BatchSaveMenusReq,
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "菜单管理"
|
||||
)]
|
||||
/// PUT /api/v1/config/menus/batch
|
||||
///
|
||||
/// 批量保存菜单列表。
|
||||
pub async fn batch_save_menus<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<BatchSaveMenusReq>,
|
||||
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "menu.update")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
for item in &req.menus {
|
||||
match item.id {
|
||||
Some(id) => {
|
||||
let version = item.version.unwrap_or(0);
|
||||
let update_req = UpdateMenuReq {
|
||||
title: Some(item.title.clone()),
|
||||
path: item.path.clone(),
|
||||
icon: item.icon.clone(),
|
||||
sort_order: item.sort_order,
|
||||
visible: item.visible,
|
||||
permission: item.permission.clone(),
|
||||
role_ids: item.role_ids.clone(),
|
||||
version,
|
||||
};
|
||||
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db).await?;
|
||||
}
|
||||
None => {
|
||||
let create_req = CreateMenuReq {
|
||||
parent_id: item.parent_id,
|
||||
title: item.title.clone(),
|
||||
path: item.path.clone(),
|
||||
icon: item.icon.clone(),
|
||||
sort_order: item.sort_order,
|
||||
visible: item.visible,
|
||||
menu_type: item.menu_type.clone(),
|
||||
permission: item.permission.clone(),
|
||||
role_ids: item.role_ids.clone(),
|
||||
};
|
||||
MenuService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&create_req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(JsonResponse(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("菜单批量保存成功".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/menus/user",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<MenuResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "菜单管理"
|
||||
)]
|
||||
/// GET /api/v1/menus/user
|
||||
///
|
||||
/// 获取当前用户可见的菜单树(无需 menu.list 权限,仅需登录)。
|
||||
pub async fn get_user_menus<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<JsonResponse<ApiResponse<Vec<MenuResp>>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?;
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(menus)))
|
||||
}
|
||||
|
||||
/// 删除菜单的乐观锁版本号请求体。
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteMenuVersionReq {
|
||||
pub version: i32,
|
||||
}
|
||||
6
crates/erp-config/src/handler/mod.rs
Normal file
6
crates/erp-config/src/handler/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod dictionary_handler;
|
||||
pub mod language_handler;
|
||||
pub mod menu_handler;
|
||||
pub mod numbering_handler;
|
||||
pub mod setting_handler;
|
||||
pub mod theme_handler;
|
||||
220
crates/erp-config/src/handler/numbering_handler.rs
Normal file
220
crates/erp-config/src/handler/numbering_handler.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_state::ConfigState;
|
||||
use crate::dto::{
|
||||
CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp, UpdateNumberingRuleReq,
|
||||
};
|
||||
use crate::service::numbering_service::NumberingService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/numbering-rules",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<NumberingRuleResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "编号规则"
|
||||
)]
|
||||
/// GET /api/v1/numbering-rules
|
||||
///
|
||||
/// 分页查询当前租户下的编号规则列表。
|
||||
/// 需要 `numbering.list` 权限。
|
||||
pub async fn list_numbering_rules<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<NumberingRuleResp>>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "numbering.list")?;
|
||||
|
||||
let (rules, total) = NumberingService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: rules,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/numbering-rules",
|
||||
request_body = CreateNumberingRuleReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<NumberingRuleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "编号规则"
|
||||
)]
|
||||
/// POST /api/v1/numbering-rules
|
||||
///
|
||||
/// 创建新的编号规则。
|
||||
/// 规则编码在租户内必须唯一。
|
||||
/// 需要 `numbering.create` 权限。
|
||||
pub async fn create_numbering_rule<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateNumberingRuleReq>,
|
||||
) -> Result<Json<ApiResponse<NumberingRuleResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "numbering.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let rule = NumberingService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/numbering-rules/{id}",
|
||||
params(("id" = Uuid, Path, description = "编号规则ID")),
|
||||
request_body = UpdateNumberingRuleReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<NumberingRuleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "编号规则"
|
||||
)]
|
||||
/// PUT /api/v1/numbering-rules/:id
|
||||
///
|
||||
/// 更新编号规则的可编辑字段。
|
||||
/// 需要 `numbering.update` 权限。
|
||||
pub async fn update_numbering_rule<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateNumberingRuleReq>,
|
||||
) -> Result<Json<ApiResponse<NumberingRuleResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "numbering.update")?;
|
||||
|
||||
let rule = NumberingService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/numbering-rules/{id}/generate",
|
||||
params(("id" = Uuid, Path, description = "编号规则ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<GenerateNumberResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "编号规则"
|
||||
)]
|
||||
/// POST /api/v1/numbering-rules/:id/generate
|
||||
///
|
||||
/// 根据编号规则生成新的编号。
|
||||
/// 使用 PostgreSQL advisory lock 保证并发安全。
|
||||
/// 需要 `numbering.generate` 权限。
|
||||
pub async fn generate_number<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<GenerateNumberResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "numbering.generate")?;
|
||||
|
||||
let result = NumberingService::generate_number(id, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/numbering-rules/{id}",
|
||||
params(("id" = Uuid, Path, description = "编号规则ID")),
|
||||
request_body = DeleteNumberingVersionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "编号规则"
|
||||
)]
|
||||
/// DELETE /api/v1/numbering-rules/:id
|
||||
///
|
||||
/// 软删除编号规则,设置 deleted_at 时间戳。
|
||||
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||
/// 需要 `numbering.delete` 权限。
|
||||
pub async fn delete_numbering_rule<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<DeleteNumberingVersionReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "numbering.delete")?;
|
||||
|
||||
NumberingService::delete(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.version,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("编号规则已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 删除编号规则的乐观锁版本号请求体。
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteNumberingVersionReq {
|
||||
pub version: i32,
|
||||
}
|
||||
169
crates/erp-config/src/handler/setting_handler.rs
Normal file
169
crates/erp-config/src/handler/setting_handler.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_state::ConfigState;
|
||||
use crate::dto::{SetSettingParams, SettingResp, UpdateSettingReq};
|
||||
use crate::service::setting_service::SettingService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/settings/{key}",
|
||||
params(
|
||||
("key" = String, Path, description = "设置键名"),
|
||||
("scope" = Option<String>, Query, description = "作用域"),
|
||||
("scope_id" = Option<Uuid>, Query, description = "作用域ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<SettingResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "系统设置"
|
||||
)]
|
||||
/// GET /api/v1/settings/:key?scope=tenant&scope_id=xxx
|
||||
///
|
||||
/// 获取设置值,支持分层回退查找。
|
||||
/// 解析顺序:精确匹配 -> 按作用域层级向上回退。
|
||||
/// 需要 `setting.read` 权限。
|
||||
pub async fn get_setting<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(key): Path<String>,
|
||||
Query(query): Query<SettingQuery>,
|
||||
) -> Result<Json<ApiResponse<SettingResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "setting.read")?;
|
||||
|
||||
let scope = query.scope.unwrap_or_else(|| "tenant".to_string());
|
||||
|
||||
let setting =
|
||||
SettingService::get(&key, &scope, &query.scope_id, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(setting)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/settings/{key}",
|
||||
params(("key" = String, Path, description = "设置键名")),
|
||||
request_body = UpdateSettingReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<SettingResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "系统设置"
|
||||
)]
|
||||
/// PUT /api/v1/settings/:key
|
||||
///
|
||||
/// 创建或更新设置值。
|
||||
/// 如果相同 (scope, scope_id, key) 的记录存在则更新,否则插入。
|
||||
/// 需要 `setting.update` 权限。
|
||||
pub async fn update_setting<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(key): Path<String>,
|
||||
Json(req): Json<UpdateSettingReq>,
|
||||
) -> Result<Json<ApiResponse<SettingResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "setting.update")?;
|
||||
|
||||
let setting = SettingService::set(
|
||||
SetSettingParams {
|
||||
key,
|
||||
scope: "tenant".to_string(),
|
||||
scope_id: None,
|
||||
value: req.setting_value,
|
||||
version: req.version,
|
||||
},
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(setting)))
|
||||
}
|
||||
|
||||
/// 设置查询参数。
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SettingQuery {
|
||||
pub scope: Option<String>,
|
||||
pub scope_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/settings/{key}",
|
||||
params(
|
||||
("key" = String, Path, description = "设置键名"),
|
||||
("scope" = Option<String>, Query, description = "作用域"),
|
||||
("scope_id" = Option<Uuid>, Query, description = "作用域ID"),
|
||||
),
|
||||
request_body = DeleteSettingVersionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "系统设置"
|
||||
)]
|
||||
/// DELETE /api/v1/settings/:key
|
||||
///
|
||||
/// 软删除设置值,设置 deleted_at 时间戳。
|
||||
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||
/// 需要 `setting.delete` 权限。
|
||||
pub async fn delete_setting<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(key): Path<String>,
|
||||
Query(query): Query<SettingQuery>,
|
||||
Json(req): Json<DeleteSettingVersionReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "setting.delete")?;
|
||||
|
||||
let scope = query.scope.unwrap_or_else(|| "tenant".to_string());
|
||||
|
||||
SettingService::delete(
|
||||
&key,
|
||||
&scope,
|
||||
&query.scope_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("设置已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 删除设置的乐观锁版本号请求体。
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteSettingVersionReq {
|
||||
pub version: i32,
|
||||
}
|
||||
176
crates/erp-config/src/handler/theme_handler.rs
Normal file
176
crates/erp-config/src/handler/theme_handler.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, State};
|
||||
use axum::response::Json as JsonResponse;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::config_state::ConfigState;
|
||||
use crate::dto::{PublicBrandResp, SetSettingParams, ThemeResp};
|
||||
use crate::error::ConfigError;
|
||||
use crate::service::setting_service::SettingService;
|
||||
|
||||
/// 默认主题配置。
|
||||
fn default_theme() -> ThemeResp {
|
||||
ThemeResp {
|
||||
primary_color: None,
|
||||
logo_url: None,
|
||||
sidebar_style: None,
|
||||
brand_name: Some("HMS 健康管理平台".into()),
|
||||
brand_slogan: Some("新一代健康管理平台".into()),
|
||||
brand_features: Some("患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
||||
brand_copyright: Some("HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/themes",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ThemeResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "主题设置"
|
||||
)]
|
||||
/// GET /api/v1/theme
|
||||
///
|
||||
/// 获取当前租户的主题配置。
|
||||
/// 主题配置存储在 settings 表中,key 为 "theme",scope 为 "tenant"。
|
||||
/// 当没有任何主题配置时,返回默认主题(所有字段为 null)。
|
||||
/// 需要 `theme.read` 权限。
|
||||
pub async fn get_theme<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "theme.read")?;
|
||||
|
||||
let theme = match SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await
|
||||
{
|
||||
Ok(setting) => serde_json::from_value(setting.setting_value)
|
||||
.map_err(|e| AppError::Validation(format!("主题配置解析失败: {e}")))?,
|
||||
Err(ConfigError::NotFound(_)) => default_theme(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(theme)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/themes",
|
||||
request_body = ThemeResp,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ThemeResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "主题设置"
|
||||
)]
|
||||
/// PUT /api/v1/theme
|
||||
///
|
||||
/// 更新当前租户的主题配置。
|
||||
/// 将主题配置序列化为 JSON 存储到 settings 表。
|
||||
/// 需要 `theme.update` 权限。
|
||||
pub async fn update_theme<S>(
|
||||
State(state): State<ConfigState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<ThemeResp>,
|
||||
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||||
where
|
||||
ConfigState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "theme.update")?;
|
||||
|
||||
let value = serde_json::to_value(&req)
|
||||
.map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?;
|
||||
|
||||
SettingService::set(
|
||||
SetSettingParams {
|
||||
key: "theme".to_string(),
|
||||
scope: "tenant".to_string(),
|
||||
scope_id: None,
|
||||
value,
|
||||
version: None,
|
||||
},
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(req)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/public/brand",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PublicBrandResp>),
|
||||
),
|
||||
tag = "主题设置"
|
||||
)]
|
||||
/// GET /api/v1/public/brand
|
||||
///
|
||||
/// 获取公开品牌信息(无需认证)。
|
||||
pub async fn get_public_brand() -> JsonResponse<ApiResponse<PublicBrandResp>> {
|
||||
let defaults = default_theme();
|
||||
JsonResponse(ApiResponse::ok(PublicBrandResp {
|
||||
brand_name: defaults
|
||||
.brand_name
|
||||
.unwrap_or_else(|| "HMS 健康管理平台".into()),
|
||||
brand_slogan: defaults
|
||||
.brand_slogan
|
||||
.unwrap_or_else(|| "新一代健康管理平台".into()),
|
||||
brand_features: defaults
|
||||
.brand_features
|
||||
.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
||||
brand_copyright: defaults
|
||||
.brand_copyright
|
||||
.unwrap_or_else(|| "HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_theme_has_brand_defaults() {
|
||||
let theme = default_theme();
|
||||
assert!(theme.primary_color.is_none());
|
||||
assert!(theme.logo_url.is_none());
|
||||
assert!(theme.sidebar_style.is_none());
|
||||
assert_eq!(theme.brand_name, Some("HMS 健康管理平台".to_string()));
|
||||
assert_eq!(theme.brand_slogan, Some("新一代健康管理平台".to_string()));
|
||||
assert!(theme.brand_features.is_some());
|
||||
assert!(theme.brand_copyright.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_resp_serde_roundtrip() {
|
||||
let theme = ThemeResp {
|
||||
primary_color: Some("#1890ff".to_string()),
|
||||
logo_url: None,
|
||||
sidebar_style: Some("dark".to_string()),
|
||||
brand_name: Some("测试平台".to_string()),
|
||||
brand_slogan: None,
|
||||
brand_features: None,
|
||||
brand_copyright: None,
|
||||
};
|
||||
let json = serde_json::to_string(&theme).unwrap();
|
||||
let back: ThemeResp = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.primary_color, Some("#1890ff".to_string()));
|
||||
assert_eq!(back.brand_name, Some("测试平台".to_string()));
|
||||
assert!(back.brand_slogan.is_none());
|
||||
}
|
||||
}
|
||||
10
crates/erp-config/src/lib.rs
Normal file
10
crates/erp-config/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod config_state;
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
|
||||
pub use config_state::ConfigState;
|
||||
pub use module::ConfigModule;
|
||||
267
crates/erp-config/src/module.rs
Normal file
267
crates/erp-config/src/module.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post, put};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||
|
||||
use crate::handler::{
|
||||
dictionary_handler, language_handler, menu_handler, numbering_handler, setting_handler,
|
||||
theme_handler,
|
||||
};
|
||||
|
||||
/// Config module implementing the `ErpModule` trait.
|
||||
///
|
||||
/// Manages system configuration: dictionaries, menus, settings,
|
||||
/// numbering rules, languages, and themes.
|
||||
pub struct ConfigModule;
|
||||
|
||||
impl ConfigModule {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Build protected (authenticated) routes for the config module.
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::config_state::ConfigState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
// Dictionary routes
|
||||
.route(
|
||||
"/config/dictionaries",
|
||||
get(dictionary_handler::list_dictionaries)
|
||||
.post(dictionary_handler::create_dictionary),
|
||||
)
|
||||
.route(
|
||||
"/config/dictionaries/{id}",
|
||||
put(dictionary_handler::update_dictionary)
|
||||
.delete(dictionary_handler::delete_dictionary),
|
||||
)
|
||||
.route(
|
||||
"/config/dictionaries/items",
|
||||
get(dictionary_handler::list_items_by_code),
|
||||
)
|
||||
.route(
|
||||
"/config/dictionaries/{dict_id}/items",
|
||||
post(dictionary_handler::create_item),
|
||||
)
|
||||
.route(
|
||||
"/config/dictionaries/{dict_id}/items/{item_id}",
|
||||
put(dictionary_handler::update_item).delete(dictionary_handler::delete_item),
|
||||
)
|
||||
// Menu routes
|
||||
.route(
|
||||
"/config/menus",
|
||||
get(menu_handler::get_menus)
|
||||
.post(menu_handler::create_menu)
|
||||
.put(menu_handler::batch_save_menus),
|
||||
)
|
||||
.route(
|
||||
"/config/menus/{id}",
|
||||
put(menu_handler::update_menu).delete(menu_handler::delete_menu),
|
||||
)
|
||||
// User menu tree (no special permission required)
|
||||
.route("/menus/user", get(menu_handler::get_user_menus))
|
||||
// Setting routes
|
||||
.route(
|
||||
"/config/settings/{key}",
|
||||
get(setting_handler::get_setting)
|
||||
.put(setting_handler::update_setting)
|
||||
.delete(setting_handler::delete_setting),
|
||||
)
|
||||
// Numbering rule routes
|
||||
.route(
|
||||
"/config/numbering-rules",
|
||||
get(numbering_handler::list_numbering_rules)
|
||||
.post(numbering_handler::create_numbering_rule),
|
||||
)
|
||||
.route(
|
||||
"/config/numbering-rules/{id}",
|
||||
put(numbering_handler::update_numbering_rule)
|
||||
.delete(numbering_handler::delete_numbering_rule),
|
||||
)
|
||||
.route(
|
||||
"/config/numbering-rules/{id}/generate",
|
||||
post(numbering_handler::generate_number),
|
||||
)
|
||||
// Theme routes
|
||||
.route(
|
||||
"/config/themes",
|
||||
get(theme_handler::get_theme).put(theme_handler::update_theme),
|
||||
)
|
||||
// Language routes
|
||||
.route("/config/languages", get(language_handler::list_languages))
|
||||
.route(
|
||||
"/config/languages/{code}",
|
||||
put(language_handler::update_language),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build public (unauthenticated) routes for the config module.
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new().route("/public/brand", get(theme_handler::get_public_brand))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConfigModule {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ErpModule for ConfigModule {
|
||||
fn name(&self) -> &str {
|
||||
"config"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec!["auth"]
|
||||
}
|
||||
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {}
|
||||
|
||||
async fn on_tenant_created(
|
||||
&self,
|
||||
_tenant_id: Uuid,
|
||||
_db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_tenant_deleted(
|
||||
&self,
|
||||
_tenant_id: Uuid,
|
||||
_db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.list".into(),
|
||||
name: "查看字典".into(),
|
||||
description: "查看数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.create".into(),
|
||||
name: "创建字典".into(),
|
||||
description: "创建数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.update".into(),
|
||||
name: "编辑字典".into(),
|
||||
description: "编辑数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.delete".into(),
|
||||
name: "删除字典".into(),
|
||||
description: "删除数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "menu.list".into(),
|
||||
name: "查看菜单".into(),
|
||||
description: "查看菜单配置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "menu.update".into(),
|
||||
name: "编辑菜单".into(),
|
||||
description: "编辑菜单配置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "setting.read".into(),
|
||||
name: "查看配置".into(),
|
||||
description: "查看系统参数".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "setting.update".into(),
|
||||
name: "编辑配置".into(),
|
||||
description: "编辑系统参数".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "setting.delete".into(),
|
||||
name: "删除配置".into(),
|
||||
description: "删除系统参数".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.list".into(),
|
||||
name: "查看编号规则".into(),
|
||||
description: "查看编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.create".into(),
|
||||
name: "创建编号规则".into(),
|
||||
description: "创建编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.update".into(),
|
||||
name: "编辑编号规则".into(),
|
||||
description: "编辑编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.delete".into(),
|
||||
name: "删除编号规则".into(),
|
||||
description: "删除编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.generate".into(),
|
||||
name: "生成编号".into(),
|
||||
description: "生成文档编号".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "theme.read".into(),
|
||||
name: "查看主题".into(),
|
||||
description: "查看主题设置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "theme.update".into(),
|
||||
name: "编辑主题".into(),
|
||||
description: "编辑主题设置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "language.list".into(),
|
||||
name: "查看语言".into(),
|
||||
description: "查看语言配置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "language.update".into(),
|
||||
name: "编辑语言".into(),
|
||||
description: "编辑语言设置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
628
crates/erp-config/src/service/dictionary_service.rs
Normal file
628
crates/erp-config/src/service/dictionary_service.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
600
crates/erp-config/src/service/menu_service.rs
Normal file
600
crates/erp-config/src/service/menu_service.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
4
crates/erp-config/src/service/mod.rs
Normal file
4
crates/erp-config/src/service/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod dictionary_service;
|
||||
pub mod menu_service;
|
||||
pub mod numbering_service;
|
||||
pub mod setting_service;
|
||||
747
crates/erp-config/src/service/numbering_service.rs
Normal file
747
crates/erp-config/src/service/numbering_service.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
447
crates/erp-config/src/service/setting_service.rs
Normal file
447
crates/erp-config/src/service/setting_service.rs
Normal 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(¶ms.scope))
|
||||
.filter(setting::Column::SettingKey.eq(¶ms.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user