Files
competition-website/app/lib/nodeBackupScheduler.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

844 lines
35 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';
/**
* 节点备份调度器(Node 内置定时器)—— 考试开始前 / 考试结束后 / 成绩发布后
* ============================================================================
* 需求来源(用户 2026-10-16提出):「系统在考试开始前,考试结束后,成绩发布后
* 这三个节点应该执行数据备份」。
*
* ============================ 为什么不用巡检脚本 ============================
* 用户裁定:巡检脚本(cron 每分钟扫状态跃迁)会占用额外内存 ⇒ 改用**后端钩子**。
* 本模块就是该钩子的落点:**进程内 Node 定时器**,不新增常驻进程、不新增轮询。
* 额外内存开销 = 最多 8 个 setTimeout 句柄(4 组 × 2 节点),可忽略。
*
* ============================ 三节点的时间口径 ============================
* ① before-start :考试开始前 30 分钟(缓冲,留出 dump 落盘时间)
* ② after-end :考试结束后 10 分钟(缓冲,等最后一批提交落库)
* ③ score-published:成绩发布**瞬间**触发(发布态本身不可逆,见下方「撤销」说明)
*
* ⚠️ 「撤销发布」不作为节点(用户裁定):撤销发生时备份到的是**已经发布过的数据**,
* 对「回退到未发布态」这个诉求毫无意义 ⇒ 只在 publish === true 时触发。
*
* ============================ 幂等:为什么用「计划键」而不是「组别」 ============================
* 定时器只活在内存里,进程重启即丢失 ⇒ 必须在 `didReady` 重新 hydrate。
* 若 hydrate 时无条件按组别重排,会出现「重启一次 = 重复备份一次」。
* 故幂等键 = `${group}:${node}:${fireAtISO}` —— **时间点本身就是身份**:
* · 管理员把考试时间改成新值 ⇒ fireAt 变了 ⇒ 是新计划,理应再备一次 ✅
* · 进程重启 hydrate 出同一 fireAt ⇒ 键相同 ⇒ 覆盖同一计划,不重复 ❌→ ✅ 不触发
* 另有一层持久化去重:`system_configs` 的 `node_backup_fired_<key>`
* (哈希后落键),用于「重启后 fireAt 已在过去」的场景下仍不重复备份。
*
* ============================ 🔴 生产隔离铁律 ============================
* 本模块**只在生产环境启用**(`config.isProduction === true`)。
* 非生产一律 `enabled = false` 且 `schedule()` 直接返回空 —— 因为备份目标是
* **生产库**(用户裁定②),本地/测试环境触发它等于让本机进程去 dump 生产库。
* 这与 config.default.js 里 `DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', ...]`
* 是同一条纪律的延伸:非生产进程**不得持有生产连接凭据**。
*
* ============================ 备份执行 ============================
* 不自己实现 dump ⇒ **复用** `scripts/backup_db.py`(同一份 mysqldump + gzip +
* 完整性校验 + 保留期治理逻辑,避免两套备份实现的校验口径漂移)。
* 通过 `child_process.spawn` 异步执行,**绝不阻塞业务请求**:
* · setTimes 写完库立即返回,备份在后台跑
* · publishScores 同理
* 失败只落日志 + 状态文件 + 告警日志,**绝不 throw 到调用方** ——
* 备份失败不能把「设置考试时间」「发布成绩」这两个管理动作搞挂。
*
* ============================ 🔴 多 worker 必须处理 ============================
* 🔴 R168 修正(生产取证):生产 `EGG_WORKERS=2`(deploy/.env.production:77)⇒ Egg 起
* **2 个 app worker 进程**,而 `didReady` 在**每个 worker 里各跑一次** ⇒ 原实现每个
* worker 各建一个调度器、各自 hydrate 出同一批计划 ⇒ **到点两个进程各 spawn 一次
* mysqldump**(重复备份 + 双倍 IO + 竞态写同一目录)。
* ⚠️ 本地 dev 单 worker(egg-bin 强制)⇒ 测试完全看不出来 —— 又一个
* 「测试环境掩盖生产缺陷」的实例,故判据必须显式模拟多 worker。
* 同理 `setTimes` 只落到「处理该请求的那一个 worker」⇒ 另一个 worker 的旧计划
* **不会被清除** ⇒ 改期后残留定时器仍会在旧时刻误触发一次。
*
* 两道防线(缺一不可):
* ① **只在 master 进程排程**(`isMaster` 门禁)—— 从根上消除重复源头;
* ② **跨进程文件锁**(`_fire` 内)—— 兜底:worker 数配置漂移 / 时序异常时
* 也不会并发 dump。
* ============================================================================
*/
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const crypto = require('crypto');
// ---- 三节点常量 ----
const NODE_BEFORE_START = 'before-start';
const NODE_AFTER_END = 'after-end';
const NODE_SCORE_PUBLISHED = 'score-published';
/**
* 计划键:节点身份 = 组别 + 节点 + 触发时刻
* @param {string} group 组别
* @param {string} node 节点常量
* @param {Date} fireAt 触发时刻
* @returns {string}
*/
function planKey(group, node, fireAt) {
return `${group}:${node}:${new Date(fireAt).toISOString()}`;
}
// ===========================================================================
// 结果台账(P1「生产备份静默失败防护」的落地)
// ===========================================================================
// 🔴 为什么必须有台账,而不是「只写 logger」:
// 台账 P1 的原始形态就是「失败只落日志,没人看 ⇒ 直到需要恢复时才发现备份
// 根本不存在」。logger 是**运维异步消费**的通道,管理后台读不到它。
// 故这里额外维护一份 **append-only JSONL**(`node-backup-history.jsonl`),
// 管理端状态接口直接读它 ⇒ 失败在设置页立刻可见,不必 ssh 翻日志。
//
// 为什么用 JSONL 而不是一个 status.json:
// · 生产 EGG_WORKERS=2,多进程并发写同一状态文件必定互相覆盖(读-改-写竞态);
// · 单行 append + O_APPEND 是原子写(< PIPE_BUF),多进程安全;
// · 天然保留**历史**,才能判「漏备」「连续失败」这类需要时间序列的结论。
// 体量估算:4 组 × 2 节点 + 少量即时节点 ≈ 每天数十行(<10KB/天),
// 90 天后仍 <1MB ⇒ 暂不做轮转(轮转需 mv 归档,等真的有量再说)。
const HISTORY_FILE_NAME = 'node-backup-history.jsonl';
/** 台账文件路径 */
function historyPath(logDir) {
return path.join(logDir, HISTORY_FILE_NAME);
}
/**
* 读取台账尾部 N 条(**最新在前**)
*
* ⚠️ 单行损坏(进程在 append 中途被 kill)只跳过该行,绝不让整个读取失败 ——
* 台账读不出来等于把可见性做回到「只能翻日志」。
* @param {string} logDir
* @param {number} limit
* @returns {Array<object>}
*/
function readHistory(logDir, limit) {
const n = Math.max(1, Math.min(Number(limit) || 50, 500));
let text = '';
try {
text = fs.readFileSync(historyPath(logDir), 'utf8');
} catch (e) {
return []; // 文件不存在 = 从未触发过,是正常状态而非错误
}
const lines = String(text).split('\n');
const out = [];
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
const s = lines[i].trim();
if (!s) continue;
try { out.push(JSON.parse(s)); } catch (e) { /* 半行/损坏行跳过 */ }
}
return out;
}
/**
* 列出备份产物(按 mtime 倒序)
* @param {string} outDir
* @param {number} limit
*/
function listArtifacts(outDir, limit) {
const n = Math.max(1, Math.min(Number(limit) || 10, 100));
const items = [];
try {
for (const name of fs.readdirSync(outDir)) {
if (!/\.(sql|gz|tgz|zip)$/i.test(name)) continue;
try {
const st = fs.statSync(path.join(outDir, name));
if (!st.isFile()) continue;
items.push({
name,
sizeMB: Math.round((st.size / 1048576) * 100) / 100,
mtime: new Date(st.mtimeMs).toISOString(),
});
} catch (e) { /* 并发被清理 ⇒ 跳过 */ }
}
} catch (e) {
return [];
}
items.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : 0));
return items.slice(0, n);
}
/**
* 健康判定(**纯函数**:不读磁盘、不看时钟以外的外部状态 ⇒ 可直接单测)
*
* 判据设计原则:每条都对应一种「备份实际没做,但界面上看不出来」的真实故障形态,
* 而不是泛泛的「有错就红」:
* H1 PROD_DISABLED 生产环境调度器没启用 ⇒ 三节点备份永远不会执行
* H2 LAST_RUN_FAILED 最近一次有结果执行是失败(含连续失败)⇒ 需人工介入
* H3 MISSED_RUN 计划已到点超宽限期,却既无成功也无失败也无跳过记录
* ⇒ 定时器丢了(进程重启/hydrate 漏了/kill -9),最危险
* H4 NO_ARTIFACT 台账说成功,但备份目录里一个产物都没有 ⇒ 假成功
* H5 NO_PLAN 已启用却没有任何待触发计划 ⇒ 提示(可能是善意状态)
*
* ⚠️ 非生产环境不报 H1:本地/测试本来就不该排生产备份,报红会造成告警疲劳
* (与 backup_daily.sh「不比对比报警」同一条纪律)。
*
* @param {object} input
* @param {boolean} input.enabled
* @param {boolean} input.isProduction
* @param {Array} input.plans [{ group, node, fireAt, key }]
* @param {Array} input.history readHistory() 的结果(最新在前)
* @param {number} input.artifactCount
* @param {number} [input.now]
* @returns {{level:string,reasons:Array,stats:object}}
*/
function evaluateHealth(input) {
const now = Number.isFinite(Number(input && input.now)) ? Number(input.now) : Date.now();
const GRACE_MS = 10 * 60 * 1000; // 到点后 10 分钟内不判漏(dump 可能正在跑)
const enabled = !!(input && input.enabled === true);
const isProduction = !!(input && input.isProduction === true);
const plans = (input && Array.isArray(input.plans)) ? input.plans : [];
const history = (input && Array.isArray(input.history)) ? input.history : [];
const artifactCount = Number(input && input.artifactCount) || 0;
const reasons = [];
const add = (level, code, message) => reasons.push({ level, code, message });
// ---- H1 生产未启用 ----
if (isProduction && !enabled) {
add('error', 'PROD_DISABLED', '生产环境节点备份调度器未启用:考试前/考试后/发布后三节点均不会执行');
}
// ---- H2 最近有结果的执行是失败 ----
let consecutiveFails = 0;
let lastFailMsg = '';
for (const h of history) {
if (!h || typeof h !== 'object') continue;
if (h.event === 'fail') {
consecutiveFails++;
if (!lastFailMsg) lastFailMsg = h.message || h.stage || '';
} else if (h.event === 'success') {
break; // 有成功兜底 ⇒ 连续失败链断裂
}
// start / plan / lock-skip / invalid-skip 不参与连续失败计数
}
if (consecutiveFails > 0) {
add('error', 'LAST_RUN_FAILED',
`最近 ${consecutiveFails} 次节点备份执行失败${lastFailMsg ? '(' + lastFailMsg + ')' : ''} ⇒ 需人工介入`);
}
// ---- H3 已到点却无任何结果(漏备)----
const settled = new Set();
for (const h of history) {
if (!h || !h.key) continue;
if (h.event === 'success' || h.event === 'fail' || h.event === 'lock-skip' || h.event === 'invalid-skip') {
settled.add(h.key);
}
}
const seenPlan = new Set();
const missed = [];
for (const h of history) {
if (!h || h.event !== 'plan' || !h.key) continue;
if (seenPlan.has(h.key)) continue;
seenPlan.add(h.key);
const t = new Date(h.fireAt).getTime();
if (!Number.isFinite(t)) continue;
if (t + GRACE_MS > now) continue; // 还没到点 / 刚过点,仍在宽限期内
if (settled.has(h.key)) continue;
missed.push(h.key);
}
if (missed.length > 0) {
add('error', 'MISSED_RUN',
`有 ${missed.length} 个已到点计划既无成功也无失败记录(疑似漏备):${missed.slice(0, 3).join(' / ')}`);
}
// ---- H4 假成功 ----
if (history.some(h => h && h.event === 'success') && artifactCount === 0) {
add('error', 'NO_ARTIFACT', '台账记录过备份成功,但备份目录中没有任何产物(疑似假成功或产物被清理)');
}
// ---- H5 无待触发计划 ----
if (enabled && plans.length === 0) {
add('warn', 'NO_PLAN', '当前没有任何待触发计划:四组均未设置考试时间,或时间节点均已过期(重启后不会补备)');
}
const level = reasons.some(r => r.level === 'error') ? 'error'
: reasons.some(r => r.level === 'warn') ? 'warn' : 'ok';
return {
level,
reasons,
stats: {
consecutiveFails,
missedCount: missed.length,
artifactCount,
planCount: plans.length,
historyCount: history.length,
},
};
}
/**
* 计算某组某节点的触发时刻
*
* ⚠️ 口径纪律:
* · before-start = start_time − 30min,**不得**早于「现在」(管理员可能把开始
* 时间设成过去时间 ⇒ 减完仍在过去 ⇒ 必须钳到 now,否则会立刻触发一次
* 「已经开考」的备份,语义错误且浪费 IO)。
* · after-end = end_time + 10min,同样钳到 now 之后一点点(+1s),
* 保证 setTimeout 拿到的是**未来**时刻而不是恰好相等(相等时行为依赖实现)。
*
* 🔴 opts.clamp = false 时**不钳制**,返回数学上的真实触发时刻。
* 这不是可选优化,而是 hydrate 判据所必需:hydrate 必须能分辨
* 「这个节点本来就还没到」vs「这个节点的时间设置在過去、早就该跳过了」。
* 若 hydrate 也走钳制路径,过期节点会被伪造成 now+1s ⇒ 进程一重启就立刻
* 误触发一次备份(2026-10-16 由 C28 判据 J4.2 实测抓到,非理论风险)。
*
* @param {string} group
* @param {string} node
* @param {Date|null} baseTime start_time 或 end_time
* @param {object} opts
* @param {boolean} [opts.clamp=true] 是否把过去时刻钳到 now+1s
* @returns {Date|null} 触发时刻;不可调度时返回 null
*/
function computeFireAt(group, node, baseTime, opts) {
if (!baseTime) return null;
const t = new Date(baseTime).getTime();
if (!Number.isFinite(t)) return null;
const leadMs = node === NODE_BEFORE_START
? (opts.beforeStartBufferMin || 30) * 60 * 1000
: (opts.afterEndDelayMin || 10) * 60 * 1000;
const fire = node === NODE_BEFORE_START ? t - leadMs : t + leadMs;
// ⛔ clamp=false ⇒ 返回数学时刻,交由调用方(hydrate)自行判断过期
if (opts.clamp === false) return new Date(fire);
// 钳制:绝不让计划落在过去(否则 setTimeout 立刻 fire,语义错误)
const now = Date.now();
if (fire <= now) return new Date(now + 1000);
return new Date(fire);
}
/**
* 节点备份调度器(挂在 app 上,全进程单例)
*/
class NodeBackupScheduler {
/**
* @param {object} app Egg app
*/
constructor(app) {
this.app = app;
this.logger = app.logger;
this.config = (app.config && app.config.nodeBackup) || {};
// key → timeout 句柄
this.timers = new Map();
// ⛔ 非生产直接封死:不排任何计划、不spawn 任何进程
// ⚠️ fail-safe(不是 fail-open):配置段缺失 / 非对象 / enabled 未显式为 true
// 时一律判为**关闭**。曾写成 `enabled !== false` ⇒ 配置段整个缺失时算出
// true(fail-open),由 C28 判据 J3.6 实测抓到 —— 生产隔离绝不能靠默认值。
this.enabled = !!(
app.config &&
app.config.isProduction &&
this.config &&
typeof this.config === 'object' &&
this.config.enabled === true
);
// 🔴 多 worker:这里**不能**按 isMaster 门禁关停 —— Egg 的 `didReady` 运行在
// **app worker 进程**里,master 根本不执行 app.js 生命周期钩子
// (曾一度想用 `EGG_SERVER_ENV === 'master'` 门禁,实测会<E6B58B><E4BC9A>整个调度器
// 永远关闭 ⇒ 永不备份)。故改为「每个 worker 都排程」+ 触发时刻两道去重:
// ① `_acquireLock` 跨进程文件锁(原子 mkdir)⇒ 并发到点只有一个真正 dump;
// ② `_stillValid` 触发前回库核对当前时间配置 ⇒ 改期后其它 worker 的
// **残留旧定时器**在触发时发现计划已作废 ⇒ 直接放弃(不误备份)。
// 这样既不会重复备份,也不会漏备份,且对 worker 数配置漂移免疫。
if (!this.enabled) {
this.logger.info(
'[NodeBackup] 未启用(enabled=%s · isProduction=%s)—— 非生产环境禁止触发生产库备份',
String(this.config.enabled !== false),
String(!!(app.config && app.config.isProduction))
);
}
}
/**
* 排一个节点备份计划(幂等:同key 覆盖旧句柄)
*
* @param {object} p
* @param {string} p.group
* @param {string} p.node
* @param {Date|null} p.baseTime start_time / end_time
* @param {string} p.reason 人类可读原因(落日志)
* @returns {string|null} 计划键;未启用/不可调度返回 null
*/
schedule(p) {
if (!this.enabled) return null;
const { group, node, baseTime, reason } = p;
const fireAt = computeFireAt(group, node, baseTime, this.config);
if (!fireAt) {
this.logger.warn(
'[NodeBackup] 跳过排程(缺少有效时间):group=%s node=%s baseTime=%s',
group, node, String(baseTime)
);
return null;
}
const key = planKey(group, node, fireAt);
// 同 key 已在册 ⇒ 说明是 hydrate 撞车,不重复排
if (this.timers.has(key)) {
this.logger.info('[NodeBackup] 计划已存在,跳过重复排程:%s', key);
return key;
}
const delay = Math.max(0, fireAt.getTime() - Date.now());
// setTimeout 上限 2^31-1 ms(约 24.8 天);超出则分段等待(考试时间被设到
// 很远以后时会真实触发此分支 —— 不处理会让定时器立即 fire ⇒ 立即误备份)
const MAX_DELAY = 2147483647;
const arm = ms => {
const handle = setTimeout(async () => {
this.timers.delete(key);
if (ms <= MAX_DELAY) {
await this._fire({ group, node, fireAt, key, reason });
} else {
// 分段续等
const rest = ms - MAX_DELAY;
this._rearm(key, { group, node, fireAt, reason }, rest);
}
}, ms);
// ⛔ 定时器不应阻止进程退出(正常退出时无需等备份跑完)
if (handle.unref) handle.unref();
this.timers.set(key, handle);
};
arm(delay);
// 台账:记下「曾经排过这个计划」—— 这是 H3「漏备」判定的唯一依据。
// 若不记,重启后计划丢了就再也无从发现「该备却没备」。
this._record('plan', {
key, group, node, fireAt: fireAt.toISOString(), reason: reason || '-',
});
this.logger.info(
'[NodeBackup] 已排程:key=%s 触发=%s(%s 分钟后)原因=%s',
key,
fireAt.toISOString(),
Math.round(delay / 60000),
reason || '-'
);
return key;
}
/**
* 追加一条台账事件(管理后台可见性的数据源)
*
* ⛔ 台账写失败**绝不能**影响备份本身:备份是保命的,台账是观测。
* 故这里吞掉所有异常,只回落到 logger.warn 留痕。
*
* @param {string} event plan | start | success | fail | lock-skip | invalid-skip
* @param {object} meta 计划/执行元信息
* @param {object} [extra] 附加字段(rc / logFile / message / stage)
*/
_record(event, meta, extra) {
try {
const logDir = this.config && this.config.logDir;
if (!logDir) return;
fs.mkdirSync(logDir, { recursive: true });
const row = Object.assign({
ts: new Date().toISOString(),
event,
pid: process.pid,
}, meta || {}, extra || {});
fs.appendFileSync(historyPath(logDir), JSON.stringify(row) + '\n');
} catch (e) {
this.logger.warn('[NodeBackup] 台账写入失败(不影响备份):event=%s err=%s', event, e.message);
}
}
/**
* 管理端状态快照(供 GET /api/admin/node-backup/status)
*
* ⛔ 返回值**不含任何凭据**:只有路径、时间、计数、判定结论。
* envFile 只给路径不给内容(内容里有 DB 口令)。
*
* @param {number} [limit] 台账取多少条
* @returns {object}
*/
status(limit) {
const cfg = this.config || {};
const n = Math.max(1, Math.min(Number(limit) || 50, 200));
const isProduction = !!(this.app && this.app.config && this.app.config.isProduction);
const history = cfg.logDir ? readHistory(cfg.logDir, n) : [];
const plans = this.list()
.map(p => {
const t = new Date(p.fireAt).getTime();
return Object.assign({}, p, {
remainingSec: Number.isFinite(t) ? Math.max(0, Math.round((t - Date.now()) / 1000)) : null,
});
})
.sort((a, b) => (a.fireAt < b.fireAt ? -1 : a.fireAt > b.fireAt ? 1 : 0));
const artifacts = cfg.outDir ? listArtifacts(cfg.outDir, 10) : [];
const health = evaluateHealth({
enabled: this.enabled,
isProduction,
plans,
history,
artifactCount: artifacts.length,
});
return {
enabled: this.enabled,
isProduction,
buffer: {
beforeStartMin: cfg.beforeStartBufferMin || 30,
afterEndMin: cfg.afterEndDelayMin || 10,
},
keepDays: cfg.keepDays || 90,
plans,
history,
artifacts,
health,
paths: {
outDir: cfg.outDir || null,
logDir: cfg.logDir || null,
historyFile: cfg.logDir ? historyPath(cfg.logDir) : null,
script: cfg.scriptPath || null,
envFile: cfg.envFile || null,
},
};
}
/**
* 分段续等(>24.8 天的远期计划)
*/
_rearm(key, meta, restMs) {
const handle = setTimeout(() => {
this.timers.delete(key);
if (restMs <= 2147483647) {
this._fire(meta);
} else {
this._rearm(key, meta, restMs - 2147483647);
}
}, 2147483647);
if (handle.unref) handle.unref();
this.timers.set(key, handle);
}
/**
* 组别时间被修改/保存后重排该组的两个时间节点
* @param {Array<{group:string,start_time?:Date,end_time?:Date}>} rows
*/
rescheduleGroupTimes(rows) {
if (!this.enabled) return;
for (const r of rows || []) {
this.schedule({
group: r.group, node: NODE_BEFORE_START, baseTime: r.start_time,
reason: `考试开始前备份(管理员设置/修改了 ${r.group} 的开始时间)`,
});
this.schedule({
group: r.group, node: NODE_AFTER_END, baseTime: r.end_time,
reason: `考试结束后备份(管理员设置/修改了 ${r.group} 的结束时间)`,
});
}
}
/**
* 成绩发布节点:立即异步备份(不等待)
*
* @param {string|null} group 缺省 = 全部组
*/
async triggerScorePublished(group) {
if (!this.enabled) return;
const key = `publish:${group || 'ALL'}:${new Date().toISOString()}`;
this.logger.info('[NodeBackup] 触发节点备份:成绩已发布 group=%s key=%s', group || 'ALL', key);
await this._fire({
group: group || 'ALL', node: NODE_SCORE_PUBLISHED,
fireAt: new Date(), key, reason: '成绩发布后备份',
});
}
/**
* 进程启动时从 competition_times 重建未到期的计划
*
* ⛔ 只重建**尚未到点**的计划:已过点的 fireAt 若 hydrate 出来会立刻触发,
* 等于「重启一次 = 补备一次」,与幂等键设计相悖。
* @param {Array} rows competition_times 行
*/
async hydrate(rows) {
if (!this.enabled) return;
const now = Date.now();
let armed = 0;
let skipped = 0;
for (const r of rows || []) {
for (const node of [ NODE_BEFORE_START, NODE_AFTER_END ]) {
const base = node === NODE_BEFORE_START ? r.start_time : r.end_time;
if (!base) continue;
// 🔴 必须用 clamp=false 取**数学时刻**来判过期,不能用默认的钳制结果 ——
// 钳制会把过期时刻伪造成 now+1s,于是过期节点也会被排上,
// 结果是「进程一重启就立刻误备份一次」(C28 判据 J4.2 实测抓到)。
const raw = computeFireAt(r.group, node, base, Object.assign({}, this.config, { clamp: false }));
if (!raw) continue;
if (raw.getTime() <= now) { skipped++; continue; }
this.schedule({
group: r.group, node, baseTime: base,
reason: `进程启动重建计划(${r.group})`,
});
armed++;
}
}
this.logger.info(
'[NodeBackup] hydrate 完成:已排 %d 个未来计划,跳过 %d 个已过期节点(避免重启即误备份)',
armed, skipped
);
}
/**
* 列出当前在册计划(供管理端排障接口读取)
*
* 🔴 R171 修既有缺陷:原实现用 `k.split(/:(.*):(.*)/)` 解析,正则是贪婪的 ⇒
* `zhongzhi:before-start:2026-11-01T08:30:00.000Z` 会被拆成
* ['zhongzhi', 'before-start:2026-11-01T08:30', '00.000Z'],
* 即 node 串进时间、fireAt 只剩碎片。group/node 本身不含 ':',
* 而 ISO 时刻含 ':' ⇒ 只能「按前两个冒号切、剩余全部归还 fireAt」。
*/
list() {
return Array.from(this.timers.keys()).map(k => {
const parts = String(k).split(':');
const group = parts[0] || '';
const node = parts[1] || '';
const at = parts.slice(2).join(':');
return { key: k, group, node, fireAt: at };
});
}
/**
* 🔴 防线①:跨进程文件锁(原子 mkdir,与 scripts/backup_daily.sh 的锁同法)
*
* 为什么必须有:生产 EGG_WORKERS=2 ⇒ 两个 app worker 进程各自持有一份**相同**的
* 计划(Egg 的 app worker 是独立进程,内存定时器**不共享**)⇒ 到点时两个进程
* 会同时触发。文件锁保证同一时刻只有一个进程真正 dump。
*
* ⚠️ 锁目录用 `mv` 归档而非 `rm`(⛔ 全项目禁 rm)。
* ⚠️ 残留锁(进程被 kill -9)会让后续备份永久拒绝执行 ⇒ 故设 TTL:
* 超过 lockTtlSec(默认 2h,远大于一次 dump 的耗时)视为陈旧锁,可接管。
* @returns {string|null} 持有则返回锁路径,未持有返回 null
*/
_acquireLock(lockKey) {
const lockDir = path.join(this.config.logDir, '.lock-' + lockKey.replace(/[^\w.-]/g, '_'));
try {
fs.mkdirSync(lockDir); // 原子:并发下只有一个成功
} catch (e) {
if (e.code !== 'EEXIST') {
this.logger.error('[NodeBackup] 创建锁失败(%s):%s', lockDir, e.message);
return null;
}
// 已存在 ⇒ 判断是否陈旧(残留锁)
const metaFile = path.join(lockDir, 'meta.json');
let ageMs = 0;
try {
const st = fs.statSync(lockDir);
ageMs = Date.now() - st.mtimeMs;
} catch (statErr) {
return null; // 锁刚被别人释放/改名 ⇒ 本次保守放弃
}
const ttlMs = (this.config.lockTtlSec || 7200) * 1000;
if (ageMs < ttlMs) {
this.logger.warn(
'[NodeBackup] 已有进程持锁(age=%ds),本次跳过(多 worker 去重生效):%s',
Math.round(ageMs / 1000), lockDir
);
return null;
}
// 陈旧锁 ⇒ 改名归档后重试一次(⛔ 不用 rm)
const stale = lockDir + '.stale-' + Date.now();
try {
fs.renameSync(lockDir, stale);
this.logger.warn('[NodeBackup] 清理陈旧锁(age=%ds)→ %s', Math.round(ageMs / 1000), stale);
} catch (renErr) {
return null;
}
try {
fs.mkdirSync(lockDir);
} catch (e2) {
return null; // 又被别人抢先 ⇒ 让给它
}
}
try {
fs.writeFileSync(path.join(lockDir, 'meta.json'),
JSON.stringify({ pid: process.pid, started: new Date().toISOString(), key: lockKey }));
} catch (e) { /* 锁本身已拿到,元信息写失败不阻塞 */ }
return lockDir;
}
/**
* 🔴 防线②:触发前回库核对计划是否仍然有效
*
* 解决的问题:`setTimes` 请求只落到「处理它的那一个 worker」⇒ 其它 worker 里
* 的**旧定时器不会被清除**。若管理员把考试时间改了,别的 worker 仍会在旧时刻
* 触发一次备份(备份内容是新配置下的库,触发时刻却是错的)。
* 修法:触发时按「当前 DB 里的时间」重算计划键;与本进程的旧键不符 ⇒ 已作废,放弃。
* ⛔ 对 score-published 节点**不做**此核对:它是即时事件、无时间基线可比。
*/
async _stillValid(meta) {
if (meta.node === NODE_SCORE_PUBLISHED) return true;
const col = meta.node === NODE_BEFORE_START ? 'start_time' : 'end_time';
try {
const row = await this.app.model.CompetitionTime.findOne({
where: { group: meta.group },
attributes: [ 'start_time', 'end_time' ],
raw: true,
});
if (!row || !row[col]) return false;
const current = computeFireAt(meta.group, meta.node, row[col],
Object.assign({}, this.config, { clamp: false }));
const currentKey = planKey(meta.group, meta.node, current);
if (currentKey !== meta.key) {
this.logger.info(
'[NodeBackup] 计划已作废(时间配置已被修改),放弃触发:旧=%s 新=%s', meta.key, currentKey
);
return false;
}
return true;
} catch (e) {
// 🔴 核对失败**不放行**:宁可漏一次备份,也不误备份(误备份会覆盖正确语义)。
// 漏备份可由下一节点/每日备份兜底。
this.logger.error('[NodeBackup] 计划有效性核对失败 ⇒ 放弃本次触发(防误备份):%s', e.message);
return false;
}
}
/**
* 实际执行备份(异步、不抛错)
*/
async _fire(meta) {
const { group, node, key, reason } = meta;
// ---- 防线②:计划是否仍然有效 ----
if (!(await this._stillValid(meta))) {
// 计划已作废(管理员改过时间)⇒ 必须记一笔:否则 H3 会把它误判成「漏备」
this._record('invalid-skip', { key, group, node, reason: '计划已作废(时间配置已被修改)' });
return;
}
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const logDir = this.config.logDir;
const outDir = this.config.outDir;
try {
fs.mkdirSync(logDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
} catch (e) {
this.logger.error('[NodeBackup] 建目录失败,放弃本次备份:%s', e.message);
// 目录建不出来 ⇒ 台账也写不进去,只能靠 logger;管理端会由 H3「漏备」暴露
return;
}
// ---- 防线①:跨进程锁(多 worker 去重)----
const lockDir = this._acquireLock(node === NODE_SCORE_PUBLISHED ? `publish-${group}` : key);
if (!lockDir) {
// 另一个 worker 正在做同一件事 ⇒ 记 lock-skip,H3 才不判成漏备
this._record('lock-skip', { key, group, node, reason: '另一进程持锁(多 worker 去重)' });
return;
}
const releaseLock = () => {
try {
fs.renameSync(lockDir, lockDir + '.done-' + Date.now());
} catch (e) { /* ignore */ }
};
const logFile = path.join(logDir, `node-backup-${stamp}.log`);
const args = [
this.config.scriptPath,
'--env-file', this.config.envFile,
'--out', outDir,
'--keep-days', String(this.config.keepDays || 90),
];
if (this.config.mysqldump) args.push('--mysqldump', this.config.mysqldump);
const env = Object.assign({}, process.env);
if (this.config.pythonPath) env.PYTHONPATH = this.config.pythonPath;
if (this.config.python) env.PYTHON_BIN = this.config.python;
const header = [
`===== 节点备份 ${stamp} =====`,
`节点=${node} 组别=${group} 计划键=${key}`,
`原因=${reason || '-'}`,
`命令=python ${args.join(' ')}`,
'',
].join('\n');
const out = fs.openSync(logFile, 'a');
try {
fs.writeSync(out, header);
} finally {
fs.closeSync(out);
}
this.logger.info('[NodeBackup] 开始备份 → %s(日志:%s)', outDir, logFile);
this._record('start', {
key, group, node, logFile,
reason: reason || '-',
cmd: `python ${args.join(' ')}`,
});
const child = spawn('python', args, { env, cwd: this.config.repoRoot });
const fd = fs.openSync(logFile, 'a');
child.stdout.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 日志失败不阻塞备份 */ } });
child.stderr.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 同上 */ } });
// ⚠️ spawn 的 'error' 与 'close' 可能**同时**触发(如 ENOENT)⇒ 用 settled 保证
// 只落一条结果,否则台账会出现「一次执行 = 一个 fail + 一个 success」的假象。
let settled = false;
const finish = (event, extra) => {
if (settled) return;
settled = true;
this._record(event, { key, group, node, logFile }, extra);
};
child.on('error', err => {
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
releaseLock();
// 🔴 备份失败必须**响亮**:这正是台账 P1「生产备份静默失败」的成因 ——
// 旧实现失败只落文件没人看 ⇒ 直到需要恢复时才发现备份根本不存在。
// R171:除 logger 外再落台账 ⇒ 管理后台「时间设置」页可直接看到红灯。
this.logger.error(
'[NodeBackup] 🔴 节点备份启动失败 group=%s node=%s key=%s err=%s(日志:%s)',
group, node, key, err.message, logFile
);
finish('fail', { stage: 'spawn', message: err.message });
});
child.on('close', code => {
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
releaseLock(); // ⚠️ 必须在 close 后释放,否则同 key 的重排会被自己的锁挡住
if (code === 0) {
this.logger.info(
'[NodeBackup] ✅ 节点备份成功 group=%s node=%s key=%s 日志=%s', group, node, key, logFile
);
finish('success', { rc: 0 });
} else {
this.logger.error(
'[NodeBackup] 🔴 节点备份失败(rc=%s)group=%s node=%s key=%s ⇒ 需人工介入 日志=%s',
code, group, node, key, logFile
);
finish('fail', { stage: 'exit', rc: code, message: `备份脚本退出码 ${code}` });
}
});
}
}
module.exports = {
NodeBackupScheduler,
NODE_BEFORE_START,
NODE_AFTER_END,
NODE_SCORE_PUBLISHED,
planKey,
computeFireAt,
// 台账 / 可见性(纯函数,便于 C28 判据独立验证)
HISTORY_FILE_NAME,
historyPath,
readHistory,
listArtifacts,
evaluateHealth,
};