11
This commit is contained in:
@@ -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}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
48
src-tauri/src/device_serial.rs
Normal file
48
src-tauri/src/device_serial.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! 设备唯一序列号:MD5(cpuid 序列 + MAC 地址),进程内缓存。
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
static DEVICE_SERIAL: Lazy<String> = Lazy::new(compute_device_serial);
|
||||
|
||||
/// 返回 MD5 十六进制小写字符串(32 字符)。
|
||||
pub fn device_serial() -> &'static str {
|
||||
&DEVICE_SERIAL
|
||||
}
|
||||
|
||||
fn compute_device_serial() -> String {
|
||||
let cpuid = cpuid_serial();
|
||||
let mac = mac_serial();
|
||||
let raw = format!("{cpuid}{mac}");
|
||||
format!("{:x}", md5::compute(raw.as_bytes()))
|
||||
}
|
||||
|
||||
/// CPUID leaf 3 的 edx+ecx(与示例 `__cpuid(3)` 一致)。
|
||||
fn cpuid_serial() -> String {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
use std::arch::x86_64::__cpuid;
|
||||
let r = __cpuid(3);
|
||||
return format!("{:08X}{:08X}", r.edx, r.ecx);
|
||||
}
|
||||
#[cfg(target_arch = "x86")]
|
||||
{
|
||||
use std::arch::x86::__cpuid;
|
||||
let r = __cpuid(3);
|
||||
return format!("{:08X}{:08X}", r.edx, r.ecx);
|
||||
}
|
||||
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn mac_serial() -> String {
|
||||
match mac_address::get_mac_address() {
|
||||
Ok(Some(ma)) => ma.to_string(),
|
||||
Ok(None) => String::new(),
|
||||
Err(e) => {
|
||||
log::warn!(target: "device_serial", "读取 MAC 地址失败: {e}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod auth_session;
|
||||
pub mod chat;
|
||||
pub mod commands;
|
||||
pub mod cover_python;
|
||||
pub mod device_serial;
|
||||
pub mod ffmpeg;
|
||||
pub mod http;
|
||||
pub mod js_runtime;
|
||||
@@ -38,6 +39,7 @@ pub fn run() {
|
||||
.manage(publish_store::PublishStore::new())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
let heartbeat_handle = handle.clone();
|
||||
let oem_id = handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
@@ -64,6 +66,8 @@ pub fn run() {
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||
|
||||
api_client::spawn_heartbeat_loop(heartbeat_handle);
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
// 心跳定时任务在 `tauri_app_lib::run` → `api_client::spawn_heartbeat_loop` 中启动(每 30 秒)。
|
||||
|
||||
fn main() {
|
||||
tauri_app_lib::run()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user