web_ppt_7.7 / backend /src /services /githubService.js
CatPtain's picture
Upload 85 files
28e1dba verified
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();