feat: improve upload resilience and release 0.1.23

This commit is contained in:
2026-02-20 20:21:42 +08:00
parent 6618de1aed
commit 01384a2215
9 changed files with 435 additions and 115 deletions

View File

@@ -693,7 +693,7 @@ dependencies = [
[[package]]
name = "desktop-client"
version = "0.1.22"
version = "0.1.23"
dependencies = [
"reqwest 0.12.28",
"rusqlite",

View File

@@ -1,6 +1,6 @@
[package]
name = "desktop-client"
version = "0.1.22"
version = "0.1.23"
description = "A Tauri App"
authors = ["you"]
edition = "2021"

View File

@@ -11,11 +11,14 @@ use std::io::{Read, Seek, SeekFrom};
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
const RESUMABLE_CHUNK_MAX_RETRIES: u32 = 3;
const RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS: u64 = 900;
struct ApiState {
client: reqwest::Client,
@@ -165,6 +168,36 @@ fn build_desktop_client_meta() -> (String, String, String) {
(platform, device_name, device_id)
}
fn build_upload_file_fingerprint(meta: &fs::Metadata) -> Option<String> {
let size = meta.len();
let modified_ms = meta
.modified()
.ok()
.and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or(0);
let fingerprint = format!("v1:size:{}:mtime:{}", size, modified_ms);
if fingerprint.len() > 120 {
None
} else {
Some(fingerprint)
}
}
fn is_retryable_upload_status(status: u16) -> bool {
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
}
fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
err.is_timeout() || err.is_connect() || err.is_request() || err.is_body()
}
fn build_chunk_retry_delay(attempt: u32) -> Duration {
let multiplier = 2_u64.saturating_pow(attempt.min(5));
let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS.saturating_mul(multiplier).min(15_000);
Duration::from_millis(ms)
}
fn fallback_json(status: StatusCode, text: &str) -> Value {
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(status.is_success()));
@@ -624,6 +657,21 @@ async fn api_logout(
.await
}
#[tauri::command]
async fn api_refresh_token(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_json(
&state.client,
Method::POST,
join_api_url(&base_url, "/api/refresh-token"),
Some(Value::Object(Map::new())),
None,
)
.await
}
#[tauri::command]
async fn api_search_files(
state: tauri::State<'_, ApiState>,
@@ -1325,6 +1373,7 @@ async fn api_upload_file_resumable(
if file_size == 0 {
return Err("空文件不支持分片上传".to_string());
}
let file_fingerprint = build_upload_file_fingerprint(&metadata);
let file_name = source_path
.file_name()
@@ -1350,6 +1399,9 @@ async fn api_upload_file_resumable(
"chunk_size".to_string(),
Value::Number(serde_json::Number::from(effective_chunk)),
);
if let Some(hash) = file_fingerprint.clone() {
init_body.insert("file_hash".to_string(), Value::String(hash));
}
let init_resp = request_json(
&state.client,
@@ -1426,32 +1478,67 @@ async fn api_upload_file_resumable(
.map_err(|err| format!("读取分片失败: {}", err))?;
let chunk_part_name = format!("{}.part{}", file_name, chunk_index);
let multipart = reqwest::multipart::Form::new()
.text("session_id", session_id.clone())
.text("chunk_index", chunk_index.to_string())
.part(
"chunk",
reqwest::multipart::Part::bytes(buf).file_name(chunk_part_name),
);
let mut chunk_done = false;
for attempt in 0..=RESUMABLE_CHUNK_MAX_RETRIES {
let multipart = reqwest::multipart::Form::new()
.text("session_id", session_id.clone())
.text("chunk_index", chunk_index.to_string())
.part(
"chunk",
reqwest::multipart::Part::bytes(buf.clone()).file_name(chunk_part_name.clone()),
);
let mut request = state
.client
.post(join_api_url(&base_url, "/api/upload/resumable/chunk"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(60 * 10))
.multipart(multipart);
if let Some(token) = csrf_token.clone() {
request = request.header("X-CSRF-Token", token);
}
let mut request = state
.client
.post(join_api_url(&base_url, "/api/upload/resumable/chunk"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(60 * 10))
.multipart(multipart);
if let Some(token) = csrf_token.clone() {
request = request.header("X-CSRF-Token", token);
}
let chunk_resp = request
.send()
.await
.map_err(|err| format!("上传分片失败: {}", err))?;
let chunk_bridge = parse_response_as_bridge(chunk_resp).await?;
if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
let chunk_bridge = match request.send().await {
Ok(chunk_resp) => parse_response_as_bridge(chunk_resp).await?,
Err(err) => {
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && is_retryable_transport_error(&err) {
thread::sleep(build_chunk_retry_delay(attempt));
continue;
}
return Err(format!("上传分片失败: {}", err));
}
};
let chunk_success = chunk_bridge.ok
&& chunk_bridge
.data
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false);
if chunk_success {
chunk_done = true;
break;
}
let message = chunk_bridge
.data
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let retryable_status = is_retryable_upload_status(chunk_bridge.status);
let retryable_message = message.contains("超时")
|| message.to_lowercase().contains("timeout")
|| message.contains("稍后重试");
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && (retryable_status || retryable_message) {
thread::sleep(build_chunk_retry_delay(attempt));
continue;
}
return Ok(chunk_bridge);
}
if !chunk_done {
return Err("上传分片失败,请重试".to_string());
}
uploaded_bytes = uploaded_bytes.saturating_add(read_size as u64).min(file_size);
if let Some(ref id) = task_id {
@@ -1464,14 +1551,21 @@ async fn api_upload_file_resumable(
let mut complete_body = Map::new();
complete_body.insert("session_id".to_string(), Value::String(session_id));
let complete_resp = request_json(
&state.client,
Method::POST,
join_api_url(&base_url, "/api/upload/resumable/complete"),
Some(Value::Object(complete_body)),
csrf_token,
)
.await?;
let mut complete_request = state
.client
.post(join_api_url(&base_url, "/api/upload/resumable/complete"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60 * 20))
.json(&Value::Object(complete_body));
if let Some(token) = csrf_token {
complete_request = complete_request.header("X-CSRF-Token", token);
}
let complete_raw = complete_request
.send()
.await
.map_err(|err| format!("完成分片上传失败: {}", err))?;
let complete_resp = parse_response_as_bridge(complete_raw).await?;
if complete_resp.ok && complete_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
if let Some(ref id) = task_id {
@@ -1503,9 +1597,9 @@ async fn api_upload_file(
if !source_path.is_file() {
return Err("仅支持上传文件,不支持文件夹".to_string());
}
let file_size = fs::metadata(&source_path)
.map(|meta| meta.len())
.unwrap_or(0);
let file_meta = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
let file_size = file_meta.len();
let file_fingerprint = build_upload_file_fingerprint(&file_meta);
let file_name = source_path
.file_name()
@@ -1534,9 +1628,12 @@ async fn api_upload_file(
.map_err(|err| format!("读取文件失败: {}", err))?
.file_name(file_name);
let multipart = reqwest::multipart::Form::new()
let mut multipart = reqwest::multipart::Form::new()
.text("path", normalized_target)
.part("file", file_part);
if let Some(hash) = file_fingerprint {
multipart = multipart.text("file_hash", hash);
}
let mut request = state
.client
@@ -1581,7 +1678,7 @@ async fn api_upload_file(
pub fn run() {
let client = reqwest::Client::builder()
.cookie_store(true)
.timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(90))
.build()
.expect("failed to build reqwest client");
@@ -1599,6 +1696,7 @@ pub fn run() {
api_kick_online_device,
api_list_files,
api_logout,
api_refresh_token,
api_search_files,
api_mkdir,
api_rename_file,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "玩玩云",
"version": "0.1.22",
"version": "0.1.23",
"identifier": "cn.workyai.wanwancloud.desktop",
"build": {
"beforeDevCommand": "npm run dev",