diff --git a/backend/services/file-manager.js b/backend/services/file-manager.js index aa5edaf..95cab88 100644 --- a/backend/services/file-manager.js +++ b/backend/services/file-manager.js @@ -4,6 +4,7 @@ const path = require('path'); const crypto = require('crypto'); const ConfigManager = require('../config/config-manager'); const FileUtils = require('../utils/file-utils'); +const ErrorHandler = require('../utils/error-handler'); /** * 文件管理器 - 负责文件下载、检查和目录管理 @@ -92,32 +93,90 @@ class FileManager { * 简单的文件下载方法 */ async downloadFile(url, filePath) { - const response = await axios({ - method: 'GET', - url: url, - responseType: 'stream', - headers: { - 'Referer': 'https://www.pixiv.net/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - }, - timeout: 60000 - }); + const maxRetries = this.downloadConfig.retryAttempts; + let lastError = null; - const writer = fs.createWriteStream(filePath); - response.data.pipe(writer); - - return new Promise((resolve, reject) => { - writer.on('finish', resolve); - writer.on('error', async (error) => { - // 下载失败时删除文件 - try { - await this.safeDeleteFile(filePath); - } catch (removeError) { - console.warn('清理失败文件时出错:', removeError.message); + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // 使用增强的文件工具类确保目录存在 + const dirPath = path.dirname(filePath); + const dirCreated = await FileUtils.safeEnsureDirEnhanced(dirPath); + + if (!dirCreated) { + throw new Error(`无法创建目录: ${dirPath}`); } - reject(error); - }); - }); + + // 检查文件是否被占用 + if (await fs.pathExists(filePath)) { + const fileReleased = await FileUtils.waitForFileRelease(filePath); + if (!fileReleased) { + throw new Error(`文件被占用,无法写入: ${filePath}`); + } + } + + const response = await axios({ + method: 'GET', + url: url, + responseType: 'stream', + headers: { + 'Referer': 'https://www.pixiv.net/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + }, + timeout: 60000 + }); + + // 使用增强的写入流创建方法 + const writer = await FileUtils.safeCreateWriteStream(filePath); + response.data.pipe(writer); + + return new Promise((resolve, reject) => { + writer.on('finish', () => { + // 验证文件是否写入成功 + fs.access(filePath) + .then(() => resolve()) + .catch(error => { + console.error(`文件写入验证失败: ${filePath}`, error.message); + reject(error); + }); + }); + writer.on('error', async (error) => { + // 下载失败时删除文件 + try { + await this.safeDeleteFile(filePath); + } catch (removeError) { + console.warn('清理失败文件时出错:', removeError.message); + } + reject(error); + }); + }); + } catch (error) { + lastError = error; + + // 处理文件系统错误 + const errorResult = ErrorHandler.handleFileSystemError(error, filePath, 'download'); + + console.error(`下载文件失败 (尝试 ${attempt}/${maxRetries}): ${filePath}`, error.message); + + // 如果不是可重试的错误,直接抛出 + if (!errorResult.retryable) { + throw error; + } + + // 如果是最后一次尝试,抛出错误 + if (attempt === maxRetries) { + console.error(`下载文件最终失败: ${filePath}`, error.message); + throw error; + } + + // 等待后重试 + const retryDelay = ErrorHandler.getRetryDelay(error, attempt); + console.log(`等待 ${retryDelay}ms 后重试下载: ${filePath}`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + + // 如果所有重试都失败了 + throw lastError; } /** @@ -157,7 +216,19 @@ class FileManager { if (!name) return 'Untitled'; // 移除或替换Windows文件系统不允许的字符 - let safeName = name.replace(/[<>:"/\\|?*]/g, '_'); + let safeName = name + // 替换Windows文件系统不允许的字符 + .replace(/[<>:"/\\|?*]/g, '_') + // 替换波浪号和其他可能导致问题的字符 + .replace(/[~`!@#$%^&*()+=\[\]{};',]/g, '_') + // 替换控制字符 + .replace(/[\x00-\x1f\x7f]/g, '_') + // 替换Unicode控制字符 + .replace(/[\u0000-\u001F\u007F-\u009F]/g, '_') + // 替换零宽字符 + .replace(/[\u200B-\u200D\uFEFF]/g, '_') + // 替换其他可能导致问题的Unicode字符 + .replace(/[\uFFFD]/g, '_'); // 移除前后空格和点 safeName = safeName.trim().replace(/^\.+|\.+$/g, ''); @@ -167,11 +238,22 @@ class FileManager { safeName = 'Untitled'; } - // 限制长度,避免路径过长 + // 限制长度,避免路径过长(Windows路径限制为260字符) if (safeName.length > 100) { safeName = safeName.substring(0, 100); } + // 确保不以数字开头(避免与Windows保留名称冲突) + if (/^\d/.test(safeName)) { + safeName = 'artwork_' + safeName; + } + + // 检查是否为Windows保留名称 + const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']; + if (reservedNames.includes(safeName.toUpperCase())) { + safeName = 'artwork_' + safeName; + } + return safeName; } @@ -179,7 +261,7 @@ class FileManager { * 确保目录存在 */ async ensureDirectory(dirPath) { - const success = await FileUtils.safeEnsureDir(dirPath); + const success = await FileUtils.safeEnsureDirEnhanced(dirPath); if (!success) { throw new Error(`目录创建失败: ${dirPath}`); } diff --git a/backend/utils/error-handler.js b/backend/utils/error-handler.js new file mode 100644 index 0000000..3326e44 --- /dev/null +++ b/backend/utils/error-handler.js @@ -0,0 +1,328 @@ +const path = require('path'); + +/** + * 错误处理工具类 - 专门处理打包后的权限问题 + */ +class ErrorHandler { + /** + * 处理文件系统错误 + */ + static handleFileSystemError(error, filePath, operation = 'unknown') { + const errorInfo = { + originalError: error, + filePath: filePath, + operation: operation, + isPkg: process.pkg !== undefined, + platform: process.platform, + errorCode: error.code, + errorMessage: error.message + }; + + // 记录错误信息 + console.error(`文件系统错误 [${operation}]:`, { + filePath: filePath, + errorCode: error.code, + errorMessage: error.message, + isPkg: errorInfo.isPkg, + platform: errorInfo.platform + }); + + // 根据错误类型提供解决方案 + switch (error.code) { + case 'EPERM': + return this.handlePermissionError(errorInfo); + case 'EACCES': + return this.handleAccessError(errorInfo); + case 'EBUSY': + return this.handleBusyError(errorInfo); + case 'ENOENT': + return this.handleNotFoundError(errorInfo); + case 'EISDIR': + return this.handleIsDirectoryError(errorInfo); + case 'ENOTDIR': + return this.handleNotDirectoryError(errorInfo); + default: + return this.handleGenericError(errorInfo); + } + } + + /** + * 处理权限错误 (EPERM) + */ + static handlePermissionError(errorInfo) { + const { filePath, isPkg, platform } = errorInfo; + + console.error('权限错误 (EPERM) 解决方案:'); + + if (platform === 'win32') { + console.error('Windows 权限问题解决方案:'); + console.error('1. 以管理员身份运行程序'); + console.error('2. 检查文件/目录权限'); + console.error('3. 检查防病毒软件是否阻止访问'); + console.error('4. 检查文件是否被其他程序占用'); + + if (isPkg) { + console.error('5. 打包环境特殊处理:'); + console.error(' - 确保程序有写入权限'); + console.error(' - 尝试使用用户目录而不是程序目录'); + } + } else { + console.error('Unix/Linux 权限问题解决方案:'); + console.error('1. 检查文件权限: chmod 755 '); + console.error('2. 检查目录权限: chmod 755 '); + console.error('3. 检查用户权限'); + } + + return { + type: 'PERMISSION_ERROR', + message: `权限不足,无法${errorInfo.operation}文件: ${filePath}`, + solutions: this.getPermissionSolutions(errorInfo), + retryable: true + }; + } + + /** + * 处理访问错误 (EACCES) + */ + static handleAccessError(errorInfo) { + const { filePath, platform } = errorInfo; + + console.error('访问错误 (EACCES) 解决方案:'); + console.error('1. 检查文件/目录是否存在'); + console.error('2. 检查用户是否有访问权限'); + console.error('3. 检查文件系统权限'); + + if (platform === 'win32') { + console.error('4. 检查 Windows 安全设置'); + console.error('5. 尝试以管理员身份运行'); + } + + return { + type: 'ACCESS_ERROR', + message: `访问被拒绝: ${filePath}`, + solutions: this.getAccessSolutions(errorInfo), + retryable: true + }; + } + + /** + * 处理文件占用错误 (EBUSY) + */ + static handleBusyError(errorInfo) { + const { filePath } = errorInfo; + + console.error('文件占用错误 (EBUSY) 解决方案:'); + console.error('1. 关闭可能占用文件的程序'); + console.error('2. 等待文件释放后重试'); + console.error('3. 重启相关程序'); + console.error('4. 检查是否有其他进程在使用文件'); + + return { + type: 'BUSY_ERROR', + message: `文件被占用: ${filePath}`, + solutions: this.getBusySolutions(errorInfo), + retryable: true, + retryDelay: 1000 // 1秒后重试 + }; + } + + /** + * 处理文件不存在错误 (ENOENT) + */ + static handleNotFoundError(errorInfo) { + const { filePath } = errorInfo; + + console.error('文件不存在错误 (ENOENT) 解决方案:'); + console.error('1. 检查文件路径是否正确'); + console.error('2. 确保目录存在'); + console.error('3. 检查文件名是否正确'); + + return { + type: 'NOT_FOUND_ERROR', + message: `文件不存在: ${filePath}`, + solutions: this.getNotFoundSolutions(errorInfo), + retryable: false + }; + } + + /** + * 处理是目录错误 (EISDIR) + */ + static handleIsDirectoryError(errorInfo) { + const { filePath } = errorInfo; + + console.error('是目录错误 (EISDIR) 解决方案:'); + console.error('1. 检查路径是否指向目录而不是文件'); + console.error('2. 确保使用正确的文件路径'); + + return { + type: 'IS_DIRECTORY_ERROR', + message: `路径是目录而不是文件: ${filePath}`, + solutions: this.getIsDirectorySolutions(errorInfo), + retryable: false + }; + } + + /** + * 处理不是目录错误 (ENOTDIR) + */ + static handleNotDirectoryError(errorInfo) { + const { filePath } = errorInfo; + + console.error('不是目录错误 (ENOTDIR) 解决方案:'); + console.error('1. 检查路径是否指向文件而不是目录'); + console.error('2. 确保使用正确的目录路径'); + + return { + type: 'NOT_DIRECTORY_ERROR', + message: `路径不是目录: ${filePath}`, + solutions: this.getNotDirectorySolutions(errorInfo), + retryable: false + }; + } + + /** + * 处理通用错误 + */ + static handleGenericError(errorInfo) { + const { filePath, errorCode, errorMessage } = errorInfo; + + console.error(`通用文件系统错误 (${errorCode}): ${errorMessage}`); + console.error('1. 检查文件系统状态'); + console.error('2. 检查磁盘空间'); + console.error('3. 检查文件系统权限'); + + return { + type: 'GENERIC_ERROR', + message: `文件系统错误: ${errorMessage}`, + errorCode: errorCode, + solutions: this.getGenericSolutions(errorInfo), + retryable: true + }; + } + + /** + * 获取权限错误解决方案 + */ + static getPermissionSolutions(errorInfo) { + const solutions = []; + + if (errorInfo.platform === 'win32') { + solutions.push('以管理员身份运行程序'); + solutions.push('检查文件和目录权限'); + solutions.push('检查防病毒软件设置'); + solutions.push('检查文件是否被其他程序占用'); + + if (errorInfo.isPkg) { + solutions.push('尝试使用用户目录而不是程序目录'); + solutions.push('检查程序安装目录的写入权限'); + } + } else { + solutions.push('使用 chmod 命令修改文件权限'); + solutions.push('检查用户和组权限'); + solutions.push('使用 sudo 运行程序'); + } + + return solutions; + } + + /** + * 获取访问错误解决方案 + */ + static getAccessSolutions(errorInfo) { + return [ + '检查文件/目录是否存在', + '检查用户访问权限', + '检查文件系统权限', + errorInfo.platform === 'win32' ? '以管理员身份运行' : '使用 sudo 运行' + ]; + } + + /** + * 获取文件占用错误解决方案 + */ + static getBusySolutions(errorInfo) { + return [ + '关闭占用文件的程序', + '等待文件释放后重试', + '重启相关程序', + '检查进程列表' + ]; + } + + /** + * 获取文件不存在错误解决方案 + */ + static getNotFoundSolutions(errorInfo) { + return [ + '检查文件路径是否正确', + '确保目录存在', + '检查文件名是否正确', + '检查路径分隔符' + ]; + } + + /** + * 获取是目录错误解决方案 + */ + static getIsDirectorySolutions(errorInfo) { + return [ + '检查路径是否指向目录', + '使用正确的文件路径', + '检查路径分隔符' + ]; + } + + /** + * 获取不是目录错误解决方案 + */ + static getNotDirectorySolutions(errorInfo) { + return [ + '检查路径是否指向文件', + '使用正确的目录路径', + '检查路径分隔符' + ]; + } + + /** + * 获取通用错误解决方案 + */ + static getGenericSolutions(errorInfo) { + return [ + '检查文件系统状态', + '检查磁盘空间', + '检查文件系统权限', + '重启程序或系统' + ]; + } + + /** + * 检查是否为可重试的错误 + */ + static isRetryableError(error) { + const retryableCodes = ['EPERM', 'EACCES', 'EBUSY', 'EAGAIN', 'ENOSPC']; + return retryableCodes.includes(error.code); + } + + /** + * 获取重试延迟时间 + */ + static getRetryDelay(error, attempt = 1) { + const baseDelay = 1000; // 1秒 + const maxDelay = 10000; // 10秒 + const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay); + + // 对于特定错误类型,使用不同的延迟策略 + switch (error.code) { + case 'EBUSY': + return Math.min(delay, 2000); // 文件占用错误,较短延迟 + case 'EPERM': + case 'EACCES': + return Math.min(delay, 5000); // 权限错误,中等延迟 + default: + return delay; + } + } +} + +module.exports = ErrorHandler; \ No newline at end of file diff --git a/backend/utils/file-utils.js b/backend/utils/file-utils.js index afa38f5..022a014 100644 --- a/backend/utils/file-utils.js +++ b/backend/utils/file-utils.js @@ -50,6 +50,141 @@ class FileUtils { } } + /** + * 增强的目录创建方法(专门处理打包后的权限问题) + */ + static async safeEnsureDirEnhanced(dirPath) { + try { + // 规范化路径 + const normalizedPath = path.resolve(dirPath); + + // 检查是否在打包环境中 + const isPkg = process.pkg !== undefined; + + if (isPkg) { + // 在打包环境中,使用更保守的方法 + return await this.createDirectoryRecursive(normalizedPath); + } else { + // 在开发环境中,使用标准方法 + await fs.ensureDir(normalizedPath); + return true; + } + } catch (error) { + console.error(`增强目录创建失败: ${dirPath}`, error.message); + return false; + } + } + + /** + * 递归创建目录(处理权限问题) + */ + static async createDirectoryRecursive(dirPath) { + try { + const parts = dirPath.split(path.sep); + let currentPath = ''; + + for (const part of parts) { + if (!part) continue; + + currentPath = currentPath ? path.join(currentPath, part) : part; + + try { + // 检查目录是否已存在 + const stats = await fs.stat(currentPath); + if (!stats.isDirectory()) { + throw new Error(`路径存在但不是目录: ${currentPath}`); + } + } catch (error) { + if (error.code === 'ENOENT') { + // 目录不存在,尝试创建 + try { + await fs.mkdir(currentPath); + // 验证创建是否成功 + await fs.access(currentPath); + } catch (mkdirError) { + console.error(`创建目录失败: ${currentPath}`, mkdirError.message); + throw mkdirError; + } + } else { + throw error; + } + } + } + + return true; + } catch (error) { + console.error(`递归创建目录失败: ${dirPath}`, error.message); + return false; + } + } + + /** + * 安全写入文件(处理权限问题) + */ + static async safeWriteFile(filePath, data, options = {}) { + try { + // 确保目录存在 + const dirPath = path.dirname(filePath); + const dirCreated = await this.safeEnsureDirEnhanced(dirPath); + + if (!dirCreated) { + throw new Error(`无法创建目录: ${dirPath}`); + } + + // 检查文件是否被占用 + if (await fs.pathExists(filePath)) { + try { + // 尝试删除现有文件 + await fs.remove(filePath); + } catch (removeError) { + console.warn(`删除现有文件失败: ${filePath}`, removeError.message); + // 继续尝试写入,可能会覆盖 + } + } + + // 写入文件 + await fs.writeFile(filePath, data, options); + + // 验证写入是否成功 + await fs.access(filePath); + + return true; + } catch (error) { + console.error(`安全写入文件失败: ${filePath}`, error.message); + return false; + } + } + + /** + * 安全创建写入流(处理权限问题) + */ + static async safeCreateWriteStream(filePath) { + try { + // 确保目录存在 + const dirPath = path.dirname(filePath); + const dirCreated = await this.safeEnsureDirEnhanced(dirPath); + + if (!dirCreated) { + throw new Error(`无法创建目录: ${dirPath}`); + } + + // 检查文件是否被占用 + if (await fs.pathExists(filePath)) { + try { + await fs.remove(filePath); + } catch (removeError) { + console.warn(`删除现有文件失败: ${filePath}`, removeError.message); + } + } + + // 创建写入流 + return fs.createWriteStream(filePath); + } catch (error) { + console.error(`创建写入流失败: ${filePath}`, error.message); + throw error; + } + } + /** * 安全检查文件是否存在(兼容 pkg 打包) */ @@ -129,6 +264,57 @@ class FileUtils { pkgVersion: process.pkg ? process.pkg.version : null, }; } + + /** + * 检查文件权限 + */ + static async checkFilePermissions(filePath, mode = 'w') { + try { + const nativeFs = require('fs').promises; + await nativeFs.access(filePath, mode === 'w' ? nativeFs.constants.W_OK : nativeFs.constants.R_OK); + return true; + } catch (error) { + return false; + } + } + + /** + * 检查目录权限 + */ + static async checkDirectoryPermissions(dirPath, mode = 'w') { + try { + const nativeFs = require('fs').promises; + await nativeFs.access(dirPath, mode === 'w' ? nativeFs.constants.W_OK : nativeFs.constants.R_OK); + return true; + } catch (error) { + return false; + } + } + + /** + * 等待文件释放(处理文件占用问题) + */ + static async waitForFileRelease(filePath, maxWaitTime = 5000) { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + try { + const nativeFs = require('fs').promises; + await nativeFs.access(filePath, nativeFs.constants.W_OK); + return true; + } catch (error) { + if (error.code === 'EBUSY' || error.code === 'EACCES') { + // 文件被占用或无权限,等待一段时间后重试 + await new Promise(resolve => setTimeout(resolve, 100)); + continue; + } + // 其他错误,直接返回 + return false; + } + } + + return false; + } } module.exports = FileUtils; diff --git a/ui/src/views/RepositoryView.vue b/ui/src/views/RepositoryView.vue index 563a30d..871900d 100644 --- a/ui/src/views/RepositoryView.vue +++ b/ui/src/views/RepositoryView.vue @@ -24,7 +24,7 @@
diff --git a/ui/src/views/repository/RepositoryConfig.vue b/ui/src/views/repository/RepositoryConfig.vue index fe380cd..04400fa 100644 --- a/ui/src/views/repository/RepositoryConfig.vue +++ b/ui/src/views/repository/RepositoryConfig.vue @@ -5,15 +5,8 @@
- - + @@ -25,22 +18,18 @@ • 当前目录:../
- +
启用后,保存配置时会自动将旧下载目录中的文件移动到新目录
- +
@@ -51,22 +40,18 @@
- +

迁移完成

✅ 成功迁移: {{ migrationResult.totalMigrated }} 个作品

-

⏭️ 跳过: {{ migrationResult.log.filter((item: any) => item.status === 'skipped').length }} 个作品

+

⏭️ 跳过: {{migrationResult.log.filter((item: any) => item.status === 'skipped').length}} 个作品

详细日志
-
+
{{ (item as any).status === 'success' ? '✅' : '⏭️' }} {{ (item as any).title }} (ID: {{ (item as any).id }}) {{ (item as any).reason }} @@ -76,7 +61,7 @@
- +
+
- +
- + type="text" placeholder=".jpg,.png,.gif,.webp" class="form-input" />