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 为空"), } } } /// 规范化 base_url:非本机主机的 `http://` 一律升级为 `https://`。 /// /// 生产 `http://cfc.cf-club.com` 会被 nginx 以 301 重定向到 https,而 reqwest 在 /// 301/302/303 上会把 POST 降级为 GET —— 后端对 GET 一律返回 405 /// ("请求方法不支持,本平台接口统一使用POST")。直接升级协议头即可让 POST 原样直达, /// 从根上消除该 405;本机 / 内网地址保留 http,便于本地联调。 pub fn normalize_base_url(raw: &str) -> String { let t = raw.trim().trim_end_matches('/'); if let Some(rest) = t.strip_prefix("http://") { let host_port = rest.split('/').next().unwrap_or(""); let host = host_port.split(':').next().unwrap_or(""); let is_local = host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" || host.starts_with("192.168.") || host.starts_with("10.") || host.starts_with("172."); if !is_local { return format!("https://{rest}"); } } t.to_string() } pub struct ApiClient { http: reqwest::Client, base_url: RwLock, token: RwLock, } impl ApiClient { pub fn new(base_url: String, token: String) -> Self { Self { http: reqwest::Client::new(), base_url: RwLock::new(normalize_base_url(&base_url)), 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() = normalize_base_url(&base_url); } pub fn parse_response(&self, body: &str) -> Result { let result: ApiResult = 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(&self, path: &str, body: Option<&serde_json::Value>) -> Result { 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, ApiError> { self.post("/api/tasks/today", Some(&serde_json::json!({}))).await } pub async fn energy_overview(&self) -> Result { // 后端 @RequestBody(required=false),但实测必须带 body 才会进入业务逻辑 self.post("/api/energy/overview", Some(&serde_json::json!({}))).await } /// 最近喝水记录(memberId 由后端从 JWT 解析) pub async fn water_list(&self) -> Result, ApiError> { self.post("/api/health/water/list", Some(&serde_json::json!({}))).await } /// 完成一次喝水打卡(固定快捷打卡:250ml / plain / 当前时间) pub async fn complete_water(&self) -> Result { 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 { self.post("/api/user/mascot/get", None).await } /// 一键完成任务(memberId 由后端从 JWT 解析) pub async fn complete_task(&self, task_id: i64) -> Result { self.post(&format!("/api/tasks/{task_id}/complete"), Some(&serde_json::json!({}))).await } /// 上传图片等媒体文件(multipart,复用 /api/media/upload) pub async fn upload_media( &self, filename: &str, bytes: Vec, mime: &str, ) -> Result { let url = format!("{}/api/media/upload", *self.base_url.read().unwrap()); let token = self.token.read().unwrap().clone(); let part = reqwest::multipart::Part::bytes(bytes.clone()).file_name(filename.to_string()); let part = if mime.is_empty() { part } else { part.mime_str(mime).unwrap_or_else(|_| { reqwest::multipart::Part::bytes(bytes).file_name(filename.to_string()) }) }; let form = reqwest::multipart::Form::new().part("file", part); let resp = self .http .post(&url) .bearer_auth(token) .multipart(form) .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 wechat_web_qr(&self) -> Result { self.post("/api/auth/wechat-web/qr", Some(&serde_json::json!({}))).await } /// 轮询扫码登录结果(state 由 wechat_web_qr 返回) pub async fn wechat_web_poll(&self, state: &str) -> Result { self.post("/api/auth/wechat-web/poll", Some(&serde_json::json!({ "state": state }))).await } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_success_response() { let client = ApiClient::new("http://x".into(), "t".into()); let data: Vec = client .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].deadline.as_ref().unwrap().to_local_date(), chrono::NaiveDate::from_ymd_opt(2026, 8, 15) ); } #[test] fn parse_error_code_returns_api_error() { let client = ApiClient::new("http://x".into(), "t".into()); let err = client.parse_response::(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::("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::(r#"{"code":200}"#), Err(ApiError::EmptyData))); } #[test] fn normalize_upgrades_public_http_to_https() { assert_eq!( normalize_base_url("http://cfc.cf-club.com"), "https://cfc.cf-club.com" ); assert_eq!( normalize_base_url("http://cfc.cf-club.com/"), "https://cfc.cf-club.com" ); assert_eq!( normalize_base_url(" http://cfc.cf-club.com "), "https://cfc.cf-club.com" ); // 已是 https 保持不变 assert_eq!( normalize_base_url("https://cfc.cf-club.com"), "https://cfc.cf-club.com" ); } #[test] fn normalize_keeps_local_and_private_http() { assert_eq!(normalize_base_url("http://localhost:9082"), "http://localhost:9082"); assert_eq!(normalize_base_url("http://127.0.0.1:9082"), "http://127.0.0.1:9082"); assert_eq!( normalize_base_url("http://192.168.16.251:9082"), "http://192.168.16.251:9082" ); assert_eq!(normalize_base_url("http://10.0.0.5"), "http://10.0.0.5"); } }