From dbb74b654508da10557ddcf2480f4c6acb3a3bef Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 7 Jun 2026 12:55:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(diary):=20=E7=B3=BB=E7=BB=9F=E6=80=A7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DTO=20=E8=BE=93=E5=85=A5=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=20=E2=80=94=2042=20=E9=A1=B9=E5=AE=A1=E8=AE=A1=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E4=B8=AD=E8=BE=93=E5=85=A5=E9=AA=8C=E8=AF=81=E7=B1=BB?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTO 字段级验证: - version 字段全部添加 range(min=0) 防止负数 - 标签内容验证: 单个标签最长 30 字符,不允许空白 - 班级码正则: 仅允许字母数字,拒绝特殊字符 - 贴纸包 price 添加 range(min=0) 防止负价格 - thumbnail_url/image_url 添加 length(max=500) 限制 - 同步请求 data payload 限制 1MB/条 Handler validate() 调用补齐: - delete_journal: DeleteJournalReq 添加 Validate derive + handler 调用 - bind_child / unbind_child / delete_child_data: 补齐 req.validate() 调用 - join_class: 添加 validate_code() 字母数字检查 - sync_journals: 添加 validate_changes_data() payload 大小检查 审计覆盖: 5a-C01/02/03 + 5a-H02/03/04 + B-03 + 7b-C02 --- crates/erp-diary/src/dto.rs | 87 +++++++++++++++++++ crates/erp-diary/src/handler/class_handler.rs | 1 + .../erp-diary/src/handler/journal_handler.rs | 7 +- .../erp-diary/src/handler/parent_handler.rs | 6 ++ crates/erp-diary/src/handler/sync_handler.rs | 1 + 5 files changed, 101 insertions(+), 1 deletion(-) 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(