- P3-2: ArticleEditor 图片上传接入 /upload 端点 + 封面图上传按钮 - P4-3: recover_plugins 添加 tenant 日志 + 同 ID 去重保护 - P4-4: LanguageManager 编辑弹窗改为真实表单 (name 字段) + 后端 name 持久化 - P4-6: Settings API getSetting/updateSetting 添加 encodeURIComponent
671 lines
19 KiB
Rust
671 lines
19 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
use uuid::Uuid;
|
|
use validator::Validate;
|
|
|
|
// --- Dictionary DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct DictionaryItemResp {
|
|
pub id: Uuid,
|
|
pub dictionary_id: Uuid,
|
|
pub label: String,
|
|
pub value: String,
|
|
pub sort_order: i32,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub color: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct DictionaryResp {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub code: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
pub items: Vec<DictionaryItemResp>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateDictionaryReq {
|
|
#[validate(length(min = 1, max = 100, message = "字典名称不能为空"))]
|
|
pub name: String,
|
|
#[validate(length(min = 1, max = 50, message = "字典编码不能为空"))]
|
|
pub code: String,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateDictionaryReq {
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateDictionaryItemReq {
|
|
#[validate(length(min = 1, max = 100, message = "标签不能为空"))]
|
|
pub label: String,
|
|
#[validate(length(min = 1, max = 100, message = "值不能为空"))]
|
|
pub value: String,
|
|
pub sort_order: Option<i32>,
|
|
pub color: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateDictionaryItemReq {
|
|
pub label: Option<String>,
|
|
pub value: Option<String>,
|
|
pub sort_order: Option<i32>,
|
|
pub color: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
// --- Menu DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema, Clone)]
|
|
pub struct MenuResp {
|
|
pub id: Uuid,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub parent_id: Option<Uuid>,
|
|
pub title: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub path: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<String>,
|
|
pub sort_order: i32,
|
|
pub visible: bool,
|
|
pub menu_type: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub permission: Option<String>,
|
|
pub children: Vec<MenuResp>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateMenuReq {
|
|
pub parent_id: Option<Uuid>,
|
|
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
|
pub title: String,
|
|
pub path: Option<String>,
|
|
pub icon: Option<String>,
|
|
pub sort_order: Option<i32>,
|
|
pub visible: Option<bool>,
|
|
#[validate(length(min = 1, message = "菜单类型不能为空"))]
|
|
pub menu_type: Option<String>,
|
|
pub permission: Option<String>,
|
|
pub role_ids: Option<Vec<Uuid>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, 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>>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct BatchSaveMenusReq {
|
|
#[validate(length(min = 1, message = "菜单列表不能为空"), nested)]
|
|
pub menus: Vec<MenuItemReq>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
|
|
pub struct MenuItemReq {
|
|
pub id: Option<Uuid>,
|
|
pub parent_id: Option<Uuid>,
|
|
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
|
pub title: String,
|
|
pub path: Option<String>,
|
|
pub icon: Option<String>,
|
|
pub sort_order: Option<i32>,
|
|
pub visible: Option<bool>,
|
|
pub menu_type: Option<String>,
|
|
pub permission: Option<String>,
|
|
pub role_ids: Option<Vec<Uuid>>,
|
|
/// 乐观锁版本号。更新已有菜单时必填。
|
|
pub version: Option<i32>,
|
|
}
|
|
|
|
// --- Setting DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct SettingResp {
|
|
pub id: Uuid,
|
|
pub scope: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub scope_id: Option<Uuid>,
|
|
pub setting_key: String,
|
|
pub setting_value: serde_json::Value,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct UpdateSettingReq {
|
|
pub setting_value: serde_json::Value,
|
|
/// 乐观锁版本号。更新已有设置时必填,创建新设置时忽略。
|
|
pub version: Option<i32>,
|
|
}
|
|
|
|
/// 内部参数结构体,用于减少 SettingService::set 的参数数量。
|
|
pub struct SetSettingParams {
|
|
pub key: String,
|
|
pub scope: String,
|
|
pub scope_id: Option<Uuid>,
|
|
pub value: serde_json::Value,
|
|
/// 乐观锁版本号。更新已有设置时用于校验。
|
|
pub version: Option<i32>,
|
|
}
|
|
|
|
// --- Numbering Rule DTOs ---
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct NumberingRuleResp {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub code: String,
|
|
pub prefix: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub date_format: Option<String>,
|
|
pub seq_length: i32,
|
|
pub seq_start: i32,
|
|
pub seq_current: i64,
|
|
pub separator: String,
|
|
pub reset_cycle: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub last_reset_date: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateNumberingRuleReq {
|
|
#[validate(length(min = 1, max = 100, message = "规则名称不能为空"))]
|
|
pub name: String,
|
|
#[validate(length(min = 1, max = 50, message = "规则编码不能为空"))]
|
|
pub code: String,
|
|
pub prefix: Option<String>,
|
|
pub date_format: Option<String>,
|
|
pub seq_length: Option<i32>,
|
|
pub seq_start: Option<i32>,
|
|
pub separator: Option<String>,
|
|
pub reset_cycle: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, 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>,
|
|
pub version: i32,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct GenerateNumberResp {
|
|
pub number: String,
|
|
}
|
|
|
|
// --- Theme DTOs (stored via settings) ---
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
|
|
pub struct ThemeResp {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub primary_color: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub logo_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub sidebar_style: Option<String>,
|
|
}
|
|
|
|
// --- 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,
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use validator::Validate;
|
|
|
|
// ---- CreateDictionaryReq 验证 ----
|
|
|
|
#[test]
|
|
fn create_dictionary_req_valid() {
|
|
let req = CreateDictionaryReq {
|
|
name: "状态字典".to_string(),
|
|
code: "status".to_string(),
|
|
description: Some("通用状态".to_string()),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_req_empty_name_fails() {
|
|
let req = CreateDictionaryReq {
|
|
name: "".to_string(),
|
|
code: "status".to_string(),
|
|
description: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_req_empty_code_fails() {
|
|
let req = CreateDictionaryReq {
|
|
name: "状态字典".to_string(),
|
|
code: "".to_string(),
|
|
description: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_req_name_too_long_fails() {
|
|
let req = CreateDictionaryReq {
|
|
name: "x".repeat(101),
|
|
code: "status".to_string(),
|
|
description: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_req_code_too_long_fails() {
|
|
let req = CreateDictionaryReq {
|
|
name: "状态字典".to_string(),
|
|
code: "x".repeat(51),
|
|
description: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_req_max_boundary_ok() {
|
|
let req = CreateDictionaryReq {
|
|
name: "x".repeat(100),
|
|
code: "x".repeat(50),
|
|
description: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
// ---- CreateDictionaryItemReq 验证 ----
|
|
|
|
#[test]
|
|
fn create_dictionary_item_req_valid() {
|
|
let req = CreateDictionaryItemReq {
|
|
label: "启用".to_string(),
|
|
value: "active".to_string(),
|
|
sort_order: Some(1),
|
|
color: Some("#00FF00".to_string()),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_item_req_empty_label_fails() {
|
|
let req = CreateDictionaryItemReq {
|
|
label: "".to_string(),
|
|
value: "active".to_string(),
|
|
sort_order: None,
|
|
color: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_item_req_empty_value_fails() {
|
|
let req = CreateDictionaryItemReq {
|
|
label: "启用".to_string(),
|
|
value: "".to_string(),
|
|
sort_order: None,
|
|
color: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_item_req_label_too_long_fails() {
|
|
let req = CreateDictionaryItemReq {
|
|
label: "x".repeat(101),
|
|
value: "active".to_string(),
|
|
sort_order: None,
|
|
color: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_item_req_value_too_long_fails() {
|
|
let req = CreateDictionaryItemReq {
|
|
label: "启用".to_string(),
|
|
value: "x".repeat(101),
|
|
sort_order: None,
|
|
color: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_item_req_min_boundary_ok() {
|
|
let req = CreateDictionaryItemReq {
|
|
label: "x".to_string(),
|
|
value: "x".to_string(),
|
|
sort_order: None,
|
|
color: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_dictionary_item_req_max_boundary_ok() {
|
|
let req = CreateDictionaryItemReq {
|
|
label: "x".repeat(100),
|
|
value: "x".repeat(100),
|
|
sort_order: Some(99),
|
|
color: Some("#FFFFFF".to_string()),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
// ---- CreateMenuReq 验证 ----
|
|
|
|
#[test]
|
|
fn create_menu_req_valid() {
|
|
let req = CreateMenuReq {
|
|
parent_id: None,
|
|
title: "系统设置".to_string(),
|
|
path: Some("/settings".to_string()),
|
|
icon: Some("SettingOutlined".to_string()),
|
|
sort_order: Some(1),
|
|
visible: Some(true),
|
|
menu_type: Some("menu".to_string()),
|
|
permission: None,
|
|
role_ids: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_menu_req_empty_title_fails() {
|
|
let req = CreateMenuReq {
|
|
parent_id: None,
|
|
title: "".to_string(),
|
|
path: None,
|
|
icon: None,
|
|
sort_order: None,
|
|
visible: None,
|
|
menu_type: None,
|
|
permission: None,
|
|
role_ids: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_menu_req_title_too_long_fails() {
|
|
let req = CreateMenuReq {
|
|
parent_id: None,
|
|
title: "x".repeat(101),
|
|
path: None,
|
|
icon: None,
|
|
sort_order: None,
|
|
visible: None,
|
|
menu_type: None,
|
|
permission: None,
|
|
role_ids: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_menu_req_title_max_boundary_ok() {
|
|
let req = CreateMenuReq {
|
|
parent_id: None,
|
|
title: "x".repeat(100),
|
|
path: None,
|
|
icon: None,
|
|
sort_order: None,
|
|
visible: None,
|
|
menu_type: None,
|
|
permission: None,
|
|
role_ids: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
// ---- BatchSaveMenusReq 验证 ----
|
|
|
|
#[test]
|
|
fn batch_save_menus_req_valid() {
|
|
let req = BatchSaveMenusReq {
|
|
menus: vec![MenuItemReq {
|
|
id: None,
|
|
parent_id: None,
|
|
title: "首页".to_string(),
|
|
path: Some("/home".to_string()),
|
|
icon: None,
|
|
sort_order: Some(0),
|
|
visible: Some(true),
|
|
menu_type: Some("menu".to_string()),
|
|
permission: None,
|
|
role_ids: None,
|
|
version: None,
|
|
}],
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn batch_save_menus_req_empty_list_fails() {
|
|
let req = BatchSaveMenusReq { menus: vec![] };
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn batch_save_menus_req_item_empty_title_fails() {
|
|
let req = BatchSaveMenusReq {
|
|
menus: vec![MenuItemReq {
|
|
id: None,
|
|
parent_id: None,
|
|
title: "".to_string(),
|
|
path: None,
|
|
icon: None,
|
|
sort_order: None,
|
|
visible: None,
|
|
menu_type: None,
|
|
permission: None,
|
|
role_ids: None,
|
|
version: None,
|
|
}],
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn batch_save_menus_req_item_title_too_long_fails() {
|
|
let req = BatchSaveMenusReq {
|
|
menus: vec![MenuItemReq {
|
|
id: None,
|
|
parent_id: None,
|
|
title: "x".repeat(101),
|
|
path: None,
|
|
icon: None,
|
|
sort_order: None,
|
|
visible: None,
|
|
menu_type: None,
|
|
permission: None,
|
|
role_ids: None,
|
|
version: None,
|
|
}],
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn batch_save_menus_req_multiple_items_ok() {
|
|
let req = BatchSaveMenusReq {
|
|
menus: vec![
|
|
MenuItemReq {
|
|
id: None,
|
|
parent_id: None,
|
|
title: "菜单A".to_string(),
|
|
path: Some("/a".to_string()),
|
|
icon: None,
|
|
sort_order: Some(0),
|
|
visible: Some(true),
|
|
menu_type: Some("menu".to_string()),
|
|
permission: None,
|
|
role_ids: None,
|
|
version: None,
|
|
},
|
|
MenuItemReq {
|
|
id: None,
|
|
parent_id: None,
|
|
title: "菜单B".to_string(),
|
|
path: Some("/b".to_string()),
|
|
icon: None,
|
|
sort_order: Some(1),
|
|
visible: Some(true),
|
|
menu_type: Some("menu".to_string()),
|
|
permission: None,
|
|
role_ids: None,
|
|
version: Some(1),
|
|
},
|
|
],
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
// ---- CreateNumberingRuleReq 验证 ----
|
|
|
|
#[test]
|
|
fn create_numbering_rule_req_valid() {
|
|
let req = CreateNumberingRuleReq {
|
|
name: "订单编号".to_string(),
|
|
code: "ORDER".to_string(),
|
|
prefix: Some("ORD".to_string()),
|
|
date_format: Some("%Y%m%d".to_string()),
|
|
seq_length: Some(4),
|
|
seq_start: Some(1),
|
|
separator: Some("-".to_string()),
|
|
reset_cycle: Some("daily".to_string()),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn create_numbering_rule_req_empty_name_fails() {
|
|
let req = CreateNumberingRuleReq {
|
|
name: "".to_string(),
|
|
code: "ORDER".to_string(),
|
|
prefix: None,
|
|
date_format: None,
|
|
seq_length: None,
|
|
seq_start: None,
|
|
separator: None,
|
|
reset_cycle: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_numbering_rule_req_empty_code_fails() {
|
|
let req = CreateNumberingRuleReq {
|
|
name: "订单编号".to_string(),
|
|
code: "".to_string(),
|
|
prefix: None,
|
|
date_format: None,
|
|
seq_length: None,
|
|
seq_start: None,
|
|
separator: None,
|
|
reset_cycle: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_numbering_rule_req_name_too_long_fails() {
|
|
let req = CreateNumberingRuleReq {
|
|
name: "x".repeat(101),
|
|
code: "ORDER".to_string(),
|
|
prefix: None,
|
|
date_format: None,
|
|
seq_length: None,
|
|
seq_start: None,
|
|
separator: None,
|
|
reset_cycle: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_numbering_rule_req_code_too_long_fails() {
|
|
let req = CreateNumberingRuleReq {
|
|
name: "订单编号".to_string(),
|
|
code: "x".repeat(51),
|
|
prefix: None,
|
|
date_format: None,
|
|
seq_length: None,
|
|
seq_start: None,
|
|
separator: None,
|
|
reset_cycle: None,
|
|
};
|
|
assert!(req.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn create_numbering_rule_req_max_boundary_ok() {
|
|
let req = CreateNumberingRuleReq {
|
|
name: "x".repeat(100),
|
|
code: "x".repeat(50),
|
|
prefix: None,
|
|
date_format: None,
|
|
seq_length: None,
|
|
seq_start: None,
|
|
separator: None,
|
|
reset_cycle: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
// ---- UpdateSettingReq 验证 ----
|
|
|
|
#[test]
|
|
fn update_setting_req_valid() {
|
|
let req = UpdateSettingReq {
|
|
setting_value: serde_json::json!({"key": "value"}),
|
|
version: Some(1),
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn update_setting_req_without_version_ok() {
|
|
let req = UpdateSettingReq {
|
|
setting_value: serde_json::json!("hello"),
|
|
version: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
}
|