204 lines
6.2 KiB
Rust
204 lines
6.2 KiB
Rust
//! 经 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);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|