Files
yaoayanui/src-tauri/src/api_client.rs
fengchuanhn@gmail.com 0eb5dbde5b 11
2026-05-22 18:13:05 +08:00

204 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 经 Rust 发起的 `/api/v1` HTTP 请求(可选 AES 加解密)。
use std::collections::HashMap;
use std::time::Duration;
use chrono::Utc;
use rand::Rng;
use tauri::{AppHandle, Emitter, Manager};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::Value;
use crate::api_crypto::{self, ENCRYPTED_HEADER};
use crate::app_config;
use crate::auth_session::AuthSession;
use crate::device_serial;
use crate::oem_context;
pub async fn request_json(
method: &str,
path: &str,
body: Option<&str>,
extra_headers: HashMap<String, String>,
) -> Result<(u16, Value), String> {
let path = if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
};
let base = app_config::api_base().trim_end_matches('/').to_string();
let url = format!("{base}/api/v1{path}");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| e.to_string())?;
let method = reqwest::Method::from_bytes(method.to_uppercase().as_bytes())
.map_err(|e| format!("无效 HTTP 方法: {e}"))?;
let aes_key = api_crypto::aes_key_from_env();
let mut req = client.request(method, &url);
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
if let Ok(val) = HeaderValue::from_str(device_serial::device_serial()) {
headers.insert(HeaderName::from_static("device_serial"), val);
}
oem_context::apply_install_headers(&mut headers);
for (k, v) in extra_headers {
if let (Ok(name), Ok(val)) = (
HeaderName::from_bytes(k.as_bytes()),
HeaderValue::from_str(&v),
) {
headers.insert(name, val);
}
}
if let Some(key) = aes_key {
headers.insert(
HeaderName::from_static("x-aiclient-encrypted"),
HeaderValue::from_static("1"),
);
if let Some(raw) = body.filter(|s| !s.is_empty()) {
let wire = api_crypto::wrap_encrypted(raw.as_bytes(), &key)?;
req = req.body(wire);
}
} else if let Some(raw) = body {
req = req.body(raw.to_string());
}
req = req.headers(headers);
let res = req.send().await.map_err(|e| {
if e.is_connect() {
println!("无法连接服务器,请确认后端已启动");
"无法连接服务器,请确认后端已启动".to_string()
} else {
println!("e: {}", e);
e.to_string()
}
})?;
let status = res.status().as_u16();
println!("status: {}", status);
let encrypted = res
.headers()
.get(ENCRYPTED_HEADER)
.and_then(|v| v.to_str().ok())
== Some("1");
let text = res.text().await.map_err(|e| e.to_string())?;
let json_text = if encrypted {
let key = aes_key.ok_or_else(|| "响应已加密但未配置 AICLIENT_API_AES_KEY".to_string())?;
let plain = api_crypto::unwrap_encrypted(&text, &key)?;
String::from_utf8(plain).map_err(|e| format!("响应 UTF-8 无效: {e}"))?
} else {
text
};
let value: Value =
serde_json::from_str(&json_text).unwrap_or(Value::String(json_text.clone()));
Ok((status, value))
}
const HEARTBEAT_INTERVAL_SECS: u64 = 30;
/// 与 pythonbackend `heartbeat_time_bucket` 一致UTC ``%Y-%m-%d %H``。
fn heartbeat_time_bucket() -> String {
Utc::now().format("%Y-%m-%d %H").to_string()
}
/// MD5(时间桶 + verify_code),小写十六进制。
fn compute_server_verify_code(verify_code: &str) -> String {
let raw = format!("{}{}", heartbeat_time_bucket(), verify_code);
format!("{:x}", md5::compute(raw.as_bytes()))
}
fn random_verify_code() -> String {
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::thread_rng();
(0..32)
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
.collect()
}
fn response_server_verify_code(value: &Value) -> Option<&str> {
value
.get("data")
.and_then(|d| d.get("server_verify_code"))
.and_then(|v| v.as_str())
}
/// 已登录时向服务端发送心跳(校验设备序列号与会员状态)。
pub async fn send_heartbeat(token: &str) -> Result<(), String> {
let token = token.trim();
if token.is_empty() {
return Ok(());
}
let verify_code = random_verify_code();
let body = serde_json::json!({ "verify_code": verify_code }).to_string();
let expected = compute_server_verify_code(&verify_code);
let mut headers = HashMap::new();
headers.insert(
"Authorization".to_string(),
format!("Bearer {token}"),
);
let (status, value) =
request_json("POST", "/auth/heartbeat", Some(&body), headers).await?;
if status == 401 {
return Err("未登录或登录已过期".into());
}
let server_code = response_server_verify_code(&value);
if server_code != Some(expected.as_str()) {
return Err("会员已过期".into());
}
if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
let msg = value
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("心跳校验失败");
return Err(msg.to_string());
}
Ok(())
}
/// 应用启动后每 30 秒发送一次心跳(仅在有 token 时)。
pub fn spawn_heartbeat_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval =
tokio::time::interval(Duration::from_secs(HEARTBEAT_INTERVAL_SECS));
interval.tick().await;
loop {
interval.tick().await;
let token = app
.try_state::<AuthSession>()
.map(|s| s.token())
.unwrap_or_default();
if token.trim().is_empty() {
continue;
}
match send_heartbeat(&token).await {
Ok(()) => {}
Err(e) => {
println!("[heartbeat] failed: {e}");
let _ = app.emit("auth-heartbeat-failed", e);
}
}
}
});
}