// 商店资源管理模块 // 负责获取商店中的资源列表(角色、道具、特效、Q版) const fs = require('fs').promises; const path = require('path'); const { readdir, stat } = require('fs').promises; const http = require('http'); class StoreManager { constructor() { // 商店资源根目录(使用 market_data 目录) this.storeRoot = path.join(__dirname, '..', 'market_data'); // 价格文件路径 this.pricesFilePath = path.join(this.storeRoot, 'prices.json'); // 分类排序文件路径 this.categoryOrderFilePath = path.join(this.storeRoot, 'category-order.json'); this.pricesCache = null; // 价格缓存 this.categoriesCache = null; // 分类缓存 } /** * 获取分类排序配置 * @returns {Promise} 排序后的分类名称数组 */ async getCategoryOrder() { try { const data = await fs.readFile(this.categoryOrderFilePath, 'utf-8'); return JSON.parse(data); } catch (error) { // 文件不存在或解析失败,返回空数组 return []; } } /** * 保存分类排序配置 * @param {Array} order 分类名称数组 */ async saveCategoryOrder(order) { try { await fs.writeFile(this.categoryOrderFilePath, JSON.stringify(order, null, 2), 'utf-8'); this.clearCategoriesCache(); return true; } catch (error) { console.error('[StoreManager] 保存分类排序失败:', error); return false; } } /** * 动态获取所有分类(读取根目录下的文件夹) * @returns {Promise} 分类列表,每个分类包含 name 和 dir */ async getCategories() { try { if (this.categoriesCache !== null) { return this.categoriesCache; } const items = await readdir(this.storeRoot); const categories = []; for (const item of items) { const itemPath = path.join(this.storeRoot, item); const stats = await stat(itemPath); // 只处理文件夹,排除 prices.json 等文件和特殊目录 if (stats.isDirectory() && item !== 'node_modules' && item !== 'temp' && !item.startsWith('.')) { categories.push({ name: item, // 文件夹名称就是分类名称 dir: item }); } } // 按保存的顺序排序 const savedOrder = await this.getCategoryOrder(); if (savedOrder.length > 0) { categories.sort((a, b) => { const indexA = savedOrder.indexOf(a.name); const indexB = savedOrder.indexOf(b.name); // 不在排序列表中的放最后,按名称排序 if (indexA === -1 && indexB === -1) return a.name.localeCompare(b.name); if (indexA === -1) return 1; if (indexB === -1) return -1; return indexA - indexB; }); } else { // 没有保存的顺序,按名称排序 categories.sort((a, b) => a.name.localeCompare(b.name)); } this.categoriesCache = categories; return categories; } catch (error) { console.error('[StoreManager] 获取分类列表失败:', error); return []; } } /** * 清除分类缓存 */ clearCategoriesCache() { this.categoriesCache = null; } /** * 读取价格文件 * @returns {Promise} 价格对象,key为资源路径,value为价格 */ async loadPrices() { try { if (this.pricesCache !== null) { return this.pricesCache; } const pricesData = await fs.readFile(this.pricesFilePath, 'utf-8'); this.pricesCache = JSON.parse(pricesData); return this.pricesCache; } catch (error) { // 文件不存在或读取失败,返回空对象 if (error.code === 'ENOENT') { this.pricesCache = {}; return {}; } console.error('[StoreManager] 读取价格文件失败:', error); return {}; } } /** * 获取资源价格 * @param {string} resourcePath - 资源路径 * @returns {Promise} 价格,默认为0 */ async getResourcePrice(resourcePath) { const prices = await this.loadPrices(); return prices[resourcePath] !== undefined ? prices[resourcePath] : 0; } /** * 清除价格缓存(当价格更新后调用) */ clearPricesCache() { this.pricesCache = null; } /** * 获取文件夹的第一帧预览图 * @param {string} folderPath - 文件夹路径 * @returns {Promise<{hasPreview: boolean, previewFile: string|null}>} */ async getFirstFrame(folderPath) { const commonNames = ['01.png', '00.png', '001.png', '0001.png', '1.png', '0.png']; try { const items = await readdir(folderPath); // 首先检查常见的帧文件名 for (const name of commonNames) { if (items.includes(name)) { return { hasPreview: true, previewFile: name }; } } // 检查是否有任何PNG文件 const pngFiles = items.filter(item => item.toLowerCase().endsWith('.png')); if (pngFiles.length > 0) { // 按文件名排序,取第一个 pngFiles.sort(); return { hasPreview: true, previewFile: pngFiles[0] }; } return { hasPreview: false, previewFile: null }; } catch (error) { return { hasPreview: false, previewFile: null }; } } /** * 获取指定分类的资源列表 * @param {string} categoryDir - 分类文件夹名称(直接使用文件夹名称) * @returns {Promise} 资源列表 */ async getResourcesByCategory(categoryDir) { if (!categoryDir) { return []; } const categoryPath = path.join(this.storeRoot, categoryDir); try { // 检查目录是否存在 await fs.access(categoryPath); const items = await readdir(categoryPath); const resources = []; for (const item of items) { const itemPath = path.join(categoryPath, item); const stats = await stat(itemPath); // 只处理文件夹 if (stats.isDirectory()) { // 获取第一帧预览图 const previewInfo = await this.getFirstFrame(itemPath); // 获取文件夹中的PNG文件数量 const files = await readdir(itemPath); const pngCount = files.filter(f => f.toLowerCase().endsWith('.png')).length; const resourcePath = `${categoryDir}/${item}`; // 获取资源价格 const price = await this.getResourcePrice(resourcePath); const resource = { name: item, category: categoryDir, // 分类名称就是文件夹名称 categoryDir: categoryDir, previewUrl: previewInfo.hasPreview ? `/api/store/preview?category=${encodeURIComponent(categoryDir)}&folder=${encodeURIComponent(item)}&file=${encodeURIComponent(previewInfo.previewFile)}` : null, frameCount: pngCount, path: resourcePath, points: price // 添加价格信息 }; resources.push(resource); } } // 按名称排序 resources.sort((a, b) => a.name.localeCompare(b.name)); return resources; } catch (error) { console.error(`[StoreManager] 获取分类资源失败: ${categoryDir}`, error); return []; } } /** * 获取所有资源(可选的分类筛选) * @param {string|null} category - 分类名称,null 表示所有分类 * @param {string|null} searchQuery - 搜索关键词 * @returns {Promise} 资源列表 */ async getAllResources(category = null, searchQuery = null) { let allResources = []; if (category) { // 只获取指定分类 allResources = await this.getResourcesByCategory(category); } else { // 获取所有分类 const categories = await this.getCategories(); for (const cat of categories) { const resources = await this.getResourcesByCategory(cat.dir); allResources = allResources.concat(resources); } } // 搜索筛选 if (searchQuery && searchQuery.trim()) { const query = searchQuery.trim().toLowerCase(); allResources = allResources.filter(resource => resource.name.toLowerCase().includes(query) ); } return allResources; } /** * 处理获取分类列表的 API 请求 * @param {http.IncomingMessage} req - HTTP 请求对象 * @param {http.ServerResponse} res - HTTP 响应对象 */ async handleGetCategories(req, res) { try { const categories = await this.getCategories(); res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: true, categories: categories, count: categories.length })); } catch (error) { console.error('[StoreManager] 获取分类列表失败:', error); res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: false, error: error.message })); } } /** * 处理获取资源列表的 API 请求 * @param {http.IncomingMessage} req - HTTP 请求对象 * @param {http.ServerResponse} res - HTTP 响应对象 */ async handleGetResources(req, res) { const parsedUrl = require('url').parse(req.url, true); const { category, search } = parsedUrl.query; try { const resources = await this.getAllResources(category || null, search || null); res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: true, resources: resources, count: resources.length })); } catch (error) { console.error('[StoreManager] 获取资源列表失败:', error); res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: false, error: error.message })); } } /** * 处理预览图请求 * @param {http.IncomingMessage} req - HTTP 请求对象 * @param {http.ServerResponse} res - HTTP 响应对象 */ async handlePreview(req, res) { const parsedUrl = require('url').parse(req.url, true); const { category, folder, file } = parsedUrl.query; if (!category || !folder || !file) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Missing parameters'); return; } try { const filePath = path.join(this.storeRoot, category, folder, file); // 安全检查:确保文件在商店目录内 const normalizedPath = path.normalize(filePath); const normalizedRoot = path.normalize(this.storeRoot); if (!normalizedPath.startsWith(normalizedRoot)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Access denied'); return; } // 检查文件是否存在 await fs.access(filePath); // 读取文件并返回 const fileData = await fs.readFile(filePath); res.writeHead(200, { 'Content-Type': 'image/png', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600' }); res.end(fileData); } catch (error) { console.error('[StoreManager] 获取预览图失败:', error); res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('File not found'); } } /** * 处理获取帧列表的 API 请求(用于播放动画) * @param {http.IncomingMessage} req - HTTP 请求对象 * @param {http.ServerResponse} res - HTTP 响应对象 */ async handleGetFrames(req, res) { const parsedUrl = require('url').parse(req.url, true); const { category, folder } = parsedUrl.query; if (!category || !folder) { res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: false, error: 'Missing parameters' })); return; } try { const folderPath = path.join(this.storeRoot, category, folder); // 安全检查 const normalizedPath = path.normalize(folderPath); const normalizedRoot = path.normalize(this.storeRoot); if (!normalizedPath.startsWith(normalizedRoot)) { res.writeHead(403, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: false, error: 'Access denied' })); return; } const files = await readdir(folderPath); // 过滤PNG文件并按数字排序 const pngFiles = files .filter(file => file.toLowerCase().endsWith('.png')) .map(file => { let frameNum = null; // 先尝试匹配纯数字格式:00.png, 01.png let match = /^(\d+)\.png$/i.exec(file); if (match) { frameNum = parseInt(match[1], 10); } else { // 再尝试匹配文件名末尾的数字格式 match = /(\d+)\.png$/i.exec(file); if (match) { frameNum = parseInt(match[1], 10); } } if (frameNum !== null) { return { frameNum: frameNum, fileName: file, url: `/api/store/frame?category=${encodeURIComponent(category)}&folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}` }; } return null; }) .filter(item => item !== null) .sort((a, b) => a.frameNum - b.frameNum); res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: true, frames: pngFiles.map(item => item.frameNum), fileNames: pngFiles.map(item => item.fileName), frameUrls: pngFiles.map(item => item.url), maxFrame: pngFiles.length > 0 ? Math.max(...pngFiles.map(item => item.frameNum)) : 0 })); } catch (error) { console.error('[StoreManager] 获取帧列表失败:', error); res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ success: false, error: error.message })); } } /** * 处理帧图片请求 * @param {http.IncomingMessage} req - HTTP 请求对象 * @param {http.ServerResponse} res - HTTP 响应对象 */ async handleGetFrame(req, res) { const parsedUrl = require('url').parse(req.url, true); const { category, folder, file } = parsedUrl.query; if (!category || !folder || !file) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Missing parameters'); return; } try { const filePath = path.join(this.storeRoot, category, folder, file); // 安全检查 const normalizedPath = path.normalize(filePath); const normalizedRoot = path.normalize(this.storeRoot); if (!normalizedPath.startsWith(normalizedRoot)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Access denied'); return; } await fs.access(filePath); const fileData = await fs.readFile(filePath); res.writeHead(200, { 'Content-Type': 'image/png', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600' }); res.end(fileData); } catch (error) { console.error('[StoreManager] 获取帧图片失败:', error); res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('File not found'); } } } module.exports = StoreManager;