// erp-diary 数据传输对象 (DTO) 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")] pub enum Mood { Happy, Calm, Sad, Angry, Thinking, } /// 天气枚举 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum Weather { Sunny, Cloudy, Rainy, Snowy, Windy, } /// 创建日记请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateJournalReq { #[validate(length(min = 1, max = 200, message = "标题长度 1-200 字符"))] pub title: String, pub date: chrono::NaiveDate, pub mood: Mood, pub weather: Weather, #[validate(length(max = 20, message = "标签最多 20 个"))] pub tags: Vec, pub is_private: bool, pub class_id: Option, 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 { #[validate(length(min = 1, max = 200, message = "标题长度 1-200 字符"))] pub title: Option, pub mood: Option, pub weather: Option, #[validate(length(max = 20, message = "标签最多 20 个"))] 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 { pub id: uuid::Uuid, pub author_id: uuid::Uuid, pub class_id: Option, pub title: String, pub date: chrono::NaiveDate, pub mood: Mood, pub weather: Weather, pub tags: Vec, pub is_private: bool, pub shared_to_class: bool, pub assigned_topic_id: Option, pub version: i32, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } /// 创建班级请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateClassReq { #[validate(length(min = 1, max = 50, message = "班级名称长度 1-50 字符"))] pub name: String, #[validate(length(max = 100, message = "学校名称最长 100 字符"))] pub school_name: Option, } /// 加入班级请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct JoinClassReq { #[validate(length(min = 6, max = 6, message = "班级码必须为 6 位"))] 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 { /// 班级名称 #[validate(length(min = 1, max = 50, message = "班级名称长度 1-50 字符"))] pub name: Option, /// 学校名称 #[validate(length(max = 100, message = "学校名称最长 100 字符"))] pub school_name: Option, /// 乐观锁版本号 #[validate(range(min = 0, message = "版本号不能为负数"))] pub version: i32, } /// 重置班级码响应 #[derive(Debug, Serialize, ToSchema)] pub struct ResetClassCodeResp { /// 班级 ID pub class_id: uuid::Uuid, /// 新的 6 位班级码 pub new_class_code: String, } /// 班级响应 #[derive(Debug, Serialize, ToSchema)] pub struct ClassResp { pub id: uuid::Uuid, pub name: String, pub school_name: Option, pub teacher_id: uuid::Uuid, pub class_code: String, pub member_count: i32, pub is_active: bool, } /// 同步请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct SyncReq { pub last_sync_time: Option>, #[validate(length(max = 100, message = "单次同步最多 100 条变更"))] 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 { CreateJournal { data: serde_json::Value }, UpdateJournal { id: uuid::Uuid, version: i32, data: serde_json::Value }, DeleteJournal { id: uuid::Uuid, version: i32 }, } /// 同步响应 #[derive(Debug, Serialize, ToSchema)] pub struct SyncResp { pub server_changes: Vec, pub conflicts: Vec, pub sync_time: chrono::DateTime, } /// 冲突信息 #[derive(Debug, Serialize, ToSchema)] pub struct ConflictInfo { pub journal_id: uuid::Uuid, pub local_version: i32, pub server_version: i32, } // ========== 班级成员 ========== /// 班级成员响应 #[derive(Debug, Serialize, ToSchema)] pub struct ClassMemberResp { pub user_id: uuid::Uuid, pub role: String, pub nickname: Option, pub joined_at: chrono::DateTime, } // ========== 主题布置 ========== /// 创建主题请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateTopicReq { /// 主题标题 #[validate(length(min = 1, max = 200, message = "主题标题长度 1-200 字符"))] pub title: String, /// 主题描述/要求 #[validate(length(max = 2000, message = "主题描述最长 2000 字符"))] pub description: Option, /// 截止日期 pub due_date: Option, } /// 主题响应 #[derive(Debug, Serialize, ToSchema)] pub struct TopicResp { pub id: uuid::Uuid, pub class_id: uuid::Uuid, pub teacher_id: uuid::Uuid, pub title: String, pub description: Option, pub due_date: Option, pub is_active: bool, } // ========== 评语 ========== /// 创建评语请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateCommentReq { /// 评语内容 #[validate(length(min = 1, max = 1000, message = "评语长度 1-1000 字符"))] pub content: String, } /// 更新主题请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateTopicReq { /// 主题标题 #[validate(length(min = 1, max = 200, message = "主题标题长度 1-200 字符"))] pub title: Option, /// 主题描述 #[validate(length(max = 2000, message = "主题描述最长 2000 字符"))] pub description: Option, /// 截止日期 pub due_date: Option, /// 乐观锁版本号 #[validate(range(min = 0, message = "版本号不能为负数"))] pub version: i32, } /// 创建贴纸包请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateStickerPackReq { /// 贴纸包名称 #[validate(length(min = 1, max = 50, message = "贴纸包名称长度 1-50 字符"))] pub name: String, /// 描述 #[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, /// 分类 #[validate(length(max = 30, message = "分类最长 30 字符"))] pub category: Option, } fn default_true() -> bool { true } /// 更新贴纸包请求 — 所有字段可选,仅更新传入的字段 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateStickerPackReq { /// 贴纸包名称 #[validate(length(min = 1, max = 50, message = "贴纸包名称长度 1-50 字符"))] pub name: Option, /// 描述 #[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 字符"))] pub category: Option, } /// 创建贴纸请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateStickerReq { /// 贴纸名称 #[validate(length(min = 1, max = 30, message = "贴纸名称长度 1-30 字符"))] pub name: String, /// 图片 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, } /// 评语响应 #[derive(Debug, Serialize, ToSchema)] pub struct CommentResp { pub id: uuid::Uuid, pub journal_id: uuid::Uuid, pub author_id: uuid::Uuid, pub content: String, pub created_at: chrono::DateTime, } // ========== 通知 ========== /// 通知类型 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum NotificationType { /// 收到评语 CommentReceived, /// 主题布置 TopicAssigned, /// 成就解锁 AchievementUnlocked, /// 班级动态 ClassUpdate, } /// SSE 通知推送负载 #[derive(Debug, Clone, Serialize, ToSchema)] pub struct NotificationPayload { /// 通知类型 pub notification_type: NotificationType, /// 目标用户 ID pub recipient_id: uuid::Uuid, /// 通知标题 pub title: String, /// 通知内容 pub body: String, /// 关联业务 ID(评语 ID / 主题 ID / 成就 ID) pub business_id: Option, /// 附加数据 #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option, } // ========== 心情统计 ========== /// 心情统计响应 #[derive(Debug, Serialize, ToSchema)] pub struct MoodStatsResp { /// 统计周期内各心情出现次数 pub mood_counts: Vec, /// 连续写日记天数 pub streak_days: i32, /// 统计周期内总日记数 pub total_journals: i32, /// 最常用的心情 pub dominant_mood: Option, } /// 单种心情的统计 #[derive(Debug, Serialize, ToSchema)] pub struct MoodCount { pub mood: Mood, pub count: i32, pub percentage: f64, } // ========== 贴纸/模板 ========== /// 贴纸包响应 #[derive(Debug, Serialize, ToSchema)] pub struct StickerPackResp { pub id: uuid::Uuid, pub name: String, pub description: Option, pub cover_image_url: Option, pub sticker_count: i32, pub is_free: bool, pub category: Option, } /// 贴纸响应 #[derive(Debug, Serialize, ToSchema)] pub struct StickerResp { pub id: uuid::Uuid, pub pack_id: uuid::Uuid, pub name: String, pub image_url: String, pub category: Option, } /// 模板响应 #[derive(Debug, Serialize, ToSchema)] pub struct TemplateResp { pub id: uuid::Uuid, pub name: String, pub description: Option, pub preview_url: Option, pub template_data: Option, pub category: Option, pub is_free: bool, } // ========== 发现页 ========== /// 发现页聚合响应 — 一次返回全部板块数据 #[derive(Debug, Serialize, ToSchema)] pub struct DiscoverResp { /// 每日推荐(无共享日记时为 null) pub daily_inspiration: Option, /// 热门话题(标签频率 TOP 8) pub hot_topics: Vec, /// 精选模板(官方模板) pub featured_templates: Vec, /// 达人日记(不同作者最近共享日记) pub expert_diaries: Vec, } /// 每日推荐条目 #[derive(Debug, Serialize, ToSchema)] pub struct InspirationItem { pub journal_id: uuid::Uuid, pub title: String, pub author_name: String, pub mood: String, pub date: chrono::NaiveDate, } /// 热门话题 #[derive(Debug, Serialize, ToSchema)] pub struct TagCount { pub tag: String, pub count: i64, } /// 达人日记条目 #[derive(Debug, Serialize, ToSchema)] pub struct ExpertDiaryItem { pub journal_id: uuid::Uuid, pub title: String, pub author_id: uuid::Uuid, pub author_name: String, pub author_emoji: String, pub content_preview: String, pub like_count: i64, pub created_at: chrono::DateTime, } /// 成就响应 #[derive(Debug, Serialize, ToSchema)] pub struct AchievementResp { pub id: uuid::Uuid, pub code: String, pub name: String, pub description: Option, pub icon: Option, pub category: String, pub is_unlocked: bool, pub unlocked_at: Option>, } #[cfg(test)] mod tests { use super::*; #[test] fn mood_serializes_to_lowercase() { let json = serde_json::to_string(&Mood::Happy).unwrap(); assert_eq!(json, "\"happy\""); let json = serde_json::to_string(&Mood::Thinking).unwrap(); assert_eq!(json, "\"thinking\""); } #[test] fn mood_deserializes() { let m: Mood = serde_json::from_str("\"calm\"").unwrap(); assert!(matches!(m, Mood::Calm)); let m: Mood = serde_json::from_str("\"angry\"").unwrap(); assert!(matches!(m, Mood::Angry)); } #[test] fn weather_roundtrip() { let w = Weather::Snowy; let json = serde_json::to_string(&w).unwrap(); let back: Weather = serde_json::from_str(&json).unwrap(); assert!(matches!(back, Weather::Snowy)); } #[test] fn create_journal_req_deserializes() { let json = r#"{ "title": "测试日记", "date": "2026-06-01", "mood": "happy", "weather": "sunny", "tags": ["日常"], "is_private": true }"#; let req: CreateJournalReq = serde_json::from_str(json).unwrap(); assert_eq!(req.title, "测试日记"); assert!(req.is_private); assert!(req.class_id.is_none()); } #[test] fn update_journal_req_minimal() { let req: UpdateJournalReq = serde_json::from_str(r#"{"version": 2}"#).unwrap(); assert_eq!(req.version, 2); assert!(req.title.is_none()); assert!(req.mood.is_none()); } #[test] fn notification_type_serializes_snake_case() { assert_eq!( serde_json::to_string(&NotificationType::CommentReceived).unwrap(), "\"comment_received\"" ); assert_eq!( serde_json::to_string(&NotificationType::AchievementUnlocked).unwrap(), "\"achievement_unlocked\"" ); } #[test] fn sync_change_variants_serialize() { let create = SyncChange::CreateJournal { data: serde_json::json!({"title": "test"}), }; let json = serde_json::to_string(&create).unwrap(); assert!(json.contains("CreateJournal")); let delete = SyncChange::DeleteJournal { id: uuid::Uuid::nil(), version: 3, }; let json = serde_json::to_string(&delete).unwrap(); assert!(json.contains("DeleteJournal")); } #[test] fn conflict_info_serializes() { let info = ConflictInfo { journal_id: uuid::Uuid::nil(), local_version: 1, server_version: 3, }; let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("\"local_version\":1")); assert!(json.contains("\"server_version\":3")); } #[test] fn sticker_pack_resp_serializes() { let resp = StickerPackResp { id: uuid::Uuid::nil(), name: "测试贴纸包".into(), description: None, cover_image_url: None, sticker_count: 10, is_free: true, category: Some("动物".into()), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"sticker_count\":10")); assert!(json.contains("\"is_free\":true")); } #[test] fn mood_count_percentage() { let mc = MoodCount { mood: Mood::Happy, count: 3, percentage: 42.5, }; let json = serde_json::to_string(&mc).unwrap(); assert!(json.contains("\"percentage\":42.5")); } #[test] fn class_member_resp_fields() { let resp = ClassMemberResp { user_id: uuid::Uuid::nil(), role: "student".into(), nickname: Some("小明".into()), joined_at: chrono::Utc::now(), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"role\":\"student\"")); } #[test] fn topic_resp_serializes() { let resp = TopicResp { id: uuid::Uuid::nil(), class_id: uuid::Uuid::nil(), teacher_id: uuid::Uuid::nil(), title: "我的周末".into(), description: Some("描述".into()), due_date: None, is_active: true, }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"is_active\":true")); } #[test] fn achievement_resp_serializes() { let resp = AchievementResp { id: uuid::Uuid::nil(), code: "first_diary".into(), name: "初次落笔".into(), description: Some("写下第一篇日记".into()), icon: Some("✏️".into()), category: "writing".into(), is_unlocked: true, unlocked_at: None, }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"code\":\"first_diary\"")); assert!(json.contains("\"is_unlocked\":true")); } #[test] fn achievement_resp_unlocked_at_present() { let now = chrono::Utc::now(); let resp = AchievementResp { id: uuid::Uuid::nil(), code: "streak_7".into(), name: "坚持一周".into(), description: None, icon: None, category: "writing".into(), is_unlocked: true, unlocked_at: Some(now), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("unlocked_at")); } #[test] fn mood_stats_resp_structure() { let resp = MoodStatsResp { mood_counts: vec![ MoodCount { mood: Mood::Happy, count: 12, percentage: 60.0, }, MoodCount { mood: Mood::Calm, count: 8, percentage: 40.0, }, ], streak_days: 7, total_journals: 20, dominant_mood: Some(Mood::Happy), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"streak_days\":7")); assert!(json.contains("\"total_journals\":20")); assert!(json.contains("\"dominant_mood\":\"happy\"")); } #[test] fn sticker_resp_fields() { let resp = StickerResp { id: uuid::Uuid::nil(), pack_id: uuid::Uuid::nil(), name: "笑脸".into(), image_url: "https://cdn.example.com/smile.png".into(), category: Some("表情".into()), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"image_url\"")); assert!(json.contains("\"category\":\"表情\"")); } #[test] fn template_resp_with_layout_data() { let resp = TemplateResp { id: uuid::Uuid::nil(), name: "校园日记".into(), description: Some("在学校的一天".into()), preview_url: Some("https://cdn.example.com/preview.png".into()), template_data: Some(serde_json::json!({ "sections": [{"type": "title"}, {"type": "body"}] })), category: Some("校园".into()), is_free: true, }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"sections\"")); assert!(json.contains("\"preview_url\"")); } #[test] fn create_journal_req_with_class() { let json = r#"{ "title": "班级日记", "date": "2026-06-01", "mood": "happy", "weather": "sunny", "tags": ["校园"], "is_private": false, "class_id": "00000000-0000-0000-0000-000000000001" }"#; let req: CreateJournalReq = serde_json::from_str(json).unwrap(); assert!(!req.is_private); assert!(req.class_id.is_some()); } #[test] fn join_class_req() { let json = r#"{"class_code": "ABC123"}"#; let req: JoinClassReq = serde_json::from_str(json).unwrap(); assert_eq!(req.class_code, "ABC123"); } #[test] fn create_class_req() { let json = r#"{"name": "三年二班", "school_name": "阳光小学"}"#; let req: CreateClassReq = serde_json::from_str(json).unwrap(); assert_eq!(req.name, "三年二班"); assert_eq!(req.school_name, Some("阳光小学".into())); } #[test] fn notification_payload_structure() { let payload = NotificationPayload { notification_type: NotificationType::AchievementUnlocked, recipient_id: uuid::Uuid::nil(), title: "成就解锁".into(), body: "你解锁了「初次落笔」成就".into(), business_id: Some(uuid::Uuid::nil()), extra: None, }; let json = serde_json::to_string(&payload).unwrap(); assert!(json.contains("\"notification_type\":\"achievement_unlocked\"")); } }