'use strict'; const fs = require('fs'); const path = require('path'); /** * Egg.js 应用启动钩子 - AppBootHook * 表结构通过 migration/seeder 管理,不在启动时自动同步 */ class AppBootHook { constructor(app) { this.app = app; } async willReady() { // 数据库表结构由 migration 管理,不在启动时自动 sync this.app.logger.info('[AppBoot] 服务启动完成'); } /** * 进程就绪后,确保 app/public 下所有业务目录存在。 * * 关键背景(生产踩坑): * app/public/{works,answers,materials,tmp,...} 这类上传目录通常被 .gitignore 忽略, * 每次「整目录重新部署」(rsync --delete / 重新 clone) 都会被清空。 * 若运行中的代码某条写入路径恰好没有即时 mkdir,就会 ENOENT → 上传失败 / 下载 404, * 且旧版 auth 中间件会把这类磁盘异常伪装成「401 登录过期」误导排查。 * 此处统一在启动时把全部已知写入目录递归建好,作为永久兜底, * 与各处写入点的 fs.mkdirSync(recursive:true) 形成双保险。 */ async didReady() { const publicDir = path.join(this.app.baseDir, 'app/public'); const groups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi']; // 需要按「目录/组别」创建的(组别子目录) const grouped = ['works', 'answers', 'materials', 'templates']; // 需要直接创建(无组别)的 const flat = ['tmp', 'tmp/excel', 'tmp/zip', 'carousel', 'news']; const targets = []; for (const d of grouped) { for (const g of groups) targets.push(path.join(publicDir, d, g)); } for (const d of flat) targets.push(path.join(publicDir, d)); let created = 0; let existed = 0; for (const t of targets) { try { if (!fs.existsSync(t)) { fs.mkdirSync(t, { recursive: true }); created++; } else { existed++; } } catch (e) { this.app.logger.error('[AppBoot] 创建上传目录失败', { target: t, message: e.message }); } } this.app.logger.info( `[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})` ); // 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。 // 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire // timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。 // // 🔴 2026-09-21 修正(并发压测结论):原实现用 `os.cpus().length` 估 worker 数, // 这是**误估** —— 真实 worker 数由 egg-scripts 的 --workers / EGG_WORKERS 决定 // (生产显式设为 2,见 config.default.js「集群 worker 数」段),与 CPU 核数无必然关系。 // 误估会在 8 核机上把「2×20=40(安全)」误报成「8×20=160(越限)」, // 既制造假告警、也会掩盖真实的越限,使这条断言失去作用。 // 改为优先读 EGG_WORKERS(worker 进程继承 master 的环境变量),回退才用 CPU 数, // 并在日志中显式标注估算依据,便于排障时判断该行可信度。 try { const os = require('os'); const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20; const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT }); const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151; const explicitWorkers = parseInt(process.env.EGG_WORKERS, 10); const usingExplicit = Number.isFinite(explicitWorkers) && explicitWorkers > 0; // 🔴 2026-09-21 二次修正:egg-bin dev / test 会**显式传** `workers: 1` // (node_modules/egg-bin/scripts/start-cluster.js),此时即便环境里存在 // EGG_WORKERS(本地 dev 也会加载 deploy/.env.production,其中写着 2), // 也不是真实 worker 数 —— 真实值是 1。故必须先按 app.config.env 判定 // 单 worker 的运行形态,否则本地仍会打出一条假数字日志(实测:dev 下 // 真 worker=1 却报 2×20=40)。 const envName = (this.app.config && this.app.config.env) || ''; const singleWorkerEnv = envName === 'local' || envName === 'unittest'; let workers; let workersSource; if (singleWorkerEnv) { workers = 1; workersSource = `egg env=${envName}(egg-bin 强制单 app worker)`; } else if (usingExplicit) { workers = explicitWorkers; workersSource = `EGG_WORKERS=${explicitWorkers}(显式)`; } else { workers = os.cpus().length; workersSource = `os.cpus()=${workers}(EGG_WORKERS 未设置,为回退估算)`; } const estimated = workers * poolMax; if (estimated > dbMax) { this.app.logger.warn( `[AppBoot][pool] 预估总连接数 ${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX / EGG_WORKERS` ); } else { this.app.logger.info( `[AppBoot][pool] 连接池容量 OK:${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) ≤ MySQL max_connections=${dbMax}` ); } } catch (e) { this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message)); } // ----------------------------------------------------------------- // 节点备份调度器:进程启动 + hydrate 未到期的三节点计划 // ----------------------------------------------------------------- // 需求:考试开始前 30 分钟 / 考试结束后 10 分钟 / 成绩发布后 触发生产库备份。 // 实现:app/lib/nodeBackupScheduler.js(进程内 setTimeout,无额外常驻进程 —— // 用户裁定①「巡检脚本会占用额外内存」的替代方案)。 // ⛔ 非生产环境调度器构造时即 enabled=false ⇒ 这里只在生产实际排计划。 try { const { NodeBackupScheduler } = require('./app/lib/nodeBackupScheduler'); const scheduler = new NodeBackupScheduler(this.app); // 挂到 app 上:service/controller 通过 app.nodeBackupScheduler 访问(单例) this.app.nodeBackupScheduler = scheduler; if (scheduler.enabled) { const rows = await this.app.model.CompetitionTime.findAll({ attributes: [ 'group', 'start_time', 'end_time' ], raw: true, }); await scheduler.hydrate(rows); } } catch (e) { this.app.logger.error('[AppBoot][NodeBackup] 调度器初始化失败(不影响主服务):' + (e && e.message)); } } } module.exports = AppBootHook;