This commit is contained in:
fengchuanhn@gmail.com
2026-05-22 14:40:32 +08:00
parent eba781d15d
commit ce1fe99883
12 changed files with 277 additions and 5 deletions

View File

@@ -3,11 +3,18 @@
use std::collections::HashMap;
use std::time::Duration;
use chrono::Utc;
use rand::Rng;
use tauri::AppHandle;
use tauri::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;
pub async fn request_json(
method: &str,
@@ -39,6 +46,9 @@ pub async fn request_json(
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);
}
for (k, v) in extra_headers {
if let (Ok(name), Ok(val)) = (
HeaderName::from_bytes(k.as_bytes()),
@@ -98,3 +108,93 @@ pub async fn request_json(
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 let Some(ok) = value.get("ok").and_then(|v| v.as_bool()) {
if !ok {
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(()) => log::debug!(target: "heartbeat", "heartbeat ok"),
Err(e) => log::warn!(target: "heartbeat", "heartbeat failed: {e}"),
}
}
});
}