feat: initialize Nuanji (Warm Notes) project
- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin) - Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs) - Integrated erp-diary into workspace and erp-server - Added DiaryModule registration in main.rs - Added DiaryState FromRef in state.rs - Diary routes mounted (empty routes, ready for implementation) - Product design spec v1.2 preserved in docs/ - Implementation plan preserved in plans/ Cargo check: OK Cargo test: OK (78+ base tests passing)
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user