mod.rs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. pub mod models;
  2. pub use models::*;
  3. use serde::de::DeserializeOwned;
  4. use std::sync::RwLock;
  5. #[derive(Debug, Clone)]
  6. pub enum ApiError {
  7. Http(String),
  8. Parse,
  9. Api { code: i32, message: String },
  10. EmptyData,
  11. }
  12. impl std::fmt::Display for ApiError {
  13. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  14. match self {
  15. ApiError::Http(m) => write!(f, "http: {m}"),
  16. ApiError::Parse => write!(f, "响应解析失败"),
  17. ApiError::Api { code, message } => write!(f, "服务错误 {code}: {message}"),
  18. ApiError::EmptyData => write!(f, "响应 data 为空"),
  19. }
  20. }
  21. }
  22. /// 规范化 base_url:非本机主机的 `http://` 一律升级为 `https://`。
  23. ///
  24. /// 生产 `http://cfc.cf-club.com` 会被 nginx 以 301 重定向到 https,而 reqwest 在
  25. /// 301/302/303 上会把 POST 降级为 GET —— 后端对 GET 一律返回 405
  26. /// ("请求方法不支持,本平台接口统一使用POST")。直接升级协议头即可让 POST 原样直达,
  27. /// 从根上消除该 405;本机 / 内网地址保留 http,便于本地联调。
  28. pub fn normalize_base_url(raw: &str) -> String {
  29. let t = raw.trim().trim_end_matches('/');
  30. if let Some(rest) = t.strip_prefix("http://") {
  31. let host_port = rest.split('/').next().unwrap_or("");
  32. let host = host_port.split(':').next().unwrap_or("");
  33. let is_local = host == "localhost"
  34. || host == "127.0.0.1"
  35. || host == "::1"
  36. || host == "[::1]"
  37. || host.starts_with("192.168.")
  38. || host.starts_with("10.")
  39. || host.starts_with("172.");
  40. if !is_local {
  41. return format!("https://{rest}");
  42. }
  43. }
  44. t.to_string()
  45. }
  46. pub struct ApiClient {
  47. http: reqwest::Client,
  48. base_url: RwLock<String>,
  49. token: RwLock<String>,
  50. }
  51. impl ApiClient {
  52. pub fn new(base_url: String, token: String) -> Self {
  53. Self {
  54. http: reqwest::Client::new(),
  55. base_url: RwLock::new(normalize_base_url(&base_url)),
  56. token: RwLock::new(token),
  57. }
  58. }
  59. pub fn update_token(&self, token: String) {
  60. *self.token.write().unwrap() = token;
  61. }
  62. pub fn set_base_url(&self, base_url: String) {
  63. *self.base_url.write().unwrap() = normalize_base_url(&base_url);
  64. }
  65. pub fn parse_response<T: DeserializeOwned>(&self, body: &str) -> Result<T, ApiError> {
  66. let result: ApiResult<T> = serde_json::from_str(body).map_err(|_| ApiError::Parse)?;
  67. if !result.is_success() {
  68. return Err(ApiError::Api {
  69. code: result.code,
  70. message: result.message.unwrap_or_default(),
  71. });
  72. }
  73. result.data.ok_or(ApiError::EmptyData)
  74. }
  75. async fn post<T: DeserializeOwned>(&self, path: &str, body: Option<&serde_json::Value>) -> Result<T, ApiError> {
  76. let url = format!("{}{}", *self.base_url.read().unwrap(), path);
  77. let token = self.token.read().unwrap().clone();
  78. let mut req = self.http.post(&url).bearer_auth(token);
  79. if let Some(b) = body {
  80. req = req.json(b);
  81. }
  82. let resp = req.send().await.map_err(|e| ApiError::Http(e.to_string()))?;
  83. let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
  84. self.parse_response(&text)
  85. }
  86. pub async fn tasks_today(&self) -> Result<Vec<CfcTask>, ApiError> {
  87. self.post("/api/tasks/today", Some(&serde_json::json!({}))).await
  88. }
  89. pub async fn energy_overview(&self) -> Result<EnergyOverview, ApiError> {
  90. // 后端 @RequestBody(required=false),但实测必须带 body 才会进入业务逻辑
  91. self.post("/api/energy/overview", Some(&serde_json::json!({}))).await
  92. }
  93. /// 最近喝水记录(memberId 由后端从 JWT 解析)
  94. pub async fn water_list(&self) -> Result<Vec<WaterRecord>, ApiError> {
  95. self.post("/api/health/water/list", Some(&serde_json::json!({}))).await
  96. }
  97. /// 完成一次喝水打卡(固定快捷打卡:250ml / plain / 当前时间)
  98. pub async fn complete_water(&self) -> Result<WaterRecord, ApiError> {
  99. let drunk_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
  100. self.post(
  101. "/api/health/water/create",
  102. Some(&serde_json::json!({
  103. "amountMl": 250,
  104. "waterType": "plain",
  105. "drunkAt": drunk_at
  106. })),
  107. )
  108. .await
  109. }
  110. pub async fn mascot_get(&self) -> Result<MascotInfo, ApiError> {
  111. self.post("/api/user/mascot/get", None).await
  112. }
  113. /// 一键完成任务(memberId 由后端从 JWT 解析)
  114. pub async fn complete_task(&self, task_id: i64) -> Result<serde_json::Value, ApiError> {
  115. self.post(&format!("/api/tasks/{task_id}/complete"), Some(&serde_json::json!({}))).await
  116. }
  117. /// 上传图片等媒体文件(multipart,复用 /api/media/upload)
  118. pub async fn upload_media(
  119. &self,
  120. filename: &str,
  121. bytes: Vec<u8>,
  122. mime: &str,
  123. ) -> Result<MediaRecord, ApiError> {
  124. let url = format!("{}/api/media/upload", *self.base_url.read().unwrap());
  125. let token = self.token.read().unwrap().clone();
  126. let part = reqwest::multipart::Part::bytes(bytes.clone()).file_name(filename.to_string());
  127. let part = if mime.is_empty() {
  128. part
  129. } else {
  130. part.mime_str(mime).unwrap_or_else(|_| {
  131. reqwest::multipart::Part::bytes(bytes).file_name(filename.to_string())
  132. })
  133. };
  134. let form = reqwest::multipart::Form::new().part("file", part);
  135. let resp = self
  136. .http
  137. .post(&url)
  138. .bearer_auth(token)
  139. .multipart(form)
  140. .send()
  141. .await
  142. .map_err(|e| ApiError::Http(e.to_string()))?;
  143. let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
  144. self.parse_response(&text)
  145. }
  146. /// 微信网站应用扫码登录:获取二维码链接
  147. pub async fn wechat_web_qr(&self) -> Result<WechatQrResp, ApiError> {
  148. self.post("/api/auth/wechat-web/qr", Some(&serde_json::json!({}))).await
  149. }
  150. /// 轮询扫码登录结果(state 由 wechat_web_qr 返回)
  151. pub async fn wechat_web_poll(&self, state: &str) -> Result<WechatPollResp, ApiError> {
  152. self.post("/api/auth/wechat-web/poll", Some(&serde_json::json!({ "state": state }))).await
  153. }
  154. }
  155. #[cfg(test)]
  156. mod tests {
  157. use super::*;
  158. #[test]
  159. fn parse_success_response() {
  160. let client = ApiClient::new("http://x".into(), "t".into());
  161. let data: Vec<CfcTask> = client
  162. .parse_response(r#"{"code":200,"data":[{"id":1,"title":"t","status":"PENDING","deadline":"2026-08-15T00:00:00"}]}"#)
  163. .unwrap();
  164. assert_eq!(data.len(), 1);
  165. assert_eq!(
  166. data[0].deadline.as_ref().unwrap().to_local_date(),
  167. chrono::NaiveDate::from_ymd_opt(2026, 8, 15)
  168. );
  169. }
  170. #[test]
  171. fn parse_error_code_returns_api_error() {
  172. let client = ApiClient::new("http://x".into(), "t".into());
  173. let err = client.parse_response::<serde_json::Value>(r#"{"code":401,"message":"unauthorized"}"#).unwrap_err();
  174. match err {
  175. ApiError::Api { code, .. } => assert_eq!(code, 401),
  176. other => panic!("unexpected error: {other:?}"),
  177. }
  178. }
  179. #[test]
  180. fn parse_malformed_returns_parse_error() {
  181. let client = ApiClient::new("http://x".into(), "t".into());
  182. assert!(matches!(client.parse_response::<serde_json::Value>("not json"), Err(ApiError::Parse)));
  183. }
  184. #[test]
  185. fn parse_empty_data_returns_empty_error() {
  186. let client = ApiClient::new("http://x".into(), "t".into());
  187. assert!(matches!(client.parse_response::<CfcTask>(r#"{"code":200}"#), Err(ApiError::EmptyData)));
  188. }
  189. #[test]
  190. fn normalize_upgrades_public_http_to_https() {
  191. assert_eq!(
  192. normalize_base_url("http://cfc.cf-club.com"),
  193. "https://cfc.cf-club.com"
  194. );
  195. assert_eq!(
  196. normalize_base_url("http://cfc.cf-club.com/"),
  197. "https://cfc.cf-club.com"
  198. );
  199. assert_eq!(
  200. normalize_base_url(" http://cfc.cf-club.com "),
  201. "https://cfc.cf-club.com"
  202. );
  203. // 已是 https 保持不变
  204. assert_eq!(
  205. normalize_base_url("https://cfc.cf-club.com"),
  206. "https://cfc.cf-club.com"
  207. );
  208. }
  209. #[test]
  210. fn normalize_keeps_local_and_private_http() {
  211. assert_eq!(normalize_base_url("http://localhost:9082"), "http://localhost:9082");
  212. assert_eq!(normalize_base_url("http://127.0.0.1:9082"), "http://127.0.0.1:9082");
  213. assert_eq!(
  214. normalize_base_url("http://192.168.16.251:9082"),
  215. "http://192.168.16.251:9082"
  216. );
  217. assert_eq!(normalize_base_url("http://10.0.0.5"), "http://10.0.0.5");
  218. }
  219. }