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)
This commit is contained in:
429
app/lib/bigScoreImport.js
Normal file
429
app/lib/bigScoreImport.js
Normal file
@@ -0,0 +1,429 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 大题成绩 Excel 导入 —— 纯判定层(R172 建,R174 改三小问四列制)
|
||||
*
|
||||
* 为什么单独抽一个 lib:
|
||||
* 1) **可被 `--self-test` 直接自证**:本文件零依赖(不 require Egg / 不连库 / 不读文件),
|
||||
* `scripts/verify_big_score_import.py` 可直接 `node -e "require(...)"` 拿到**真实实现**,
|
||||
* 而不是在测试里另写一份「看起来一样」的副本(⛔ 副本自证 = 恒绿假自证)。
|
||||
* 2) 判据与主流程**同源**:service/bigQuestion.js 只能调这里,不得内联第二套算法。
|
||||
*
|
||||
* 业务口径(用户 2026-10-19 / 2026-10-20 两次裁定):
|
||||
* · Excel 为**四列**:`小组编号` + `第1小问` + `第2小问` + `第3小问`,四列都必填;
|
||||
* 总分由系统**求和**,表中不再有「分数」列。
|
||||
* · **任一小问留空 ⇒ 整行拒绝并报错**(用户明确裁定:留空按 0 会静默抹分)。
|
||||
* · **缺任一小问列 ⇒ 拒绝整份文件**,并提示重新下载模板。
|
||||
* · **旧版两列模板(小组编号 + 分数)不再支持**,整份拒绝并提示改用三小问模板。
|
||||
* · **一个小组只会有一人参加考试**(真正抽题作答的那位),但成绩**组内共享**
|
||||
* —— 因此只需把分写给「抽题者」,其余成员由 score.js `_resolveTeamScoreSource` 共享读取;
|
||||
* · 已评分默认拒绝,需显式勾选「覆盖已评分」。
|
||||
*
|
||||
* ⚠️ R174 变更史(改动前必读):
|
||||
* 旧实现只有 `scoreRaw` 一列总分,由 `splitScoreAcrossSubs()` **均分**到三小问。
|
||||
* 用户指出「模板忘了改成三小问形式」,故:
|
||||
* · 删除 `splitScoreAcrossSubs()`;
|
||||
* · 新增 `scoreSubs()`(逐项取值 + 逐项校验,不再做均分);
|
||||
* · 行状态 `SCORE_MISSING/SCORE_INVALID/SCORE_OUT_OF_RANGE` → `SUB_MISSING/SUB_INVALID/SUB_OUT_OF_RANGE`;
|
||||
* · 文件级错误码新增 `LEGACY_TEMPLATE` / `SUB_COLUMNS_MISSING`。
|
||||
* ⛔ 不得为「兼容老文件」保留均分分支 —— 用户已裁定「不再支持,只认三小问模板」。
|
||||
*/
|
||||
|
||||
/** 小问数量(题制固定 3 问,与 BIG_QUESTION 域一致) */
|
||||
const SUB_COUNT = 3;
|
||||
|
||||
/** 默认每小问分值(题库可配 score_per_sub,此处仅兜底) */
|
||||
const DEFAULT_PER_SUB = 10;
|
||||
|
||||
/** 分数两位小数(与 DECIMAL(6,2) 同口径) */
|
||||
const SCORE_SCALE = 100;
|
||||
|
||||
/** 表头别名(与实际 Excel 填写习惯对齐;⛔ 大小写与空格在匹配前已归一) */
|
||||
const COLUMN_ALIAS = {
|
||||
teamNumber: ['小组编号', '队伍编号', '团队编号', '小组号', '队号', 'team_number', 'teamnumber', 'team_id', 'teamid'],
|
||||
sub1: [
|
||||
'第1小问', '第1问', '小问1', '小问一', '第一小问', '1小问',
|
||||
'score1', 'score_1', 'sub1', 'sub_score_1', '大题第1小问',
|
||||
],
|
||||
sub2: [
|
||||
'第2小问', '第2问', '小问2', '小问二', '第二小问', '2小问',
|
||||
'score2', 'score_2', 'sub2', 'sub_score_2', '大题第2小问',
|
||||
],
|
||||
sub3: [
|
||||
'第3小问', '第3问', '小问3', '小问三', '第三小问', '3小问',
|
||||
'score3', 'score_3', 'sub3', 'sub_score_3', '大题第3小问',
|
||||
],
|
||||
/**
|
||||
* 旧版两列模板的「总分」列。
|
||||
* ⚠️ 只用于**识别旧文件并给出可读拒绝**,绝不参与取值(用户裁定:不再支持旧模板)。
|
||||
*/
|
||||
legacyTotal: ['分数', '得分', '成绩', '总分', '大题得分', '大题总分', 'score', 'totalscore', 'total'],
|
||||
};
|
||||
|
||||
const ROW_STATUS = {
|
||||
OK: 'OK', // 可导入(该考生尚未评分)
|
||||
OVERWRITE: 'OVERWRITE', // 可导入,但会覆盖既有评分 ⇒ 需显式勾选
|
||||
EMPTY_ROW: 'EMPTY_ROW', // 整行空白 ⇒ 静默跳过,不计错误
|
||||
TEAM_MISSING: 'TEAM_MISSING', // 小组编号列为空
|
||||
SUB_MISSING: 'SUB_MISSING', // 至少一个小问留空 ⇒ 整行拒绝(不按 0 分处理)
|
||||
SUB_INVALID: 'SUB_INVALID', // 小问不是数值 / 负数
|
||||
SUB_OUT_OF_RANGE: 'SUB_OUT_OF_RANGE', // 某小问超过单价分值(perSub)
|
||||
DUPLICATE_IN_FILE: 'DUPLICATE_IN_FILE', // 同一文件内该小组编号出现多次
|
||||
TEAM_NOT_FOUND: 'TEAM_NOT_FOUND', // 全库都没有这个小组编号
|
||||
TEAM_IN_OTHER_GROUP: 'TEAM_IN_OTHER_GROUP', // 小组编号存在,但属于别的组
|
||||
NO_OPERATOR: 'NO_OPERATOR', // 组内无人抽取大题 ⇒ 无 rows 可评分
|
||||
MULTI_OPERATOR: 'MULTI_OPERATOR', // 组内多人抽取大题 ⇒ 违反「一组一人参赛」,拒绝猜测
|
||||
};
|
||||
|
||||
/** 判定为「阻断」的状态(不写库、计入错误) */
|
||||
const BLOCKING_STATUS = [
|
||||
ROW_STATUS.TEAM_MISSING,
|
||||
ROW_STATUS.SUB_MISSING,
|
||||
ROW_STATUS.SUB_INVALID,
|
||||
ROW_STATUS.SUB_OUT_OF_RANGE,
|
||||
ROW_STATUS.DUPLICATE_IN_FILE,
|
||||
ROW_STATUS.TEAM_NOT_FOUND,
|
||||
ROW_STATUS.TEAM_IN_OTHER_GROUP,
|
||||
ROW_STATUS.NO_OPERATOR,
|
||||
ROW_STATUS.MULTI_OPERATOR,
|
||||
];
|
||||
|
||||
/** 文件级错误码(`parseScoreRows` 返回的 `code`) */
|
||||
const FILE_CODE = {
|
||||
EMPTY_FILE: 'EMPTY_FILE',
|
||||
HEADER_NOT_FOUND: 'HEADER_NOT_FOUND',
|
||||
LEGACY_TEMPLATE: 'LEGACY_TEMPLATE', // 只有「分数」一列 ⇒ 旧版两列模板
|
||||
SUB_COLUMNS_MISSING: 'SUB_COLUMNS_MISSING', // 有小问列但不足三列
|
||||
NO_DATA_ROW: 'NO_DATA_ROW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 规范化「小组编号」单元格。
|
||||
*
|
||||
* Excel 常见污染:首尾空格、全角空格(U+3000)、不换行空格(U+00A0)、零宽字符(U+200B/200C/200D/FEFF)。
|
||||
* 这些肉眼不可见,但会让「BRICS2026-ST-027-91001」与库中值不相等 ⇒ 全部行莫名「小组编号不存在」。
|
||||
*
|
||||
* ⛔ 不做大小写折叠:队号含字母,大小写是其字面的一部分,擅自 toUpperCase 会掩盖录入错误。
|
||||
* @param {*} v
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeTeamNumber(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v)
|
||||
.replace(/[\u3000\u00a0\u200b-\u200d\ufeff]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 表头单元格归一:去空白/下划线/全角空格后小写,仅用于别名匹配 */
|
||||
function normalizeHeader(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v)
|
||||
.replace(/[\s\u3000\u00a0_\-()()]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按别名在同义词表里定位列下标。
|
||||
* @param {Array<string>} headerRow
|
||||
* @param {Array<string>} aliases
|
||||
* @returns {number} 命中列下标,未命中 -1
|
||||
*/
|
||||
function findColumn(headerRow, aliases) {
|
||||
const cells = (headerRow || []).map(normalizeHeader);
|
||||
for (const alias of aliases) {
|
||||
const want = normalizeHeader(alias);
|
||||
const idx = cells.indexOf(want);
|
||||
if (idx >= 0) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 单元格是否「空」(null / undefined / 纯空白串三者都算空) */
|
||||
function isBlank(v) {
|
||||
return v === null || v === undefined || String(v).trim() === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐项取三小问得分并校验(**不再均分**)。
|
||||
*
|
||||
* 校验顺序(顺序即报错优先级,⛔ 不可调换):
|
||||
* 1) 先扫全量「留空」⇒ 一次性报出**所有**空列(用户裁定:留空整行拒绝,不按 0 分处理);
|
||||
* 2) 再逐项验「是否数值 / 是否负数」;
|
||||
* 3) 再逐项验「是否超过单问上限 perSub」。
|
||||
*
|
||||
* 为什么「留空」优先于「数值非法」:
|
||||
* 评卷人填到一半时最常见的形态就是「后两问还空着」,此时报「第 2、3 小问未填写」
|
||||
* 比报「第 2 小问 xx 不是数值」更贴近真实原因。
|
||||
*
|
||||
* ⚠️ 为什么不给「留空按 0」的宽松模式:
|
||||
* 用户 2026-10-20 明确裁定「整行拒绝并报错」——留空按 0 会**静默抹掉**已评分数,
|
||||
* 且导入后三问合计与评卷人预期不一致时,无从判断是漏填还是真 0 分。
|
||||
*
|
||||
* @param {Array<*>} subRaws - 三小问原始单元格值,长度不足按空处理
|
||||
* @param {number} [subCount=3]
|
||||
* @param {number} [perSub=10]
|
||||
* @returns {{ok:boolean, subs?:number[], total?:number, message?:string, status?:string}}
|
||||
*/
|
||||
function scoreSubs(subRaws, subCount, perSub) {
|
||||
const n = Number.isFinite(Number(subCount)) && Number(subCount) > 0
|
||||
? Math.floor(Number(subCount))
|
||||
: SUB_COUNT;
|
||||
const per = Number.isFinite(Number(perSub)) && Number(perSub) > 0
|
||||
? Number(perSub)
|
||||
: DEFAULT_PER_SUB;
|
||||
const perCents = Math.round(per * SCORE_SCALE);
|
||||
|
||||
const list = Array.isArray(subRaws) ? subRaws : [];
|
||||
const raws = [];
|
||||
for (let i = 0; i < n; i++) raws.push(list[i]);
|
||||
|
||||
// 1) 留空优先:一次性报出所有空列
|
||||
const emptyIdx = [];
|
||||
raws.forEach((raw, i) => { if (isBlank(raw)) emptyIdx.push(i + 1); });
|
||||
if (emptyIdx.length) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_MISSING,
|
||||
message: `第 ${emptyIdx.join('、')} 小问未填写(三小问必须逐项填写,留空不会按 0 分处理)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2) / 3) 逐项验数值与上限
|
||||
const cents = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const raw = raws[i];
|
||||
const v = Number(raw);
|
||||
if (!Number.isFinite(v)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_INVALID,
|
||||
message: `第 ${i + 1} 小问「${raw}」不是数值`,
|
||||
};
|
||||
}
|
||||
const c = Math.round(v * SCORE_SCALE);
|
||||
if (c < 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_INVALID,
|
||||
message: `第 ${i + 1} 小问不能为负数(填的是 ${(c / SCORE_SCALE).toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
if (c > perCents) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_OUT_OF_RANGE,
|
||||
message: `第 ${i + 1} 小问 ${(c / SCORE_SCALE).toFixed(2)} 超过单问上限 ${(perCents / SCORE_SCALE).toFixed(2)} 分`,
|
||||
};
|
||||
}
|
||||
cents.push(c);
|
||||
}
|
||||
|
||||
const totalCents = cents.reduce((s, c) => s + c, 0);
|
||||
return {
|
||||
ok: true,
|
||||
subs: cents.map(c => c / SCORE_SCALE),
|
||||
total: totalCents / SCORE_SCALE,
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行判定(纯函数,零 IO)。
|
||||
*
|
||||
* 入参是**预查好的上下文**,判定逻辑与主流程共用同一份代码:
|
||||
* · foundInGroup / foundInOtherGroup 由 service 查 teams 得到;
|
||||
* · operatorCount = 该小组内「有 big_question_answers 行」的考生数;
|
||||
* · currentTotal = 该考生现有大题总分(null/undefined ⇒ 未评分)。
|
||||
*
|
||||
* ⚠️ 判定顺序即优先级:先排「表格自身问题」,再排「编码/归属问题」,最后才是「覆盖确认」。
|
||||
* 顺序不能乱:否则「已评分但小问越界」会被报成 OVERWRITE,前端勾选覆盖后照样写不进去。
|
||||
*
|
||||
* @param {Object} input
|
||||
* @param {*} input.teamNumber 小组编号单元格原值
|
||||
* @param {Array<*>} input.subRaws 三小问原始单元格值
|
||||
* @returns {{status:string, message:string, subs?:number[], total?:number}}
|
||||
*/
|
||||
function classifyScoreRow(input) {
|
||||
const it = input || {};
|
||||
const teamNumber = normalizeTeamNumber(it.teamNumber);
|
||||
const subRaws = Array.isArray(it.subRaws) ? it.subRaws : [];
|
||||
const allSubsBlank = subRaws.every(isBlank);
|
||||
|
||||
// 1) 整行空白 ⇒ 静默跳过(模板示例后的空行常见)
|
||||
if (!teamNumber && allSubsBlank) {
|
||||
return { status: ROW_STATUS.EMPTY_ROW, message: '' };
|
||||
}
|
||||
// 2) 小组编号为空
|
||||
if (!teamNumber) {
|
||||
return { status: ROW_STATUS.TEAM_MISSING, message: '小组编号为空' };
|
||||
}
|
||||
// 3) 本文件内重复
|
||||
if (Number(it.dupCount) > 1) {
|
||||
return {
|
||||
status: ROW_STATUS.DUPLICATE_IN_FILE,
|
||||
message: `小组编号「${teamNumber}」在本文件中出现 ${it.dupCount} 次,无法判断以哪行为准 ⇒ 全部拒绝`,
|
||||
};
|
||||
}
|
||||
// 4) 三小问本身非法(留空 / 非数值 / 负数 / 越界)
|
||||
const subs = scoreSubs(subRaws, it.subCount, it.perSub);
|
||||
if (!subs.ok) {
|
||||
return { status: subs.status, message: subs.message };
|
||||
}
|
||||
// 5) 小组编号归属
|
||||
if (!it.foundInGroup) {
|
||||
if (it.foundInOtherGroup) {
|
||||
return {
|
||||
status: ROW_STATUS.TEAM_IN_OTHER_GROUP,
|
||||
// ⚠️ otherGroupLabel 已含「组」字(GROUP_LABELS 的值形如「高职组」)⇒ 这里不得再拼「组」
|
||||
message: `小组编号「${teamNumber}」属于${it.otherGroupLabel || '其他组别'},请在对应组别页签导入`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: ROW_STATUS.TEAM_NOT_FOUND,
|
||||
message: `小组编号「${teamNumber}」在本组不存在(请核对空格 / 是否为纯数字编号被 Excel 转成数值)`,
|
||||
};
|
||||
}
|
||||
// 6) 参赛者(抽题者)唯一性
|
||||
const opCount = Number(it.operatorCount) || 0;
|
||||
if (opCount === 0) {
|
||||
return {
|
||||
status: ROW_STATUS.NO_OPERATOR,
|
||||
message: '该小组尚无人抽取综合大题,无法录入成绩(考试结束后再导入)',
|
||||
};
|
||||
}
|
||||
if (opCount > 1) {
|
||||
return {
|
||||
status: ROW_STATUS.MULTI_OPERATOR,
|
||||
message: `该小组有 ${opCount} 人抽取了大题,与「一组一人参赛」不符 ⇒ 拒绝猜测,请先人工核对`,
|
||||
};
|
||||
}
|
||||
// 7) 覆盖确认
|
||||
const hasScore = !isBlank(it.currentTotal);
|
||||
if (hasScore) {
|
||||
return {
|
||||
status: ROW_STATUS.OVERWRITE,
|
||||
message: `该考生已评分(${Number(it.currentTotal).toFixed(2)} 分),需勾选「覆盖已评分」才会写入`,
|
||||
subs: subs.subs,
|
||||
total: subs.total,
|
||||
};
|
||||
}
|
||||
return { status: ROW_STATUS.OK, message: '', subs: subs.subs, total: subs.total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 sheet 的 AOA 解析成规范化行(纯函数)。
|
||||
*
|
||||
* 表头契约:**同一行**须同时命中「小组编号」列 + 三个小问列。
|
||||
* 缺列时的错误码必须可区分,否则用户只看到「表头未找到」,不知道是自己删了列还是用了旧模板:
|
||||
* · 只有「分数」一列(无任何小问列)⇒ `LEGACY_TEMPLATE`(提示重新下载三小问模板)
|
||||
* · 有小问列但不足三列 ⇒ `SUB_COLUMNS_MISSING`(点名缺第几列)
|
||||
* · 两者都认不出 ⇒ `HEADER_NOT_FOUND`
|
||||
*
|
||||
* @param {Array<Array>} aoa - 含表头的二维数组(`sheet_to_json(sheet, { header: 1 })`)
|
||||
* @returns {{ok:boolean, code?:string, message?:string, rows?:Array}}
|
||||
*/
|
||||
function parseScoreRows(aoa) {
|
||||
const grid = Array.isArray(aoa) ? aoa : [];
|
||||
if (!grid.length) {
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.EMPTY_FILE,
|
||||
message: 'Excel 内容为空(首行须为表头,第 1 页为「成绩导入」)',
|
||||
};
|
||||
}
|
||||
|
||||
// 表头可能不在第 1 行(用户常先写标题)⇒ 在前 5 行里找同时命中「编号 + 三小问」的
|
||||
let headerRowIdx = -1;
|
||||
let teamCol = -1;
|
||||
let subCols = null;
|
||||
/** 部分命中的证据,用于给出可区分的拒绝理由 */
|
||||
let partial = null;
|
||||
|
||||
for (let i = 0; i < Math.min(grid.length, 5); i++) {
|
||||
const line = grid[i] || [];
|
||||
const t = findColumn(line, COLUMN_ALIAS.teamNumber);
|
||||
if (t < 0) continue;
|
||||
const found = [
|
||||
findColumn(line, COLUMN_ALIAS.sub1),
|
||||
findColumn(line, COLUMN_ALIAS.sub2),
|
||||
findColumn(line, COLUMN_ALIAS.sub3),
|
||||
];
|
||||
const hit = found.filter(x => x >= 0).length;
|
||||
if (hit === SUB_COUNT) {
|
||||
headerRowIdx = i;
|
||||
teamCol = t;
|
||||
subCols = found;
|
||||
break;
|
||||
}
|
||||
if (!partial) {
|
||||
if (hit > 0) {
|
||||
partial = { kind: 'subs', missing: [1, 2, 3].filter(k => found[k - 1] < 0) };
|
||||
} else if (findColumn(line, COLUMN_ALIAS.legacyTotal) >= 0) {
|
||||
partial = { kind: 'legacy' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (headerRowIdx < 0) {
|
||||
if (partial && partial.kind === 'legacy') {
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.LEGACY_TEMPLATE,
|
||||
message: '检测到旧版「小组编号 + 分数」两列模板,该模板已不再支持。'
|
||||
+ '请在管理端重新下载「成绩导入模板」,按「小组编号 + 第1/2/3小问」四列填写后重试',
|
||||
};
|
||||
}
|
||||
if (partial && partial.kind === 'subs') {
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.SUB_COLUMNS_MISSING,
|
||||
message: `表头缺少「第 ${partial.missing.join('、')} 小问」列。`
|
||||
+ '三小问必须逐列填写(缺列会导致该小问成绩被静默丢失)⇒ 整份文件已拒绝,'
|
||||
+ '请重新下载模板后重试',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.HEADER_NOT_FOUND,
|
||||
message: '未找到表头:同一行须同时存在「小组编号」列与「第1小问」「第2小问」「第3小问」三列(请先下载模板)',
|
||||
};
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
const seen = {};
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const line = grid[i] || [];
|
||||
const teamNumber = normalizeTeamNumber(line[teamCol]);
|
||||
const subRaws = subCols.map(c => line[c]);
|
||||
const rowIndex = i + 1; // Excel 行号(1 基)
|
||||
rows.push({ row: rowIndex, teamNumber, subRaws });
|
||||
if (teamNumber) {
|
||||
const k = teamNumber;
|
||||
seen[k] = (seen[k] || 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const r of rows) {
|
||||
r.dupCount = r.teamNumber ? (seen[r.teamNumber] || 0) : 0;
|
||||
}
|
||||
if (!rows.length) {
|
||||
return { ok: false, code: FILE_CODE.NO_DATA_ROW, message: '表头之后没有数据行' };
|
||||
}
|
||||
return { ok: true, rows };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUB_COUNT,
|
||||
DEFAULT_PER_SUB,
|
||||
SCORE_SCALE,
|
||||
COLUMN_ALIAS,
|
||||
ROW_STATUS,
|
||||
BLOCKING_STATUS,
|
||||
FILE_CODE,
|
||||
isBlank,
|
||||
normalizeTeamNumber,
|
||||
normalizeHeader,
|
||||
findColumn,
|
||||
scoreSubs,
|
||||
classifyScoreRow,
|
||||
parseScoreRows,
|
||||
};
|
||||
@@ -159,7 +159,8 @@ function firstLineTwips(node) {
|
||||
* 深度优先遍历,产出「块」序列:
|
||||
* { type:'p', runs:[...], align, firstLine }
|
||||
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
|
||||
* run 形态:{ text, bold, italic } 或 { br: true }
|
||||
* run 形态:{ text, bold, italic, size, color } 或 { br: true }
|
||||
* color 为 6 位十六进制(可带 `#` 前缀),如 'FF0000' / '#FF0000';非法值忽略。
|
||||
*/
|
||||
function walk(nodes, fmt, out) {
|
||||
const flush = () => {
|
||||
@@ -274,11 +275,31 @@ function textToBlocks(text) {
|
||||
|
||||
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
|
||||
/**
|
||||
* 规范化 run 的颜色值。
|
||||
*
|
||||
* ⛔ 只接受 6 位十六进制(可带 `#`),其余一律返回 '':
|
||||
* OOXML 的 `w:val` 若被填入 `red` / `#F00` / 任意字符串,Word 会拒绝打开整个文档,
|
||||
* 而调用方(导出服务)无法感知 —— 表现为「导出的 docx 打不开」,排查成本极高。
|
||||
*
|
||||
* @param {*} v
|
||||
* @returns {string} 6 位大写十六进制或 ''
|
||||
*/
|
||||
function normalizeColor(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v).trim().replace(/^#/, '');
|
||||
return /^[0-9a-fA-F]{6}$/.test(s) ? s.toUpperCase() : '';
|
||||
}
|
||||
|
||||
function rPrOf(run) {
|
||||
const parts = [];
|
||||
if (run.bold) parts.push('<w:b/><w:bCs/>');
|
||||
if (run.italic) parts.push('<w:i/><w:iCs/>');
|
||||
parts.push('<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>');
|
||||
// ⚠️ 顺序硬约束:CT_RPr 是 sequence,`w:color` 必须排在 rFonts 之后、`w:sz` 之前。
|
||||
// 顺序错乱(如放到 sz 之后)Word 同样会判文档损坏 —— 与「pPr 必须先于 runs」同理。
|
||||
const color = normalizeColor(run.color);
|
||||
if (color) parts.push(`<w:color w:val="${color}"/>`);
|
||||
const sz = Number(run.size) > 0 ? Number(run.size) : 21;
|
||||
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
|
||||
return `<w:rPr>${parts.join('')}</w:rPr>`;
|
||||
@@ -464,6 +485,6 @@ module.exports = {
|
||||
textToBlocks,
|
||||
decodeEntities,
|
||||
esc,
|
||||
// 导出内部件便于单测
|
||||
_internal: { parseHtml, buildDocumentXml, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
|
||||
// 导出内部件便于单测(`scripts/verify_big_score_import.py` 会断言 color 渲染与序列位置)
|
||||
_internal: { parseHtml, buildDocumentXml, rPrOf, normalizeColor, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
|
||||
};
|
||||
|
||||
844
app/lib/nodeBackupScheduler.js
Normal file
844
app/lib/nodeBackupScheduler.js
Normal file
@@ -0,0 +1,844 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user