Explorar el Código

fix(desktop-pet): 对齐真实后台接口契约(任务/能量/吉祥物/喝水)

liaoxg hace 2 días
padre
commit
709cd4fb95

+ 22 - 7
cfc-desktop-pet/src-tauri/src/api/mod.rs

@@ -74,15 +74,27 @@ impl ApiClient {
     }
 
     pub async fn energy_overview(&self) -> Result<EnergyOverview, ApiError> {
-        self.post("/api/energy/overview", None).await
+        // 后端 @RequestBody(required=false),但实测必须带 body 才会进入业务逻辑
+        self.post("/api/energy/overview", Some(&serde_json::json!({}))).await
     }
 
-    pub async fn daily_checkin(&self) -> Result<DailyCheckin, ApiError> {
-        self.post("/api/daily/checkin", None).await
+    /// 最近喝水记录(memberId 由后端从 JWT 解析)
+    pub async fn water_list(&self) -> Result<Vec<WaterRecord>, ApiError> {
+        self.post("/api/health/water/list", Some(&serde_json::json!({}))).await
     }
 
-    pub async fn complete_water(&self) -> Result<serde_json::Value, ApiError> {
-        self.post("/api/daily/checkin", Some(&serde_json::json!({"type": "water"}))).await
+    /// 完成一次喝水打卡(固定快捷打卡:250ml / plain / 当前时间)
+    pub async fn complete_water(&self) -> Result<WaterRecord, ApiError> {
+        let drunk_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
+        self.post(
+            "/api/health/water/create",
+            Some(&serde_json::json!({
+                "amountMl": 250,
+                "waterType": "plain",
+                "drunkAt": drunk_at
+            })),
+        )
+        .await
     }
 
     pub async fn mascot_get(&self) -> Result<MascotInfo, ApiError> {
@@ -98,10 +110,13 @@ mod tests {
     fn parse_success_response() {
         let client = ApiClient::new("http://x".into(), "t".into());
         let data: Vec<CfcTask> = client
-            .parse_response(r#"{"code":200,"data":[{"id":1,"title":"t","status":"PENDING","dueDate":"2026-08-15"}]}"#)
+            .parse_response(r#"{"code":200,"data":[{"id":1,"title":"t","status":"PENDING","deadline":"2026-08-15T00:00:00"}]}"#)
             .unwrap();
         assert_eq!(data.len(), 1);
-        assert_eq!(data[0].due_date.as_deref(), Some("2026-08-15"));
+        assert_eq!(
+            data[0].deadline.as_ref().unwrap().to_local_date(),
+            chrono::NaiveDate::from_ymd_opt(2026, 8, 15)
+        );
     }
 
     #[test]

+ 99 - 19
cfc-desktop-pet/src-tauri/src/api/models.rs

@@ -1,3 +1,4 @@
+use chrono::TimeZone;
 use serde::Deserialize;
 
 /// CFC 统一响应包装 Result<T>(code=200 视为成功)
@@ -15,21 +16,58 @@ impl<T> ApiResult<T> {
     }
 }
 
+/// 兼容后端可能返回的日期格式: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 due_date: Option<String>,
+    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 dimension: Option<String>,
-    pub value: i32,
-    pub color: Option<String>,
+    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)]
@@ -37,21 +75,30 @@ pub struct DimensionValue {
 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 DailyCheckin {
-    pub checked: bool,
-    #[serde(default)]
-    pub items: Vec<String>,
+pub struct MascotInfo {
+    pub mascot: Option<String>,
 }
 
+/// /api/health/water/list|create(`water_record` 表)
 #[derive(Debug, Clone, Deserialize)]
 #[serde(rename_all = "camelCase")]
-pub struct MascotInfo {
-    pub mascot_id: Option<String>,
-    pub name: Option<String>,
+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>,
 }
 
 #[cfg(test)]
@@ -59,30 +106,63 @@ mod tests {
     use super::*;
 
     #[test]
-    fn parses_task_with_camelcase_fields() {
-        let json = r#"{"id":3,"title":"喝水","status":"PENDING","dueDate":"2026-08-15"}"#;
+    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.due_date.as_deref(), Some("2026-08-15"));
+        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":[{"dimension":"身","value":65,"color":"#FF8C42"}]}"##;
+        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].value, 65);
+        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_api_result_envelope() {
-        let json = r#"{"code":200,"message":"ok","data":{"mascotId":"xibao","name":"浠宝"}}"#;
+    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_eq!(r.data.unwrap().mascot_id.as_deref(), Some("xibao"));
+        assert!(r.data.unwrap().mascot.is_none());
     }
 }

+ 52 - 9
cfc-desktop-pet/src-tauri/src/poller.rs

@@ -1,4 +1,4 @@
-use crate::api::{models::DailyCheckin, ApiClient};
+use crate::api::{models::WaterRecord, ApiClient};
 use crate::config::PetConfig;
 use crate::state::{map_state, PetState, PetStatePayload};
 use chrono::Timelike;
@@ -33,7 +33,7 @@ fn in_active_hour(hour: u32) -> bool {
 
 async fn sync_mascot(app: &AppHandle, api: &ApiClient) {
     let Ok(mascot) = api.mascot_get().await else { return };
-    let pet_name = mascot.mascot_id.as_deref().unwrap_or("xibao");
+    let pet_name = mascot.mascot.as_deref().unwrap_or("xibao");
     let path = format!("DefaultPets/{pet_name}/pet-config.json");
 
     // 优先读嵌入资产;失败回退 resource_dir
@@ -56,7 +56,7 @@ async fn sync_mascot(app: &AppHandle, api: &ApiClient) {
 async fn poll_tasks(app: &AppHandle, api: &ApiClient) {
     match api.tasks_today().await {
         Ok(tasks) => {
-            let state = map_state(Some(&tasks), None, None, false);
+            let state = map_state(Some(&tasks), None, false, false);
             let payload = PetStatePayload { state, reason: Some("tasks".into()), data: None };
             let _ = app.emit("pet://state", payload);
         }
@@ -70,9 +70,10 @@ async fn poll_tasks(app: &AppHandle, api: &ApiClient) {
 }
 
 async fn poll_health(app: &AppHandle, api: &ApiClient) {
-    match (api.energy_overview().await, api.daily_checkin().await) {
-        (Ok(energy), Ok(checkin)) => {
-            let state = map_state(None, Some(&energy), Some(&checkin), false);
+    match (api.energy_overview().await, api.water_list().await) {
+        (Ok(energy), Ok(water)) => {
+            let done = water_done_today(&water);
+            let state = map_state(None, Some(&energy), done, false);
             let payload = PetStatePayload { state, reason: Some("health".into()), data: None };
             let _ = app.emit("pet://state", payload);
         }
@@ -85,11 +86,11 @@ async fn check_water(app: &AppHandle, api: &ApiClient) {
     if !in_active_hour(hour) {
         return;
     }
-    let checkin: DailyCheckin = match api.daily_checkin().await {
-        Ok(c) => c,
+    let water = match api.water_list().await {
+        Ok(w) => w,
         Err(_) => return,
     };
-    if !checkin.items.iter().any(|i| i == "water") {
+    if !water_done_today(&water) {
         let payload = PetStatePayload {
             state: PetState::WaitingForInteraction,
             reason: Some("drink_water".into()),
@@ -103,9 +104,23 @@ async fn check_water(app: &AppHandle, api: &ApiClient) {
     }
 }
 
+/// 今天是否已有喝水记录(drunkAt 优先,回退 createdAt)
+fn water_done_today(records: &[WaterRecord]) -> bool {
+    let today = chrono::Local::now().date_naive();
+    records.iter().any(|r| {
+        r.drunk_at
+            .as_ref()
+            .or(r.created_at.as_ref())
+            .and_then(|d| d.to_local_date())
+            .map(|d| d == today)
+            .unwrap_or(false)
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::api::models::FlexDate;
 
     #[test]
     fn active_hour_window_is_10_to_20_inclusive() {
@@ -115,4 +130,32 @@ mod tests {
         assert!(in_active_hour(20));
         assert!(!in_active_hour(21));
     }
+
+    fn water(drunk_at: Option<&str>, created_at: Option<&str>) -> WaterRecord {
+        WaterRecord {
+            id: None,
+            member_id: None,
+            checkin_id: None,
+            amount_ml: None,
+            water_type: None,
+            drunk_at: drunk_at.map(|s| FlexDate::Iso(s.to_string())),
+            created_at: created_at.map(|s| FlexDate::Iso(s.to_string())),
+        }
+    }
+
+    #[test]
+    fn water_done_today_matches_today_only() {
+        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
+        let today_dt = format!("{today}T08:00:00");
+        assert!(water_done_today(&[water(Some(&today_dt), None)]));
+        assert!(!water_done_today(&[water(Some("2000-01-01T08:00:00"), None)]));
+        assert!(!water_done_today(&[]));
+    }
+
+    #[test]
+    fn water_done_today_falls_back_to_created_at() {
+        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
+        let today_dt = format!("{today}T09:30:00");
+        assert!(water_done_today(&[water(None, Some(&today_dt))]));
+    }
 }

+ 40 - 41
cfc-desktop-pet/src-tauri/src/state.rs

@@ -1,6 +1,6 @@
 use serde::Serialize;
 
-use crate::api::models::{CfcTask, DailyCheckin, EnergyOverview};
+use crate::api::models::{CfcTask, EnergyOverview};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
 #[serde(rename_all = "camelCase")]
@@ -33,7 +33,7 @@ pub struct PetStatePayload {
 pub fn map_state(
     tasks: Option<&[CfcTask]>,
     energy: Option<&EnergyOverview>,
-    checkin: Option<&DailyCheckin>,
+    water_done_today: bool,
     user_inactive: bool,
 ) -> PetState {
     let has_pending = tasks
@@ -46,10 +46,8 @@ pub fn map_state(
         return PetState::Sleeping;
     }
 
-    if let Some(c) = checkin {
-        if c.checked {
-            return PetState::Excited;
-        }
+    if water_done_today {
+        return PetState::Excited;
     }
 
     if let Some(ts) = tasks {
@@ -60,12 +58,9 @@ pub fn map_state(
             let today = chrono::Local::now().date_naive();
             let overdue = ts.iter().any(|t| {
                 matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS"))
-                    && t.due_date
-                        .as_deref()
-                        .and_then(|d| {
-                            let head = &d[..d.len().min(10)];
-                            chrono::NaiveDate::parse_from_str(head, "%Y-%m-%d").ok()
-                        })
+                    && t.deadline
+                        .as_ref()
+                        .and_then(|d| d.to_local_date())
                         .map(|due| due < today)
                         .unwrap_or(false)
             });
@@ -78,22 +73,22 @@ pub fn map_state(
 
     if let Some(e) = energy {
         if !e.dimensions.is_empty() {
-            let all_low = e.dimensions.iter().all(|d| d.value < 30);
+            let all_low = e.dimensions.iter().all(|d| d.energy < 30);
             if all_low {
                 return PetState::Sad;
             }
-            let body = e.dimensions.iter().find(|d| d.dimension.as_deref() == Some("身"));
+            let body = e.dimensions.iter().find(|d| d.code.as_deref() == Some("body"));
             if let Some(b) = body {
-                if b.value < 30 {
+                if b.energy < 30 {
                     return PetState::Tired;
                 }
-                if b.value >= 85 {
+                if b.energy >= 85 {
                     return PetState::Energetic;
                 }
             }
-            let mind = e.dimensions.iter().find(|d| d.dimension.as_deref() == Some("智"));
+            let mind = e.dimensions.iter().find(|d| d.code.as_deref() == Some("wisdom"));
             if let Some(m) = mind {
-                if m.value >= 85 {
+                if m.energy >= 85 {
                     return PetState::Curious;
                 }
             }
@@ -106,14 +101,14 @@ pub fn map_state(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::api::models::{CfcTask, DailyCheckin, DimensionValue, EnergyOverview};
+    use crate::api::models::{DimensionValue, FlexDate};
 
     fn task(status: &str, due: Option<&str>) -> CfcTask {
         CfcTask {
             id: 1,
             title: None,
             status: Some(status.to_string()),
-            due_date: due.map(|s| s.to_string()),
+            deadline: due.map(|s| FlexDate::Iso(s.to_string())),
         }
     }
 
@@ -121,78 +116,82 @@ mod tests {
         EnergyOverview {
             dimensions: dims
                 .iter()
-                .map(|(d, v)| DimensionValue {
-                    dimension: Some(d.to_string()),
-                    value: *v,
-                    color: None,
+                .map(|(code, v)| DimensionValue {
+                    code: Some(code.to_string()),
+                    name: None,
+                    icon: None,
+                    element: None,
+                    energy: *v,
+                    health_index: 0,
                 })
                 .collect(),
+            total_energy: 0,
+            total_health_index: 0,
         }
     }
 
     #[test]
     fn sleeping_when_inactive_and_no_pending() {
-        assert_eq!(map_state(Some(&[]), None, None, true), PetState::Sleeping);
+        assert_eq!(map_state(Some(&[]), None, false, true), PetState::Sleeping);
     }
 
     #[test]
     fn not_sleeping_when_inactive_but_pending() {
         let tasks = vec![task("PENDING", None)];
-        assert_eq!(map_state(Some(&tasks), None, None, true), PetState::Busy);
+        assert_eq!(map_state(Some(&tasks), None, false, true), PetState::Busy);
     }
 
     #[test]
-    fn excited_beats_tasks_when_checked() {
+    fn excited_when_water_done_today() {
         let tasks = vec![task("PENDING", None)];
-        let checkin = DailyCheckin { checked: true, items: vec!["water".into()] };
-        assert_eq!(map_state(Some(&tasks), None, Some(&checkin), false), PetState::Excited);
+        assert_eq!(map_state(Some(&tasks), None, true, false), PetState::Excited);
     }
 
     #[test]
     fn happy_when_all_completed() {
         let tasks = vec![task("COMPLETED", None), task("COMPLETED", Some("2026-01-01"))];
-        assert_eq!(map_state(Some(&tasks), None, None, false), PetState::Happy);
+        assert_eq!(map_state(Some(&tasks), None, false, false), PetState::Happy);
     }
 
     #[test]
     fn worried_when_overdue() {
         let tasks = vec![task("PENDING", Some("2000-01-01"))];
-        assert_eq!(map_state(Some(&tasks), None, None, false), PetState::Worried);
+        assert_eq!(map_state(Some(&tasks), None, false, false), PetState::Worried);
     }
 
     #[test]
     fn busy_when_in_progress_not_overdue() {
         let tasks = vec![task("IN_PROGRESS", Some("2099-12-31"))];
-        assert_eq!(map_state(Some(&tasks), None, None, false), PetState::Busy);
+        assert_eq!(map_state(Some(&tasks), None, false, false), PetState::Busy);
     }
 
     #[test]
     fn sad_when_all_dimensions_low() {
-        let e = energy(&[("身", 10), ("智", 20), ("心", 15)]);
-        assert_eq!(map_state(None, Some(&e), None, false), PetState::Sad);
+        let e = energy(&[("body", 10), ("wisdom", 20), ("mind", 15)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Sad);
     }
 
     #[test]
     fn tired_when_body_low() {
-        let e = energy(&[("身", 20), ("智", 90)]);
-        assert_eq!(map_state(None, Some(&e), None, false), PetState::Tired);
+        let e = energy(&[("body", 20), ("wisdom", 90)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Tired);
     }
 
     #[test]
     fn energetic_when_body_high() {
-        let e = energy(&[("身", 90)]);
-        assert_eq!(map_state(None, Some(&e), None, false), PetState::Energetic);
+        let e = energy(&[("body", 90)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Energetic);
     }
 
     #[test]
     fn curious_when_mind_high() {
-        let e = energy(&[("身", 60), ("智", 88)]);
-        assert_eq!(map_state(None, Some(&e), None, false), PetState::Curious);
+        let e = energy(&[("body", 60), ("wisdom", 88)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Curious);
     }
 
     #[test]
     fn idle_when_nothing_qualifies() {
-        assert_eq!(map_state(None, None, None, false), PetState::Idle);
+        assert_eq!(map_state(None, None, false, false), PetState::Idle);
     }
 
     #[test]