Spaces:
Running
Running
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<Object>} 创建结果 | |
*/ | |
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<Object>} 持久化链接信息 | |
*/ | |
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<Object>} 更新结果 | |
*/ | |
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<Object>} 图片数据和元信息 | |
*/ | |
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<Array>} 持久化链接信息数组 | |
*/ | |
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<Array>} 持久化链接信息数组 | |
*/ | |
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<Buffer>} 图片数据 | |
*/ | |
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 = `<?xml version="1.0" encoding="UTF-8"?> | |
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> | |
<!-- 背景 --> | |
<rect width="100%" height="100%" fill="${slideData?.background?.color || '#ffffff'}"/> | |
<!-- 边框 --> | |
<rect x="2" y="2" width="${width-4}" height="${height-4}" fill="none" stroke="#e0e0e0" stroke-width="4"/> | |
<!-- 标题区域背景 --> | |
<rect x="50" y="50" width="${width-100}" height="120" fill="#f8f9fa" stroke="#dee2e6" stroke-width="2"/> | |
<!-- 标题文字 --> | |
<text x="50%" y="120" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="#d14424" text-anchor="middle" dominant-baseline="middle">${this.escapeXml(slideTitle)}</text> | |
<!-- 内容信息 --> | |
<text x="50%" y="${height/2}" font-family="Arial, sans-serif" font-size="32" fill="#6c757d" text-anchor="middle" dominant-baseline="middle">包含 ${elementCount} 个元素</text> | |
<!-- 页面信息 --> | |
<text x="50%" y="${height/2 + 60}" font-family="Arial, sans-serif" font-size="24" fill="#adb5bd" text-anchor="middle" dominant-baseline="middle">PPT ID: ${this.escapeXml(pptId)} | 页面: ${pageNumber}</text> | |
<!-- 时间戳 --> | |
<text x="50%" y="${height-50}" font-family="Arial, sans-serif" font-size="20" fill="#ced4da" text-anchor="middle" dominant-baseline="middle">生成时间: ${new Date().toLocaleString('zh-CN')}</text> | |
<!-- 装饰性圆圈 --> | |
<circle cx="150" cy="${height-150}" r="50" fill="#e9ecef" stroke="#d14424" stroke-width="4"/> | |
<!-- 页码 --> | |
<text x="150" y="${height-140}" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#d14424" text-anchor="middle" dominant-baseline="middle">${pageNumber}</text> | |
</svg>`; | |
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, '"') | |
.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 = `<?xml version="1.0" encoding="UTF-8"?> | |
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> | |
<rect width="100%" height="100%" fill="#f5f5f5"/> | |
<text x="50%" y="45%" font-family="Arial, sans-serif" font-size="48" fill="#999999" text-anchor="middle" dominant-baseline="middle">图片生成中...</text> | |
<text x="50%" y="55%" font-family="Arial, sans-serif" font-size="28" fill="#cccccc" text-anchor="middle" dominant-baseline="middle">Generating Image...</text> | |
</svg>`; | |
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<boolean>} 删除结果 | |
*/ | |
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(); |