import axios from 'axios'; class GitHubService { constructor() { // 延迟初始化,动态读取环境变量 this.initialized = false; this.useMemoryStorage = false; this.memoryStorage = new Map(); this.apiUrl = 'https://api.github.com'; // 首次使用时再初始化 this.initPromise = null; // 添加错误重试配置 this.retryConfig = { maxRetries: 3, retryDelay: 1000, backoffMultiplier: 2 }; // API限制处理 this.apiRateLimit = { requestQueue: [], processing: false, minDelay: 100 // 最小请求间隔 }; } // 动态初始化方法 async initialize() { if (this.initialized) { return; } console.log('=== GitHub Service Configuration ==='); try { // 动态读取环境变量 this.token = process.env.GITHUB_TOKEN; this.repositories = process.env.GITHUB_REPOS ? process.env.GITHUB_REPOS.split(',').map(repo => repo.trim()) : []; console.log('Token configured:', !!this.token); console.log('Token preview:', this.token ? `${this.token.substring(0, 8)}...` : 'Not set'); console.log('Repositories:', this.repositories); console.log('Repositories length:', this.repositories?.length || 0); // Check if token is a placeholder if (!this.token || this.token === 'your_github_token_here') { console.warn('❌ GitHub token is missing or using placeholder value!'); console.warn('⚠️ Switching to memory storage mode for development...'); this.useMemoryStorage = true; console.log('✅ Memory storage mode activated'); this.initialized = true; return; } if (!this.repositories || this.repositories.length === 0) { console.warn('❌ No GitHub repositories configured!'); console.warn('⚠️ Switching to memory storage mode...'); this.useMemoryStorage = true; console.log('✅ Memory storage mode activated'); this.initialized = true; return; } // Check for placeholder repository URLs const validRepos = this.repositories.filter(repo => { const isPlaceholder = repo.includes('your-username') || repo.includes('placeholder'); if (isPlaceholder) { console.warn(`⚠️ Skipping placeholder repository: ${repo}`); return false; } return this.isValidRepoUrl(repo); }); if (validRepos.length === 0) { console.warn('❌ No valid GitHub repositories found (all are placeholders)!'); console.warn('⚠️ Switching to memory storage mode...'); this.useMemoryStorage = true; console.log('✅ Memory storage mode activated'); this.initialized = true; return; } this.repositories = validRepos; // 验证每个仓库URL的格式 this.repositories.forEach((repo, index) => { if (!this.isValidRepoUrl(repo)) { console.error(`❌ Invalid repository URL at index ${index}: ${repo}`); throw new Error(`Invalid repository URL: ${repo}. Expected format: https://github.com/owner/repo`); } }); console.log('✅ GitHub Service initialized successfully'); this.initialized = true; } catch (error) { console.error('❌ GitHub Service initialization failed:', error); console.warn('⚠️ Falling back to memory storage mode...'); this.useMemoryStorage = true; this.initialized = true; } } // 确保在所有方法中先初始化 async ensureInitialized() { if (!this.initPromise) { this.initPromise = this.initialize(); } await this.initPromise; } // 验证仓库URL格式 isValidRepoUrl(repoUrl) { const githubUrlPattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/; return githubUrlPattern.test(repoUrl.trim()); } // 带重试的API请求方法 async makeGitHubRequest(requestFn, operation = 'GitHub API request', maxRetries = null) { const retries = maxRetries || this.retryConfig.maxRetries; let lastError = null; for (let attempt = 0; attempt <= retries; attempt++) { try { if (attempt > 0) { const delay = this.retryConfig.retryDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt - 1); console.log(`🔄 Retrying ${operation} (attempt ${attempt + 1}/${retries + 1}) after ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } const result = await requestFn(); if (attempt > 0) { console.log(`✅ ${operation} succeeded on retry attempt ${attempt + 1}`); } return result; } catch (error) { lastError = error; // 判断是否应该重试 if (!this.shouldRetry(error) || attempt === retries) { break; } console.warn(`⚠️ ${operation} failed (attempt ${attempt + 1}):`, error.message); } } console.error(`❌ ${operation} failed after ${retries + 1} attempts:`, lastError.message); throw lastError; } // 判断错误是否应该重试 shouldRetry(error) { if (!error.response) { return true; // 网络错误,重试 } const status = error.response.status; // 这些状态码不应该重试 if ([400, 401, 403, 404, 422].includes(status)) { return false; } // 5xx错误和429限制错误应该重试 if (status >= 500 || status === 429) { return true; } return false; } // 验证GitHub连接 async validateConnection() { await this.ensureInitialized(); if (this.useMemoryStorage) { console.log('📝 Memory storage mode active'); return { valid: true, useMemoryStorage: true, repositories: [], message: 'Using memory storage mode for development' }; } try { // 测试GitHub API连接 const response = await this.makeGitHubRequest(async () => { return await axios.get(`${this.apiUrl}/user`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 }); }, 'GitHub user authentication'); console.log('GitHub API connection successful:', response.data.login); // 测试仓库访问和权限 const repoResults = []; for (const repoUrl of this.repositories) { try { const { owner, repo } = this.parseRepoUrl(repoUrl); // 检查仓库基本信息 const repoResponse = await this.makeGitHubRequest(async () => { return await axios.get(`${this.apiUrl}/repos/${owner}/${repo}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 }); }, `Repository access check for ${owner}/${repo}`); // 检查权限 const permissions = repoResponse.data.permissions; console.log(`Repository permissions for ${owner}/${repo}:`, permissions); // 测试是否可以访问仓库内容 let canAccessContents = false; let repoEmpty = false; try { await axios.get(`${this.apiUrl}/repos/${owner}/${repo}/contents`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 15000 }); canAccessContents = true; } catch (contentsError) { console.log(`Cannot access repository contents: ${contentsError.message}`); // 如果是409错误,说明仓库为空 if (contentsError.response?.status === 409) { repoEmpty = true; console.log('Repository appears to be empty, will initialize when needed'); } } repoResults.push({ url: repoUrl, accessible: true, name: repoResponse.data.full_name, permissions, canAccessContents, repoEmpty, defaultBranch: repoResponse.data.default_branch, size: repoResponse.data.size }); } catch (error) { console.error(`Repository access error for ${repoUrl}:`, error.response?.data || error.message); repoResults.push({ url: repoUrl, accessible: false, error: error.message }); } } return { valid: true, user: response.data.login, repositories: repoResults }; } catch (error) { console.error('GitHub connection validation failed:', error.message); return { valid: false, reason: error.message }; } } // 获取仓库信息 parseRepoUrl(repoUrl) { const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/); if (!match) throw new Error('Invalid GitHub repository URL'); return { owner: match[1], repo: match[2] }; } // 初始化空仓库 async initializeRepository(repoIndex = 0) { await this.ensureInitialized(); if (this.useMemoryStorage) { console.log('📝 Memory storage mode - no repository initialization needed'); return { success: true, message: 'Memory storage initialized' }; } const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); try { console.log(`🚀 Initializing repository: ${owner}/${repo}`); // 创建初始README文件 const readmeContent = `# PPT Storage Repository\n\nThis repository is used to store PPT data files.\n\nCreated: ${new Date().toISOString()}`; const content = Buffer.from(readmeContent).toString('base64'); await this.makeGitHubRequest(async () => { return await axios.put( `${this.apiUrl}/repos/${owner}/${repo}/contents/README.md`, { message: 'Initialize PPT storage repository', content: content }, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Repository initialization for ${owner}/${repo}`); console.log(`✅ Repository ${owner}/${repo} initialized successfully`); return { success: true, message: 'Repository initialized' }; } catch (error) { console.error(`❌ Repository initialization failed:`, error); throw new Error(`Failed to initialize repository: ${error.message}`); } } // 兼容性方法:旧的getFile方法重定向到新的getPPT async getFile(userId, fileName, repoIndex = 0) { await this.ensureInitialized(); const pptId = fileName.replace('.json', ''); return await this.getPPT(userId, pptId, repoIndex); } // 兼容性方法:旧的saveFile方法重定向到新的savePPT async saveFile(userId, fileName, data, repoIndex = 0) { await this.ensureInitialized(); const pptId = fileName.replace('.json', ''); return await this.savePPT(userId, pptId, data, repoIndex); } // 数据格式标准化 normalizeDataFormat(data) { if (!data || typeof data !== 'object') { throw new Error('Invalid data format provided'); } const normalized = { id: data.id || data.pptId || `ppt-${Date.now()}`, title: data.title || '未命名演示文稿', slides: Array.isArray(data.slides) ? data.slides : [], theme: data.theme || { backgroundColor: '#ffffff', themeColor: '#d14424', fontColor: '#333333', fontName: 'Microsoft YaHei' }, viewportSize: data.viewportSize || 1000, viewportRatio: data.viewportRatio || 0.5625, createdAt: data.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString() }; // 标准化slides数据 normalized.slides = normalized.slides.map((slide, index) => { if (!slide || typeof slide !== 'object') { return { id: `slide-${index}`, elements: [], background: { type: 'solid', color: '#ffffff' } }; } return { id: slide.id || `slide-${index}`, elements: Array.isArray(slide.elements) ? slide.elements : [], background: slide.background || { type: 'solid', color: '#ffffff' }, ...slide }; }); // 保留原始数据的其他字段 Object.keys(data).forEach(key => { if (!normalized.hasOwnProperty(key) && key !== 'slides') { normalized[key] = data[key]; } }); return normalized; } // 兼容性:旧的deleteFile方法 async deleteFile(userId, fileName, repoIndex = 0) { await this.ensureInitialized(); if (this.useMemoryStorage) { const key = `users/${userId}/${fileName}`; return this.memoryStorage.delete(key); } const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); const path = `users/${userId}/${fileName}`; try { // 获取文件信息 const response = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${path}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Get file info for deletion: ${fileName}`); // 删除主文件 await this.makeGitHubRequest(async () => { return await axios.delete( `${this.apiUrl}/repos/${owner}/${repo}/contents/${path}`, { data: { message: `Delete legacy PPT: ${fileName}`, sha: response.data.sha }, headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Delete legacy file: ${fileName}`); console.log(`✅ Legacy file deleted: ${fileName}`); return true; } catch (error) { if (error.response?.status === 404) { console.log(`📄 File not found: ${fileName}`); return false; } console.error(`❌ Delete failed for ${fileName}:`, error); throw new Error(`Failed to delete file: ${error.message}`); } } // Memory storage methods - 兼容性方法 async saveToMemory(userId, fileName, data) { const pptId = fileName.replace('.json', ''); return await this.savePPTToMemory(userId, pptId, data); } async getFromMemory(userId, fileName) { const pptId = fileName.replace('.json', ''); const result = await this.getPPTFromMemory(userId, pptId); return result ? result.content : null; } // 新架构:获取PPT(文件夹模式) - 简化版本 async getPPT(userId, pptId, repoIndex = 0) { await this.ensureInitialized(); if (this.useMemoryStorage) { return await this.getPPTFromMemory(userId, pptId); } try { console.log(`📂 Getting PPT folder: ${pptId} for user: ${userId}`); const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); const pptFolderPath = `users/${userId}/${pptId}`; // 1. 获取PPT文件夹内容 - 带重试 const folderResponse = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${pptFolderPath}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Get PPT folder contents for ${pptId}`); // 2. 读取元数据文件 const metaFile = folderResponse.data.find(file => file.name === 'meta.json'); if (!metaFile) { throw new Error('PPT metadata file not found'); } const metaResponse = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${metaFile.path}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 15000 } ); }, `Get PPT metadata for ${pptId}`); let metadata; try { const metaContent = Buffer.from(metaResponse.data.content, 'base64').toString('utf8'); metadata = JSON.parse(metaContent); } catch (parseError) { console.error('❌ Failed to parse metadata:', parseError); throw new Error('Invalid PPT metadata format'); } // 3. 加载所有slide文件 const allFiles = folderResponse.data; const slides = []; const failedSlides = []; // 查找slide文件(只处理普通文件,忽略拆分文件) const slideFiles = allFiles.filter(file => file.name.startsWith('slide_') && file.name.endsWith('.json') && !file.name.includes('_main') && !file.name.includes('_part_') ); // 按序号排序 const sortedSlideFiles = slideFiles.sort((a, b) => { const aIndex = parseInt(a.name.match(/slide_(\d+)\.json/)?.[1] || '0'); const bIndex = parseInt(b.name.match(/slide_(\d+)\.json/)?.[1] || '0'); return aIndex - bIndex; }); for (const slideFile of sortedSlideFiles) { try { const slide = await this.loadSlideFile(slideFile, repoIndex); const slideIndex = parseInt(slideFile.name.match(/slide_(\d+)\.json/)?.[1] || '0'); slides[slideIndex] = slide; } catch (slideError) { console.warn(`⚠️ Failed to load slide ${slideFile.name}:`, slideError.message); failedSlides.push(slideFile.name); } } // 4. 组装完整PPT数据 const finalSlides = slides.filter(slide => slide !== undefined); const pptData = { ...metadata, slides: finalSlides, storage: { type: 'folder', slidesCount: finalSlides.length, folderPath: pptFolderPath, loadedAt: new Date().toISOString(), failedSlides: failedSlides.length > 0 ? failedSlides : undefined } }; if (failedSlides.length > 0) { console.warn(`⚠️ PPT loaded with ${failedSlides.length} failed slides`); } else { console.log(`✅ PPT loaded successfully: ${finalSlides.length} slides`); } return { content: pptData }; } catch (error) { if (error.response?.status === 404) { console.log(`📄 PPT folder not found: ${pptId}`); return null; } console.error(`❌ Get PPT failed:`, error); throw new Error(`Failed to get PPT: ${error.message}`); } } // 新增:检查文件大小 async checkFileSize(filePath, repoIndex) { const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); try { const response = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 15000 } ); }, `Check file size for ${filePath}`); return { size: response.data.size, sha: response.data.sha }; } catch (error) { if (error.response?.status === 404) { return null; } throw error; } } // 新增:智能读取文件内容(根据大小选择策略) async readFileContent(filePath, repoIndex, maxDirectSize = 1024 * 1024) { // 1MB限制 const fileInfo = await this.checkFileSize(filePath, repoIndex); if (!fileInfo) { return null; } // 如果文件小于限制,直接读取 if (fileInfo.size <= maxDirectSize) { return await this.readFileContentDirect(filePath, repoIndex); } // 大文件:使用Git Blob API读取 console.log(`📊 Large file detected (${(fileInfo.size / 1024 / 1024).toFixed(2)} MB), using blob API`); return await this.readFileContentViaBlob(filePath, fileInfo.sha, repoIndex); } // 直接读取文件(小文件) async readFileContentDirect(filePath, repoIndex) { const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); const response = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Read file content directly: ${filePath}`); const content = Buffer.from(response.data.content, 'base64').toString('utf8'); return JSON.parse(content); } // 通过Git Blob API读取大文件 async readFileContentViaBlob(filePath, sha, repoIndex) { const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); try { console.log(`🔍 Reading large file via blob API: ${filePath}`); const response = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/git/blobs/${sha}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 120000 // 2分钟超时 } ); }, `Read blob for large file: ${filePath}`); if (response.data.encoding === 'base64') { const content = Buffer.from(response.data.content, 'base64').toString('utf8'); return JSON.parse(content); } else { // 如果不是base64编码,直接解析 return JSON.parse(response.data.content); } } catch (error) { console.error(`❌ Blob API read failed for ${filePath}:`, error.message); // 回退到分批读取策略 console.log(`🔄 Falling back to chunked reading...`); return await this.readLargeFileInChunks(filePath, repoIndex); } } // 分批读取大文件(最后的回退策略) async readLargeFileInChunks(filePath, repoIndex) { console.log(`📦 Attempting chunked read for: ${filePath}`); // 检查是否存在分块文件(PPT文件夹结构) const folderPath = filePath.replace(/\/[^\/]+$/, ''); try { const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); // 尝试读取文件夹内容 const folderResponse = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${folderPath}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Read folder for chunked file: ${folderPath}`); // 查找相关的分块文件 const chunkFiles = folderResponse.data.filter(file => file.name.includes('_part_') || file.name.includes('_chunk_') ); if (chunkFiles.length > 0) { console.log(`📊 Found ${chunkFiles.length} chunk files, reassembling...`); return await this.reassembleChunkedFiles(chunkFiles, repoIndex); } throw new Error('No chunked files found, cannot read large file'); } catch (error) { console.error(`❌ Chunked read failed:`, error.message); throw new Error(`Unable to read large file: ${filePath}. File may be too large for current GitHub API limits.`); } } // 重组分块文件 async reassembleChunkedFiles(chunkFiles, repoIndex) { const chunks = []; // 按顺序读取所有分块 const sortedChunks = chunkFiles.sort((a, b) => { const aIndex = parseInt(a.name.match(/_(\d+)(?:_|\.)/)?.[1] || '0'); const bIndex = parseInt(b.name.match(/_(\d+)(?:_|\.)/)?.[1] || '0'); return aIndex - bIndex; }); for (const chunkFile of sortedChunks) { try { const chunkContent = await this.readFileContentDirect(chunkFile.path, repoIndex); chunks.push(chunkContent); } catch (error) { console.error(`⚠️ Failed to read chunk ${chunkFile.name}:`, error.message); } } // 重组数据 if (chunks.length === 0) { throw new Error('No valid chunks found'); } // 假设第一个chunk包含基础结构 const baseData = chunks[0]; const allSlides = []; chunks.forEach(chunk => { if (chunk.slides && Array.isArray(chunk.slides)) { allSlides.push(...chunk.slides); } }); return { ...baseData, slides: allSlides, storage: { type: 'reassembled', chunksCount: chunks.length, reassembledAt: new Date().toISOString() } }; } // 修改现有的loadSlideFile方法,使用新的智能读取 async loadSlideFile(slideFile, repoIndex) { try { // 使用智能读取策略 const slideContent = await this.readFileContent(slideFile.path, repoIndex); return slideContent; } catch (error) { console.error(`❌ Failed to load slide file ${slideFile.name}:`, error.message); throw error; } } // slide压缩算法 - 保留轻量压缩 async compressSlide(slide) { let compressedSlide = JSON.parse(JSON.stringify(slide)); // 深拷贝 // 压缩策略1:移除不必要的属性 if (compressedSlide.elements) { compressedSlide.elements = compressedSlide.elements.map(element => { // 移除编辑状态属性 element = this.removeUnnecessaryProps(element); // 压缩文本内容(移除多余空白) if (element.type === 'text' && element.content && typeof element.content === 'string') { element.content = element.content.replace(/\s+/g, ' ').trim(); } return element; }); } // 压缩策略2:精简数值精度 compressedSlide = this.roundNumericValues(compressedSlide); const compressedSize = Buffer.byteLength(JSON.stringify(compressedSlide), 'utf8'); console.log(`🗜️ Light compression applied: ${(compressedSize / 1024).toFixed(2)} KB`); return compressedSlide; } // 移除不必要的属性 removeUnnecessaryProps(element) { const unnecessaryProps = [ 'selected', 'editing', 'dragData', 'resizeData', 'tempData', 'cache', 'debug' ]; unnecessaryProps.forEach(prop => { if (element[prop] !== undefined) { delete element[prop]; } }); return element; } // 精简数值精度 - 保持高精度的关键属性 roundNumericValues(obj, precision = 2) { if (typeof obj !== 'object' || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(item => this.roundNumericValues(item, precision)); } const rounded = {}; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'number') { // 🎯 关键布局属性保持高精度 if (['left', 'top', 'width', 'height', 'x', 'y', 'viewportRatio', 'viewportSize'].includes(key)) { rounded[key] = Math.round(value * 1000000000) / 1000000000; // 9位精度 } // 🎯 变换和旋转属性保持高精度 else if (key.includes('transform') || key.includes('rotate') || key === 'rotate') { rounded[key] = Math.round(value * 1000000000) / 1000000000; } // 📐 其他数值属性适度精简 else { rounded[key] = typeof value === 'number' && !isNaN(value) && isFinite(value) ? Math.round(value * 1000000) / 1000000 // 6位精度 : value; } } else if (typeof value === 'object') { rounded[key] = this.roundNumericValues(value, precision); } else { rounded[key] = value; } } return rounded; } // 通用文件保存到仓库 - 简化版本 async saveFileToRepo(filePath, data, commitMessage, repoIndex) { const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); try { // 验证数据 if (!data || typeof data !== 'object') { throw new Error('Invalid data provided for file save'); } const content = Buffer.from(JSON.stringify(data)).toString('base64'); const fileSize = Buffer.byteLength(JSON.stringify(data), 'utf8'); // 检查文件大小(GitHub限制100MB) if (fileSize > 100 * 1024 * 1024) { throw new Error(`File too large: ${(fileSize / 1024 / 1024).toFixed(2)} MB exceeds GitHub's 100MB limit`); } console.log(`💾 Saving file: ${filePath} (${(fileSize / 1024).toFixed(2)} KB)`); // 检查文件是否已存在 let sha = null; try { const existingResponse = await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); sha = existingResponse.data.sha; } catch (error) { // 文件不存在,将创建新文件 } // 保存文件 const response = await axios.put( `${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, { message: commitMessage, content: content, ...(sha && { sha }) }, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 60000 } ); console.log(`✅ File saved successfully: ${filePath}`); return response.data; } catch (error) { console.error(`❌ File save failed for ${filePath}:`, error.message); if (error.response?.status === 422) { if (error.response?.data?.message?.includes('file is too large')) { throw new Error(`File too large: ${filePath} exceeds GitHub size limits`); } if (error.response?.data?.message?.includes('Invalid request')) { throw new Error(`Invalid file format for: ${filePath}`); } } if (error.response?.status === 409) { throw new Error(`File conflict for: ${filePath}. File may have been modified by another process.`); } throw new Error(`Failed to save file ${filePath}: ${error.message}`); } } // 单个slide保存 - 简化版本 async saveSlideWithCompression(filePath, slide, slideIndex, repoIndex) { try { // 应用轻量压缩 const compressedSlide = await this.compressSlide(slide); await this.makeGitHubRequest(async () => { return await this.saveFileToRepo( filePath, compressedSlide, `Save slide ${slideIndex}`, repoIndex ); }, `Save slide ${slideIndex}`); const finalSize = Buffer.byteLength(JSON.stringify(compressedSlide), 'utf8'); return { slideIndex, finalSize, compressed: true }; } catch (error) { console.error(`❌ Failed to save slide ${slideIndex}:`, error); throw error; } } // 新架构:保存PPT(文件夹模式) - 简化版本 async savePPT(userId, pptId, pptData, repoIndex = 0) { await this.ensureInitialized(); if (this.useMemoryStorage) { return await this.savePPTToMemory(userId, pptId, pptData); } try { console.log(`📂 Saving PPT to folder: ${pptId} for user: ${userId}`); const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); const pptFolderPath = `users/${userId}/${pptId}`; // 验证输入数据 if (!pptData || typeof pptData !== 'object') { throw new Error('Invalid PPT data provided'); } // 1. 准备元数据(不包含slides) const metadata = { id: pptData.id || pptId, title: pptData.title || '未命名演示文稿', theme: pptData.theme || { backgroundColor: '#ffffff', themeColor: '#d14424', fontColor: '#333333', fontName: 'Microsoft YaHei' }, viewportSize: pptData.viewportSize || 1000, viewportRatio: pptData.viewportRatio || 0.5625, createdAt: pptData.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), storage: { type: 'folder', version: '2.0', slidesCount: pptData.slides?.length || 0 } }; // 2. 保存元数据文件 await this.makeGitHubRequest(async () => { return await this.saveFileToRepo( `${pptFolderPath}/meta.json`, metadata, `Update PPT metadata: ${metadata.title}`, repoIndex ); }, `Save metadata for PPT ${pptId}`); console.log(`✅ Metadata saved for PPT: ${pptId}`); // 3. 串行化保存每个slide文件 const slides = Array.isArray(pptData.slides) ? pptData.slides : []; const saveResults = []; console.log(`📊 Starting to save ${slides.length} slides...`); for (let i = 0; i < slides.length; i++) { const slide = slides[i]; const slideFileName = `slide_${String(i).padStart(3, '0')}.json`; console.log(`💾 Saving slide ${i + 1}/${slides.length}: ${slideFileName}`); try { // 验证slide数据 if (!slide || typeof slide !== 'object') { throw new Error(`Invalid slide data at index ${i}`); } const result = await this.saveSlideWithCompression( `${pptFolderPath}/${slideFileName}`, slide, i, repoIndex ); saveResults.push(result); console.log(`✅ Slide ${i} saved: ${(result.finalSize / 1024).toFixed(2)} KB`); // 添加延迟避免GitHub API速率限制 if (i < slides.length - 1) { await new Promise(resolve => setTimeout(resolve, this.apiRateLimit.minDelay)); } } catch (slideError) { console.error(`❌ Failed to save slide ${i}: ${slideError.message}`); throw slideError; // 任何slide保存失败都应该停止整个过程 } } // 4. 计算保存统计 const totalSize = saveResults.reduce((sum, r) => sum + r.finalSize, 0); console.log(`🎉 PPT saved successfully: ${slides.length} slides, total size: ${(totalSize / 1024).toFixed(2)} KB`); return { success: true, pptId: pptId, storage: 'folder', slidesCount: slides.length, folderPath: pptFolderPath, size: totalSize }; } catch (error) { console.error(`❌ PPT save failed for ${pptId}: ${error.message}`); throw new Error(`Failed to save PPT: ${error.message}`); } } // Memory storage methods - 简化版本 async getPPTFromMemory(userId, pptId) { const metaKey = `users/${userId}/${pptId}/meta`; const metadata = this.memoryStorage.get(metaKey); if (!metadata) { console.log(`📄 PPT not found in memory: ${pptId}`); return null; } // 重组slides const slides = []; const slidesCount = metadata.storage?.slidesCount || 0; for (let i = 0; i < slidesCount; i++) { const slideKey = `users/${userId}/${pptId}/slide_${String(i).padStart(3, '0')}`; const slide = this.memoryStorage.get(slideKey); if (slide) { slides.push(slide); } } const pptData = { ...metadata, slides: slides, storage: { ...metadata.storage, loadedAt: new Date().toISOString() } }; console.log(`📖 Read PPT from memory: ${pptId} (${slides.length} slides)`); return { content: pptData }; } // 简化内存存储 async savePPTToMemory(userId, pptId, pptData) { // 保存元数据 const metadata = { id: pptData.id || pptId, title: pptData.title || '未命名演示文稿', theme: pptData.theme, viewportSize: pptData.viewportSize || 1000, viewportRatio: pptData.viewportRatio || 0.5625, createdAt: pptData.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), storage: { type: 'folder', version: '2.0', slidesCount: pptData.slides?.length || 0 } }; const metaKey = `users/${userId}/${pptId}/meta`; this.memoryStorage.set(metaKey, metadata); // 保存slides const slides = pptData.slides || []; let totalSize = 0; for (let i = 0; i < slides.length; i++) { const slide = slides[i]; const compressedSlide = await this.compressSlide(slide); const slideKey = `users/${userId}/${pptId}/slide_${String(i).padStart(3, '0')}`; this.memoryStorage.set(slideKey, compressedSlide); totalSize += Buffer.byteLength(JSON.stringify(compressedSlide), 'utf8'); } console.log(`💾 Saved PPT to memory: ${pptId} (${slides.length} slides, ${(totalSize / 1024).toFixed(2)} KB)`); return { success: true, storage: 'folder', slidesCount: slides.length, size: totalSize }; } // 删除PPT时清理所有文件 async deletePPTFromMemory(userId, pptId) { let deleted = 0; const prefix = `users/${userId}/${pptId}/`; // 删除所有相关键 for (const key of this.memoryStorage.keys()) { if (key.startsWith(prefix)) { this.memoryStorage.delete(key); deleted++; } } console.log(`📝 Memory storage: Deleted ${deleted} files for PPT ${pptId}`); return deleted > 0; } // 更新用户PPT列表 async getUserPPTListFromMemory(userId) { const results = []; const userPrefix = `users/${userId}/`; const pptIds = new Set(); // 收集所有PPT ID for (const key of this.memoryStorage.keys()) { if (key.startsWith(userPrefix) && key.includes('/meta')) { const pptId = key.replace(userPrefix, '').replace('/meta', ''); pptIds.add(pptId); } } // 获取每个PPT的元数据 for (const pptId of pptIds) { const metaKey = `${userPrefix}${pptId}/meta`; const metadata = this.memoryStorage.get(metaKey); if (metadata) { results.push({ name: pptId, title: metadata.title || '未命名演示文稿', updatedAt: metadata.updatedAt || new Date().toISOString(), slidesCount: metadata.storage?.slidesCount || 0, isChunked: false, storageType: 'folder', size: JSON.stringify(metadata).length, repoIndex: 0 }); } } console.log(`📋 Found ${results.length} PPTs in memory for user ${userId}`); return results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); } // 获取用户PPT列表(新架构) - 简化版本 async getUserPPTList(userId) { await this.ensureInitialized(); if (this.useMemoryStorage) { return await this.getUserPPTListFromMemory(userId); } const pptList = []; // 检查所有仓库 for (let repoIndex = 0; repoIndex < this.repositories.length; repoIndex++) { try { const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); const userDirPath = `users/${userId}`; const response = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${userDirPath}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Get user directory for ${userId} in repo ${repoIndex}`); // 查找PPT文件夹 const pptFolders = response.data.filter(item => item.type === 'dir' // PPT存储为文件夹 ); for (const folder of pptFolders) { try { const pptId = folder.name; // 获取PPT元数据 const metaResponse = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${userDirPath}/${pptId}/meta.json`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 15000 } ); }, `Get metadata for PPT ${pptId}`); const metaContent = Buffer.from(metaResponse.data.content, 'base64').toString('utf8'); const metadata = JSON.parse(metaContent); pptList.push({ name: pptId, title: metadata.title || '未命名演示文稿', updatedAt: metadata.updatedAt || new Date().toISOString(), slidesCount: metadata.storage?.slidesCount || 0, isChunked: false, storageType: 'folder', size: metaResponse.data.size || 0, repoIndex: repoIndex }); } catch (error) { console.warn(`跳过无效PPT文件夹 ${folder.name}:`, error.message); } } // 兼容性:同时检查旧的单文件格式 const jsonFiles = response.data.filter(file => file.type === 'file' && file.name.endsWith('.json') && !file.name.includes('_chunk_') ); for (const file of jsonFiles) { try { const pptId = file.name.replace('.json', ''); // 避免重复添加(如果已经有文件夹版本) if (pptList.some(p => p.name === pptId)) { continue; } const fileResponse = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${userDirPath}/${file.name}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 15000 } ); }, `Get legacy PPT file ${file.name}`); const content = Buffer.from(fileResponse.data.content, 'base64').toString('utf8'); const pptData = JSON.parse(content); pptList.push({ name: pptId, title: pptData.title || '未命名演示文稿', updatedAt: pptData.updatedAt || new Date().toISOString(), slidesCount: pptData.isChunked ? pptData.totalSlides : (pptData.slides?.length || 0), isChunked: pptData.isChunked || false, storageType: 'legacy', size: file.size, repoIndex: repoIndex }); } catch (error) { console.warn(`跳过无效文件 ${file.name}:`, error.message); } } } catch (error) { console.warn(`仓库 ${repoIndex} 中没有找到用户目录或访问失败:`, error.message); continue; } } // 按更新时间排序 return pptList.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); } // 删除PPT(新架构) - 简化版本 async deletePPT(userId, pptId, repoIndex = 0) { await this.ensureInitialized(); if (this.useMemoryStorage) { return await this.deletePPTFromMemory(userId, pptId); } const repoUrl = this.repositories[repoIndex]; const { owner, repo } = this.parseRepoUrl(repoUrl); const pptFolderPath = `users/${userId}/${pptId}`; try { console.log(`🗑️ Deleting PPT folder: ${pptFolderPath}`); // 1. 获取文件夹内容 const folderResponse = await this.makeGitHubRequest(async () => { return await axios.get( `${this.apiUrl}/repos/${owner}/${repo}/contents/${pptFolderPath}`, { headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Get PPT folder contents for deletion: ${pptId}`); // 2. 串行删除所有文件 for (const file of folderResponse.data) { try { await this.makeGitHubRequest(async () => { return await axios.delete( `${this.apiUrl}/repos/${owner}/${repo}/contents/${file.path}`, { data: { message: `Delete PPT file: ${file.name}`, sha: file.sha }, headers: { 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json' }, timeout: 30000 } ); }, `Delete file ${file.name}`); console.log(`✅ Deleted file: ${file.name}`); // 添加延迟避免API限制 await new Promise(resolve => setTimeout(resolve, this.apiRateLimit.minDelay)); } catch (error) { console.warn(`⚠️ Failed to delete file ${file.name}:`, error.message); } } console.log(`✅ PPT folder deleted successfully: ${pptId}`); return true; } catch (error) { if (error.response?.status === 404) { console.log(`📄 PPT folder not found, trying legacy format: ${pptId}`); return await this.deleteFile(userId, `${pptId}.json`, repoIndex); } console.error(`❌ Delete PPT failed for ${pptId}:`, error); throw new Error(`Failed to delete PPT: ${error.message}`); } } } export default new GitHubService();