import express from 'express'; import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; import huggingfaceStorageService from './huggingfaceStorageService.js'; import backupSchedulerService from './backupSchedulerService.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** * 持久化图片链接服务 * 实现PPT页面图片的唯一链接管理、自动更新和定时备份 */ class PersistentImageLinkService { constructor() { this.initialized = false; this.dataDir = process.env.HF_DATA_DIR || (process.platform === 'win32' ? './data' : '/data'); this.linksDir = path.join(this.dataDir, 'persistent-links'); this.linkMapFile = path.join(this.linksDir, 'link-map.json'); // 持久化链接映射: linkId -> { userId, pptId, pageIndex, linkId, createdAt, lastUpdated } this.persistentLinks = new Map(); // PPT页面到链接的映射: "userId:pptId:pageIndex" -> linkId this.pageToLinkMap = new Map(); // 图片更新队列 this.updateQueue = new Set(); this.isProcessingUpdates = false; } /** * 初始化服务 */ async initialize() { if (this.initialized) return; try { // 创建必要目录 await fs.mkdir(this.linksDir, { recursive: true }); // 加载现有的链接映射 await this.loadLinkMap(); // 启动定时备份任务(每8小时) this.startBackupScheduler(); this.initialized = true; console.log('✅ PersistentImageLinkService initialized successfully'); console.log(`🔗 Persistent links directory: ${this.linksDir}`); console.log(`📊 Loaded ${this.persistentLinks.size} persistent links`); } catch (error) { console.error('❌ Failed to initialize PersistentImageLinkService:', error); throw error; } } /** * 加载链接映射 */ async loadLinkMap() { try { const data = await fs.readFile(this.linkMapFile, 'utf8'); const linkData = JSON.parse(data); this.persistentLinks.clear(); this.pageToLinkMap.clear(); for (const [linkId, linkInfo] of Object.entries(linkData.persistentLinks || {})) { this.persistentLinks.set(linkId, linkInfo); const pageKey = `${linkInfo.userId}:${linkInfo.pptId}:${linkInfo.pageIndex}`; this.pageToLinkMap.set(pageKey, linkId); } console.log(`📋 Loaded ${this.persistentLinks.size} persistent links from storage`); } catch (error) { if (error.code !== 'ENOENT') { console.warn('⚠️ Failed to load link map:', error.message); } // 文件不存在或损坏,从空开始 this.persistentLinks.clear(); this.pageToLinkMap.clear(); } } /** * 保存链接映射 */ async saveLinkMap() { try { const linkData = { version: '1.0', lastUpdated: new Date().toISOString(), persistentLinks: Object.fromEntries(this.persistentLinks) }; await fs.writeFile(this.linkMapFile, JSON.stringify(linkData, null, 2)); } catch (error) { console.error('❌ Failed to save link map:', error); } } /** * 生成唯一的持久化链接ID * @param {string} userId - 用户ID * @param {string} pptId - PPT ID * @param {string} slideId - 幻灯片唯一ID * @returns {string} 持久化链接ID */ generatePersistentLinkId(userId, pptId, slideId) { // 使用确定性哈希确保同一页面总是生成相同的链接ID // 现在基于slideId而不是pageIndex,确保不受页面顺序影响 const data = `persistent:${userId}:${pptId}:${slideId}`; return crypto.createHash('sha256').update(data).digest('hex').substring(0, 20); } /** * 创建持久化链接(接收前端生成的图片数据) * @param {string} userId - 用户ID * @param {Object} linkData - 链接数据 * @returns {Promise} 创建结果 */ async createLink(userId, linkData) { if (!this.initialized) { await this.initialize(); } const { pptId, slideId, slideIndex, imageData, format = 'jpeg', metadata = {} } = linkData; // 优先使用slideId,如果没有则回退到slideIndex(向后兼容) const uniqueId = slideId || slideIndex; if (!uniqueId && uniqueId !== 0) { throw new Error('Either slideId or slideIndex must be provided'); } // 生成持久化链接ID const linkId = this.generatePersistentLinkId(userId, pptId, uniqueId); const pageKey = `${userId}:${pptId}:${uniqueId}`; try { // 处理图片数据 if (!imageData || !imageData.startsWith('data:image/')) { throw new Error('Invalid image data format'); } // 提取base64数据 const base64Data = imageData.split(',')[1]; const imageBuffer = Buffer.from(base64Data, 'base64'); // 保存图片到持久化目录 const imagePath = path.join(this.linksDir, `${linkId}.${format}`); await fs.writeFile(imagePath, imageBuffer); // 创建或更新链接信息 const linkInfo = { linkId, userId, pptId, slideId: slideId || null, // 存储幻灯片唯一ID pageIndex: slideIndex || null, // 保留页面索引用于向后兼容 uniqueId, // 当前使用的唯一标识符 createdAt: this.persistentLinks.get(linkId)?.createdAt || new Date().toISOString(), lastUpdated: new Date().toISOString(), updateCount: (this.persistentLinks.get(linkId)?.updateCount || 0) + 1, hasImage: true, imageSize: imageBuffer.length, format, imagePath, metadata }; this.persistentLinks.set(linkId, linkInfo); this.pageToLinkMap.set(pageKey, linkId); // 保存映射 await this.saveLinkMap(); // 调度备份 this.scheduleBackup(userId); console.log(`✅ Created/updated persistent link: ${linkId} (${imageBuffer.length} bytes)`); // 构建完整的URL,确保包含正确的协议和端口 const baseUrl = process.env.PUBLIC_URL ? (process.env.PUBLIC_URL.startsWith('http') ? process.env.PUBLIC_URL : `http://${process.env.PUBLIC_URL}`) : `http://localhost:${process.env.PORT || 7860}`; return { success: true, linkId, imageUrl: `${baseUrl}/api/persistent-images/${linkId}`, downloadUrl: `${baseUrl}/api/persistent-images/${linkId}?download=true`, publicUrl: `${baseUrl}/api/persistent-images/${linkId}`, metadata: linkInfo }; } catch (error) { console.error(`❌ Failed to create persistent link ${linkId}:`, error); throw error; } } /** * 获取或创建PPT页面的持久化链接 * @param {string} userId - 用户ID * @param {string} pptId - PPT ID * @param {string|number} uniqueId - 幻灯片唯一ID或页面索引 * @param {Object} slideData - 幻灯片数据(用于首次生成图片) * @param {Object} options - 生成选项 * @returns {Promise} 持久化链接信息 */ async getOrCreatePersistentLink(userId, pptId, uniqueId, slideData = null, options = {}) { if (!this.initialized) { await this.initialize(); } const pageKey = `${userId}:${pptId}:${uniqueId}`; let linkId = this.pageToLinkMap.get(pageKey); if (!linkId) { // 创建新的持久化链接 linkId = this.generatePersistentLinkId(userId, pptId, uniqueId); // 判断uniqueId是字符串(slideId)还是数字(pageIndex) const isSlideId = typeof uniqueId === 'string'; const linkInfo = { linkId, userId, pptId, slideId: isSlideId ? uniqueId : null, pageIndex: isSlideId ? null : uniqueId, uniqueId, createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString(), updateCount: 0, hasImage: false }; this.persistentLinks.set(linkId, linkInfo); this.pageToLinkMap.set(pageKey, linkId); // 保存映射 await this.saveLinkMap(); console.log(`🔗 Created persistent link: ${linkId} for ${pageKey}`); } // 如果提供了幻灯片数据,生成或更新图片 if (slideData) { await this.updatePageImage(linkId, slideData, options); } const linkInfo = this.persistentLinks.get(linkId); // 构建完整的URL,确保包含正确的协议和端口 const baseUrl = process.env.PUBLIC_URL ? (process.env.PUBLIC_URL.startsWith('http') ? process.env.PUBLIC_URL : `http://${process.env.PUBLIC_URL}`) : `http://localhost:${process.env.PORT || 7860}`; return { success: true, linkId, url: `${baseUrl}/api/persistent-images/${linkId}`, publicUrl: `${baseUrl}/api/persistent-images/${linkId}`, ...linkInfo }; } /** * 更新页面图片 * @param {string} linkId - 持久化链接ID * @param {Object} slideData - 幻灯片数据 * @param {Object} options - 生成选项 */ async updatePageImage(linkId, slideData, options = {}) { const linkInfo = this.persistentLinks.get(linkId); if (!linkInfo) { throw new Error(`Persistent link not found: ${linkId}`); } try { console.log(`🔄 Updating image for persistent link: ${linkId}`); // 生成新图片 const generateOptions = { format: 'jpg', quality: 0.9, width: 1920, height: 1080, viewportSize: 1000, viewportRatio: 0.5625, ...options }; // 使用前端导出服务生成图片 const uniqueId = linkInfo.uniqueId || linkInfo.slideId || linkInfo.pageIndex; const imageBuffer = await this.generateImageWithFrontendExport( linkInfo.userId, linkInfo.pptId, uniqueId, slideData, generateOptions ); // 保存图片到持久化目录 const fileExtension = generateOptions.format === 'jpg' ? 'jpg' : generateOptions.format; const imagePath = path.join(this.linksDir, `${linkId}.${fileExtension}`); await fs.writeFile(imagePath, imageBuffer); // 更新链接信息 linkInfo.lastUpdated = new Date().toISOString(); linkInfo.updateCount = (linkInfo.updateCount || 0) + 1; linkInfo.hasImage = true; linkInfo.imageSize = imageBuffer.length; linkInfo.format = generateOptions.format; linkInfo.imagePath = imagePath; this.persistentLinks.set(linkId, linkInfo); // 保存映射 await this.saveLinkMap(); // 添加到备份队列 this.scheduleBackup(linkInfo.userId); console.log(`✅ Updated image for persistent link: ${linkId} (${imageBuffer.length} bytes)`); } catch (error) { console.error(`❌ Failed to update image for persistent link ${linkId}:`, error); throw error; } } /** * 直接更新持久化链接的图片内容 * @param {string} linkId - 持久化链接ID * @param {Buffer} imageBuffer - 图片数据Buffer * @param {string} format - 图片格式 * @returns {Promise} 更新结果 */ async updateImageContent(linkId, imageBuffer, format = 'jpeg') { if (!this.initialized) { await this.initialize(); } const linkInfo = this.persistentLinks.get(linkId); if (!linkInfo) { return { success: false, error: `Persistent link not found: ${linkId}` }; } try { console.log(`🔄 Updating image content for persistent link: ${linkId}`); // 保存图片到持久化目录 const fileExtension = format === 'jpg' ? 'jpg' : format; const imagePath = path.join(this.linksDir, `${linkId}.${fileExtension}`); await fs.writeFile(imagePath, imageBuffer); // 更新链接信息 linkInfo.lastUpdated = new Date().toISOString(); linkInfo.updateCount = (linkInfo.updateCount || 0) + 1; linkInfo.hasImage = true; linkInfo.imageSize = imageBuffer.length; linkInfo.format = format; linkInfo.imagePath = imagePath; this.persistentLinks.set(linkId, linkInfo); // 保存映射 await this.saveLinkMap(); // 添加到备份队列 this.scheduleBackup(linkInfo.userId); console.log(`✅ Updated image content for persistent link: ${linkId} (${imageBuffer.length} bytes)`); return { success: true, linkId, size: imageBuffer.length, format, lastUpdated: linkInfo.lastUpdated }; } catch (error) { console.error(`❌ Failed to update image content for persistent link ${linkId}:`, error); return { success: false, error: error.message }; } } /** * 获取持久化链接的图片 * @param {string} linkId - 持久化链接ID * @returns {Promise} 图片数据和元信息 */ async getPersistentImage(linkId) { if (!this.initialized) { await this.initialize(); } const linkInfo = this.persistentLinks.get(linkId); if (!linkInfo) { throw new Error(`Persistent link not found: ${linkId}`); } // 如果没有图片信息,尝试查找可能存在的图片文件 if (!linkInfo.hasImage || !linkInfo.imagePath) { console.log(`⚠️ No image info for ${linkId}, attempting to find existing image files...`); // 尝试查找可能存在的图片文件 const extensions = ['jpeg', 'jpg', 'png', 'webp']; let foundImagePath = null; let foundFormat = null; for (const ext of extensions) { const potentialPath = path.join(this.linksDir, `${linkId}.${ext}`); try { await fs.access(potentialPath); foundImagePath = path.relative(process.cwd(), potentialPath); foundFormat = ext; console.log(`✅ Found existing image: ${potentialPath}`); break; } catch (error) { // 文件不存在,继续尝试下一个扩展名 } } if (!foundImagePath) { throw new Error(`Image not available for persistent link: ${linkId}`); } // 更新 linkInfo linkInfo.hasImage = true; linkInfo.imagePath = foundImagePath; linkInfo.format = foundFormat; this.persistentLinks.set(linkId, linkInfo); await this.saveLinkMap(); console.log(`📝 Updated link info for ${linkId} with found image`); } try { // 确保使用绝对路径 let imagePath = linkInfo.imagePath; if (!path.isAbsolute(imagePath)) { // 如果是相对路径,则相对于项目根目录 imagePath = path.resolve(imagePath); } console.log(`📖 Reading image from: ${imagePath}`); // 尝试读取文件,如果失败则尝试其他扩展名 let imageBuffer; try { imageBuffer = await fs.readFile(imagePath); } catch (readError) { console.warn(`⚠️ Failed to read ${imagePath}, trying alternative extensions...`); // 尝试其他常见的图片扩展名 const basePath = imagePath.replace(/\.[^.]+$/, ''); const extensions = ['jpeg', 'jpg', 'png', 'webp']; for (const ext of extensions) { const altPath = `${basePath}.${ext}`; try { console.log(`🔍 Trying: ${altPath}`); imageBuffer = await fs.readFile(altPath); console.log(`✅ Found image at: ${altPath}`); // 更新 linkInfo 中的路径信息 linkInfo.imagePath = path.relative(process.cwd(), altPath); linkInfo.format = ext; this.persistentLinks.set(linkId, linkInfo); await this.saveLinkMap(); break; } catch (altError) { // 继续尝试下一个扩展名 } } if (!imageBuffer) { throw readError; // 如果所有尝试都失败,抛出原始错误 } } return { success: true, data: imageBuffer, metadata: { linkId, format: linkInfo.format || 'png', size: linkInfo.imageSize || imageBuffer.length, createdAt: linkInfo.createdAt, lastUpdated: linkInfo.lastUpdated, updateCount: linkInfo.updateCount || 0 } }; } catch (error) { console.error(`❌ Failed to get persistent image ${linkId}:`, error); console.error(`❌ Attempted to read from: ${linkInfo.imagePath}`); throw error; } } /** * 批量更新PPT所有页面的持久化链接 * @param {string} userId - 用户ID * @param {string} pptId - PPT ID * @param {Array} slides - 幻灯片数据数组 * @param {Object} options - 生成选项 * @returns {Promise} 持久化链接信息数组 */ async updateAllPersistentLinks(userId, pptId, slides, options = {}) { const results = []; for (let pageIndex = 0; pageIndex < slides.length; pageIndex++) { try { const slide = slides[pageIndex]; // 优先使用slideId,如果不存在则使用pageIndex const uniqueId = slide?.id || pageIndex; const result = await this.getOrCreatePersistentLink( userId, pptId, uniqueId, slide, options ); results.push({ pageIndex, slideId: slide?.id, uniqueId, success: true, ...result }); } catch (error) { console.error(`❌ Failed to update persistent link for page ${pageIndex}:`, error); results.push({ pageIndex, slideId: slides[pageIndex]?.id, success: false, error: error.message }); } } return results; } /** * 使用前端导出服务批量更新PPT所有页面的持久化链接 * @param {string} userId - 用户ID * @param {string} pptId - PPT ID * @param {Array} slides - 幻灯片数据数组 * @param {Object} options - 生成选项 * @returns {Promise} 持久化链接信息数组 */ async updateAllPersistentLinksWithFrontendExport(userId, pptId, slides, options = {}) { const results = []; console.log(`🔄 Starting batch update of ${slides.length} persistent links for PPT ${pptId}`); for (let pageIndex = 0; pageIndex < slides.length; pageIndex++) { try { const slide = slides[pageIndex]; // 优先使用slideId,如果不存在则使用pageIndex const uniqueId = slide?.id || pageIndex; // 获取或创建持久化链接(不生成图片) const linkResult = await this.getOrCreatePersistentLink( userId, pptId, uniqueId, null, // 不传递slideData,避免立即生成图片 options ); // 使用前端导出服务生成图片 const linkInfo = this.persistentLinks.get(linkResult.linkId); if (linkInfo) { try { const imageBuffer = await this.generateImageWithFrontendExport( userId, pptId, uniqueId, slide, options ); // 保存图片 const imagePath = path.join(this.linksDir, `${linkResult.linkId}.${options.format || 'jpg'}`); await fs.writeFile(imagePath, imageBuffer); // 更新链接信息 linkInfo.lastUpdated = new Date().toISOString(); linkInfo.updateCount = (linkInfo.updateCount || 0) + 1; linkInfo.hasImage = true; linkInfo.imageSize = imageBuffer.length; linkInfo.format = options.format || 'jpg'; linkInfo.imagePath = imagePath; this.persistentLinks.set(linkResult.linkId, linkInfo); console.log(`✅ Updated persistent link ${linkResult.linkId} for page ${pageIndex} (${imageBuffer.length} bytes)`); } catch (imageError) { console.error(`❌ Failed to generate image for page ${pageIndex}:`, imageError); throw imageError; } } results.push({ pageIndex, success: true, linkId: linkResult.linkId, url: linkResult.url, publicUrl: linkResult.publicUrl }); } catch (error) { console.error(`❌ Failed to update persistent link for page ${pageIndex}:`, error); results.push({ pageIndex, success: false, error: error.message }); } } // 保存映射 await this.saveLinkMap(); // 添加到备份队列 this.scheduleBackup(userId); const successCount = results.filter(r => r.success).length; console.log(`✅ Batch update completed: ${successCount}/${slides.length} links updated successfully`); return results; } /** * 使用前端导出服务生成图片 * @param {string} userId - 用户ID * @param {string} pptId - PPT ID * @param {string|number} uniqueId - 幻灯片唯一ID或页面索引 * @param {Object} slideData - 幻灯片数据 * @param {Object} options - 生成选项 * @returns {Promise} 图片数据 */ async generateImageWithFrontendExport(userId, pptId, uniqueId, slideData, options = {}) { try { console.log(`🖼️ Generating JPG image for slide ${uniqueId} of PPT ${pptId}`); // 尝试使用Canvas生成JPG图片 const { createCanvas } = await import('canvas'); const width = Math.round(options.width || 1920); const height = Math.round(options.height || 1080); const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d'); // 设置背景 ctx.fillStyle = slideData?.background?.color || '#ffffff'; ctx.fillRect(0, 0, width, height); // 添加边框 ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 4; ctx.strokeRect(0, 0, width, height); // 获取幻灯片信息 const pageNumber = typeof uniqueId === 'number' ? uniqueId + 1 : '未知'; const slideTitle = slideData?.title || `第 ${pageNumber} 页`; const slideElements = slideData?.elements || []; const elementCount = slideElements.length; // 绘制标题区域背景 ctx.fillStyle = '#f8f9fa'; ctx.fillRect(50, 50, width - 100, 120); ctx.strokeStyle = '#dee2e6'; ctx.lineWidth = 2; ctx.strokeRect(50, 50, width - 100, 120); // 绘制标题文字 ctx.fillStyle = '#d14424'; ctx.font = 'bold 48px Arial, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(slideTitle, width / 2, 120); // 绘制内容信息 ctx.fillStyle = '#6c757d'; ctx.font = '32px Arial, sans-serif'; ctx.fillText(`包含 ${elementCount} 个元素`, width / 2, height / 2); // 绘制页面信息 ctx.fillStyle = '#adb5bd'; ctx.font = '24px Arial, sans-serif'; ctx.fillText(`PPT ID: ${pptId} | 页面: ${pageNumber}`, width / 2, height / 2 + 60); // 绘制时间戳 ctx.fillStyle = '#ced4da'; ctx.font = '20px Arial, sans-serif'; ctx.fillText(`生成时间: ${new Date().toLocaleString('zh-CN')}`, width / 2, height - 50); // 绘制装饰性圆圈 ctx.fillStyle = '#e9ecef'; ctx.beginPath(); ctx.arc(150, height - 150, 50, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = '#d14424'; ctx.lineWidth = 4; ctx.stroke(); // 绘制页码(修复变量名) ctx.fillStyle = '#d14424'; ctx.font = 'bold 28px Arial, sans-serif'; ctx.textAlign = 'center'; const displayPageNumber = typeof uniqueId === 'number' ? uniqueId + 1 : uniqueId; ctx.fillText(displayPageNumber.toString(), 150, height - 140); // 转换为JPG Buffer return canvas.toBuffer('image/jpeg', { quality: options.quality || 0.9 }); } catch (error) { console.warn(`⚠️ Canvas module not available for slide ${uniqueId}, generating SVG placeholder:`, error.message); // 回退:生成一个SVG占位图片 const width = Math.round(options.width || 1920); const height = Math.round(options.height || 1080); const pageNumber = typeof uniqueId === 'number' ? uniqueId + 1 : '未知'; const slideTitle = slideData?.title || `第 ${pageNumber} 页`; const slideElements = slideData?.elements || []; const elementCount = slideElements.length; const svgContent = ` ${this.escapeXml(slideTitle)} 包含 ${elementCount} 个元素 PPT ID: ${this.escapeXml(pptId)} | 页面: ${pageNumber} 生成时间: ${new Date().toLocaleString('zh-CN')} ${pageNumber} `; return Buffer.from(svgContent, 'utf8'); } } /** * 转义XML特殊字符 * @param {string} text - 要转义的文本 * @returns {string} 转义后的文本 */ escapeXml(text) { if (!text) return ''; return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * 生成占位图片 * @param {Object} options - 生成选项 * @returns {Buffer} 图片数据 */ async generatePlaceholderImage(options = {}) { try { // 尝试使用 canvas 模块 const { createCanvas } = await import('canvas'); const width = Math.round(options.width || 1920); const height = Math.round(options.height || 1080); const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d'); // 设置背景 ctx.fillStyle = '#f5f5f5'; ctx.fillRect(0, 0, width, height); // 绘制主要文字 ctx.fillStyle = '#999999'; ctx.font = '48px Arial, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('图片生成中...', width / 2, height / 2 - 30); // 绘制副标题 ctx.fillStyle = '#cccccc'; ctx.font = '28px Arial, sans-serif'; ctx.fillText('Generating Image...', width / 2, height / 2 + 30); // 转换为JPG Buffer return canvas.toBuffer('image/jpeg', { quality: options.quality || 0.9 }); } catch (error) { console.warn('⚠️ Canvas module not available, generating simple placeholder:', error.message); // 如果Canvas失败,生成一个简单的SVG图片 const width = Math.round(options.width || 1920); const height = Math.round(options.height || 1080); const svgContent = ` 图片生成中... Generating Image... `; return Buffer.from(svgContent, 'utf8'); } } /** * 获取用户的所有持久化链接 * @param {string} userId - 用户ID * @param {string} pptId - PPT ID(可选) * @returns {Array} 持久化链接列表 */ getUserPersistentLinks(userId, pptId = null) { const links = []; for (const [linkId, linkInfo] of this.persistentLinks) { if (linkInfo.userId === userId && (!pptId || linkInfo.pptId === pptId)) { // 使用正确的链接格式:/api/persistent-images/slideID const baseUrl = process.env.PUBLIC_URL || 'http://localhost:7860'; links.push({ linkId, url: `/api/persistent-images/${linkId}`, publicUrl: `${baseUrl}/api/persistent-images/${linkId}`, ...linkInfo }); } } return links; } /** * 获取用户的所有持久化链接(别名方法) * @param {string} userId - 用户ID * @returns {Array} 持久化链接列表 */ getUserLinks(userId) { return this.getUserPersistentLinks(userId); } /** * 通过 PPT ID 和 Slide ID 查找链接信息 * @param {string} pptId - PPT ID * @param {string} slideId - 幻灯片 ID * @returns {Object|null} 链接信息或 null */ async findLinkByPptAndSlide(pptId, slideId) { if (!this.initialized) { await this.initialize(); } // 遍历所有链接,查找匹配的 pptId 和 slideId for (const [linkId, linkInfo] of this.persistentLinks) { if (linkInfo.pptId === pptId && linkInfo.slideId === slideId) { return linkInfo; } } // 如果没有找到基于 slideId 的匹配,尝试基于 uniqueId 的匹配(向后兼容) for (const [linkId, linkInfo] of this.persistentLinks) { if (linkInfo.pptId === pptId && linkInfo.uniqueId === slideId) { return linkInfo; } } return null; } /** * 删除持久化链接 * @param {string} linkId - 持久化链接ID * @returns {Promise} 删除结果 */ async deletePersistentLink(linkId) { const linkInfo = this.persistentLinks.get(linkId); if (!linkInfo) { return false; } try { // 删除图片文件 if (linkInfo.imagePath) { await fs.unlink(linkInfo.imagePath).catch(() => {}); } // 从映射中移除 const pageKey = `${linkInfo.userId}:${linkInfo.pptId}:${linkInfo.pageIndex}`; this.persistentLinks.delete(linkId); this.pageToLinkMap.delete(pageKey); // 保存映射 await this.saveLinkMap(); console.log(`✅ Deleted persistent link: ${linkId}`); return true; } catch (error) { console.error(`❌ Failed to delete persistent link ${linkId}:`, error); return false; } } /** * 调度备份 * @param {string} userId - 用户ID */ scheduleBackup(userId) { try { backupSchedulerService.scheduleUserBackup(userId); } catch (error) { console.warn('⚠️ Failed to schedule backup:', error.message); } } /** * 启动定时备份调度器(每8小时) */ startBackupScheduler() { const BACKUP_INTERVAL = 8 * 60 * 60 * 1000; // 8小时 setInterval(async () => { try { console.log('🔄 Starting scheduled backup of persistent image links...'); // 获取所有用户ID const userIds = new Set(); for (const linkInfo of this.persistentLinks.values()) { userIds.add(linkInfo.userId); } // 为每个用户调度备份 for (const userId of userIds) { this.scheduleBackup(userId); } console.log(`✅ Scheduled backup for ${userIds.size} users`); } catch (error) { console.error('❌ Failed to run scheduled backup:', error); } }, BACKUP_INTERVAL); console.log(`⏰ Backup scheduler started (every 8 hours)`); } /** * 根据用户ID、PPT ID和页面索引获取持久化图片 * @param {string} userId - 用户ID * @param {string} pptId - PPT ID * @param {number} pageIndex - 页面索引 * @returns {Object} 图片数据和元数据 */ async getPersistentImageByPage(userId, pptId, pageIndex) { if (!this.initialized) { await this.initialize(); } const pageKey = `${userId}:${pptId}:${pageIndex}`; const linkId = this.pageToLinkMap.get(pageKey); if (!linkId) { throw new Error(`No persistent link found for page ${pageIndex} of PPT ${pptId}`); } const linkInfo = this.persistentLinks.get(linkId); if (!linkInfo || !linkInfo.hasImage) { throw new Error(`No image available for link ${linkId}`); } // 读取图片文件 const imagePath = path.join(this.linksDir, `${linkId}.${linkInfo.format}`); try { const imageBuffer = await fs.readFile(imagePath); return { success: true, data: imageBuffer, metadata: { linkId, format: linkInfo.format, size: linkInfo.imageSize, createdAt: linkInfo.createdAt, lastUpdated: linkInfo.lastUpdated } }; } catch (error) { throw new Error(`Failed to read image file: ${error.message}`); } } /** * 获取服务统计信息 * @returns {Object} 统计信息 */ getStats() { const userStats = new Map(); const pptStats = new Map(); for (const linkInfo of this.persistentLinks.values()) { // 用户统计 const userCount = userStats.get(linkInfo.userId) || 0; userStats.set(linkInfo.userId, userCount + 1); // PPT统计 const pptKey = `${linkInfo.userId}:${linkInfo.pptId}`; const pptCount = pptStats.get(pptKey) || 0; pptStats.set(pptKey, pptCount + 1); } return { totalLinks: this.persistentLinks.size, totalUsers: userStats.size, totalPPTs: pptStats.size, linksWithImages: Array.from(this.persistentLinks.values()).filter(link => link.hasImage).length, userStats: Object.fromEntries(userStats), pptStats: Object.fromEntries(pptStats), averageLinksPerUser: userStats.size > 0 ? this.persistentLinks.size / userStats.size : 0 }; } /** * 健康检查 */ async healthCheck() { try { if (!this.initialized) { return { status: 'unhealthy', message: 'Service not initialized', details: { initialized: false, linksCount: 0 } }; } // 检查数据目录是否可访问 try { await fs.access(this.linksDir); } catch (error) { return { status: 'unhealthy', message: 'Links directory not accessible', details: { linksDir: this.linksDir, error: error.message } }; } // 检查链接映射文件是否可读写 try { await fs.access(this.linkMapFile, fs.constants.R_OK | fs.constants.W_OK); } catch (error) { // 文件不存在是正常的,但如果存在却无法读写则有问题 if (error.code !== 'ENOENT') { return { status: 'unhealthy', message: 'Link map file not accessible', details: { linkMapFile: this.linkMapFile, error: error.message } }; } } return { status: 'healthy', message: 'Persistent Image Link Service is running normally', details: { initialized: this.initialized, linksCount: this.persistentLinks.size, linksDir: this.linksDir, linkMapFile: this.linkMapFile, updateQueueSize: this.updateQueue.size, isProcessingUpdates: this.isProcessingUpdates } }; } catch (error) { return { status: 'unhealthy', message: 'Health check failed', details: { error: error.message, stack: error.stack } }; } } } export default new PersistentImageLinkService();