| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- 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<String>,
- token: RwLock<String>,
- }
- 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<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> {
- // 后端 @RequestBody(required=false),但实测必须带 body 才会进入业务逻辑
- self.post("/api/energy/overview", Some(&serde_json::json!({}))).await
- }
- /// 最近喝水记录(memberId 由后端从 JWT 解析)
- pub async fn water_list(&self) -> Result<Vec<WaterRecord>, ApiError> {
- self.post("/api/health/water/list", Some(&serde_json::json!({}))).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> {
- self.post("/api/user/mascot/get", None).await
- }
- /// 一键完成任务(memberId 由后端从 JWT 解析)
- pub async fn complete_task(&self, task_id: i64) -> Result<serde_json::Value, ApiError> {
- 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<u8>,
- mime: &str,
- ) -> Result<MediaRecord, ApiError> {
- 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<WechatQrResp, ApiError> {
- 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<WechatPollResp, ApiError> {
- 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<CfcTask> = 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::<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)));
- }
- #[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");
- }
- }
|