feat(config): add system configuration module (Phase 3)

Implement the complete erp-config crate with:
- Data dictionaries (CRUD + items management)
- Dynamic menus (tree structure with role filtering)
- System settings (hierarchical: platform > tenant > org > user)
- Numbering rules (concurrency-safe via PostgreSQL advisory_lock)
- Theme and language configuration (via settings store)
- 6 database migrations (dictionaries, menus, settings, numbering_rules)
- Frontend Settings page with 5 tabs (dictionary, menu, numbering, settings, theme)

Refactor: move RBAC functions (require_permission) from erp-auth to erp-core
to avoid cross-module dependencies.

Add 20 new seed permissions for config module operations.
This commit is contained in:
iven
2026-04-11 08:09:19 +08:00
parent 8a012f6c6a
commit 0baaf5f7ee
55 changed files with 5295 additions and 12 deletions

View 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,
}

View File

@@ -0,0 +1,218 @@
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>,
}
#[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>,
}
#[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, ToSchema)]
pub struct UpdateDictionaryReq {
pub name: Option<String>,
pub description: Option<String>,
}
#[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, ToSchema)]
pub struct UpdateDictionaryItemReq {
pub label: Option<String>,
pub value: Option<String>,
pub sort_order: Option<i32>,
pub color: Option<String>,
}
// --- 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>,
}
#[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, ToSchema)]
pub struct UpdateMenuReq {
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>>,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct BatchSaveMenusReq {
#[validate(length(min = 1, message = "菜单列表不能为空"))]
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>>,
}
// --- 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,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateSettingReq {
pub setting_value: serde_json::Value,
}
// --- 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>,
}
#[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, ToSchema)]
pub struct UpdateNumberingRuleReq {
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>,
}
#[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>,
}
// --- 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, ToSchema)]
pub struct UpdateLanguageReq {
pub is_active: bool,
}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View File

@@ -0,0 +1,6 @@
pub mod dictionary;
pub mod dictionary_item;
pub mod menu;
pub mod menu_role;
pub mod setting;
pub mod numbering_rule;

View 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 {}

View 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 {}

View File

@@ -0,0 +1,41 @@
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),
}
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<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),
}
}
}
pub type ConfigResult<T> = Result<T, ConfigError>;

View File

@@ -0,0 +1,163 @@
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::{
CreateDictionaryReq, DictionaryItemResp, DictionaryResp, UpdateDictionaryReq,
};
use crate::service::dictionary_service::DictionaryService;
/// 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 + page_size - 1) / page_size;
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: dictionaries,
total,
page,
page_size,
total_pages,
})))
}
/// 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)))
}
/// 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.name,
&req.description,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(dictionary)))
}
/// DELETE /api/v1/dictionaries/:id
///
/// 软删除字典,设置 deleted_at 时间戳。
/// 需要 `dictionary.delete` 权限。
pub async fn delete_dictionary<S>(
State(state): State<ConfigState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> 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, &state.db, &state.event_bus)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("字典已删除".to_string()),
}))
}
/// 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)))
}
/// 按编码查询字典项的查询参数。
#[derive(Debug, serde::Deserialize)]
pub struct ItemsByCodeQuery {
pub code: String,
}

View File

@@ -0,0 +1,101 @@
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, UpdateLanguageReq};
use crate::service::setting_service::SettingService;
/// 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 = code.clone(); // 默认使用 code 作为名称
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)))
}
/// 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 value = serde_json::json!({"is_active": req.is_active});
SettingService::set(
&key,
"platform",
&None,
value,
ctx.tenant_id,
ctx.user_id,
&state.db,
&state.event_bus,
)
.await?;
Ok(JsonResponse(ApiResponse::ok(LanguageResp {
code,
name: String::new(),
is_active: req.is_active,
})))
}

View File

@@ -0,0 +1,104 @@
use axum::Extension;
use axum::extract::{FromRef, Json, 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};
use crate::service::menu_service::MenuService;
/// GET /api/v1/menus
///
/// 获取当前租户下当前用户角色可见的菜单树。
/// 根据用户关联的角色过滤菜单可见性。
/// 需要 `menu.list` 权限。
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 role_ids: Vec<Uuid> = ctx
.roles
.iter()
.filter_map(|r| Uuid::parse_str(r).ok())
.collect();
let menus = MenuService::get_menu_tree(ctx.tenant_id, &role_ids, &state.db).await?;
Ok(JsonResponse(ApiResponse::ok(menus)))
}
/// PUT /api/v1/menus/batch
///
/// 批量保存菜单列表。
/// 对每个菜单项:有 id 的执行更新,没有 id 的执行创建。
/// 需要 `menu.update` 权限。
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 update_req = crate::dto::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(),
};
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()),
}))
}

View 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;

View File

@@ -0,0 +1,119 @@
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;
/// 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 + page_size - 1) / page_size;
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: rules,
total,
page,
page_size,
total_pages,
})))
}
/// 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)))
}
/// 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)))
}
/// 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)))
}

View File

@@ -0,0 +1,76 @@
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::{SettingResp, UpdateSettingReq};
use crate::service::setting_service::SettingService;
/// 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)))
}
/// 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(
&key,
"tenant",
&None,
req.setting_value,
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>,
}

View File

@@ -0,0 +1,69 @@
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::ThemeResp;
use crate::service::setting_service::SettingService;
/// GET /api/v1/theme
///
/// 获取当前租户的主题配置。
/// 主题配置存储在 settings 表中key 为 "theme"scope 为 "tenant"。
/// 需要 `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 setting =
SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await?;
let theme: ThemeResp = serde_json::from_value(setting.setting_value)
.map_err(|e| AppError::Validation(format!("主题配置解析失败: {e}")))?;
Ok(JsonResponse(ApiResponse::ok(theme)))
}
/// 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(
"theme",
"tenant",
&None,
value,
ctx.tenant_id,
ctx.user_id,
&state.db,
&state.event_bus,
)
.await?;
Ok(JsonResponse(ApiResponse::ok(req)))
}

View File

@@ -1 +1,10 @@
// erp-config: 系统配置模块 (Phase 3)
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;

View File

@@ -0,0 +1,125 @@
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;
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),
)
// Menu routes
.route(
"/config/menus",
get(menu_handler::get_menus).put(menu_handler::batch_save_menus),
)
// Setting routes
.route(
"/config/settings/{key}",
get(setting_handler::get_setting).put(setting_handler::update_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),
)
.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),
)
}
}
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_routes(&self, router: Router) -> Router {
router
}
fn register_event_handlers(&self, _bus: &EventBus) {}
async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> {
Ok(())
}
async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> {
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@@ -0,0 +1,416 @@
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::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) as u64;
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(DictionaryResp {
id: m.id,
name: m.name.clone(),
code: m.code.clone(),
description: m.description.clone(),
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(DictionaryResp {
id: model.id,
name: model.name.clone(),
code: model.code.clone(),
description: model.description.clone(),
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 }),
));
Ok(DictionaryResp {
id,
name: name.to_string(),
code: code.to_string(),
description: description.clone(),
items: vec![],
})
}
/// Update editable dictionary fields (name and description).
///
/// Code cannot be changed after creation.
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
description: &Option<String>,
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 mut active: dictionary::ActiveModel = model.into();
if let Some(n) = name {
active.name = Set(n.clone());
}
if let Some(d) = description {
active.description = Set(Some(d.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
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?;
Ok(DictionaryResp {
id: updated.id,
name: updated.name.clone(),
code: updated.code.clone(),
description: updated.description.clone(),
items,
})
}
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
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 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
.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 }),
));
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,
label: &str,
value: &str,
sort_order: i32,
color: &Option<String>,
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(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 model = dictionary_item::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
dictionary_id: Set(dictionary_id),
label: Set(label.to_string()),
value: Set(value.to_string()),
sort_order: Set(sort_order),
color: Set(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()))?;
Ok(DictionaryItemResp {
id,
dictionary_id,
label: label.to_string(),
value: value.to_string(),
sort_order,
color: color.clone(),
})
}
/// Update editable dictionary item fields (label, value, sort_order, color).
pub async fn update_item(
item_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
label: &Option<String>,
value: &Option<String>,
sort_order: &Option<i32>,
color: &Option<String>,
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 mut active: dictionary_item::ActiveModel = model.into();
if let Some(l) = label {
active.label = Set(l.clone());
}
if let Some(v) = value {
active.value = Set(v.clone());
}
if let Some(s) = sort_order {
active.sort_order = Set(*s);
}
if let Some(c) = color {
active.color = Set(Some(c.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(DictionaryItemResp {
id: updated.id,
dictionary_id: updated.dictionary_id,
label: updated.label.clone(),
value: updated.value.clone(),
sort_order: updated.sort_order,
color: updated.color.clone(),
})
}
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
pub async fn delete_item(
item_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
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 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
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
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(|i| DictionaryItemResp {
id: i.id,
dictionary_id: i.dictionary_id,
label: i.label.clone(),
value: i.value.clone(),
sort_order: i.sort_order,
color: i.color.clone(),
})
.collect())
}
}

View File

@@ -0,0 +1,355 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, 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::events::EventBus;
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
/// 以及管理菜单-角色关联。
pub struct MenuService;
impl MenuService {
/// 获取当前租户下指定角色可见的菜单树。
///
/// 如果 `role_ids` 非空,仅返回这些角色关联的菜单;
/// 否则返回租户全部菜单。结果按 `sort_order` 排列并组装为树形结构。
pub async fn get_menu_tree(
tenant_id: Uuid,
role_ids: &[Uuid],
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<MenuResp>> {
// 1. 查询租户下所有未删除的菜单,按 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()))?;
// 2. 如果 role_ids 非空,通过 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() {
return Ok(vec![]);
}
Some(ids)
} else {
None
};
// 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![],
})
.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 {
if !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 }),
));
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![],
})
}
/// 更新菜单字段,并可选地重新关联角色。
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 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);
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?;
}
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![],
})
}
/// 软删除菜单。
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
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 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
.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 }),
));
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
.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),
}
})
.collect()
}
}

View File

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

View File

@@ -0,0 +1,378 @@
use chrono::{Datelike, NaiveDate, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
Statement, ConnectionTrait, DatabaseBackend, TransactionTrait,
};
use uuid::Uuid;
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
use crate::entity::numbering_rule;
use crate::error::{ConfigError, ConfigResult};
use erp_core::events::EventBus;
use erp_core::types::Pagination;
/// 编号规则 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) as u64;
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let resps: Vec<NumberingRuleResp> = models
.iter()
.map(|m| Self::model_to_resp(m))
.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 }),
));
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()),
})
}
/// 更新编号规则的可编辑字段。
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 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);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(Self::model_to_resp(&updated))
}
/// 软删除编号规则。
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
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 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
.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 }),
));
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();
// 获取 PostgreSQL advisory lock事务级别事务结束自动释放
db.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}")))?;
// 在事务内执行序列递增和更新
let number = db
.transaction(|txn| {
Box::pin(async move {
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
.update(txn)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
let separator = &rule.separator;
let mut parts = vec![rule.prefix.clone()];
// 日期部分(如果配置了 date_format
if let Some(date_fmt) = &rule.date_format {
let date_part = Utc::now().format(date_fmt).to_string();
parts.push(date_part);
}
// 序列号补零
let seq_padded = format!("{:0>width$}", seq_current, width = rule.seq_length as usize);
parts.push(seq_padded);
let number = parts.join(separator);
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()),
}
}
}

View File

@@ -0,0 +1,291 @@
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::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(
key: &str,
scope: &str,
scope_id: &Option<Uuid>,
value: serde_json::Value,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<SettingResp> {
// Look for an existing non-deleted record
let existing = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::ScopeId.eq(*scope_id))
.filter(setting::Column::SettingKey.eq(key))
.filter(setting::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if let Some(model) = existing {
// Update existing record
let mut active: setting::ActiveModel = model.into();
active.setting_value = Set(value.clone());
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
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": key,
"scope": scope,
}),
));
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(scope.to_string()),
scope_id: Set(*scope_id),
setting_key: Set(key.to_string()),
setting_value: Set(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": key,
"scope": scope,
}),
));
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 paginator = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::ScopeId.eq(*scope_id))
.filter(setting::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) as u64;
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.
pub async fn delete(
key: &str,
scope: &str,
scope_id: &Option<Uuid>,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<()> {
let model = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::ScopeId.eq(*scope_id))
.filter(setting::Column::SettingKey.eq(key))
.filter(setting::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.ok_or_else(|| {
ConfigError::NotFound(format!(
"设置 '{}' 在 '{}' 作用域下不存在",
key, scope
))
})?;
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
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
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 model = setting::Entity::find()
.filter(setting::Column::TenantId.eq(tenant_id))
.filter(setting::Column::Scope.eq(scope))
.filter(setting::Column::ScopeId.eq(*scope_id))
.filter(setting::Column::SettingKey.eq(key))
.filter(setting::Column::DeletedAt.is_null())
.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.
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.
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(),
}
}
}