web_ppt_7.7 / backend /src /services /persistentImageLinkService.js
CatPtain's picture
Upload 85 files
28e1dba verified
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 生成占位图片
* @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();