Files
competition-website/app.js
qubiaobiao 5392345078 feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复
成绩导入(R173 落地 → R174 改为三小问四列制)
- app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件)
  · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值
  · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE
  · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝
  · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号
- app/service/bigQuestion.js
  · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持")
  · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores
  · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores
- app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence)
- app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores
  · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载)

节点备份调度器(R168/R170)
- app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间
  · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻
  · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源)
- config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地
  (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导)
- app.js:启动构造单例 + hydrate 未到期计划

缺陷修复
- app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理
- app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount)
- app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注

测试
- .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js,
  使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00

142 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;