| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- use chrono::TimeZone;
- use serde::Deserialize;
- /// CFC 统一响应包装 Result<T>(code=200 视为成功)
- #[derive(Debug, Clone, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct ApiResult<T> {
- pub code: i32,
- pub message: Option<String>,
- pub data: Option<T>,
- }
- impl<T> ApiResult<T> {
- 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<chrono::NaiveDate> {
- 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<String>,
- pub status: Option<String>, // PENDING / IN_PROGRESS / COMPLETED / REJECTED
- pub deadline: Option<FlexDate>,
- }
- /// /api/energy/overview 五维维度项:{code,name,icon,element,energy,healthIndex}
- #[derive(Debug, Clone, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct DimensionValue {
- pub code: Option<String>, // body/mind/wisdom/action/wealth
- pub name: Option<String>, // 身/心/智/行/富
- pub icon: Option<String>,
- pub element: Option<String>,
- #[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<DimensionValue>,
- #[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<String>,
- }
- /// /api/health/water/list|create(`water_record` 表)
- #[derive(Debug, Clone, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct WaterRecord {
- pub id: Option<i64>,
- pub member_id: Option<i64>,
- pub checkin_id: Option<i64>,
- pub amount_ml: Option<i32>,
- pub water_type: Option<String>,
- pub drunk_at: Option<FlexDate>,
- pub created_at: Option<FlexDate>,
- }
- /// /api/media/upload 返回的媒体记录(仅取所需字段,其余忽略)
- #[derive(Debug, Clone, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct MediaRecord {
- #[allow(dead_code)]
- pub id: Option<i64>,
- pub url: Option<String>,
- }
- /// 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<String>,
- }
- /// 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<String>,
- pub user_id: Option<i64>,
- pub role: Option<String>,
- pub nickname: Option<String>,
- pub message: Option<String>,
- }
- #[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<MediaRecord> = 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<MascotInfo> = 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<MascotInfo> = 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<WechatQrResp> = 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<WechatQrResp> = 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<WechatPollResp> = 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<WechatPollResp> =
- 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());
- }
- }
|