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
810 lines
24 KiB
Rust
810 lines
24 KiB
Rust
// 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\""));
|
||
}
|
||
}
|