Files
nj/crates/erp-diary/src/dto.rs
iven dbb74b6545 fix(diary): 系统性修复 DTO 输入验证 — 42 项审计发现中输入验证类全部修复
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
2026-06-07 12:55:50 +08:00

810 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<String>,
pub is_private: bool,
pub class_id: Option<uuid::Uuid>,
pub assigned_topic_id: Option<uuid::Uuid>,
}
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<String>,
pub mood: Option<Mood>,
pub weather: Option<Weather>,
#[validate(length(max = 20, message = "标签最多 20 个"))]
pub tags: Option<Vec<String>>,
pub is_private: Option<bool>,
pub shared_to_class: Option<bool>,
#[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<uuid::Uuid>,
pub title: String,
pub date: chrono::NaiveDate,
pub mood: Mood,
pub weather: Weather,
pub tags: Vec<String>,
pub is_private: bool,
pub shared_to_class: bool,
pub assigned_topic_id: Option<uuid::Uuid>,
pub version: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
/// 创建班级请求
#[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<String>,
}
/// 加入班级请求
#[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<String>,
/// 学校名称
#[validate(length(max = 100, message = "学校名称最长 100 字符"))]
pub school_name: Option<String>,
/// 乐观锁版本号
#[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<String>,
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<chrono::DateTime<chrono::Utc>>,
#[validate(length(max = 100, message = "单次同步最多 100 条变更"))]
pub changes: Vec<SyncChange>,
}
/// 单条同步变更的 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<serde_json::Value>,
pub conflicts: Vec<ConflictInfo>,
pub sync_time: chrono::DateTime<chrono::Utc>,
}
/// 冲突信息
#[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<String>,
pub joined_at: chrono::DateTime<chrono::Utc>,
}
// ========== 主题布置 ==========
/// 创建主题请求
#[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<String>,
/// 截止日期
pub due_date: Option<chrono::NaiveDate>,
}
/// 主题响应
#[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<String>,
pub due_date: Option<chrono::NaiveDate>,
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<String>,
/// 主题描述
#[validate(length(max = 2000, message = "主题描述最长 2000 字符"))]
pub description: Option<String>,
/// 截止日期
pub due_date: Option<chrono::NaiveDate>,
/// 乐观锁版本号
#[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<String>,
/// 缩略图 URL
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
pub thumbnail_url: Option<String>,
/// 是否免费
#[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<String>,
}
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<String>,
/// 描述
#[validate(length(max = 500, message = "描述最长 500 字符"))]
pub description: Option<String>,
/// 缩略图 URL
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
pub thumbnail_url: Option<String>,
/// 是否免费
pub is_free: Option<bool>,
/// 价格(积分)
#[validate(range(min = 0, message = "价格不能为负数"))]
pub price: Option<i32>,
/// 分类
#[validate(length(max = 30, message = "分类最长 30 字符"))]
pub category: Option<String>,
}
/// 创建贴纸请求
#[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<uuid::Uuid>,
/// 分类
#[validate(length(max = 30, message = "分类最长 30 字符"))]
pub category: Option<String>,
}
/// 评语响应
#[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<chrono::Utc>,
}
// ========== 通知 ==========
/// 通知类型
#[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<uuid::Uuid>,
/// 附加数据
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
// ========== 心情统计 ==========
/// 心情统计响应
#[derive(Debug, Serialize, ToSchema)]
pub struct MoodStatsResp {
/// 统计周期内各心情出现次数
pub mood_counts: Vec<MoodCount>,
/// 连续写日记天数
pub streak_days: i32,
/// 统计周期内总日记数
pub total_journals: i32,
/// 最常用的心情
pub dominant_mood: Option<Mood>,
}
/// 单种心情的统计
#[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<String>,
pub cover_image_url: Option<String>,
pub sticker_count: i32,
pub is_free: bool,
pub category: Option<String>,
}
/// 贴纸响应
#[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<String>,
}
/// 模板响应
#[derive(Debug, Serialize, ToSchema)]
pub struct TemplateResp {
pub id: uuid::Uuid,
pub name: String,
pub description: Option<String>,
pub preview_url: Option<String>,
pub template_data: Option<serde_json::Value>,
pub category: Option<String>,
pub is_free: bool,
}
// ========== 发现页 ==========
/// 发现页聚合响应 — 一次返回全部板块数据
#[derive(Debug, Serialize, ToSchema)]
pub struct DiscoverResp {
/// 每日推荐(无共享日记时为 null
pub daily_inspiration: Option<InspirationItem>,
/// 热门话题(标签频率 TOP 8
pub hot_topics: Vec<TagCount>,
/// 精选模板(官方模板)
pub featured_templates: Vec<TemplateResp>,
/// 达人日记(不同作者最近共享日记)
pub expert_diaries: Vec<ExpertDiaryItem>,
}
/// 每日推荐条目
#[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<chrono::Utc>,
}
/// 成就响应
#[derive(Debug, Serialize, ToSchema)]
pub struct AchievementResp {
pub id: uuid::Uuid,
pub code: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub category: String,
pub is_unlocked: bool,
pub unlocked_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[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\""));
}
}