From 69d3592abad5fc7e4d889c87b221c7852d87c5b3 Mon Sep 17 00:00:00 2001 From: JohnserfSeed <40727745+Johnserf-Seed@users.noreply.github.com> Date: Sat, 24 Jan 2026 01:54:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E9=94=99=E8=AF=AF=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E8=A7=A3=E5=AF=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题修复: - 添加请求锁机制防止并发访问同一页面导致的竞态条件 - 添加页面健康检查函数,在请求前验证页面有效性 - 添加自动恢复机制,页面失效时自动重置浏览器 - 为 page.evaluate 添加超时保护,根据文件大小动态计算超时时间 新功能: - 实现 PagePool 页面池管理器,支持并发处理多个解密请求 - 新增 POST /api/decrypt-concurrent 并发解密接口 - 新增 GET /api/pool-status 页面池状态查询接口 - 支持通过 POOL_SIZE 环境变量配置页面池大小(默认3) Co-Authored-By: Claude Opus 4.5 --- api-service/docker-compose.yml | 1 + api-service/server.js | 606 ++++++++++++++++++++++++++++----- 2 files changed, 526 insertions(+), 81 deletions(-) diff --git a/api-service/docker-compose.yml b/api-service/docker-compose.yml index 3c92219..446b53c 100644 --- a/api-service/docker-compose.yml +++ b/api-service/docker-compose.yml @@ -16,6 +16,7 @@ services: environment: - NODE_ENV=production - PORT=3000 + - POOL_SIZE=3 restart: unless-stopped diff --git a/api-service/server.js b/api-service/server.js index a1c483f..4f198b3 100644 --- a/api-service/server.js +++ b/api-service/server.js @@ -34,8 +34,321 @@ app.use('/wechat_files', express.static(path.join(__dirname, 'wechat_files'))); let browser = null; let page = null; let server = null; +let isProcessing = false; // 请求锁 +let requestQueue = []; // 请求队列 const getWorkerUrl = () => `http://localhost:${PORT}/worker.html`; +// 页面池配置 +const POOL_SIZE = parseInt(process.env.POOL_SIZE) || 3; // 默认3个并发页面 +let pagePool = null; + +/** + * 页面池管理器 - 支持并发处理 + */ +class PagePool { + constructor(browser, size) { + this.browser = browser; + this.size = size; + this.pages = []; // 所有页面 + this.available = []; // 可用页面队列 + this.waiting = []; // 等待获取页面的请求 + this.initializing = false; + } + + /** + * 初始化页面池 + */ + async initialize() { + if (this.initializing) return; + this.initializing = true; + + console.log(`🏊 初始化页面池 (大小: ${this.size})...`); + + for (let i = 0; i < this.size; i++) { + try { + const pg = await this._createPage(i); + this.pages.push(pg); + this.available.push(pg); + console.log(` ✅ 页面 ${i + 1}/${this.size} 已就绪`); + } catch (error) { + console.error(` ❌ 页面 ${i + 1}/${this.size} 创建失败:`, error.message); + } + } + + console.log(`🏊 页面池初始化完成,可用页面: ${this.available.length}/${this.size}`); + this.initializing = false; + } + + /** + * 创建单个页面 + */ + async _createPage(index) { + const pg = await this.browser.newPage(); + pg._poolIndex = index; + + await pg.goto(getWorkerUrl()); + + // 等待 WASM 加载 + await pg.waitForFunction( + () => typeof Module !== 'undefined' && typeof Module.WxIsaac64 !== 'undefined', + { timeout: 60000 } + ); + + return pg; + } + + /** + * 获取一个可用页面(如果没有则等待) + */ + async acquire(timeout = 300000) { + // 如果有可用页面,直接返回 + if (this.available.length > 0) { + const pg = this.available.shift(); + console.log(`🔓 获取页面 #${pg._poolIndex},剩余可用: ${this.available.length}`); + return pg; + } + + // 没有可用页面,加入等待队列 + console.log(`⏳ 页面池已满,等待中... (等待队列: ${this.waiting.length + 1})`); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const index = this.waiting.findIndex(w => w.resolve === resolve); + if (index > -1) { + this.waiting.splice(index, 1); + } + reject(new Error(`获取页面超时 (${timeout}ms),请稍后重试`)); + }, timeout); + + this.waiting.push({ + resolve: (pg) => { + clearTimeout(timer); + resolve(pg); + }, + reject + }); + }); + } + + /** + * 归还页面到池中 + */ + release(pg) { + // 检查页面是否有效 + if (!pg || pg.isClosed()) { + console.log(`⚠️ 页面 #${pg?._poolIndex} 已关闭,将重建`); + this._rebuildPage(pg?._poolIndex); + return; + } + + // 如果有等待的请求,直接给它 + if (this.waiting.length > 0) { + const waiter = this.waiting.shift(); + console.log(`🔄 页面 #${pg._poolIndex} 直接分配给等待的请求`); + waiter.resolve(pg); + return; + } + + // 否则放回可用队列 + this.available.push(pg); + console.log(`🔙 归还页面 #${pg._poolIndex},可用: ${this.available.length}`); + } + + /** + * 重建损坏的页面 + */ + async _rebuildPage(index) { + if (index === undefined) return; + + try { + console.log(`🔄 重建页面 #${index}...`); + const pg = await this._createPage(index); + this.pages[index] = pg; + + // 如果有等待的请求,直接给它 + if (this.waiting.length > 0) { + const waiter = this.waiting.shift(); + waiter.resolve(pg); + } else { + this.available.push(pg); + } + console.log(`✅ 页面 #${index} 重建完成`); + } catch (error) { + console.error(`❌ 页面 #${index} 重建失败:`, error.message); + } + } + + /** + * 获取池状态 + */ + getStatus() { + return { + total: this.size, + available: this.available.length, + inUse: this.size - this.available.length, + waiting: this.waiting.length + }; + } + + /** + * 关闭所有页面 + */ + async close() { + for (const pg of this.pages) { + try { + if (pg && !pg.isClosed()) { + await pg.close(); + } + } catch (e) { + // 忽略关闭错误 + } + } + this.pages = []; + this.available = []; + } +} + +/** + * 使用页面池执行任务 + */ +async function withPoolPage(fn) { + if (!pagePool) { + throw new Error('页面池未初始化'); + } + + const pg = await pagePool.acquire(); + try { + return await fn(pg); + } catch (error) { + // 如果是页面关闭错误,标记页面需要重建 + if (error.message && error.message.includes('closed')) { + console.log(`⚠️ 页面 #${pg._poolIndex} 执行出错,将重建`); + pagePool._rebuildPage(pg._poolIndex); + throw error; + } + throw error; + } finally { + pagePool.release(pg); + } +} + +/** + * 带超时的 page.evaluate (用于页面池) + */ +async function evaluateWithTimeoutOnPage(pg, pageFunction, arg, timeout = 30000) { + return Promise.race([ + pg.evaluate(pageFunction, arg), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`操作超时 (${timeout}ms)`)), timeout) + ) + ]); +} + +/** + * 检查页面是否健康可用 + */ +async function isPageHealthy() { + if (!browser || !page) { + return false; + } + try { + // 尝试执行简单操作来检查页面是否有效(带5秒超时) + await Promise.race([ + page.evaluate(() => true), + new Promise((_, reject) => + setTimeout(() => reject(new Error('健康检查超时')), 5000) + ) + ]); + return true; + } catch (error) { + console.log('⚠️ 页面健康检查失败:', error.message); + return false; + } +} + +/** + * 重置浏览器和页面 + */ +async function resetBrowser() { + console.log('🔄 重置浏览器...'); + + // 先尝试关闭旧的浏览器 + if (browser) { + try { + await browser.close(); + } catch (e) { + // 忽略关闭错误 + } + } + + browser = null; + page = null; + + // 重新初始化 + await initBrowser(); + console.log('✅ 浏览器重置完成'); +} + +/** + * 确保页面可用,如果不可用则重置 + */ +async function ensurePageReady() { + if (!await isPageHealthy()) { + await resetBrowser(); + } +} + +/** + * 带锁的执行函数,确保同一时间只有一个请求在使用页面 + */ +async function withPageLock(fn) { + return new Promise((resolve, reject) => { + const execute = async () => { + isProcessing = true; + try { + await ensurePageReady(); + const result = await fn(); + resolve(result); + } catch (error) { + // 如果是页面关闭相关错误,标记需要重置 + if (error.message && error.message.includes('closed')) { + console.log('⚠️ 检测到页面已关闭,将在下次请求时重置'); + browser = null; + page = null; + } + reject(error); + } finally { + isProcessing = false; + // 处理队列中的下一个请求 + if (requestQueue.length > 0) { + const next = requestQueue.shift(); + next(); + } + } + }; + + if (isProcessing) { + // 如果正在处理,加入队列等待 + console.log('⏳ 请求排队中,当前队列长度:', requestQueue.length + 1); + requestQueue.push(execute); + } else { + execute(); + } + }); +} + +/** + * 带超时的 page.evaluate 包装函数 + */ +async function evaluateWithTimeout(pageFunction, arg, timeout = 30000) { + return Promise.race([ + page.evaluate(pageFunction, arg), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`操作超时 (${timeout}ms)`)), timeout) + ) + ]); +} + /** * 初始化 Playwright 浏览器 */ @@ -141,8 +454,10 @@ app.get('/api/info', (req, res) => { github: 'https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption', endpoints: { health: 'GET /health', - decrypt: 'POST /api/decrypt', - keystream: 'POST /api/keystream' + poolStatus: 'GET /api/pool-status', + keystream: 'POST /api/keystream', + decrypt: 'POST /api/decrypt (串行)', + decryptConcurrent: 'POST /api/decrypt-concurrent (并发)' } }); }); @@ -166,43 +481,46 @@ app.post('/api/keystream', async (req, res) => { }); } - // 确保浏览器已初始化 - if (!page) { - await initBrowser(); - } - console.log(`🔑 生成密钥流: decode_key=${decode_key}, format=${format}`); - // 调用浏览器中的 RPC 方法 - const startTime = Date.now(); - const keystreamBase64 = await page.evaluate(async (key) => { - return await window.generateKeystream(key); - }, decode_key); + // 使用锁机制确保串行处理 + const result = await withPageLock(async () => { + const startTime = Date.now(); - const duration = Date.now() - startTime; + // 调用浏览器中的 RPC 方法(带超时保护) + const keystreamBase64 = await evaluateWithTimeout( + async (key) => await window.generateKeystream(key), + decode_key, + 30000 + ); - // 格式转换 - let keystream; - if (format === 'hex') { - const binary = atob(keystreamBase64); - keystream = Array.from(binary, c => - c.charCodeAt(0).toString(16).padStart(2, '0') - ).join(''); - } else { - keystream = keystreamBase64; - } + const duration = Date.now() - startTime; - console.log(`✅ 密钥流生成成功,耗时 ${duration}ms`); + // 格式转换 + let keystream; + if (format === 'hex') { + const binary = atob(keystreamBase64); + keystream = Array.from(binary, c => + c.charCodeAt(0).toString(16).padStart(2, '0') + ).join(''); + } else { + keystream = keystreamBase64; + } - res.json({ - decode_key, - keystream, - format, - size: 131072, - duration_ms: duration, - timestamp: new Date().toISOString() + console.log(`✅ 密钥流生成成功,耗时 ${duration}ms`); + + return { + decode_key, + keystream, + format, + size: 131072, + duration_ms: duration, + timestamp: new Date().toISOString() + }; }); + res.json(result); + } catch (error) { console.error('❌ 密钥流生成失败:', error.message); res.status(500).json({ error: error.message }); @@ -226,57 +544,62 @@ app.post('/api/decrypt', upload.single('video'), async (req, res) => { return res.status(400).json({ error: '缺少视频文件' }); } - // 确保浏览器已初始化 - if (!page) { - await initBrowser(); - } - console.log(`📹 解密请求:`); console.log(` decode_key: ${decode_key}`); console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`); - const startTime = Date.now(); + // 根据文件大小动态设置超时时间(基础30秒 + 每MB额外1秒) + const fileSizeMB = videoFile.size / 1024 / 1024; + const timeout = Math.max(60000, 30000 + fileSizeMB * 1000); - // 步骤 1: 生成密钥流 - console.log(' [1/3] 生成密钥流...'); - const keystreamBase64 = await page.evaluate(async (key) => { - return await window.generateKeystream(key); - }, decode_key); + // 使用锁机制确保串行处理 + const result = await withPageLock(async () => { + const startTime = Date.now(); - // 步骤 2: 转换视频为 Base64 - console.log(' [2/3] 编码视频数据...'); - const encryptedBase64 = videoFile.buffer.toString('base64'); + // 步骤 1: 生成密钥流 + console.log(' [1/3] 生成密钥流...'); + const keystreamBase64 = await evaluateWithTimeout( + async (key) => await window.generateKeystream(key), + decode_key, + 30000 + ); - // 步骤 3: 在浏览器中执行解密 - console.log(' [3/3] 执行 XOR 解密...'); - const decryptedBase64 = await page.evaluate( - (params) => { - return window.decryptVideo(params.encrypted, params.keystream); - }, - { encrypted: encryptedBase64, keystream: keystreamBase64 } - ); + // 步骤 2: 转换视频为 Base64 + console.log(' [2/3] 编码视频数据...'); + const encryptedBase64 = videoFile.buffer.toString('base64'); - // Base64 → Buffer - const decrypted = Buffer.from(decryptedBase64, 'base64'); + // 步骤 3: 在浏览器中执行解密(大文件需要更长超时) + console.log(' [3/3] 执行 XOR 解密...'); + const decryptedBase64 = await evaluateWithTimeout( + (params) => window.decryptVideo(params.encrypted, params.keystream), + { encrypted: encryptedBase64, keystream: keystreamBase64 }, + timeout + ); - // 验证 MP4 签名 - const ftyp = decrypted.toString('utf8', 4, 8); - if (ftyp !== 'ftyp') { - throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key'); - } + // Base64 → Buffer + const decrypted = Buffer.from(decryptedBase64, 'base64'); - const duration = Date.now() - startTime; - console.log(`✅ 解密成功,耗时 ${duration}ms`); + // 验证 MP4 签名 + const ftyp = decrypted.toString('utf8', 4, 8); + if (ftyp !== 'ftyp') { + throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key'); + } + + const duration = Date.now() - startTime; + console.log(`✅ 解密成功,耗时 ${duration}ms`); + + return { decrypted, duration }; + }); // 返回解密后的视频 res.set({ 'Content-Type': 'video/mp4', - 'Content-Length': decrypted.length, + 'Content-Length': result.decrypted.length, 'Content-Disposition': `attachment; filename="decrypted_${Date.now()}.mp4"`, - 'X-Decrypt-Duration': duration + 'X-Decrypt-Duration': result.duration }); - res.send(decrypted); + res.send(result.decrypted); } catch (error) { console.error('❌ 解密失败:', error.message); @@ -284,6 +607,112 @@ app.post('/api/decrypt', upload.single('video'), async (req, res) => { } }); +/** + * POST /api/decrypt-concurrent + * 并发解密视频(使用页面池) + */ +app.post('/api/decrypt-concurrent', upload.single('video'), async (req, res) => { + try { + const { decode_key } = req.body; + const videoFile = req.file; + + if (!decode_key) { + return res.status(400).json({ error: '缺少 decode_key 参数' }); + } + + if (!videoFile) { + return res.status(400).json({ error: '缺少视频文件' }); + } + + if (!pagePool) { + return res.status(503).json({ error: '页面池未初始化,请稍后重试' }); + } + + console.log(`📹 [并发] 解密请求:`); + console.log(` decode_key: ${decode_key}`); + console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`); + console.log(` 页面池状态: ${JSON.stringify(pagePool.getStatus())}`); + + // 根据文件大小动态设置超时时间 + const fileSizeMB = videoFile.size / 1024 / 1024; + const timeout = Math.max(60000, 30000 + fileSizeMB * 1000); + + // 使用页面池执行(支持并发) + const result = await withPoolPage(async (pg) => { + const startTime = Date.now(); + + // 步骤 1: 生成密钥流 + console.log(` [页面#${pg._poolIndex}] [1/3] 生成密钥流...`); + const keystreamBase64 = await evaluateWithTimeoutOnPage( + pg, + async (key) => await window.generateKeystream(key), + decode_key, + 30000 + ); + + // 步骤 2: 转换视频为 Base64 + console.log(` [页面#${pg._poolIndex}] [2/3] 编码视频数据...`); + const encryptedBase64 = videoFile.buffer.toString('base64'); + + // 步骤 3: 在浏览器中执行解密 + console.log(` [页面#${pg._poolIndex}] [3/3] 执行 XOR 解密...`); + const decryptedBase64 = await evaluateWithTimeoutOnPage( + pg, + (params) => window.decryptVideo(params.encrypted, params.keystream), + { encrypted: encryptedBase64, keystream: keystreamBase64 }, + timeout + ); + + // Base64 → Buffer + const decrypted = Buffer.from(decryptedBase64, 'base64'); + + // 验证 MP4 签名 + const ftyp = decrypted.toString('utf8', 4, 8); + if (ftyp !== 'ftyp') { + throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key'); + } + + const duration = Date.now() - startTime; + console.log(`✅ [页面#${pg._poolIndex}] 解密成功,耗时 ${duration}ms`); + + return { decrypted, duration }; + }); + + // 返回解密后的视频 + res.set({ + 'Content-Type': 'video/mp4', + 'Content-Length': result.decrypted.length, + 'Content-Disposition': `attachment; filename="decrypted_${Date.now()}.mp4"`, + 'X-Decrypt-Duration': result.duration + }); + + res.send(result.decrypted); + + } catch (error) { + console.error('❌ [并发] 解密失败:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/pool-status + * 获取页面池状态 + */ +app.get('/api/pool-status', (req, res) => { + if (!pagePool) { + return res.status(503).json({ + error: '页面池未初始化', + status: null + }); + } + + res.json({ + status: 'ok', + pool: pagePool.getStatus(), + timestamp: new Date().toISOString() + }); +}); + /** * 404 处理 */ @@ -294,8 +723,10 @@ app.use((req, res) => { available: [ 'GET /', 'GET /health', + 'GET /api/pool-status', 'POST /api/keystream', - 'POST /api/decrypt' + 'POST /api/decrypt', + 'POST /api/decrypt-concurrent' ] }); }); @@ -333,16 +764,23 @@ app.use((err, req, res, next) => { console.log(`🌐 访问地址: http://localhost:${PORT}\n`); try { - // 初始化浏览器 + // 初始化浏览器(用于串行接口) await initBrowser(); + // 初始化页面池(用于并发接口) + pagePool = new PagePool(browser, POOL_SIZE); + await pagePool.initialize(); + console.log('\n✅ 服务完全就绪'); console.log('\n📚 API 端点:'); - console.log(' GET / 服务信息'); - console.log(' GET /health 健康检查'); - console.log(' POST /api/keystream 生成密钥流'); - console.log(' POST /api/decrypt 解密视频'); - console.log('\n🎭 使用 Playwright 浏览器执行 WASM'); + console.log(' GET / 服务信息'); + console.log(' GET /health 健康检查'); + console.log(' GET /api/pool-status 页面池状态'); + console.log(' POST /api/keystream 生成密钥流'); + console.log(' POST /api/decrypt 解密视频 (串行)'); + console.log(' POST /api/decrypt-concurrent 解密视频 (并发)'); + console.log(`\n🏊 页面池大小: ${POOL_SIZE} (可通过 POOL_SIZE 环境变量配置)`); + console.log('🎭 使用 Playwright 浏览器执行 WASM'); console.log(' 100% 兼容微信官方模块\n'); } catch (error) { console.error('❌ 浏览器初始化失败:', error); @@ -357,18 +795,24 @@ app.use((err, req, res, next) => { })(); // 优雅关闭 -process.on('SIGTERM', async () => { +async function gracefulShutdown() { console.log('\n👋 正在关闭服务...'); - if (browser) { - await browser.close(); - } - process.exit(0); -}); -process.on('SIGINT', async () => { - console.log('\n👋 正在关闭服务...'); + // 关闭页面池 + if (pagePool) { + console.log(' 关闭页面池...'); + await pagePool.close(); + } + + // 关闭浏览器 if (browser) { + console.log(' 关闭浏览器...'); await browser.close(); } + + console.log('✅ 服务已关闭'); process.exit(0); -}); +} + +process.on('SIGTERM', gracefulShutdown); +process.on('SIGINT', gracefulShutdown);