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

@@ -12,7 +12,7 @@ use crate::dto::{
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
};
use crate::middleware::rbac::require_permission;
use erp_core::rbac::require_permission;
use crate::service::dept_service::DeptService;
use crate::service::org_service::OrgService;
use crate::service::position_service::PositionService;

View File

@@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
use crate::middleware::rbac::require_permission;
use erp_core::rbac::require_permission;
use crate::service::permission_service::PermissionService;
use crate::service::role_service::RoleService;

View File

@@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{CreateUserReq, UpdateUserReq, UserResp};
use crate::middleware::rbac::require_permission;
use erp_core::rbac::require_permission;
use crate::service::user_service::UserService;
/// GET /api/v1/users

View File

@@ -1,5 +1,4 @@
pub mod jwt_auth;
pub mod rbac;
pub use jwt_auth::jwt_auth_middleware_fn;
pub use rbac::{require_any_permission, require_permission, require_role};
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};

View File

@@ -1,96 +0,0 @@
use erp_core::error::AppError;
use erp_core::types::TenantContext;
/// Check whether the `TenantContext` includes the specified permission code.
///
/// Returns `Ok(())` if the permission is present, or `AppError::Forbidden` otherwise.
pub fn require_permission(ctx: &TenantContext, permission: &str) -> Result<(), AppError> {
if ctx.permissions.iter().any(|p| p == permission) {
Ok(())
} else {
Err(AppError::Forbidden(format!("需要权限: {}", permission)))
}
}
/// Check whether the `TenantContext` includes at least one of the specified permission codes.
///
/// Useful when multiple permissions can grant access to the same resource.
pub fn require_any_permission(
ctx: &TenantContext,
permissions: &[&str],
) -> Result<(), AppError> {
let has_any = permissions
.iter()
.any(|p| ctx.permissions.iter().any(|up| up == *p));
if has_any {
Ok(())
} else {
Err(AppError::Forbidden(format!(
"需要以下权限之一: {}",
permissions.join(", ")
)))
}
}
/// Check whether the `TenantContext` includes the specified role code.
///
/// Returns `Ok(())` if the role is present, or `AppError::Forbidden` otherwise.
pub fn require_role(ctx: &TenantContext, role: &str) -> Result<(), AppError> {
if ctx.roles.iter().any(|r| r == role) {
Ok(())
} else {
Err(AppError::Forbidden(format!("需要角色: {}", role)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn test_ctx(roles: Vec<&str>, permissions: Vec<&str>) -> TenantContext {
TenantContext {
tenant_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
roles: roles.into_iter().map(String::from).collect(),
permissions: permissions.into_iter().map(String::from).collect(),
}
}
#[test]
fn require_permission_succeeds_when_present() {
let ctx = test_ctx(vec![], vec!["user.read", "user.write"]);
assert!(require_permission(&ctx, "user.read").is_ok());
}
#[test]
fn require_permission_fails_when_missing() {
let ctx = test_ctx(vec![], vec!["user.read"]);
assert!(require_permission(&ctx, "user.delete").is_err());
}
#[test]
fn require_any_permission_succeeds_with_match() {
let ctx = test_ctx(vec![], vec!["user.read"]);
assert!(require_any_permission(&ctx, &["user.delete", "user.read"]).is_ok());
}
#[test]
fn require_any_permission_fails_with_no_match() {
let ctx = test_ctx(vec![], vec!["user.read"]);
assert!(require_any_permission(&ctx, &["user.delete", "user.admin"]).is_err());
}
#[test]
fn require_role_succeeds_when_present() {
let ctx = test_ctx(vec!["admin", "user"], vec![]);
assert!(require_role(&ctx, "admin").is_ok());
}
#[test]
fn require_role_fails_when_missing() {
let ctx = test_ctx(vec!["user"], vec![]);
assert!(require_role(&ctx, "admin").is_err());
}
}

View File

@@ -84,10 +84,27 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
("position:read", "查看岗位", "position", "read", "查看岗位"),
("position:update", "编辑岗位", "position", "update", "编辑岗位"),
("position:delete", "删除岗位", "position", "delete", "删除岗位"),
// Config module permissions
("dictionary:create", "创建字典", "dictionary", "create", "创建数据字典"),
("dictionary:list", "查看字典", "dictionary", "list", "查看数据字典"),
("dictionary:update", "编辑字典", "dictionary", "update", "编辑数据字典"),
("dictionary:delete", "删除字典", "dictionary", "delete", "删除数据字典"),
("menu:list", "查看菜单", "menu", "list", "查看菜单配置"),
("menu:update", "编辑菜单", "menu", "update", "编辑菜单配置"),
("setting:read", "查看配置", "setting", "read", "查看系统参数"),
("setting:update", "编辑配置", "setting", "update", "编辑系统参数"),
("numbering:create", "创建编号规则", "numbering", "create", "创建编号规则"),
("numbering:list", "查看编号规则", "numbering", "list", "查看编号规则"),
("numbering:update", "编辑编号规则", "numbering", "update", "编辑编号规则"),
("numbering:generate", "生成编号", "numbering", "generate", "生成文档编号"),
("theme:read", "查看主题", "theme", "read", "查看主题设置"),
("theme:update", "编辑主题", "theme", "update", "编辑主题设置"),
("language:list", "查看语言", "language", "list", "查看语言配置"),
("language:update", "编辑语言", "language", "update", "编辑语言配置"),
];
/// Indices of read-only permissions within DEFAULT_PERMISSIONS.
const READ_PERM_INDICES: &[usize] = &[1, 5, 9, 11, 15, 19];
const READ_PERM_INDICES: &[usize] = &[1, 5, 9, 11, 15, 19, 23, 24, 28, 29, 34, 38];
/// Seed default auth data for a new tenant.
///