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:
2026-10-19 13:03:48 +08:00
parent 192b43bc67
commit 5392345078
13 changed files with 2400 additions and 20 deletions

View File

@@ -163,6 +163,11 @@ class AdminController extends Controller {
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
// 🔴 R161:临时文件路径必须在 try **外**声明,才能被 finally 看到。
// 原实现把 `unlinkSync` 写在 `await importFromExcel` 之后、外层 catch 之前
// ⇒ 解析/导入一抛错就跳过清理,永久泄漏 tmp/excel/import_*.xlsx(实测存量 41 个)。
let tempFilePath = null;
try {
// 获取上传的文件流
const stream = await ctx.getFileStream();
@@ -200,8 +205,7 @@ class AdminController extends Controller {
const testFlag = rawTestFlag === '1' || rawTestFlag === 'true' || rawTestFlag === true;
const result = await service.admin.importFromExcel(tempFilePath, { testFlag });
// 清理临时文件
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
// 🔴 R161:清理已移至外层 finally(此处不再单独清理,避免重复与漏清)。
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
@@ -218,6 +222,12 @@ class AdminController extends Controller {
const msg = (err && err.message) ? err.message : '未知错误';
// 注意:前端 onExcelImport 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 R161:三态可观测(removed/missing/skipped),失败 logger.warn 不静默。
// 原 `catch (e) { /* ignore */ }` 完全静默 ⇒ 「已清理」不可观测。
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importContestants-tmp' });
}
}
}
@@ -338,7 +348,7 @@ class AdminController extends Controller {
* 文件重命名为 "组号-学校-姓名-原文件名" 格式,流式下载 ZIP
*/
async batchDownload() {
const { ctx, service } = this;
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
try {
@@ -354,8 +364,18 @@ class AdminController extends Controller {
await sendFile(ctx, this.app, { absolutePath: zipFilePath, fileName, contentType: 'application/zip', useXAccel: false });
// 下载完成后清理 ZIP 文件(Node 流模式下 res.finish 在流发完后触发)
// 🔴 R161:原实现只删 zipFilePath,**漏删 batchDownloadZip 建的 zipDir 工作目录**
// (tmp/zip/<group>/<ts>/*_chunk.pdf,实测残留 340 文件 / 4 目录 ⇒ 每次导出必泄漏)。
// 改用 safeCleanup(三态可观测,失败 logger.warn 不静默)并补删工作目录。
ctx.res.on('finish', () => {
try { fs.unlinkSync(zipFilePath); } catch (e) { /* ignore */ }
ctx.helper.safeCleanup(zipFilePath, { label: 'batchDownload-zipFile' });
// 🔴 R161:清工作目录。zipDir 形如 `tmp/zip/<group>/<ts>/`,zipFile 形如
// `tmp/zip/<group>_submissions_<ts>.zip` ⇒ 二者**无派生关系**,不能靠去扩展名反解。
// ⛔ 不改 service 返回契约(会波及调用方);改为从 `tmp/zip` 根取出 group 后删其目录。
// 删整个 group 目录是安全的:该目录**只**存本方法的 Date.now() 工作目录,
// 不含其他业务文件(works/answers/materials 等均在 tmp 之外)。
const zipRoot = path.join(app.baseDir, 'app/public/tmp/zip');
ctx.helper.safeCleanup(path.join(zipRoot, group), { label: 'batchDownload-zipDir' });
});
} catch (err) {
ctx.logger.error('批量下载失败:', err);
@@ -428,6 +448,56 @@ class AdminController extends Controller {
}
}
/**
* 节点备份调度器状态
* GET /api/admin/node-backup/status
*
* P1「生产备份静默失败防护」的可见性出口:调度器此前只在 logger 里喊失败,
* 管理后台看不到 ⇒ 直到需要恢复时才发现备份不存在。本接口把
* **待触发计划 / 最近台账 / 备份产物 / 健康判定** 一次性暴露给管理页。
*
* ⛔ 只读接口:不排程、不触发备份、不写任何文件(读台账与目录而已)。
* ⛔ 返回体不含任何凭据:envFile 只给路径,绝不回显其内容(内含 DB 口令)。
*/
async getNodeBackupStatus() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
try {
const scheduler = this.app.nodeBackupScheduler;
const isProduction = !!(this.app.config && this.app.config.isProduction);
if (!scheduler) {
// 🔴 最隐蔽的一种「静默失败」:调度器压根没构造成功(app.js 里被 try/catch 吞了)。
// 此时其余一切正常,只是备份永远不会执行 ⇒ 生产环境必须报红。
ctx.body = ctx.helper.success({
available: false,
enabled: false,
isProduction,
plans: [],
history: [],
artifacts: [],
health: {
level: isProduction ? 'error' : 'ok',
reasons: [{
level: isProduction ? 'error' : 'warn',
code: 'SCHEDULER_MISSING',
message: '节点备份调度器未初始化(app.js didReady 构造失败)⇒ 三节点备份不会执行,请查看应用启动日志',
}],
stats: { consecutiveFails: 0, missedCount: 0, artifactCount: 0, planCount: 0, historyCount: 0 },
},
});
return;
}
const status = scheduler.status(50);
ctx.body = ctx.helper.success(Object.assign({ available: true }, status));
} catch (err) {
ctx.logger.error('获取节点备份状态失败:', err);
ctx.body = ctx.helper.error(500, '获取节点备份状态失败');
}
}
// ==================== 评分管理 ====================
/**
@@ -623,6 +693,9 @@ class AdminController extends Controller {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 🔴 R161:须在 try 外声明,finally 才可见(原来没有 finally ⇒ 抛错即泄漏)。
let tempFilePath = null;
try {
const stream = await ctx.getFileStream();
if (!stream) {
@@ -639,7 +712,7 @@ class AdminController extends Controller {
const tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const tempFilePath = path.join(tempDir, `questions_import_${Date.now()}${ext}`);
tempFilePath = path.join(tempDir, `questions_import_${Date.now()}${ext}`);
const writeStream = fs.createWriteStream(tempFilePath);
await new Promise((resolve, reject) => {
@@ -651,14 +724,19 @@ class AdminController extends Controller {
const result = await service.admin.importQuestionsFromExcel(tempFilePath, { group });
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
} catch (err) {
ctx.logger.error('客观题 Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
// 前端 onExcelFileChange 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 R161:原实现把 unlink 写在 `await importQuestionsFromExcel` 之后、catch 之前
// ⇒ 解析/导入一抛错即永久泄漏 tmp/excel/questions_import_*.xlsx。
// 移入 finally + 改 safeCleanup(三态可观测,失败 logger.warn 不静默)。
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importQuestions-tmp' });
}
}
}
@@ -1900,7 +1978,10 @@ class AdminController extends Controller {
ctx.body = ctx.helper.error(500, '导入失败:' + msg);
} finally {
// 无论成功/失败都清临时文件:题目内容属敏感数据,不留副本
if (tempFilePath) { try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ } }
// 🔴 R161:finally 位置本就正确,仅把静默 unlinkSync 换成 safeCleanup(三态可观测)
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importBigQuestions-tmp' });
}
}
}
@@ -1982,6 +2063,162 @@ class AdminController extends Controller {
}
}
// ==================== 大题成绩 Excel 导入(R172) ====================
//
// 流程:下载模板 → 上传并**预览**(零写入)→ 确认后**导入**(写入走唯一评分路径)。
// 两段式是刻意的:成绩误写不可撤销,先让管理员看到每一行的判定结果再落库。
/**
* 把上传的 Excel 读进内存 Buffer(⛔ 不落盘)。
*
* 为什么不落盘:
* ① 成绩表含考生成绩(敏感),既有题目导入落在 `app/public/tmp/excel`,
* 而 `app/public` 由 egg-static 对外暴露 ⇒ 文件名可猜即可下载;
* ② 不落盘就不存在「finally 忘了清理」留下的孤儿文件(R161 那类问题的根因)。
* 体积上限 2MB 足够(成绩表通常几十行)。
*
* @param {number} maxBytes
* @returns {Promise<Buffer|null>} 失败时已写好响应体,返回 null
*/
async _readExcelBuffer(maxBytes = 2 * 1024 * 1024) {
const { ctx } = this;
let stream;
try {
stream = await ctx.getFileStream();
} catch (e) {
ctx.body = ctx.helper.error(400, '未接收到文件(需 multipart/form-data,字段名 file)');
return null;
}
const ext = path.extname(stream.filename || '').toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
// 必须把流消费掉,否则连接会悬挂
stream.resume();
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式');
return null;
}
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
size += chunk.length;
if (size > maxBytes) {
stream.resume();
ctx.body = ctx.helper.error(400, `文件过大(上限 ${Math.floor(maxBytes / 1024 / 1024)} MB)`);
return null;
}
chunks.push(chunk);
}
} catch (e) {
ctx.body = ctx.helper.error(400, '读取上传文件失败:' + (e && e.message ? e.message : '未知错误'));
return null;
}
if (!size) {
ctx.body = ctx.helper.error(400, '上传文件为空');
return null;
}
return Buffer.concat(chunks, size);
}
/**
* 下载「综合大题成绩导入」模板
* GET /api/admin/big-answers/:group/import-template
*/
async downloadBigScoreTemplate() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const buffer = await service.bigQuestion.buildScoreTemplate(group);
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ctx.attachment(`综合大题成绩导入模板_${group}.xlsx`);
ctx.body = buffer;
} catch (err) {
ctx.logger.error('下载大题成绩导入模板失败:', err);
ctx.body = ctx.helper.error(500, '生成模板失败');
}
}
/**
* 预览成绩导入(⛔ 零写入)
* POST /api/admin/big-answers/:group/import-preview
* multipart/form-data: file
*/
async previewBigScoreImport() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const buffer = await this._readExcelBuffer();
if (!buffer) return;
const result = await service.bigQuestion.previewScoreImport(group, buffer);
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success(
result,
`预览完成:可导入 ${result.importable} 行,将覆盖 ${result.overwritable} 行,被拒绝 ${result.blocked} 行`
);
} catch (err) {
ctx.logger.error('大题成绩导入预览失败:', err);
ctx.body = ctx.helper.error(500, '预览失败:' + (err && err.message ? err.message : '未知错误'));
}
}
/**
* 执行成绩导入
* POST /api/admin/big-answers/:group/import-scores?overwrite=1
* multipart/form-data: file
*/
async importBigScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const buffer = await this._readExcelBuffer();
if (!buffer) return;
const overwrite = ctx.query.overwrite === '1' || ctx.query.overwrite === 'true';
const result = await service.bigQuestion.importScores(group, buffer, { overwrite });
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
// preview 明细过大时不必每次回传全部行;这里保留前 200 条错误供前端展示
const errors = (result.errors || []).slice(0, 200);
ctx.body = ctx.helper.success({
imported: result.imported,
skipped: result.skipped,
overwritten: result.overwritten,
errors,
errorTotal: (result.errors || []).length,
untruncated: (result.errors || []).length <= 200,
}, `导入完成:成功 ${result.imported} 行(其中覆盖 ${result.overwritten} 行),跳过 ${result.skipped} 行`);
} catch (err) {
ctx.logger.error('大题成绩导入失败:', err);
ctx.body = ctx.helper.error(500, '导入失败:' + (err && err.message ? err.message : '未知错误'));
}
}
/**
* 新增大题
* POST /api/admin/big-questions/:group

View File

@@ -191,6 +191,8 @@ class UploadController extends Controller {
*
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
*/
// @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并;
// 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。
async uploadChunk() {
const { ctx, app } = this;
@@ -389,6 +391,8 @@ class UploadController extends Controller {
* 查询分片上传进度
* GET /api/upload/:group/chunk-status?fileId=xxx
*/
// @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync),
// 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。
async chunkStatus() {
const { ctx, app } = this;