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

@@ -2512,8 +2512,9 @@ const UploadSessionDB = {
return db.prepare('SELECT * FROM upload_sessions WHERE session_id = ?').get(sid); 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 uid = Number(userId);
const safeStorageType = storageType === 'local' ? 'local' : 'oss';
const safePath = typeof targetPath === 'string' ? targetPath : ''; const safePath = typeof targetPath === 'string' ? targetPath : '';
const size = Math.floor(Number(fileSize) || 0); const size = Math.floor(Number(fileSize) || 0);
const hash = typeof fileHash === 'string' ? fileHash.trim() : ''; const hash = typeof fileHash === 'string' ? fileHash.trim() : '';
@@ -2526,6 +2527,7 @@ const UploadSessionDB = {
SELECT * SELECT *
FROM upload_sessions FROM upload_sessions
WHERE user_id = ? WHERE user_id = ?
AND storage_type = ?
AND target_path = ? AND target_path = ?
AND file_size = ? AND file_size = ?
AND status = 'active' AND status = 'active'
@@ -2533,20 +2535,21 @@ const UploadSessionDB = {
AND COALESCE(file_hash, '') = ? AND COALESCE(file_hash, '') = ?
ORDER BY updated_at DESC, created_at DESC ORDER BY updated_at DESC, created_at DESC
LIMIT 1 LIMIT 1
`).get(Math.floor(uid), safePath, size, hash); `).get(Math.floor(uid), safeStorageType, safePath, size, hash);
} }
return db.prepare(` return db.prepare(`
SELECT * SELECT *
FROM upload_sessions FROM upload_sessions
WHERE user_id = ? WHERE user_id = ?
AND storage_type = ?
AND target_path = ? AND target_path = ?
AND file_size = ? AND file_size = ?
AND status = 'active' AND status = 'active'
AND expires_at > datetime('now', 'localtime') AND expires_at > datetime('now', 'localtime')
ORDER BY updated_at DESC, created_at DESC ORDER BY updated_at DESC, created_at DESC
LIMIT 1 LIMIT 1
`).get(Math.floor(uid), safePath, size); `).get(Math.floor(uid), safeStorageType, safePath, size);
}, },
updateProgress(sessionId, uploadedChunks = [], uploadedBytes = 0, expiresAt = null) { updateProgress(sessionId, uploadedChunks = [], uploadedBytes = 0, expiresAt = null) {

View File

@@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
10, 10,
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30)) 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_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || ''; const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads'); 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({ const upload = multer({
dest: path.join(__dirname, 'uploads'), dest: path.join(__dirname, 'uploads'),
limits: { fileSize: 5 * 1024 * 1024 * 1024 } // 5GB限制 limits: { fileSize: MULTER_UPLOAD_MAX_BYTES }
}); });
// ===== TTL缓存类 ===== // ===== 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) => { app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => {
try { try {
if ((req.user.current_storage_type || 'oss') !== 'local') { const uploadStorageType = (req.user.current_storage_type || 'oss') === 'local' ? 'local' : 'oss';
return res.status(400).json({ if (uploadStorageType === 'oss') {
success: false, const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
message: '当前存储模式不支持分片上传' 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() : ''; 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 totalChunks = Math.ceil(fileSize / chunkSize);
const existingSession = UploadSessionDB.findActiveForResume( const existingSession = UploadSessionDB.findActiveForResume(
req.user.id, req.user.id,
uploadStorageType,
targetPath, targetPath,
fileSize, fileSize,
fileHash || null fileHash || null
@@ -6038,6 +6047,7 @@ app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => {
return res.json({ return res.json({
success: true, success: true,
resumed: true, resumed: true,
storage_type: existingSession.storage_type || uploadStorageType,
session_id: existingSession.session_id, session_id: existingSession.session_id,
target_path: existingSession.target_path, target_path: existingSession.target_path,
file_name: existingSession.file_name, file_name: existingSession.file_name,
@@ -6061,7 +6071,7 @@ app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => {
const createdSession = UploadSessionDB.create({ const createdSession = UploadSessionDB.create({
sessionId, sessionId,
userId: req.user.id, userId: req.user.id,
storageType: 'local', storageType: uploadStorageType,
targetPath, targetPath,
fileName: filename, fileName: filename,
fileSize, fileSize,
@@ -6085,6 +6095,7 @@ app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => {
res.json({ res.json({
success: true, success: true,
resumed: false, resumed: false,
storage_type: createdSession.storage_type || uploadStorageType,
session_id: createdSession.session_id, session_id: createdSession.session_id,
target_path: createdSession.target_path, target_path: createdSession.target_path,
file_name: createdSession.file_name, 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) => { app.post('/api/upload/resumable/chunk', authMiddleware, upload.single('chunk'), async (req, res) => {
const tempChunkPath = req.file?.path; const tempChunkPath = req.file?.path;
try { 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 sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : '';
const chunkIndex = Math.floor(Number(req.body?.chunk_index)); const chunkIndex = Math.floor(Number(req.body?.chunk_index));
if (!sessionId) { 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) => { app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => {
let storage = null;
try { 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() : ''; const sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : '';
if (!sessionId) { if (!sessionId) {
return res.status(400).json({ 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); const latestUser = UserDB.findById(req.user.id);
if (!latestUser) { if (!latestUser) {
return res.status(404).json({ return res.status(404).json({
@@ -6353,12 +6352,25 @@ app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => {
}); });
} }
const localUserContext = buildStorageUserContext(latestUser, { if (sessionStorageType === 'oss') {
current_storage_type: 'local' 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); const storageInterface = new StorageInterface(storageUserContext);
await localStorage.init(); storage = await storageInterface.connect();
await localStorage.put(session.temp_file_path, session.target_path); await storage.put(session.temp_file_path, session.target_path);
if (sessionStorageType === 'oss') {
clearOssUsageCache(req.user.id);
}
UploadSessionDB.setStatus(session.session_id, 'completed', { UploadSessionDB.setStatus(session.session_id, 'completed', {
completed: true, completed: true,
@@ -6368,7 +6380,7 @@ app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => {
await trackFileHashIndexForUpload({ await trackFileHashIndexForUpload({
userId: req.user.id, userId: req.user.id,
storageType: 'local', storageType: sessionStorageType,
fileHash: session.file_hash, fileHash: session.file_hash,
fileSize, fileSize,
filePath: session.target_path filePath: session.target_path
@@ -6387,6 +6399,10 @@ app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => {
success: false, success: false,
message: getSafeErrorMessage(error, '完成分片上传失败,请稍后重试', '完成分片上传') message: getSafeErrorMessage(error, '完成分片上传失败,请稍后重试', '完成分片上传')
}); });
} finally {
if (storage) {
await storage.end().catch(() => {});
}
} }
}); });

View File

@@ -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 { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -18,6 +30,25 @@ function formatFileSize(bytes) {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; 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/网络错误转换为友好的错误信息 * 将 OSS/网络错误转换为友好的错误信息
* @param {Error} error - 原始错误 * @param {Error} error - 原始错误
@@ -1090,9 +1121,13 @@ class OssStorageClient {
* @param {string} remotePath - 远程文件路径 * @param {string} remotePath - 远程文件路径
*/ */
async put(localPath, remotePath) { async put(localPath, remotePath) {
let bucket = '';
let key = '';
let uploadId = null;
let fileHandle = null;
try { try {
const key = this.getObjectKey(remotePath); key = this.getObjectKey(remotePath);
const bucket = this.getBucket(); bucket = this.getBucket();
// 检查本地文件是否存在 // 检查本地文件是否存在
if (!fs.existsSync(localPath)) { if (!fs.existsSync(localPath)) {
@@ -1102,30 +1137,107 @@ class OssStorageClient {
const fileStats = fs.statSync(localPath); const fileStats = fs.statSync(localPath);
const fileSize = fileStats.size; const fileSize = fileStats.size;
// 检查文件大小AWS S3 单次上传最大 5GB if (fileSize <= OSS_SINGLE_UPLOAD_THRESHOLD) {
const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB // 小文件保持单请求上传,兼容性更高。
if (fileSize > MAX_SINGLE_UPLOAD_SIZE) { const fileContent = fs.readFileSync(localPath);
throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB请使用分片上传`); 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编码 // 大文件自动切换到分片上传,避免整文件读入内存和 5GB 单请求上限。
// 这样可以确保与阿里云OSS的兼容性 const minPartSizeByCount = Math.ceil(fileSize / OSS_MULTIPART_MAX_PARTS);
const fileContent = fs.readFileSync(localPath); 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 createResp = await this.s3Client.send(new CreateMultipartUploadCommand({
const command = new PutObjectCommand({ 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, Bucket: bucket,
Key: key, Key: key,
Body: fileContent, UploadId: uploadId,
ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题 MultipartUpload: { Parts: uploadedParts }
// 禁用checksum算法阿里云OSS不完全支持AWS的x-amz-content-sha256头 }));
ChecksumAlgorithm: undefined uploadId = null;
}); console.log(
`[OSS存储] 分片上传完成: ${key} (${formatFileSize(fileSize)}, ${uploadedParts.length} 片)`
await this.s3Client.send(command); );
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
} catch (error) { } 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); console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
// 判断错误类型并给出友好的错误信息 // 判断错误类型并给出友好的错误信息
@@ -1139,6 +1251,10 @@ class OssStorageClient {
throw new Error(`本地文件不存在: ${localPath}`); throw new Error(`本地文件不存在: ${localPath}`);
} }
throw new Error(`文件上传失败: ${error.message}`); throw new Error(`文件上传失败: ${error.message}`);
} finally {
if (fileHandle) {
await fileHandle.close().catch(() => {});
}
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "desktop-client", "name": "desktop-client",
"private": true, "private": true,
"version": "0.1.22", "version": "0.1.23",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

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

View File

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

View File

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

View File

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

View File

@@ -153,7 +153,7 @@ const syncState = reactive({
nextRunAt: "", nextRunAt: "",
}); });
const updateState = reactive({ const updateState = reactive({
currentVersion: "0.1.22", currentVersion: "0.1.23",
latestVersion: "", latestVersion: "",
available: false, available: false,
mandatory: false, mandatory: false,
@@ -241,6 +241,7 @@ let unlistenNativeDownloadProgress: UnlistenFn | null = null;
let unlistenNativeUploadProgress: UnlistenFn | null = null; let unlistenNativeUploadProgress: UnlistenFn | null = null;
let syncTimer: ReturnType<typeof setInterval> | null = null; let syncTimer: ReturnType<typeof setInterval> | null = null;
let hasCheckedUpdateAfterAuth = false; let hasCheckedUpdateAfterAuth = false;
let authRefreshPromise: Promise<boolean> | null = null;
const toast = reactive({ const toast = reactive({
visible: false, visible: false,
@@ -806,12 +807,40 @@ function toggleFileSortOrder() {
fileViewState.sortOrder = fileViewState.sortOrder === "asc" ? "desc" : "asc"; fileViewState.sortOrder = fileViewState.sortOrder === "asc" ? "desc" : "asc";
} }
async function invokeBridge(command: string, payload: Record<string, any>) { 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<BridgeResponse>("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<string, any>,
allowAuthRetry = true,
) {
let response: BridgeResponse;
try { try {
return await invoke<BridgeResponse>(command, payload); response = await invoke<BridgeResponse>(command, payload);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return { response = {
ok: false, ok: false,
status: 0, status: 0,
data: { data: {
@@ -820,6 +849,20 @@ async function invokeBridge(command: string, payload: Record<string, any>) {
}, },
} satisfies BridgeResponse; } 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() { async function initClientVersion() {
@@ -1106,34 +1149,78 @@ async function chooseSyncDirectory() {
} }
} }
async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) { function isRetryableUploadResponse(response: BridgeResponse) {
const resumableResponse = await invokeBridge("api_upload_file_resumable", { const status = Number(response.status || 0);
baseUrl: appConfig.baseUrl, if ([0, 408, 425, 429, 500, 502, 503, 504].includes(status)) {
filePath, return true;
targetPath,
chunkSize: 4 * 1024 * 1024,
taskId: taskId || null,
});
if (resumableResponse.ok && resumableResponse.data?.success) {
return resumableResponse;
} }
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 || ""); async function waitMs(ms: number) {
if ( await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
message.includes("当前存储模式不支持分片上传") }
|| message.includes("分片上传会话")
|| message.includes("上传会话") async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) {
) { const maxAttempts = 3;
return await invokeBridge("api_upload_file", { let lastResponse: BridgeResponse | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
baseUrl: appConfig.baseUrl, baseUrl: appConfig.baseUrl,
filePath, filePath,
targetPath, targetPath,
chunkSize: 4 * 1024 * 1024,
taskId: taskId || null, 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() { function rebuildSyncScheduler() {