store.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. // 商店资源管理模块
  2. // 负责获取商店中的资源列表(角色、道具、特效、Q版)
  3. const fs = require('fs').promises;
  4. const path = require('path');
  5. const { readdir, stat } = require('fs').promises;
  6. const http = require('http');
  7. class StoreManager {
  8. constructor() {
  9. // 商店资源根目录(使用 market_data 目录)
  10. this.storeRoot = path.join(__dirname, '..', 'market_data');
  11. // 价格文件路径
  12. this.pricesFilePath = path.join(this.storeRoot, 'prices.json');
  13. // 分类排序文件路径
  14. this.categoryOrderFilePath = path.join(this.storeRoot, 'category-order.json');
  15. this.pricesCache = null; // 价格缓存
  16. this.categoriesCache = null; // 分类缓存
  17. }
  18. /**
  19. * 获取分类排序配置
  20. * @returns {Promise<Array>} 排序后的分类名称数组
  21. */
  22. async getCategoryOrder() {
  23. try {
  24. const data = await fs.readFile(this.categoryOrderFilePath, 'utf-8');
  25. return JSON.parse(data);
  26. } catch (error) {
  27. // 文件不存在或解析失败,返回空数组
  28. return [];
  29. }
  30. }
  31. /**
  32. * 保存分类排序配置
  33. * @param {Array} order 分类名称数组
  34. */
  35. async saveCategoryOrder(order) {
  36. try {
  37. await fs.writeFile(this.categoryOrderFilePath, JSON.stringify(order, null, 2), 'utf-8');
  38. this.clearCategoriesCache();
  39. return true;
  40. } catch (error) {
  41. console.error('[StoreManager] 保存分类排序失败:', error);
  42. return false;
  43. }
  44. }
  45. /**
  46. * 动态获取所有分类(读取根目录下的文件夹)
  47. * @returns {Promise<Array>} 分类列表,每个分类包含 name 和 dir
  48. */
  49. async getCategories() {
  50. try {
  51. if (this.categoriesCache !== null) {
  52. return this.categoriesCache;
  53. }
  54. const items = await readdir(this.storeRoot);
  55. const categories = [];
  56. for (const item of items) {
  57. const itemPath = path.join(this.storeRoot, item);
  58. const stats = await stat(itemPath);
  59. // 只处理文件夹,排除 prices.json 等文件和特殊目录
  60. if (stats.isDirectory() && item !== 'node_modules' && item !== 'temp' && !item.startsWith('.')) {
  61. categories.push({
  62. name: item, // 文件夹名称就是分类名称
  63. dir: item
  64. });
  65. }
  66. }
  67. // 按保存的顺序排序
  68. const savedOrder = await this.getCategoryOrder();
  69. if (savedOrder.length > 0) {
  70. categories.sort((a, b) => {
  71. const indexA = savedOrder.indexOf(a.name);
  72. const indexB = savedOrder.indexOf(b.name);
  73. // 不在排序列表中的放最后,按名称排序
  74. if (indexA === -1 && indexB === -1) return a.name.localeCompare(b.name);
  75. if (indexA === -1) return 1;
  76. if (indexB === -1) return -1;
  77. return indexA - indexB;
  78. });
  79. } else {
  80. // 没有保存的顺序,按名称排序
  81. categories.sort((a, b) => a.name.localeCompare(b.name));
  82. }
  83. this.categoriesCache = categories;
  84. return categories;
  85. } catch (error) {
  86. console.error('[StoreManager] 获取分类列表失败:', error);
  87. return [];
  88. }
  89. }
  90. /**
  91. * 清除分类缓存
  92. */
  93. clearCategoriesCache() {
  94. this.categoriesCache = null;
  95. }
  96. /**
  97. * 读取价格文件
  98. * @returns {Promise<Object>} 价格对象,key为资源路径,value为价格
  99. */
  100. async loadPrices() {
  101. try {
  102. if (this.pricesCache !== null) {
  103. return this.pricesCache;
  104. }
  105. const pricesData = await fs.readFile(this.pricesFilePath, 'utf-8');
  106. this.pricesCache = JSON.parse(pricesData);
  107. return this.pricesCache;
  108. } catch (error) {
  109. // 文件不存在或读取失败,返回空对象
  110. if (error.code === 'ENOENT') {
  111. this.pricesCache = {};
  112. return {};
  113. }
  114. console.error('[StoreManager] 读取价格文件失败:', error);
  115. return {};
  116. }
  117. }
  118. /**
  119. * 获取资源价格
  120. * @param {string} resourcePath - 资源路径
  121. * @returns {Promise<number>} 价格,默认为0
  122. */
  123. async getResourcePrice(resourcePath) {
  124. const prices = await this.loadPrices();
  125. return prices[resourcePath] !== undefined ? prices[resourcePath] : 0;
  126. }
  127. /**
  128. * 清除价格缓存(当价格更新后调用)
  129. */
  130. clearPricesCache() {
  131. this.pricesCache = null;
  132. }
  133. /**
  134. * 获取文件夹的第一帧预览图
  135. * @param {string} folderPath - 文件夹路径
  136. * @returns {Promise<{hasPreview: boolean, previewFile: string|null}>}
  137. */
  138. async getFirstFrame(folderPath) {
  139. const commonNames = ['01.png', '00.png', '001.png', '0001.png', '1.png', '0.png'];
  140. try {
  141. const items = await readdir(folderPath);
  142. // 首先检查常见的帧文件名
  143. for (const name of commonNames) {
  144. if (items.includes(name)) {
  145. return {
  146. hasPreview: true,
  147. previewFile: name
  148. };
  149. }
  150. }
  151. // 检查是否有任何PNG文件
  152. const pngFiles = items.filter(item => item.toLowerCase().endsWith('.png'));
  153. if (pngFiles.length > 0) {
  154. // 按文件名排序,取第一个
  155. pngFiles.sort();
  156. return {
  157. hasPreview: true,
  158. previewFile: pngFiles[0]
  159. };
  160. }
  161. return {
  162. hasPreview: false,
  163. previewFile: null
  164. };
  165. } catch (error) {
  166. return {
  167. hasPreview: false,
  168. previewFile: null
  169. };
  170. }
  171. }
  172. /**
  173. * 获取指定分类的资源列表
  174. * @param {string} categoryDir - 分类文件夹名称(直接使用文件夹名称)
  175. * @returns {Promise<Array>} 资源列表
  176. */
  177. async getResourcesByCategory(categoryDir) {
  178. if (!categoryDir) {
  179. return [];
  180. }
  181. const categoryPath = path.join(this.storeRoot, categoryDir);
  182. try {
  183. // 检查目录是否存在
  184. await fs.access(categoryPath);
  185. const items = await readdir(categoryPath);
  186. const resources = [];
  187. for (const item of items) {
  188. const itemPath = path.join(categoryPath, item);
  189. const stats = await stat(itemPath);
  190. // 只处理文件夹
  191. if (stats.isDirectory()) {
  192. // 获取第一帧预览图
  193. const previewInfo = await this.getFirstFrame(itemPath);
  194. // 获取文件夹中的PNG文件数量
  195. const files = await readdir(itemPath);
  196. const pngCount = files.filter(f => f.toLowerCase().endsWith('.png')).length;
  197. const resourcePath = `${categoryDir}/${item}`;
  198. // 获取资源价格
  199. const price = await this.getResourcePrice(resourcePath);
  200. const resource = {
  201. name: item,
  202. category: categoryDir, // 分类名称就是文件夹名称
  203. categoryDir: categoryDir,
  204. previewUrl: previewInfo.hasPreview
  205. ? `/api/store/preview?category=${encodeURIComponent(categoryDir)}&folder=${encodeURIComponent(item)}&file=${encodeURIComponent(previewInfo.previewFile)}`
  206. : null,
  207. frameCount: pngCount,
  208. path: resourcePath,
  209. points: price // 添加价格信息
  210. };
  211. resources.push(resource);
  212. }
  213. }
  214. // 按名称排序
  215. resources.sort((a, b) => a.name.localeCompare(b.name));
  216. return resources;
  217. } catch (error) {
  218. console.error(`[StoreManager] 获取分类资源失败: ${categoryDir}`, error);
  219. return [];
  220. }
  221. }
  222. /**
  223. * 获取所有资源(可选的分类筛选)
  224. * @param {string|null} category - 分类名称,null 表示所有分类
  225. * @param {string|null} searchQuery - 搜索关键词
  226. * @returns {Promise<Array>} 资源列表
  227. */
  228. async getAllResources(category = null, searchQuery = null) {
  229. let allResources = [];
  230. if (category) {
  231. // 只获取指定分类
  232. allResources = await this.getResourcesByCategory(category);
  233. } else {
  234. // 获取所有分类
  235. const categories = await this.getCategories();
  236. for (const cat of categories) {
  237. const resources = await this.getResourcesByCategory(cat.dir);
  238. allResources = allResources.concat(resources);
  239. }
  240. }
  241. // 搜索筛选
  242. if (searchQuery && searchQuery.trim()) {
  243. const query = searchQuery.trim().toLowerCase();
  244. allResources = allResources.filter(resource =>
  245. resource.name.toLowerCase().includes(query)
  246. );
  247. }
  248. return allResources;
  249. }
  250. /**
  251. * 处理获取分类列表的 API 请求
  252. * @param {http.IncomingMessage} req - HTTP 请求对象
  253. * @param {http.ServerResponse} res - HTTP 响应对象
  254. */
  255. async handleGetCategories(req, res) {
  256. try {
  257. const categories = await this.getCategories();
  258. res.writeHead(200, {
  259. 'Content-Type': 'application/json',
  260. 'Access-Control-Allow-Origin': '*'
  261. });
  262. res.end(JSON.stringify({
  263. success: true,
  264. categories: categories,
  265. count: categories.length
  266. }));
  267. } catch (error) {
  268. console.error('[StoreManager] 获取分类列表失败:', error);
  269. res.writeHead(500, {
  270. 'Content-Type': 'application/json',
  271. 'Access-Control-Allow-Origin': '*'
  272. });
  273. res.end(JSON.stringify({
  274. success: false,
  275. error: error.message
  276. }));
  277. }
  278. }
  279. /**
  280. * 处理获取资源列表的 API 请求
  281. * @param {http.IncomingMessage} req - HTTP 请求对象
  282. * @param {http.ServerResponse} res - HTTP 响应对象
  283. */
  284. async handleGetResources(req, res) {
  285. const parsedUrl = require('url').parse(req.url, true);
  286. const { category, search } = parsedUrl.query;
  287. try {
  288. const resources = await this.getAllResources(category || null, search || null);
  289. res.writeHead(200, {
  290. 'Content-Type': 'application/json',
  291. 'Access-Control-Allow-Origin': '*'
  292. });
  293. res.end(JSON.stringify({
  294. success: true,
  295. resources: resources,
  296. count: resources.length
  297. }));
  298. } catch (error) {
  299. console.error('[StoreManager] 获取资源列表失败:', error);
  300. res.writeHead(500, {
  301. 'Content-Type': 'application/json',
  302. 'Access-Control-Allow-Origin': '*'
  303. });
  304. res.end(JSON.stringify({
  305. success: false,
  306. error: error.message
  307. }));
  308. }
  309. }
  310. /**
  311. * 处理预览图请求
  312. * @param {http.IncomingMessage} req - HTTP 请求对象
  313. * @param {http.ServerResponse} res - HTTP 响应对象
  314. */
  315. async handlePreview(req, res) {
  316. const parsedUrl = require('url').parse(req.url, true);
  317. const { category, folder, file } = parsedUrl.query;
  318. if (!category || !folder || !file) {
  319. res.writeHead(400, { 'Content-Type': 'text/plain' });
  320. res.end('Missing parameters');
  321. return;
  322. }
  323. try {
  324. const filePath = path.join(this.storeRoot, category, folder, file);
  325. // 安全检查:确保文件在商店目录内
  326. const normalizedPath = path.normalize(filePath);
  327. const normalizedRoot = path.normalize(this.storeRoot);
  328. if (!normalizedPath.startsWith(normalizedRoot)) {
  329. res.writeHead(403, { 'Content-Type': 'text/plain' });
  330. res.end('Access denied');
  331. return;
  332. }
  333. // 检查文件是否存在
  334. await fs.access(filePath);
  335. // 读取文件并返回
  336. const fileData = await fs.readFile(filePath);
  337. res.writeHead(200, {
  338. 'Content-Type': 'image/png',
  339. 'Access-Control-Allow-Origin': '*',
  340. 'Cache-Control': 'public, max-age=3600'
  341. });
  342. res.end(fileData);
  343. } catch (error) {
  344. console.error('[StoreManager] 获取预览图失败:', error);
  345. res.writeHead(404, { 'Content-Type': 'text/plain' });
  346. res.end('File not found');
  347. }
  348. }
  349. /**
  350. * 处理获取帧列表的 API 请求(用于播放动画)
  351. * @param {http.IncomingMessage} req - HTTP 请求对象
  352. * @param {http.ServerResponse} res - HTTP 响应对象
  353. */
  354. async handleGetFrames(req, res) {
  355. const parsedUrl = require('url').parse(req.url, true);
  356. const { category, folder } = parsedUrl.query;
  357. if (!category || !folder) {
  358. res.writeHead(400, {
  359. 'Content-Type': 'application/json',
  360. 'Access-Control-Allow-Origin': '*'
  361. });
  362. res.end(JSON.stringify({
  363. success: false,
  364. error: 'Missing parameters'
  365. }));
  366. return;
  367. }
  368. try {
  369. const folderPath = path.join(this.storeRoot, category, folder);
  370. // 安全检查
  371. const normalizedPath = path.normalize(folderPath);
  372. const normalizedRoot = path.normalize(this.storeRoot);
  373. if (!normalizedPath.startsWith(normalizedRoot)) {
  374. res.writeHead(403, {
  375. 'Content-Type': 'application/json',
  376. 'Access-Control-Allow-Origin': '*'
  377. });
  378. res.end(JSON.stringify({
  379. success: false,
  380. error: 'Access denied'
  381. }));
  382. return;
  383. }
  384. const files = await readdir(folderPath);
  385. // 过滤PNG文件并按数字排序
  386. const pngFiles = files
  387. .filter(file => file.toLowerCase().endsWith('.png'))
  388. .map(file => {
  389. let frameNum = null;
  390. // 先尝试匹配纯数字格式:00.png, 01.png
  391. let match = /^(\d+)\.png$/i.exec(file);
  392. if (match) {
  393. frameNum = parseInt(match[1], 10);
  394. } else {
  395. // 再尝试匹配文件名末尾的数字格式
  396. match = /(\d+)\.png$/i.exec(file);
  397. if (match) {
  398. frameNum = parseInt(match[1], 10);
  399. }
  400. }
  401. if (frameNum !== null) {
  402. return {
  403. frameNum: frameNum,
  404. fileName: file,
  405. url: `/api/store/frame?category=${encodeURIComponent(category)}&folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}`
  406. };
  407. }
  408. return null;
  409. })
  410. .filter(item => item !== null)
  411. .sort((a, b) => a.frameNum - b.frameNum);
  412. res.writeHead(200, {
  413. 'Content-Type': 'application/json',
  414. 'Access-Control-Allow-Origin': '*'
  415. });
  416. res.end(JSON.stringify({
  417. success: true,
  418. frames: pngFiles.map(item => item.frameNum),
  419. fileNames: pngFiles.map(item => item.fileName),
  420. frameUrls: pngFiles.map(item => item.url),
  421. maxFrame: pngFiles.length > 0 ? Math.max(...pngFiles.map(item => item.frameNum)) : 0
  422. }));
  423. } catch (error) {
  424. console.error('[StoreManager] 获取帧列表失败:', error);
  425. res.writeHead(500, {
  426. 'Content-Type': 'application/json',
  427. 'Access-Control-Allow-Origin': '*'
  428. });
  429. res.end(JSON.stringify({
  430. success: false,
  431. error: error.message
  432. }));
  433. }
  434. }
  435. /**
  436. * 处理帧图片请求
  437. * @param {http.IncomingMessage} req - HTTP 请求对象
  438. * @param {http.ServerResponse} res - HTTP 响应对象
  439. */
  440. async handleGetFrame(req, res) {
  441. const parsedUrl = require('url').parse(req.url, true);
  442. const { category, folder, file } = parsedUrl.query;
  443. if (!category || !folder || !file) {
  444. res.writeHead(400, { 'Content-Type': 'text/plain' });
  445. res.end('Missing parameters');
  446. return;
  447. }
  448. try {
  449. const filePath = path.join(this.storeRoot, category, folder, file);
  450. // 安全检查
  451. const normalizedPath = path.normalize(filePath);
  452. const normalizedRoot = path.normalize(this.storeRoot);
  453. if (!normalizedPath.startsWith(normalizedRoot)) {
  454. res.writeHead(403, { 'Content-Type': 'text/plain' });
  455. res.end('Access denied');
  456. return;
  457. }
  458. await fs.access(filePath);
  459. const fileData = await fs.readFile(filePath);
  460. res.writeHead(200, {
  461. 'Content-Type': 'image/png',
  462. 'Access-Control-Allow-Origin': '*',
  463. 'Cache-Control': 'public, max-age=3600'
  464. });
  465. res.end(fileData);
  466. } catch (error) {
  467. console.error('[StoreManager] 获取帧图片失败:', error);
  468. res.writeHead(404, { 'Content-Type': 'text/plain' });
  469. res.end('File not found');
  470. }
  471. }
  472. }
  473. module.exports = StoreManager;