| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539 |
- // 商店资源管理模块
- // 负责获取商店中的资源列表(角色、道具、特效、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<Array>} 排序后的分类名称数组
- */
- 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<Array>} 分类列表,每个分类包含 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<Object>} 价格对象,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<number>} 价格,默认为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<Array>} 资源列表
- */
- 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<Array>} 资源列表
- */
- 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;
|