use chrono::TimeZone; use serde::Deserialize; /// CFC 统一响应包装 Result(code=200 视为成功) #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApiResult { pub code: i32, pub message: Option, pub data: Option, } impl ApiResult { pub fn is_success(&self) -> bool { self.code == 200 } } /// 兼容后端可能返回的日期格式:ISO 8601 字符串 或 epoch 毫秒数字。 /// /// Spring Boot 默认把 `java.util.Date` 序列化为 epoch 毫秒(数字);项目内个别 /// 字段用 `@JsonFormat` 输出字符串。两种都接受,统一归一化为本地日期。 #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum FlexDate { Iso(String), Millis(i64), } impl FlexDate { /// 归一化为本地时区日期(yyyy-MM-dd)。 pub fn to_local_date(&self) -> Option { match self { FlexDate::Iso(s) => { if s.len() < 10 { return None; } let head = &s[..10]; chrono::NaiveDate::parse_from_str(head, "%Y-%m-%d").ok() } FlexDate::Millis(ms) => chrono::Local .timestamp_millis_opt(*ms) .single() .map(|dt| dt.date_naive()), } } } /// POST /api/tasks/today 返回的 Task(`tasks` 表) #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CfcTask { pub id: i64, pub title: Option, pub status: Option, // PENDING / IN_PROGRESS / COMPLETED / REJECTED pub deadline: Option, } /// /api/energy/overview 五维维度项:{code,name,icon,element,energy,healthIndex} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DimensionValue { pub code: Option, // body/mind/wisdom/action/wealth pub name: Option, // 身/心/智/行/富 pub icon: Option, pub element: Option, #[serde(default)] pub energy: i32, #[serde(default)] pub health_index: i32, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EnergyOverview { #[serde(default)] pub dimensions: Vec, #[serde(default)] pub total_energy: i32, #[serde(default)] pub total_health_index: i32, } /// /api/user/mascot/get → {"mascot": "xibao"|"fubao"|null} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MascotInfo { pub mascot: Option, } /// /api/health/water/list|create(`water_record` 表) #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WaterRecord { pub id: Option, pub member_id: Option, pub checkin_id: Option, pub amount_ml: Option, pub water_type: Option, pub drunk_at: Option, pub created_at: Option, } /// /api/media/upload 返回的媒体记录(仅取所需字段,其余忽略) #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaRecord { #[allow(dead_code)] pub id: Option, pub url: Option, } /// POST /api/auth/wechat-web/qr → {state, qrImage} /// qrImage 为小程序码的 data:image/png;base64,... 数据 URI(测试模式为空串) #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WechatQrResp { pub state: String, #[serde(default)] pub qr_image: Option, } /// POST /api/auth/wechat-web/poll → {status, token?, userId?, role?, nickname?, message?} /// status: pending(等待扫码) / success / notfound(微信未绑定小程序账号) / expired / error #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WechatPollResp { pub status: String, pub token: Option, pub user_id: Option, pub role: Option, pub nickname: Option, pub message: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn parses_media_record_upload_response() { let json = r#"{"code":200,"message":"success","data":{"id":9,"type":"image","url":"https://cdn/x.png","creatorId":null}}"#; let r: ApiResult = serde_json::from_str(json).unwrap(); assert!(r.is_success()); let rec = r.data.unwrap(); assert_eq!(rec.id, Some(9)); assert_eq!(rec.url.as_deref(), Some("https://cdn/x.png")); } #[test] fn parses_task_with_iso_deadline() { let json = r#"{"id":3,"title":"喝水","status":"PENDING","deadline":"2026-08-15T10:00:00"}"#; let task: CfcTask = serde_json::from_str(json).unwrap(); assert_eq!(task.id, 3); assert_eq!(task.status.as_deref(), Some("PENDING")); assert_eq!( task.deadline.as_ref().unwrap().to_local_date(), chrono::NaiveDate::from_ymd_opt(2026, 8, 15) ); } #[test] fn parses_task_with_millis_deadline() { let json = r#"{"id":4,"title":"t","status":"PENDING","deadline":1786752000000}"#; let task: CfcTask = serde_json::from_str(json).unwrap(); assert!(task.deadline.as_ref().unwrap().to_local_date().is_some()); } #[test] fn parses_task_with_null_deadline() { let json = r#"{"id":5,"title":"t","status":"PENDING","deadline":null}"#; let task: CfcTask = serde_json::from_str(json).unwrap(); assert!(task.deadline.is_none()); } #[test] fn flex_date_millis_zero_is_a_valid_date() { let d: FlexDate = serde_json::from_str("0").unwrap(); assert!(d.to_local_date().is_some()); } #[test] fn parses_energy_overview_and_defaults_to_empty() { let json = r##"{"dimensions":[{"code":"body","name":"身","energy":65,"healthIndex":0}],"totalEnergy":65,"totalHealthIndex":0}"##; let e: EnergyOverview = serde_json::from_str(json).unwrap(); assert_eq!(e.dimensions.len(), 1); assert_eq!(e.dimensions[0].energy, 65); assert_eq!(e.dimensions[0].code.as_deref(), Some("body")); let empty: EnergyOverview = serde_json::from_str(r#"{}"#).unwrap(); assert!(empty.dimensions.is_empty()); assert_eq!(empty.total_energy, 0); } #[test] fn parses_mascot_response() { let json = r#"{"code":200,"message":"success","data":{"mascot":"xibao"}}"#; let r: ApiResult = serde_json::from_str(json).unwrap(); assert!(r.is_success()); assert_eq!(r.data.unwrap().mascot.as_deref(), Some("xibao")); } #[test] fn parses_mascot_null() { let json = r#"{"code":200,"message":"success","data":{"mascot":null}}"#; let r: ApiResult = serde_json::from_str(json).unwrap(); assert!(r.is_success()); assert!(r.data.unwrap().mascot.is_none()); } #[test] fn parses_wechat_qr_response() { let json = r#"{"code":200,"message":"success","data":{"state":"abc123","qrImage":"data:image/png;base64,AAAA"}}"#; let r: ApiResult = serde_json::from_str(json).unwrap(); assert!(r.is_success()); let q = r.data.unwrap(); assert_eq!(q.state, "abc123"); assert!(q .qr_image .as_deref() .unwrap_or("") .starts_with("data:image/png;base64,")); } #[test] fn parses_wechat_qr_response_empty_image() { // 测试模式后端返回 qrImage="":字段存在但为空 let json = r#"{"code":200,"data":{"state":"abc123","qrImage":""}}"#; let r: ApiResult = serde_json::from_str(json).unwrap(); let q = r.data.unwrap(); assert_eq!(q.state, "abc123"); assert_eq!(q.qr_image.as_deref(), Some("")); } #[test] fn parses_wechat_poll_success_and_pending() { let ok: ApiResult = serde_json::from_str( r#"{"code":200,"data":{"status":"success","token":"jwt","userId":7,"role":"parent","nickname":"牛"}}"#, ) .unwrap(); let p = ok.data.unwrap(); assert_eq!(p.status, "success"); assert_eq!(p.token.as_deref(), Some("jwt")); assert_eq!(p.user_id, Some(7)); let pending: ApiResult = serde_json::from_str(r#"{"code":200,"data":{"status":"pending"}}"#).unwrap(); let p2 = pending.data.unwrap(); assert_eq!(p2.status, "pending"); assert!(p2.token.is_none()); } }