From 6508d2c438290e1a29dd0212f3fb3f10995caa3c Mon Sep 17 00:00:00 2001 From: kjqwer <2990346238@qq.com> Date: Fri, 3 Oct 2025 10:08:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=B8=8D=E6=B7=BB=E5=8A=A0=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E5=A2=9E=E5=8A=A0=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E7=B3=BB=E7=BB=9F=E9=B2=81=E6=A3=92=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/middleware/logger.js | 4 +- backend/routes/download.js | 75 ++++ backend/services/download-executor.js | 181 ++++++++-- backend/services/download.js | 470 +++++++++++++++++++++++++- backend/services/file-manager.js | 171 +++++++++- backend/utils/error-handler.js | 9 +- start.bat | 2 +- ui/src/services/download.ts | 14 + ui/src/stores/download.ts | 30 +- ui/src/views/DownloadsView.vue | 40 ++- 10 files changed, 933 insertions(+), 63 deletions(-) diff --git a/backend/middleware/logger.js b/backend/middleware/logger.js index 1d1b99b..c0ed3b9 100644 --- a/backend/middleware/logger.js +++ b/backend/middleware/logger.js @@ -63,7 +63,9 @@ function loggerMiddleware(req, res, next) { req.path === '/api/download/tasks/active' || req.path === '/api/download/tasks/summary' || req.path === '/api/download/tasks/changes' || - req.path === '/api/download/tasks/completed'; + req.path === '/api/download/tasks/completed' || + req.path === '/api/download/registry/stats' || + /^\/api\/download\/check\/\d+/.test(req.path); // 过滤掉仓库预览请求(图片预览) const isRepositoryPreview = req.path === '/api/repository/preview'; diff --git a/backend/routes/download.js b/backend/routes/download.js index 2cab2e1..8aec0d0 100644 --- a/backend/routes/download.js +++ b/backend/routes/download.js @@ -439,6 +439,81 @@ router.post('/resume/:taskId', async (req, res) => { } }); +/** + * 暂停批量下载任务 + * POST /api/download/batch/pause/:taskId + */ +router.post('/batch/pause/:taskId', async (req, res) => { + try { + const { taskId } = req.params; + + if (!taskId) { + return res.status(400).json({ + success: false, + error: 'Task ID is required' + }); + } + + const downloadService = req.backend.getDownloadService(); + const result = await downloadService.pauseBatchTask(taskId); + + if (result.success) { + res.json({ + success: true, + message: '批量下载任务已暂停' + }); + } else { + res.status(400).json({ + success: false, + error: result.error + }); + } + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * 恢复批量下载任务 + * POST /api/download/batch/resume/:taskId + */ +router.post('/batch/resume/:taskId', async (req, res) => { + try { + const { taskId } = req.params; + + if (!taskId) { + return res.status(400).json({ + success: false, + error: 'Task ID is required' + }); + } + + const downloadService = req.backend.getDownloadService(); + const result = await downloadService.resumeBatchTask(taskId); + + if (result.success) { + res.json({ + success: true, + data: result.data, + message: '批量下载任务已恢复' + }); + } else { + res.status(400).json({ + success: false, + error: result.error + }); + } + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + /** * 取消任务 * DELETE /api/download/cancel/:taskId diff --git a/backend/services/download-executor.js b/backend/services/download-executor.js index ed13b70..639bcd8 100644 --- a/backend/services/download-executor.js +++ b/backend/services/download-executor.js @@ -76,8 +76,9 @@ class DownloadExecutor { // 检查文件是否已存在且完整 if (await this.fileManager.fileExists(filePath)) { - // 验证文件完整性 - const integrity = await this.fileManager.checkFileIntegrity(filePath); + // 验证文件完整性,传入期望的MIME类型 + const expectedMimeType = this.getMimeTypeFromUrl(imageUrl); + const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType); if (integrity.valid) { // 只有在非恢复模式下才增加计数,避免重复计算 if (!task.isResuming) { @@ -90,7 +91,7 @@ class DownloadExecutor { continue; } else { // 文件不完整,删除重新下载 - logger.info(`文件不完整,重新下载: ${filePath}`); + logger.info(`文件不完整,重新下载: ${filePath}, 原因: ${integrity.reason}`); await this.fileManager.safeDeleteFile(filePath); } } @@ -102,9 +103,12 @@ class DownloadExecutor { // 下载文件并等待完成 await this.fileManager.downloadFile(imageUrl, filePath); - // 验证下载的文件完整性 - const integrity = await this.fileManager.checkFileIntegrity(filePath); + // 验证下载的文件完整性,传入期望的MIME类型 + const expectedMimeType = this.getMimeTypeFromUrl(imageUrl); + const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType); if (!integrity.valid) { + // 删除损坏的文件 + await this.fileManager.safeDeleteFile(filePath); throw new Error(`文件下载不完整: ${integrity.reason}`); } @@ -116,9 +120,28 @@ class DownloadExecutor { results.push({ success: true, file: fileName }); } catch (error) { task.failed_files++; - logger.error(`下载图片失败 ${index + 1}: ${error.message}`); + logger.error(`下载图片失败 ${index + 1}: ${error.message}`, { + taskId: task.id, + imageUrl, + filePath, + error: error.stack + }); + + // 尝试清理可能存在的损坏文件 + try { + if (await this.fileManager.fileExists(filePath)) { + await this.fileManager.safeDeleteFile(filePath); + logger.debug('已清理损坏的文件', { filePath }); + } + } catch (cleanupError) { + logger.warn('清理损坏文件失败', { + filePath, + error: cleanupError.message + }); + } + this.progressManager.notifyProgressUpdate(task.id, task); - results.push({ success: false, error: error.message }); + results.push({ success: false, error: error.message, file: fileName }); } } @@ -139,16 +162,49 @@ class DownloadExecutor { await this.taskManager.saveTasks(); this.progressManager.notifyProgressUpdate(task.id, task); - // 如果下载成功,更新下载注册表 - if (task.status === 'completed') { - try { - await this.downloadService.downloadRegistry.addArtwork(task.artist_name, task.artwork_id); - logger.debug('已更新下载注册表', { - artistName: task.artist_name, - artworkId: task.artwork_id + // 只有在所有文件都成功下载且完整性检查通过时,才更新下载注册表 + if (task.status === 'completed' && task.failed_files === 0 && task.completed_files === task.total_files) { + // 再次验证所有文件的完整性 + let allFilesValid = true; + const artworkFiles = await fs.readdir(artworkDir); + const imageFiles = artworkFiles.filter(file => + file.startsWith('image_') && + !file.endsWith('.json') && + !file.endsWith('.txt') + ); + + for (const imageFile of imageFiles) { + const filePath = path.join(artworkDir, imageFile); + const expectedMimeType = this.getMimeTypeFromUrl(imageFile); + const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType); + if (!integrity.valid) { + allFilesValid = false; + logger.warn('发现不完整文件,不添加到下载注册表', { + file: imageFile, + reason: integrity.reason + }); + break; + } + } + + if (allFilesValid && imageFiles.length === task.total_files) { + try { + await this.downloadService.downloadRegistry.addArtwork(task.artist_name, task.artwork_id); + logger.debug('已更新下载注册表', { + artistName: task.artist_name, + artworkId: task.artwork_id, + totalFiles: task.total_files, + completedFiles: task.completed_files + }); + } catch (error) { + logger.warn('更新下载注册表失败:', error.message); + } + } else { + logger.warn('文件完整性验证失败或文件数量不匹配,不添加到下载注册表', { + expectedFiles: task.total_files, + actualFiles: imageFiles.length, + allFilesValid }); - } catch (error) { - logger.warn('更新下载注册表失败:', error.message); } } @@ -218,6 +274,16 @@ class DownloadExecutor { const batch = items.slice(i, i + batchSize); const batchPromises = batch.map(async item => { try { + // 检查是否应该暂停(在每个作品下载前检查) + if (this.shouldPause(task.id)) { + logger.info('批量下载任务已暂停,停止当前作品下载:', task.id); + // 设置任务状态为暂停 + task.status = 'paused'; + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + return { artwork_id: typeof item === 'object' ? item.id : item, success: false, paused: true }; + } + // 获取作品ID - 支持直接传入ID或作品对象 const artworkId = typeof item === 'object' ? item.id : item; @@ -237,9 +303,30 @@ class DownloadExecutor { results.push(result); return result; } else { - // 真正下载成功 + // 真正下载成功,立即添加到注册表 task.completed_files++; + // 立即添加到下载注册表 + try { + await this.downloadService.downloadRegistry.addArtwork( + downloadResult.artist_name, + artworkId + ); + logger.debug(`批量下载中的作品 ${artworkId} 已添加到下载注册表`, { + artworkId, + artistName: downloadResult.artist_name, + taskId: task.id + }); + } catch (error) { + logger.error(`批量下载中添加作品到注册表失败: ${artworkId}`, { + artworkId, + artistName: downloadResult.artist_name, + taskId: task.id, + error: error.message, + stack: error.stack + }); + } + // 添加到最近完成列表 const completedItem = { artwork_id: artworkId, @@ -294,6 +381,12 @@ class DownloadExecutor { } } + // 检查任务是否被暂停,如果是则不要更新最终状态 + if (task.status === 'paused') { + logger.info('批量下载任务已暂停,跳过最终状态更新:', task.id); + return; + } + // 更新任务状态 task.status = task.failed_files === 0 ? 'completed' : 'partial'; task.end_time = new Date(); @@ -328,6 +421,30 @@ class DownloadExecutor { } } + /** + * 根据URL获取MIME类型 + * @param {string} url - 图片URL + * @returns {string} MIME类型 + */ + getMimeTypeFromUrl(url) { + const ext = this.getFileExtension(url).toLowerCase(); + switch (ext) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + case 'webp': + return 'image/webp'; + case 'bmp': + return 'image/bmp'; + default: + return 'image/jpeg'; // 默认为JPEG + } + } + /** * 获取文件扩展名 */ @@ -423,14 +540,15 @@ class DownloadExecutor { // 检查文件是否存在且完整 if (await this.fileManager.fileExists(filePath)) { - const integrity = await this.fileManager.checkFileIntegrity(filePath); + const expectedMimeType = this.getMimeTypeFromUrl(imageUrl); + const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType); if (integrity.valid) { completedFiles.push({ index, fileName, filePath }); } else { - incompleteFiles.push({ index, fileName, filePath }); + incompleteFiles.push({ index, fileName, filePath, reason: integrity.reason }); } } else { - incompleteFiles.push({ index, fileName, filePath }); + incompleteFiles.push({ index, fileName, filePath, reason: '文件不存在' }); } } @@ -466,9 +584,28 @@ class DownloadExecutor { await this.executeArtworkDownload(task, images, 'original', artworkDir, artwork); } else if (task.type === 'batch' || task.type === 'artist') { // 批量下载和作者下载的恢复逻辑 - // 这里需要根据具体实现来恢复 logger.info('恢复批量下载任务:', taskId); - // TODO: 实现批量下载的恢复逻辑 + + // 重置任务状态为下载中 + task.status = 'downloading'; + task.isResuming = true; + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + + // 获取原始的作品列表 + const items = task.items || []; + if (items.length === 0) { + logger.error('批量下载任务没有作品列表,无法恢复', { taskId }); + throw new Error('批量下载任务没有作品列表,无法恢复'); + } + + // 重新开始批量下载 + await this.executeBatchDownload(task, items, { + size: task.size || 'original', + quality: task.quality || 'high', + format: task.format || 'auto', + concurrent: task.concurrent || 3 + }); } logger.info('任务恢复执行完成', { taskId }); diff --git a/backend/services/download.js b/backend/services/download.js index 3719e5a..a4f0e66 100644 --- a/backend/services/download.js +++ b/backend/services/download.js @@ -233,13 +233,42 @@ class DownloadService { return { success: false, error: '任务不存在' }; } - await this.taskManager.updateTask(taskId, { - status: 'cancelled', - end_time: new Date(), - }); + logger.info('开始取消任务', { taskId, status: task.status, type: task.type }); - this.progressManager.notifyProgressUpdate(taskId, task); - return { success: true }; + try { + // 更新任务状态为取消中,防止并发操作 + await this.taskManager.updateTask(taskId, { status: 'cancelling' }); + + // 清理未完成的文件 + await this.cleanupIncompleteFiles(task); + + // 最终更新任务状态 + await this.taskManager.updateTask(taskId, { + status: 'cancelled', + end_time: new Date(), + }); + + // 获取更新后的任务并通知 + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + logger.info('任务取消完成', { taskId }); + return { success: true }; + } catch (error) { + logger.error('取消任务失败', { taskId, error: error.message }); + + // 如果清理失败,仍然标记为取消,但记录错误 + await this.taskManager.updateTask(taskId, { + status: 'cancelled', + end_time: new Date(), + error: `取消时清理失败: ${error.message}` + }); + + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + return { success: true, warning: `任务已取消,但清理时出现问题: ${error.message}` }; + } } async pauseTask(taskId) { @@ -253,13 +282,38 @@ class DownloadService { return { success: false, error: '只能暂停正在下载的任务' }; } - await this.taskManager.updateTask(taskId, { status: 'paused' }); - - // 获取更新后的任务 - const updatedTask = this.taskManager.getTask(taskId); - this.progressManager.notifyProgressUpdate(taskId, updatedTask); - - return { success: true, data: updatedTask }; + logger.info('开始暂停任务', { taskId, status: task.status, type: task.type }); + + try { + // 更新任务状态为暂停中,防止并发操作 + await this.taskManager.updateTask(taskId, { status: 'pausing' }); + + // 清理未完成的文件 + await this.cleanupIncompleteFiles(task); + + // 最终更新任务状态为暂停 + await this.taskManager.updateTask(taskId, { status: 'paused' }); + + // 获取更新后的任务 + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + logger.info('任务暂停完成', { taskId }); + return { success: true, data: updatedTask }; + } catch (error) { + logger.error('暂停任务失败', { taskId, error: error.message }); + + // 如果清理失败,仍然标记为暂停,但记录错误 + await this.taskManager.updateTask(taskId, { + status: 'paused', + error: `暂停时清理失败: ${error.message}` + }); + + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + return { success: true, data: updatedTask, warning: `任务已暂停,但清理时出现问题: ${error.message}` }; + } } async resumeTask(taskId) { @@ -311,6 +365,288 @@ class DownloadService { return { success: true, data: this.taskManager.getTask(taskId) }; } + /** + * 暂停批量下载任务 + */ + async pauseBatchTask(taskId) { + const task = this.taskManager.getTask(taskId); + if (!task) { + return { success: false, error: '任务不存在' }; + } + + // 检查是否为批量下载任务 + if (!['batch', 'artist', 'art'].includes(task.type)) { + return { success: false, error: '此方法仅适用于批量下载任务' }; + } + + // 只允许暂停正在下载的任务 + if (task.status !== 'downloading') { + return { success: false, error: '只能暂停正在下载的任务' }; + } + + logger.info('开始暂停批量下载任务', { taskId, status: task.status, type: task.type }); + + try { + // 直接设置任务状态为暂停,不进行文件清理 + // 批量下载中的每个文件都是独立完成的,不需要清理 + await this.taskManager.updateTask(taskId, { status: 'paused' }); + + // 获取更新后的任务 + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + logger.info('批量下载任务暂停完成', { taskId }); + return { success: true, data: updatedTask }; + } catch (error) { + logger.error('暂停批量下载任务失败', { taskId, error: error.message }); + return { success: false, error: `暂停任务失败: ${error.message}` }; + } + } + + /** + * 恢复批量下载任务 + */ + async resumeBatchTask(taskId) { + const task = this.taskManager.getTask(taskId); + if (!task) { + logger.error('恢复批量下载任务失败:任务不存在', { taskId }); + return { success: false, error: '任务不存在' }; + } + + // 检查是否为批量下载任务 + if (!['batch', 'artist', 'art'].includes(task.type)) { + return { success: false, error: '此方法仅适用于批量下载任务' }; + } + + // 只允许恢复暂停的任务 + if (task.status !== 'paused') { + logger.warn('恢复批量下载任务失败:任务状态不是暂停状态', { + taskId, + currentStatus: task.status + }); + return { success: false, error: '只能恢复暂停的任务' }; + } + + // 重新开始批量下载执行 + try { + logger.info('开始恢复批量下载任务执行', { taskId }); + + // 直接设置任务状态为下载中 + await this.taskManager.updateTask(taskId, { status: 'downloading' }); + + // 获取原始的作品列表 + const items = task.items || []; + if (items.length === 0) { + logger.error('批量下载任务没有作品列表,无法恢复', { taskId }); + await this.taskManager.updateTask(taskId, { status: 'paused' }); + return { success: false, error: '批量下载任务没有作品列表,无法恢复' }; + } + + // 通知状态更新 + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + // 异步重新开始批量下载,不等待完成 + setImmediate(async () => { + try { + await this.downloadExecutor.executeBatchDownload(task, items, { + size: task.size || 'original', + quality: task.quality || 'high', + format: task.format || 'auto', + concurrent: task.concurrent || 3 + }); + } catch (error) { + logger.error('批量下载任务恢复执行失败', { + taskId, + error: error.message, + stack: error.stack + }); + // 如果执行失败,设置任务状态为失败 + await this.taskManager.updateTask(taskId, { + status: 'failed', + error: error.message + }); + const failedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, failedTask); + } + }); + + logger.info('批量下载任务恢复成功', { + taskId, + newStatus: updatedTask.status + }); + + return { success: true, data: updatedTask }; + } catch (error) { + logger.error('恢复批量下载任务失败', { + taskId, + error: error.message, + stack: error.stack + }); + // 如果恢复失败,保持暂停状态 + await this.taskManager.updateTask(taskId, { status: 'paused' }); + return { success: false, error: `恢复任务失败: ${error.message}` }; + } + } + + /** + * 清理未完成的文件 + * @param {Object} task - 任务对象 + */ + async cleanupIncompleteFiles(task) { + if (!task) { + logger.warn('清理未完成文件:任务对象为空'); + return; + } + + logger.info('开始清理未完成文件', { taskId: task.id, type: task.type }); + + try { + if (task.type === 'artwork') { + await this.cleanupArtworkIncompleteFiles(task); + } else if (task.type === 'batch' || task.type === 'artist') { + // 批量下载任务通常不需要清理单个文件,因为每个作品都是独立处理的 + logger.info('批量任务无需清理单个文件', { taskId: task.id, type: task.type }); + } + } catch (error) { + logger.error('清理未完成文件失败', { taskId: task.id, error: error.message }); + throw error; + } + } + + /** + * 清理单个作品任务的未完成文件 + * @param {Object} task - 作品下载任务 + */ + async cleanupArtworkIncompleteFiles(task) { + if (!task.artwork_id || !task.artist_name || !task.artwork_title) { + logger.warn('作品任务信息不完整,跳过文件清理', { + taskId: task.id, + artwork_id: task.artwork_id, + artist_name: task.artist_name, + artwork_title: task.artwork_title + }); + return; + } + + try { + // 构建作品目录路径 + const downloadPath = await this.fileManager.getDownloadPath(); + const artistName = this.fileManager.createSafeDirectoryName(task.artist_name); + const artworkTitle = this.fileManager.createSafeDirectoryName(task.artwork_title); + const artistDir = path.join(downloadPath, artistName); + const artworkDirName = `${task.artwork_id}_${artworkTitle}`; + const artworkDir = path.join(artistDir, artworkDirName); + + // 检查作品目录是否存在 + if (!(await this.fileManager.directoryExists(artworkDir))) { + logger.debug('作品目录不存在,无需清理', { taskId: task.id, artworkDir }); + return; + } + + // 获取目录中的所有文件 + const files = await this.fileManager.listDirectory(artworkDir); + const imageFiles = files.filter(file => + /\.(jpg|jpeg|png|gif|webp)$/i.test(file) && file !== 'artwork_info.json' + ); + + let cleanedCount = 0; + let errorCount = 0; + + // 检查并删除未完成的图片文件 + for (const fileName of imageFiles) { + const filePath = path.join(artworkDir, fileName); + + try { + // 检查文件完整性,根据文件扩展名推断MIME类型 + const expectedMimeType = this.getMimeTypeFromExtension(fileName); + const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType); + + if (!integrity.valid) { + // 文件不完整,删除它 + const deleted = await this.fileManager.safeDeleteFile(filePath); + if (deleted) { + cleanedCount++; + logger.debug('删除未完成文件', { + taskId: task.id, + fileName, + reason: integrity.reason + }); + } else { + errorCount++; + logger.warn('删除未完成文件失败', { + taskId: task.id, + fileName, + reason: '文件可能被占用' + }); + } + } + } catch (error) { + errorCount++; + logger.warn('检查文件完整性失败', { + taskId: task.id, + fileName, + error: error.message + }); + } + } + + // 如果目录中只剩下artwork_info.json或为空,删除整个目录 + const remainingFiles = await this.fileManager.listDirectory(artworkDir); + const remainingImageFiles = remainingFiles.filter(file => + /\.(jpg|jpeg|png|gif|webp)$/i.test(file) + ); + + if (remainingImageFiles.length === 0) { + try { + await this.fileManager.removeDirectory(artworkDir); + logger.info('删除空的作品目录', { taskId: task.id, artworkDir }); + } catch (error) { + logger.warn('删除空目录失败', { + taskId: task.id, + artworkDir, + error: error.message + }); + } + } + + logger.info('文件清理完成', { + taskId: task.id, + cleanedCount, + errorCount, + totalImageFiles: imageFiles.length + }); + + } catch (error) { + logger.error('清理作品文件失败', { taskId: task.id, error: error.message }); + throw error; + } + } + + /** + * 根据文件扩展名获取MIME类型 + * @param {string} fileName - 文件名 + * @returns {string} MIME类型 + */ + getMimeTypeFromExtension(fileName) { + const ext = path.extname(fileName).toLowerCase().replace('.', ''); + switch (ext) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + case 'webp': + return 'image/webp'; + case 'bmp': + return 'image/bmp'; + default: + return 'image/jpeg'; // 默认为JPEG + } + } + // 代理方法 - 历史记录管理 async getDownloadHistory(offset = 0, limit = 50) { const result = this.historyManager.getDownloadHistory(offset, limit); @@ -651,7 +987,12 @@ class DownloadService { }, }; } catch (error) { - logger.error('下载作品失败:', error); + logger.error('下载作品失败:', { + artworkId, + error: error.message, + stack: error.stack, + options + }); return { success: false, error: error.message, @@ -687,9 +1028,27 @@ class DownloadService { try { // 检查是否已下载 if (skipExisting && (await this.isArtworkDownloaded(artworkId))) { + // 获取作品信息用于返回 + let artistName = 'Unknown Artist'; + let artworkTitle = 'Untitled'; + + try { + const artworkResult = await this.artworkService.getArtworkDetail(artworkId); + if (artworkResult.success && artworkResult.data) { + const artwork = artworkResult.data; + artistName = this.fileManager.createSafeDirectoryName(artwork.user?.name || 'Unknown Artist'); + artworkTitle = this.fileManager.createSafeDirectoryName(artwork.title || 'Untitled'); + } + } catch (error) { + logger.debug(`获取已下载作品信息失败: ${artworkId}`, error.message); + } + return { success: true, skipped: true, + artwork_id: artworkId, + artist_name: artistName, + artwork_title: artworkTitle, message: '作品已存在且完整,跳过下载', }; } @@ -779,7 +1138,14 @@ class DownloadService { await this.fileManager.downloadFile(imageUrl, filePath); results.push({ success: true, file: fileName }); } catch (error) { - logger.error(`下载图片失败 ${index + 1}: ${error.message}`); + logger.error(`下载图片失败 ${index + 1}:`, { + artworkId, + imageIndex: index + 1, + imageUrl, + filePath, + error: error.message, + stack: error.stack + }); results.push({ success: false, error: error.message }); } } @@ -791,9 +1157,73 @@ class DownloadService { // 检查下载结果 const failedCount = results.filter(r => !r.success).length; const successCount = results.filter(r => r.success && !r.skipped).length; + const skippedCount = results.filter(r => r.success && r.skipped).length; + + // 只有在所有文件都成功下载(包括跳过的文件)时才添加到注册表 + const allFilesSuccessful = failedCount === 0; + + if (allFilesSuccessful) { + try { + // 执行文件完整性检查 + let integrityCheckPassed = true; + for (let index = 0; index < images.length; index++) { + const fileName = `image_${index + 1}.${this.getFileExtension(images[index].original || images[index].large || images[index].medium)}`; + const filePath = path.join(artworkDir, fileName); + + if (await this.fileManager.fileExists(filePath)) { + // 检查文件大小 + const stats = await fs.stat(filePath); + if (stats.size === 0) { + logger.warn(`文件大小为0,完整性检查失败: ${filePath}`); + integrityCheckPassed = false; + break; + } + + // 检查MIME类型 - 使用checkFileHeader方法来检测文件类型 + const headerCheck = await this.fileManager.checkFileHeader(filePath); + if (!headerCheck.valid || !headerCheck.detectedType || !headerCheck.detectedType.startsWith('image/')) { + logger.warn(`文件MIME类型检查失败: ${filePath}, 检测结果: ${JSON.stringify(headerCheck)}`); + integrityCheckPassed = false; + break; + } + } + } + + if (integrityCheckPassed) { + // 添加到下载注册表(仅用于单个作品下载,批量下载在executeBatchDownload中处理) + await this.downloadRegistry.addArtwork(artistName, artworkId); + logger.debug(`作品 ${artworkId} 已添加到下载注册表`, { + artworkId, + artistName, + totalFiles: images.length, + completedFiles: successCount, + skippedFiles: skippedCount + }); + } else { + logger.warn(`作品 ${artworkId} 文件完整性检查失败,未添加到下载注册表`, { + artworkId, + artistName + }); + } + } catch (error) { + logger.error(`添加作品到下载注册表失败: ${artworkId}`, { + artworkId, + artistName, + error: error.message, + stack: error.stack + }); + } + } else { + logger.debug(`作品 ${artworkId} 下载不完整,未添加到下载注册表`, { + artworkId, + artistName, + failedCount, + totalFiles: images.length + }); + } return { - success: failedCount === 0, + success: allFilesSuccessful, artwork_id: artworkId, artist_name: artistName, artwork_title: artworkTitle, @@ -803,7 +1233,12 @@ class DownloadService { results: results, }; } catch (error) { - logger.error(`下载作品 ${artworkId} 失败:`, error); + logger.error(`下载作品 ${artworkId} 失败:`, { + artworkId, + error: error.message, + stack: error.stack, + options + }); return { success: false, error: error.message, @@ -872,6 +1307,7 @@ class DownloadService { results: [], task_description: taskDescription, task_title: taskTitle, + items: items, // 保存原始的作品列表,用于恢复任务 // 保留原有的任务特定字段 ...(options.artist_id && { artist_id: options.artist_id }), ...(options.artist_name && { artist_name: options.artist_name }), diff --git a/backend/services/file-manager.js b/backend/services/file-manager.js index ea6c901..c2f6b9e 100644 --- a/backend/services/file-manager.js +++ b/backend/services/file-manager.js @@ -111,7 +111,14 @@ class FileManager { /** * 检查文件完整性 */ - async checkFileIntegrity(filePath, expectedSize = null) { + /** + * 检查文件完整性 + * @param {string} filePath - 文件路径 + * @param {number} expectedSize - 期望的文件大小 + * @param {string} expectedMimeType - 期望的MIME类型 + * @returns {Object} 检查结果 + */ + async checkFileIntegrity(filePath, expectedSize = null, expectedMimeType = null) { try { if (!await fs.pathExists(filePath)) { return { valid: false, reason: '文件不存在' }; @@ -130,8 +137,15 @@ class FileManager { } // 检查文件是否过小(可能下载不完整) - if (stats.size < 512) { // 小于512字节的文件可能是损坏的 - return { valid: false, reason: '文件过小,可能下载不完整', size: stats.size }; + const minSize = this.getMinimumFileSize(filePath, expectedMimeType); + if (stats.size < minSize) { + return { valid: false, reason: '文件过小,可能下载不完整', size: stats.size, minSize }; + } + + // 检查文件头部是否符合预期格式 + const headerCheck = await this.checkFileHeader(filePath, expectedMimeType); + if (!headerCheck.valid) { + return headerCheck; } // 文件存在且大小正常,认为有效 @@ -141,6 +155,110 @@ class FileManager { } } + /** + * 获取文件的最小合理大小 + * @param {string} filePath - 文件路径 + * @param {string} expectedMimeType - 期望的MIME类型 + * @returns {number} 最小文件大小 + */ + getMinimumFileSize(filePath, expectedMimeType) { + const ext = path.extname(filePath).toLowerCase(); + + // 根据文件类型设置最小大小 + if (expectedMimeType) { + if (expectedMimeType.startsWith('image/')) { + return 1024; // 图片文件至少1KB + } + } + + // 根据扩展名判断 + switch (ext) { + case '.jpg': + case '.jpeg': + return 1024; // JPEG文件至少1KB + case '.png': + return 512; // PNG文件至少512字节 + case '.gif': + return 256; // GIF文件至少256字节 + case '.webp': + return 512; // WebP文件至少512字节 + case '.bmp': + return 1024; // BMP文件至少1KB + default: + return 256; // 其他文件至少256字节 + } + } + + /** + * 检查文件头部格式 + * @param {string} filePath - 文件路径 + * @param {string} expectedMimeType - 期望的MIME类型 + * @returns {Object} 检查结果 + */ + async checkFileHeader(filePath, expectedMimeType) { + try { + // 读取文件前几个字节来检查文件头 + const buffer = Buffer.alloc(16); + const fd = await fs.open(filePath, 'r'); + + try { + const { bytesRead } = await fs.read(fd, buffer, 0, 16, 0); + + if (bytesRead < 4) { + return { valid: false, reason: '文件头部数据不足' }; + } + + // 检查常见图片格式的文件头 + const header = buffer.toString('hex', 0, Math.min(bytesRead, 8)); + + // JPEG文件头: FFD8FF + if (header.startsWith('ffd8ff')) { + if (expectedMimeType && !expectedMimeType.includes('jpeg') && !expectedMimeType.includes('jpg')) { + return { valid: false, reason: '文件格式不匹配:检测到JPEG但期望其他格式' }; + } + return { valid: true, detectedType: 'image/jpeg' }; + } + + // PNG文件头: 89504E47 + if (header.startsWith('89504e47')) { + if (expectedMimeType && !expectedMimeType.includes('png')) { + return { valid: false, reason: '文件格式不匹配:检测到PNG但期望其他格式' }; + } + return { valid: true, detectedType: 'image/png' }; + } + + // GIF文件头: 474946 + if (header.startsWith('474946')) { + if (expectedMimeType && !expectedMimeType.includes('gif')) { + return { valid: false, reason: '文件格式不匹配:检测到GIF但期望其他格式' }; + } + return { valid: true, detectedType: 'image/gif' }; + } + + // WebP文件头: 52494646...57454250 + if (header.startsWith('52494646') && buffer.toString('hex', 8, 12) === '57454250') { + if (expectedMimeType && !expectedMimeType.includes('webp')) { + return { valid: false, reason: '文件格式不匹配:检测到WebP但期望其他格式' }; + } + return { valid: true, detectedType: 'image/webp' }; + } + + // 如果没有明确的期望类型,且检测到了有效的图片头部,则认为有效 + if (!expectedMimeType) { + return { valid: true, detectedType: 'unknown' }; + } + + // 如果有期望类型但未匹配到已知格式,可能是损坏的文件 + return { valid: false, reason: '无法识别的文件格式或文件头部损坏' }; + + } finally { + await fs.close(fd); + } + } catch (error) { + return { valid: false, reason: '检查文件头部失败', error: error.message }; + } + } + /** * 简单的文件下载方法 */ @@ -217,11 +335,24 @@ class FileManager { if (isResolved) return; isResolved = true; + logger.error(`文件写入流错误: ${filePath}`, { + error: error.message, + stack: error.stack, + attempt, + url + }); + // 下载失败时删除文件 try { - await this.safeDeleteFile(filePath); + if (await fs.pathExists(filePath)) { + await this.safeDeleteFile(filePath); + logger.debug('已清理失败的下载文件', { filePath }); + } } catch (removeError) { - logger.warn('清理失败文件时出错:', removeError.message); + logger.warn('清理失败文件时出错:', { + filePath, + error: removeError.message + }); } cleanup(); @@ -233,6 +364,12 @@ class FileManager { if (isResolved) return; isResolved = true; + logger.error(`下载超时: ${filePath}`, { + url, + timeout: downloadConfig.timeout + 60000, + attempt + }); + const timeoutError = new Error('下载超时'); cleanup(); reject(timeoutError); @@ -257,7 +394,13 @@ class FileManager { // 处理文件系统错误 const errorResult = ErrorHandler.handleFileSystemError(error, filePath, 'download'); - logger.error(`下载文件失败 (尝试 ${attempt}/${maxRetries}): ${filePath}`, error.message); + logger.error(`下载文件失败 (尝试 ${attempt}/${maxRetries}): ${filePath}`, { + error: error.message, + stack: error.stack, + url, + retryable: errorResult.retryable, + attempt + }); // 如果不是可重试的错误,直接抛出 if (!errorResult.retryable) { @@ -266,7 +409,12 @@ class FileManager { // 如果是最后一次尝试,抛出错误 if (attempt === maxRetries) { - logger.error(`下载文件最终失败: ${filePath}`, error.message); + logger.error(`下载文件最终失败: ${filePath}`, { + error: error.message, + stack: error.stack, + url, + totalAttempts: maxRetries + }); throw error; } @@ -417,9 +565,14 @@ class FileManager { try { if (await fs.pathExists(filePath)) { await fs.unlink(filePath); + logger.debug('文件删除成功', { filePath }); } } catch (error) { - logger.error(`文件删除失败: ${filePath}`, error.message); + logger.error(`文件删除失败: ${filePath}`, { + error: error.message, + stack: error.stack, + code: error.code + }); // 不抛出错误,避免影响其他操作 } } @@ -458,4 +611,4 @@ class FileManager { } } -module.exports = FileManager; \ No newline at end of file +module.exports = FileManager; \ No newline at end of file diff --git a/backend/utils/error-handler.js b/backend/utils/error-handler.js index e4d0555..7c1256a 100644 --- a/backend/utils/error-handler.js +++ b/backend/utils/error-handler.js @@ -20,16 +20,19 @@ class ErrorHandler { isPkg: process.pkg !== undefined, platform: process.platform, errorCode: error.code, - errorMessage: error.message + errorMessage: error.message, + timestamp: new Date().toISOString() }; - // 记录错误信息 + // 记录详细错误信息 logger.error(`文件系统错误 [${operation}]:`, { filePath: filePath, errorCode: error.code, errorMessage: error.message, + stack: error.stack, isPkg: errorInfo.isPkg, - platform: errorInfo.platform + platform: errorInfo.platform, + timestamp: errorInfo.timestamp }); // 根据错误类型提供解决方案 diff --git a/start.bat b/start.bat index 9a80e27..ff7480b 100644 --- a/start.bat +++ b/start.bat @@ -20,7 +20,7 @@ REM INFO: 显示一般信息及以上级别信息 (默认) REM DEBUG: 显示调试信息及以上级别信息 REM TRACE: 显示所有级别信息 (最详细) REM ======================================== -set LOG_LEVEL= +set LOG_LEVEL=DEBUG echo. echo Pixiv Manager 启动中... diff --git a/ui/src/services/download.ts b/ui/src/services/download.ts index 64d22e4..2eb4269 100644 --- a/ui/src/services/download.ts +++ b/ui/src/services/download.ts @@ -73,6 +73,20 @@ class DownloadService { return apiService.post(`/api/download/resume/${taskId}`); } + /** + * 暂停批量下载任务 + */ + async pauseBatchTask(taskId: string) { + return apiService.post(`/api/download/batch/pause/${taskId}`); + } + + /** + * 恢复批量下载任务 + */ + async resumeBatchTask(taskId: string) { + return apiService.post(`/api/download/batch/resume/${taskId}`); + } + /** * 取消任务 */ diff --git a/ui/src/stores/download.ts b/ui/src/stores/download.ts index 64ed1f2..3ea11a5 100644 --- a/ui/src/stores/download.ts +++ b/ui/src/stores/download.ts @@ -303,7 +303,19 @@ export const useDownloadStore = defineStore('download', () => { // 恢复任务 const resumeTask = async (taskId: string) => { try { - const response = await downloadService.resumeTask(taskId); + // 获取任务信息以确定类型 + const task = getTask(taskId); + let response; + + // 判断是否为批量下载任务(batch、artist、art类型都是批量下载) + if (task && ['batch', 'artist', 'art'].includes(task.type)) { + // 使用批量下载专用API + response = await downloadService.resumeBatchTask(taskId); + } else { + // 使用单个下载API + response = await downloadService.resumeTask(taskId); + } + if (response.success) { await fetchTasks(); // 重新管理SSE连接 @@ -321,7 +333,19 @@ export const useDownloadStore = defineStore('download', () => { // 暂停任务 const pauseTask = async (taskId: string) => { try { - const response = await downloadService.pauseTask(taskId); + // 获取任务信息以确定类型 + const task = getTask(taskId); + let response; + + // 判断是否为批量下载任务(batch、artist、art类型都是批量下载) + if (task && ['batch', 'artist', 'art'].includes(task.type)) { + // 使用批量下载专用API + response = await downloadService.pauseBatchTask(taskId); + } else { + // 使用单个下载API + response = await downloadService.pauseTask(taskId); + } + if (response.success) { await fetchTasks(); } else { @@ -410,4 +434,4 @@ export const useDownloadStore = defineStore('download', () => { startRefreshInterval, stopRefreshInterval }; -}); \ No newline at end of file +}); \ No newline at end of file diff --git a/ui/src/views/DownloadsView.vue b/ui/src/views/DownloadsView.vue index 85fe4db..2eb5ee8 100644 --- a/ui/src/views/DownloadsView.vue +++ b/ui/src/views/DownloadsView.vue @@ -62,6 +62,9 @@