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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user