Selaa lähdekoodia

feat(desktop-pet): ApiClient 端点封装与响应解析

liaoxg 1 päivä sitten
vanhempi
sitoutus
ae26a274f1
1 muutettua tiedostoa jossa 127 lisäystä ja 0 poistoa
  1. 127 0
      cfc-desktop-pet/src-tauri/src/api/mod.rs

+ 127 - 0
cfc-desktop-pet/src-tauri/src/api/mod.rs

@@ -1 +1,128 @@
 pub mod models;
+
+pub use models::*;
+use serde::de::DeserializeOwned;
+use std::sync::RwLock;
+
+#[derive(Debug, Clone)]
+pub enum ApiError {
+    Http(String),
+    Parse,
+    Api { code: i32, message: String },
+    EmptyData,
+}
+
+impl std::fmt::Display for ApiError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ApiError::Http(m) => write!(f, "http: {m}"),
+            ApiError::Parse => write!(f, "响应解析失败"),
+            ApiError::Api { code, message } => write!(f, "服务错误 {code}: {message}"),
+            ApiError::EmptyData => write!(f, "响应 data 为空"),
+        }
+    }
+}
+
+pub struct ApiClient {
+    http: reqwest::Client,
+    base_url: RwLock<String>,
+    token: RwLock<String>,
+}
+
+impl ApiClient {
+    pub fn new(base_url: String, token: String) -> Self {
+        Self {
+            http: reqwest::Client::new(),
+            base_url: RwLock::new(base_url.trim_end_matches('/').to_string()),
+            token: RwLock::new(token),
+        }
+    }
+
+    pub fn update_token(&self, token: String) {
+        *self.token.write().unwrap() = token;
+    }
+
+    pub fn set_base_url(&self, base_url: String) {
+        *self.base_url.write().unwrap() = base_url.trim_end_matches('/').to_string();
+    }
+
+    pub fn parse_response<T: DeserializeOwned>(&self, body: &str) -> Result<T, ApiError> {
+        let result: ApiResult<T> = serde_json::from_str(body).map_err(|_| ApiError::Parse)?;
+        if !result.is_success() {
+            return Err(ApiError::Api {
+                code: result.code,
+                message: result.message.unwrap_or_default(),
+            });
+        }
+        result.data.ok_or(ApiError::EmptyData)
+    }
+
+    async fn post<T: DeserializeOwned>(&self, path: &str, body: Option<&serde_json::Value>) -> Result<T, ApiError> {
+        let url = format!("{}{}", *self.base_url.read().unwrap(), path);
+        let token = self.token.read().unwrap().clone();
+        let mut req = self.http.post(&url).bearer_auth(token);
+        if let Some(b) = body {
+            req = req.json(b);
+        }
+        let resp = req.send().await.map_err(|e| ApiError::Http(e.to_string()))?;
+        let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
+        self.parse_response(&text)
+    }
+
+    pub async fn tasks_today(&self) -> Result<Vec<CfcTask>, ApiError> {
+        self.post("/api/tasks/today", Some(&serde_json::json!({}))).await
+    }
+
+    pub async fn energy_overview(&self) -> Result<EnergyOverview, ApiError> {
+        self.post("/api/energy/overview", None).await
+    }
+
+    pub async fn daily_checkin(&self) -> Result<DailyCheckin, ApiError> {
+        self.post("/api/daily/checkin", None).await
+    }
+
+    pub async fn complete_water(&self) -> Result<serde_json::Value, ApiError> {
+        self.post("/api/daily/checkin", Some(&serde_json::json!({"type": "water"}))).await
+    }
+
+    pub async fn mascot_get(&self) -> Result<MascotInfo, ApiError> {
+        self.post("/api/user/mascot/get", None).await
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    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"}]}"#)
+            .unwrap();
+        assert_eq!(data.len(), 1);
+        assert_eq!(data[0].due_date.as_deref(), Some("2026-08-15"));
+    }
+
+    #[test]
+    fn parse_error_code_returns_api_error() {
+        let client = ApiClient::new("http://x".into(), "t".into());
+        let err = client.parse_response::<serde_json::Value>(r#"{"code":401,"message":"unauthorized"}"#).unwrap_err();
+        match err {
+            ApiError::Api { code, .. } => assert_eq!(code, 401),
+            other => panic!("unexpected error: {other:?}"),
+        }
+    }
+
+    #[test]
+    fn parse_malformed_returns_parse_error() {
+        let client = ApiClient::new("http://x".into(), "t".into());
+        assert!(matches!(client.parse_response::<serde_json::Value>("not json"), Err(ApiError::Parse)));
+    }
+
+    #[test]
+    fn parse_empty_data_returns_empty_error() {
+        let client = ApiClient::new("http://x".into(), "t".into());
+        assert!(matches!(client.parse_response::<CfcTask>(r#"{"code":200}"#), Err(ApiError::EmptyData)));
+    }
+}