diff --git a/backend/database.js b/backend/database.js index d52f552..0f5461e 100644 --- a/backend/database.js +++ b/backend/database.js @@ -2512,8 +2512,9 @@ const UploadSessionDB = { return db.prepare('SELECT * FROM upload_sessions WHERE session_id = ?').get(sid); }, - findActiveForResume(userId, targetPath, fileSize, fileHash = null) { + findActiveForResume(userId, storageType, targetPath, fileSize, fileHash = null) { const uid = Number(userId); + const safeStorageType = storageType === 'local' ? 'local' : 'oss'; const safePath = typeof targetPath === 'string' ? targetPath : ''; const size = Math.floor(Number(fileSize) || 0); const hash = typeof fileHash === 'string' ? fileHash.trim() : ''; @@ -2526,6 +2527,7 @@ const UploadSessionDB = { SELECT * FROM upload_sessions WHERE user_id = ? + AND storage_type = ? AND target_path = ? AND file_size = ? AND status = 'active' @@ -2533,20 +2535,21 @@ const UploadSessionDB = { AND COALESCE(file_hash, '') = ? ORDER BY updated_at DESC, created_at DESC LIMIT 1 - `).get(Math.floor(uid), safePath, size, hash); + `).get(Math.floor(uid), safeStorageType, safePath, size, hash); } return db.prepare(` SELECT * FROM upload_sessions WHERE user_id = ? + AND storage_type = ? AND target_path = ? AND file_size = ? AND status = 'active' AND expires_at > datetime('now', 'localtime') ORDER BY updated_at DESC, created_at DESC LIMIT 1 - `).get(Math.floor(uid), safePath, size); + `).get(Math.floor(uid), safeStorageType, safePath, size); }, updateProgress(sessionId, uploadedChunks = [], uploadedBytes = 0, expiresAt = null) { diff --git a/backend/server.js b/backend/server.js index abeae9b..0ae95e4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max( 10, Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30)) ); -const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.22'; +const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.23'; const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || ''; const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || ''; const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads'); @@ -2332,9 +2332,13 @@ function logSecurity(req, action, message, details = null, level = 'warn') { } // 文件上传配置(临时存储) +const MULTER_UPLOAD_MAX_BYTES = Math.max( + 1 * 1024 * 1024, + Number(process.env.MULTER_UPLOAD_MAX_BYTES || (50 * 1024 * 1024 * 1024)) +); const upload = multer({ dest: path.join(__dirname, 'uploads'), - limits: { fileSize: 5 * 1024 * 1024 * 1024 } // 5GB限制 + limits: { fileSize: MULTER_UPLOAD_MAX_BYTES } }); // ===== TTL缓存类 ===== @@ -5949,11 +5953,15 @@ app.post('/api/files/instant-upload/check', authMiddleware, async (req, res) => // 本地分片上传初始化(断点续传) app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => { try { - if ((req.user.current_storage_type || 'oss') !== 'local') { - return res.status(400).json({ - success: false, - message: '当前存储模式不支持分片上传' - }); + const uploadStorageType = (req.user.current_storage_type || 'oss') === 'local' ? 'local' : 'oss'; + if (uploadStorageType === 'oss') { + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!req.user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '未配置 OSS 服务,无法上传文件' + }); + } } const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : ''; @@ -6013,6 +6021,7 @@ app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => { const totalChunks = Math.ceil(fileSize / chunkSize); const existingSession = UploadSessionDB.findActiveForResume( req.user.id, + uploadStorageType, targetPath, fileSize, fileHash || null @@ -6038,6 +6047,7 @@ app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => { return res.json({ success: true, resumed: true, + storage_type: existingSession.storage_type || uploadStorageType, session_id: existingSession.session_id, target_path: existingSession.target_path, file_name: existingSession.file_name, @@ -6061,7 +6071,7 @@ app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => { const createdSession = UploadSessionDB.create({ sessionId, userId: req.user.id, - storageType: 'local', + storageType: uploadStorageType, targetPath, fileName: filename, fileSize, @@ -6085,6 +6095,7 @@ app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => { res.json({ success: true, resumed: false, + storage_type: createdSession.storage_type || uploadStorageType, session_id: createdSession.session_id, target_path: createdSession.target_path, file_name: createdSession.file_name, @@ -6154,14 +6165,6 @@ app.get('/api/upload/resumable/status', authMiddleware, (req, res) => { app.post('/api/upload/resumable/chunk', authMiddleware, upload.single('chunk'), async (req, res) => { const tempChunkPath = req.file?.path; try { - if ((req.user.current_storage_type || 'oss') !== 'local') { - if (tempChunkPath) safeDeleteFile(tempChunkPath); - return res.status(400).json({ - success: false, - message: '当前存储模式不支持分片上传' - }); - } - const sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : ''; const chunkIndex = Math.floor(Number(req.body?.chunk_index)); if (!sessionId) { @@ -6287,16 +6290,10 @@ app.post('/api/upload/resumable/chunk', authMiddleware, upload.single('chunk'), } }); -// 完成分片上传(写入本地存储) +// 完成分片上传(写入当前存储:本地或 OSS) app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => { + let storage = null; try { - if ((req.user.current_storage_type || 'oss') !== 'local') { - return res.status(400).json({ - success: false, - message: '当前存储模式不支持分片上传' - }); - } - const sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : ''; if (!sessionId) { return res.status(400).json({ @@ -6345,6 +6342,8 @@ app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => { }); } + const sessionStorageType = session.storage_type === 'local' ? 'local' : 'oss'; + const latestUser = UserDB.findById(req.user.id); if (!latestUser) { return res.status(404).json({ @@ -6353,12 +6352,25 @@ app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => { }); } - const localUserContext = buildStorageUserContext(latestUser, { - current_storage_type: 'local' + if (sessionStorageType === 'oss') { + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!latestUser.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '未配置 OSS 服务,无法完成分片上传' + }); + } + } + + const storageUserContext = buildStorageUserContext(latestUser, { + current_storage_type: sessionStorageType }); - const localStorage = new LocalStorageClient(localUserContext); - await localStorage.init(); - await localStorage.put(session.temp_file_path, session.target_path); + const storageInterface = new StorageInterface(storageUserContext); + storage = await storageInterface.connect(); + await storage.put(session.temp_file_path, session.target_path); + if (sessionStorageType === 'oss') { + clearOssUsageCache(req.user.id); + } UploadSessionDB.setStatus(session.session_id, 'completed', { completed: true, @@ -6368,7 +6380,7 @@ app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => { await trackFileHashIndexForUpload({ userId: req.user.id, - storageType: 'local', + storageType: sessionStorageType, fileHash: session.file_hash, fileSize, filePath: session.target_path @@ -6387,6 +6399,10 @@ app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => { success: false, message: getSafeErrorMessage(error, '完成分片上传失败,请稍后重试', '完成分片上传') }); + } finally { + if (storage) { + await storage.end().catch(() => {}); + } } }); diff --git a/backend/storage.js b/backend/storage.js index b048f94..67de8b2 100644 --- a/backend/storage.js +++ b/backend/storage.js @@ -1,4 +1,16 @@ -const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3'); +const { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectsCommand, + ListObjectsV2Command, + HeadObjectCommand, + CopyObjectCommand, + CreateMultipartUploadCommand, + UploadPartCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand +} = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const fs = require('fs'); const path = require('path'); @@ -18,6 +30,25 @@ function formatFileSize(bytes) { return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } +function parsePositiveInteger(value, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.floor(parsed); +} + +const OSS_MULTIPART_MAX_PARTS = 10000; +const OSS_MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024; // S3 协议要求最小 5MB(最后一片除外) +const OSS_MULTIPART_DEFAULT_PART_SIZE = Math.max( + OSS_MULTIPART_MIN_PART_SIZE, + parsePositiveInteger(process.env.OSS_MULTIPART_PART_SIZE_BYTES, 16 * 1024 * 1024) +); +const OSS_SINGLE_UPLOAD_THRESHOLD = Math.max( + OSS_MULTIPART_MIN_PART_SIZE, + parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024) +); + /** * 将 OSS/网络错误转换为友好的错误信息 * @param {Error} error - 原始错误 @@ -1090,9 +1121,13 @@ class OssStorageClient { * @param {string} remotePath - 远程文件路径 */ async put(localPath, remotePath) { + let bucket = ''; + let key = ''; + let uploadId = null; + let fileHandle = null; try { - const key = this.getObjectKey(remotePath); - const bucket = this.getBucket(); + key = this.getObjectKey(remotePath); + bucket = this.getBucket(); // 检查本地文件是否存在 if (!fs.existsSync(localPath)) { @@ -1102,30 +1137,107 @@ class OssStorageClient { const fileStats = fs.statSync(localPath); const fileSize = fileStats.size; - // 检查文件大小(AWS S3 单次上传最大 5GB) - const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB - if (fileSize > MAX_SINGLE_UPLOAD_SIZE) { - throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB,请使用分片上传`); + if (fileSize <= OSS_SINGLE_UPLOAD_THRESHOLD) { + // 小文件保持单请求上传,兼容性更高。 + const fileContent = fs.readFileSync(localPath); + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: fileContent, + ContentLength: fileSize, + ChecksumAlgorithm: undefined + }); + await this.s3Client.send(command); + console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`); + return; } - // 使用Buffer上传而非流式上传,避免AWS SDK使用aws-chunked编码 - // 这样可以确保与阿里云OSS的兼容性 - const fileContent = fs.readFileSync(localPath); + // 大文件自动切换到分片上传,避免整文件读入内存和 5GB 单请求上限。 + const minPartSizeByCount = Math.ceil(fileSize / OSS_MULTIPART_MAX_PARTS); + const alignedMinPartSize = Math.ceil( + Math.max(minPartSizeByCount, OSS_MULTIPART_MIN_PART_SIZE) / (1024 * 1024) + ) * 1024 * 1024; + const partSize = Math.max(OSS_MULTIPART_DEFAULT_PART_SIZE, alignedMinPartSize); + const estimatedParts = Math.ceil(fileSize / partSize); + if (estimatedParts > OSS_MULTIPART_MAX_PARTS) { + throw new Error(`文件过大,分片数量超限 (${estimatedParts}/${OSS_MULTIPART_MAX_PARTS})`); + } - // 直接上传 - const command = new PutObjectCommand({ + const createResp = await this.s3Client.send(new CreateMultipartUploadCommand({ + Bucket: bucket, + Key: key + })); + uploadId = createResp?.UploadId || null; + if (!uploadId) { + throw new Error('创建分片上传会话失败:缺少 UploadId'); + } + + const uploadedParts = []; + let offset = 0; + let partNumber = 1; + fileHandle = await fs.promises.open(localPath, 'r'); + + while (offset < fileSize) { + const remaining = fileSize - offset; + const currentPartSize = Math.min(partSize, remaining); + const buffer = Buffer.allocUnsafe(currentPartSize); + const { bytesRead } = await fileHandle.read(buffer, 0, currentPartSize, offset); + if (bytesRead <= 0) { + throw new Error('读取本地文件失败,分片上传中断'); + } + + const body = bytesRead === currentPartSize ? buffer : buffer.subarray(0, bytesRead); + const partResp = await this.s3Client.send(new UploadPartCommand({ + Bucket: bucket, + Key: key, + UploadId: uploadId, + PartNumber: partNumber, + Body: body, + ContentLength: bytesRead + })); + + if (!partResp?.ETag) { + throw new Error(`分片 ${partNumber} 上传失败:服务端未返回 ETag`); + } + uploadedParts.push({ + ETag: partResp.ETag, + PartNumber: partNumber + }); + + offset += bytesRead; + if (partNumber === 1 || partNumber % 10 === 0 || offset >= fileSize) { + console.log( + `[OSS存储] 上传分片进度: ${key} ${partNumber}/${estimatedParts} (${formatFileSize(offset)}/${formatFileSize(fileSize)})` + ); + } + partNumber += 1; + } + + await this.s3Client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, - Body: fileContent, - ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题 - // 禁用checksum算法(阿里云OSS不完全支持AWS的x-amz-content-sha256头) - ChecksumAlgorithm: undefined - }); - - await this.s3Client.send(command); - console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`); + UploadId: uploadId, + MultipartUpload: { Parts: uploadedParts } + })); + uploadId = null; + console.log( + `[OSS存储] 分片上传完成: ${key} (${formatFileSize(fileSize)}, ${uploadedParts.length} 片)` + ); } catch (error) { + if (uploadId && bucket && key) { + try { + await this.s3Client.send(new AbortMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId: uploadId + })); + console.warn(`[OSS存储] 已中止失败的分片上传: ${key}`); + } catch (abortError) { + console.error(`[OSS存储] 中止分片上传失败: ${key}`, abortError.message); + } + } + console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message); // 判断错误类型并给出友好的错误信息 @@ -1139,6 +1251,10 @@ class OssStorageClient { throw new Error(`本地文件不存在: ${localPath}`); } throw new Error(`文件上传失败: ${error.message}`); + } finally { + if (fileHandle) { + await fileHandle.close().catch(() => {}); + } } } diff --git a/desktop-client/package.json b/desktop-client/package.json index 9b0695d..fc29c0b 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -1,7 +1,7 @@ { "name": "desktop-client", "private": true, - "version": "0.1.22", + "version": "0.1.23", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock index e4fe544..43e3093 100644 --- a/desktop-client/src-tauri/Cargo.lock +++ b/desktop-client/src-tauri/Cargo.lock @@ -693,7 +693,7 @@ dependencies = [ [[package]] name = "desktop-client" -version = "0.1.22" +version = "0.1.23" dependencies = [ "reqwest 0.12.28", "rusqlite", diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index ff58d88..d19e7b1 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-client" -version = "0.1.22" +version = "0.1.23" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 0ee0602..22edf67 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -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 { + 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 { + 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, diff --git a/desktop-client/src-tauri/tauri.conf.json b/desktop-client/src-tauri/tauri.conf.json index 9df7f78..2daf196 100644 --- a/desktop-client/src-tauri/tauri.conf.json +++ b/desktop-client/src-tauri/tauri.conf.json @@ -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", diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 2e0c9f0..873c91d 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -153,7 +153,7 @@ const syncState = reactive({ nextRunAt: "", }); const updateState = reactive({ - currentVersion: "0.1.22", + currentVersion: "0.1.23", latestVersion: "", available: false, mandatory: false, @@ -241,6 +241,7 @@ let unlistenNativeDownloadProgress: UnlistenFn | null = null; let unlistenNativeUploadProgress: UnlistenFn | null = null; let syncTimer: ReturnType | null = null; let hasCheckedUpdateAfterAuth = false; +let authRefreshPromise: Promise | null = null; const toast = reactive({ visible: false, @@ -806,12 +807,40 @@ function toggleFileSortOrder() { fileViewState.sortOrder = fileViewState.sortOrder === "asc" ? "desc" : "asc"; } -async function invokeBridge(command: string, payload: Record) { +function isAuthBridgeCommand(command: string) { + return command === "api_login" || command === "api_refresh_token"; +} + +async function refreshAccessToken() { + if (authRefreshPromise) { + return authRefreshPromise; + } + authRefreshPromise = (async () => { + try { + const response = await invoke("api_refresh_token", { + baseUrl: appConfig.baseUrl, + }); + return Boolean(response.ok && response.data?.success); + } catch { + return false; + } finally { + authRefreshPromise = null; + } + })(); + return authRefreshPromise; +} + +async function invokeBridge( + command: string, + payload: Record, + allowAuthRetry = true, +) { + let response: BridgeResponse; try { - return await invoke(command, payload); + response = await invoke(command, payload); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return { + response = { ok: false, status: 0, data: { @@ -820,6 +849,20 @@ async function invokeBridge(command: string, payload: Record) { }, } satisfies BridgeResponse; } + + if ( + response.status === 401 + && allowAuthRetry + && !isAuthBridgeCommand(command) + && authenticated.value + ) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + return invokeBridge(command, payload, false); + } + } + + return response; } async function initClientVersion() { @@ -1106,34 +1149,78 @@ async function chooseSyncDirectory() { } } -async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) { - const resumableResponse = await invokeBridge("api_upload_file_resumable", { - baseUrl: appConfig.baseUrl, - filePath, - targetPath, - chunkSize: 4 * 1024 * 1024, - taskId: taskId || null, - }); - - if (resumableResponse.ok && resumableResponse.data?.success) { - return resumableResponse; +function isRetryableUploadResponse(response: BridgeResponse) { + const status = Number(response.status || 0); + if ([0, 408, 425, 429, 500, 502, 503, 504].includes(status)) { + return true; } + const message = String(response.data?.message || "").toLowerCase(); + return ( + message.includes("timeout") + || message.includes("timed out") + || message.includes("network") + || message.includes("connection") + || message.includes("超时") + || message.includes("稍后重试") + ); +} - const message = String(resumableResponse.data?.message || ""); - if ( - message.includes("当前存储模式不支持分片上传") - || message.includes("分片上传会话") - || message.includes("上传会话") - ) { - return await invokeBridge("api_upload_file", { +async function waitMs(ms: number) { + await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); +} + +async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) { + const maxAttempts = 3; + let lastResponse: BridgeResponse | null = null; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const resumableResponse = await invokeBridge("api_upload_file_resumable", { baseUrl: appConfig.baseUrl, filePath, targetPath, + chunkSize: 4 * 1024 * 1024, taskId: taskId || null, }); + + if (resumableResponse.ok && resumableResponse.data?.success) { + return resumableResponse; + } + + const message = String(resumableResponse.data?.message || ""); + if ( + message.includes("当前存储模式不支持分片上传") + || message.includes("分片上传会话") + || message.includes("上传会话") + ) { + const fallbackResponse = await invokeBridge("api_upload_file", { + baseUrl: appConfig.baseUrl, + filePath, + targetPath, + taskId: taskId || null, + }); + if (fallbackResponse.ok && fallbackResponse.data?.success) { + return fallbackResponse; + } + lastResponse = fallbackResponse; + } else { + lastResponse = resumableResponse; + } + + if (attempt < maxAttempts - 1 && lastResponse && isRetryableUploadResponse(lastResponse)) { + await waitMs(600 * (attempt + 1)); + continue; + } + break; } - return resumableResponse; + return lastResponse || { + ok: false, + status: 0, + data: { + success: false, + message: "上传失败", + }, + }; } function rebuildSyncScheduler() {