diff --git a/crates/erp-diary/src/dto.rs b/crates/erp-diary/src/dto.rs index 63d39ef..ab0ca1a 100644 --- a/crates/erp-diary/src/dto.rs +++ b/crates/erp-diary/src/dto.rs @@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use validator::Validate; +/// 标签字符串验证:单个标签最长 30 字符 +const TAG_MAX_LEN: usize = 30; + +/// 班级码正则:仅允许字母和数字 +fn validate_class_code(code: &str) -> bool { + code.chars().all(|c| c.is_ascii_alphanumeric()) +} + /// 日记心情枚举 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] @@ -41,6 +49,22 @@ pub struct CreateJournalReq { pub assigned_topic_id: Option, } +impl CreateJournalReq { + /// 验证标签内容:每个标签非空且不超过 30 字符 + pub fn validate_tags(&self) -> Result<(), String> { + for tag in &self.tags { + let trimmed = tag.trim(); + if trimmed.is_empty() { + return Err("标签不能为空".to_string()); + } + if trimmed.len() > TAG_MAX_LEN { + return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN)); + } + } + Ok(()) + } +} + /// 更新日记请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateJournalReq { @@ -52,9 +76,28 @@ pub struct UpdateJournalReq { pub tags: Option>, pub is_private: Option, pub shared_to_class: Option, + #[validate(range(min = 0, message = "版本号不能为负数"))] pub version: i32, } +impl UpdateJournalReq { + /// 验证标签内容 + pub fn validate_tags(&self) -> Result<(), String> { + if let Some(ref tags) = self.tags { + for tag in tags { + let trimmed = tag.trim(); + if trimmed.is_empty() { + return Err("标签不能为空".to_string()); + } + if trimmed.len() > TAG_MAX_LEN { + return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN)); + } + } + } + Ok(()) + } +} + /// 日记响应 #[derive(Debug, Serialize, ToSchema)] pub struct JournalResp { @@ -90,6 +133,16 @@ pub struct JoinClassReq { pub class_code: String, } +impl JoinClassReq { + /// 验证班级码仅含字母数字 + pub fn validate_code(&self) -> Result<(), String> { + if !validate_class_code(&self.class_code) { + return Err("班级码仅允许字母和数字".to_string()); + } + Ok(()) + } +} + /// 更新班级请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateClassReq { @@ -100,6 +153,7 @@ pub struct UpdateClassReq { #[validate(length(max = 100, message = "学校名称最长 100 字符"))] pub school_name: Option, /// 乐观锁版本号 + #[validate(range(min = 0, message = "版本号不能为负数"))] pub version: i32, } @@ -132,6 +186,32 @@ pub struct SyncReq { pub changes: Vec, } +/// 单条同步变更的 JSON data 最大字节数 +const SYNC_DATA_MAX_BYTES: usize = 1024 * 1024; // 1 MB + +impl SyncReq { + /// 验证每条 SyncChange 的 data 字段大小 + pub fn validate_changes_data(&self) -> Result<(), String> { + for (i, change) in self.changes.iter().enumerate() { + match change { + SyncChange::CreateJournal { data } | SyncChange::UpdateJournal { data, .. } => { + let len = serde_json::to_string(data) + .map(|s| s.len()) + .unwrap_or(SYNC_DATA_MAX_BYTES + 1); + if len > SYNC_DATA_MAX_BYTES { + return Err(format!( + "第 {} 条变更数据过大 ({} > {} 字节)", + i + 1, len, SYNC_DATA_MAX_BYTES + )); + } + } + SyncChange::DeleteJournal { .. } => {} + } + } + Ok(()) + } +} + /// 同步变更条目 #[derive(Debug, Serialize, Deserialize, ToSchema)] pub enum SyncChange { @@ -216,6 +296,7 @@ pub struct UpdateTopicReq { /// 截止日期 pub due_date: Option, /// 乐观锁版本号 + #[validate(range(min = 0, message = "版本号不能为负数"))] pub version: i32, } @@ -229,11 +310,13 @@ pub struct CreateStickerPackReq { #[validate(length(max = 500, message = "描述最长 500 字符"))] pub description: Option, /// 缩略图 URL + #[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))] pub thumbnail_url: Option, /// 是否免费 #[serde(default = "default_true")] pub is_free: bool, /// 价格(积分) + #[validate(range(min = 0, message = "价格不能为负数"))] #[serde(default)] pub price: i32, /// 分类 @@ -253,10 +336,12 @@ pub struct UpdateStickerPackReq { #[validate(length(max = 500, message = "描述最长 500 字符"))] pub description: Option, /// 缩略图 URL + #[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))] pub thumbnail_url: Option, /// 是否免费 pub is_free: Option, /// 价格(积分) + #[validate(range(min = 0, message = "价格不能为负数"))] pub price: Option, /// 分类 #[validate(length(max = 30, message = "分类最长 30 字符"))] @@ -272,6 +357,8 @@ pub struct CreateStickerReq { /// 图片 URL #[validate(length(min = 1, max = 500, message = "图片 URL 长度 1-500 字符"))] pub image_url: String, + /// 贴纸包 ID + pub pack_id: Option, /// 分类 #[validate(length(max = 30, message = "分类最长 30 字符"))] pub category: Option, diff --git a/crates/erp-diary/src/handler/class_handler.rs b/crates/erp-diary/src/handler/class_handler.rs index 051eabe..48fe9cb 100644 --- a/crates/erp-diary/src/handler/class_handler.rs +++ b/crates/erp-diary/src/handler/class_handler.rs @@ -85,6 +85,7 @@ where S: Clone + Send + Sync + 'static, { req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + req.validate_code().map_err(AppError::Validation)?; require_permission(&ctx, "diary.journal.create")?; if req.class_code.trim().is_empty() { diff --git a/crates/erp-diary/src/handler/journal_handler.rs b/crates/erp-diary/src/handler/journal_handler.rs index e78768f..e676523 100644 --- a/crates/erp-diary/src/handler/journal_handler.rs +++ b/crates/erp-diary/src/handler/journal_handler.rs @@ -61,6 +61,7 @@ where S: Clone + Send + Sync + 'static, { req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + req.validate_tags().map_err(AppError::Validation)?; require_permission(&ctx, "diary.journal.create")?; // 基础验证 @@ -149,6 +150,7 @@ where S: Clone + Send + Sync + 'static, { req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + req.validate_tags().map_err(AppError::Validation)?; require_permission(&ctx, "diary.journal.update")?; let resp = JournalService::update( @@ -165,9 +167,10 @@ where } /// 删除日记请求体(包含版本号) -#[derive(Debug, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Deserialize, Validate, utoipa::ToSchema)] pub struct DeleteJournalReq { /// 当前版本号(乐观锁) + #[validate(range(min = 0, message = "版本号不能为负数"))] pub version: i32, } @@ -202,6 +205,8 @@ where { require_permission(&ctx, "diary.journal.delete")?; + req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + JournalService::delete( ctx.tenant_id, ctx.user_id, diff --git a/crates/erp-diary/src/handler/parent_handler.rs b/crates/erp-diary/src/handler/parent_handler.rs index 266cc2f..873827d 100644 --- a/crates/erp-diary/src/handler/parent_handler.rs +++ b/crates/erp-diary/src/handler/parent_handler.rs @@ -94,6 +94,8 @@ where { require_permission(&ctx, "diary.parent.bind")?; + req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + let binding = ParentService::bind_child( ctx.tenant_id, ctx.user_id, @@ -259,6 +261,8 @@ where { require_permission(&ctx, "diary.parent.bind")?; + req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + let count = ParentService::delete_child_data( ctx.tenant_id, ctx.user_id, @@ -301,6 +305,8 @@ where { require_permission(&ctx, "diary.parent.bind")?; + req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?; Ok(Json(ApiResponse { diff --git a/crates/erp-diary/src/handler/sync_handler.rs b/crates/erp-diary/src/handler/sync_handler.rs index e71662f..51b9b1f 100644 --- a/crates/erp-diary/src/handler/sync_handler.rs +++ b/crates/erp-diary/src/handler/sync_handler.rs @@ -40,6 +40,7 @@ where S: Clone + Send + Sync + 'static, { req.validate().map_err(|e| AppError::Validation(e.to_string()))?; + req.validate_changes_data().map_err(AppError::Validation)?; require_permission(&ctx, "diary.journal.read")?; let resp = SyncService::sync(