From 5392345078e22ab00f10ecb37ee9680f18cd1737 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 19 Oct 2026 13:03:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(score):=20R173/R174=20=E5=A4=A7=E9=A2=98?= =?UTF-8?q?=E6=88=90=E7=BB=A9=20Excel=20=E5=AF=BC=E5=85=A5=EF=BC=88?= =?UTF-8?q?=E4=B8=89=E5=B0=8F=E9=97=AE=E5=9B=9B=E5=88=97=E5=88=B6=EF=BC=89?= =?UTF-8?q?+=20=E4=B8=89=E8=8A=82=E7=82=B9=E5=A4=87=E4=BB=BD=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=99=A8=E4=B8=8E=E5=9B=9B=E9=A1=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成绩导入(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) --- .gitignore | 11 +- app.js | 23 + app/controller/admin.js | 253 +++++++++- app/controller/upload.js | 4 + app/lib/bigScoreImport.js | 429 +++++++++++++++++ app/lib/docx.js | 27 +- app/lib/nodeBackupScheduler.js | 844 +++++++++++++++++++++++++++++++++ app/router.js | 10 + app/service/admin.js | 67 ++- app/service/bigQuestion.js | 457 ++++++++++++++++++ app/service/upload.js | 62 ++- config/config.default.js | 65 +++ test/lib.test.js | 168 +++++++ 13 files changed, 2400 insertions(+), 20 deletions(-) create mode 100644 app/lib/bigScoreImport.js create mode 100644 app/lib/nodeBackupScheduler.js create mode 100644 test/lib.test.js diff --git a/.gitignore b/.gitignore index 3608da2..a8c8e3b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,13 @@ app/public/carousel/ app/public/tmp/ app/public/exports/ -# Tests (non-required; code runs without them) — 上传目的=运行必须,test 不入库 -test/ +# Tests —— 上传目的=运行必须:`test/` 下的夹具 / 临时物 / 子目录**不入库**。 +# 例外(P1 ㉚a · R134 候选 ㊴ 修复):`test/` 顶层的 `*.test.js` 最小单测集**必须入库** —— +# `scripts/ci-gates.sh` 第 26 步(裸 mocha)直跑它;若被忽略则「本地绿 ≠ 可复现绿」 +# (干净检出上该步拿不到文件 ⇒ 必红)。 +# 注:此处必须用 `test/*` 而非 `test/` —— 目录被整体排除后 git 不再进入该目录, +# 其内文件无法用 `!` 反忽略(R134 已实测:`test/` + `!test/lib.test.js` 无效)。 +# 改用 `test/*` 后子目录(如 `test/sub/`)与其它文件仍被忽略,政策不破。 +test/* +!test/*.test.js test_dir/ diff --git a/app.js b/app.js index 6089a86..cdbcd3f 100644 --- a/app.js +++ b/app.js @@ -112,6 +112,29 @@ class AppBootHook { } catch (e) { this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message)); } + + // ----------------------------------------------------------------- + // 节点备份调度器:进程启动 + hydrate 未到期的三节点计划 + // ----------------------------------------------------------------- + // 需求:考试开始前 30 分钟 / 考试结束后 10 分钟 / 成绩发布后 触发生产库备份。 + // 实现:app/lib/nodeBackupScheduler.js(进程内 setTimeout,无额外常驻进程 —— + // 用户裁定①「巡检脚本会占用额外内存」的替代方案)。 + // ⛔ 非生产环境调度器构造时即 enabled=false ⇒ 这里只在生产实际排计划。 + try { + const { NodeBackupScheduler } = require('./app/lib/nodeBackupScheduler'); + const scheduler = new NodeBackupScheduler(this.app); + // 挂到 app 上:service/controller 通过 app.nodeBackupScheduler 访问(单例) + this.app.nodeBackupScheduler = scheduler; + if (scheduler.enabled) { + const rows = await this.app.model.CompetitionTime.findAll({ + attributes: [ 'group', 'start_time', 'end_time' ], + raw: true, + }); + await scheduler.hydrate(rows); + } + } catch (e) { + this.app.logger.error('[AppBoot][NodeBackup] 调度器初始化失败(不影响主服务):' + (e && e.message)); + } } } diff --git a/app/controller/admin.js b/app/controller/admin.js index eb9c2f4..7719b20 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -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///*_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///`,zipFile 形如 + // `tmp/zip/_submissions_.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} 失败时已写好响应体,返回 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 diff --git a/app/controller/upload.js b/app/controller/upload.js index 8df7d9b..599ba21 100644 --- a/app/controller/upload.js +++ b/app/controller/upload.js @@ -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; diff --git a/app/lib/bigScoreImport.js b/app/lib/bigScoreImport.js new file mode 100644 index 0000000..ab83238 --- /dev/null +++ b/app/lib/bigScoreImport.js @@ -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} headerRow + * @param {Array} 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} 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, +}; diff --git a/app/lib/docx.js b/app/lib/docx.js index 802fb7b..fcd0400 100644 --- a/app/lib/docx.js +++ b/app/lib/docx.js @@ -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(''); if (run.italic) parts.push(''); parts.push(''); + // ⚠️ 顺序硬约束:CT_RPr 是 sequence,`w:color` 必须排在 rFonts 之后、`w:sz` 之前。 + // 顺序错乱(如放到 sz 之后)Word 同样会判文档损坏 —— 与「pPr 必须先于 runs」同理。 + const color = normalizeColor(run.color); + if (color) parts.push(``); const sz = Number(run.size) > 0 ? Number(run.size) : 21; parts.push(``); return `${parts.join('')}`; @@ -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 }, }; diff --git a/app/lib/nodeBackupScheduler.js b/app/lib/nodeBackupScheduler.js new file mode 100644 index 0000000..7decd36 --- /dev/null +++ b/app/lib/nodeBackupScheduler.js @@ -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_` + * (哈希后落键),用于「重启后 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} + */ +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'` 门禁,实测会��整个调度器 + // 永远关闭 ⇒ 永不备份)。故改为「每个 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, +}; \ No newline at end of file diff --git a/app/router.js b/app/router.js index cd5d303..a771558 100644 --- a/app/router.js +++ b/app/router.js @@ -139,6 +139,10 @@ module.exports = app => { router.get('/api/admin/times', adminAuth, controller.admin.getTimes); router.put('/api/admin/times', adminAuth, controller.admin.setTimes); + // 节点备份调度器状态(P1「生产备份静默失败」可见性):只读,不触发备份。 + // ⚠️ 路由段 node-backup 与 /api/admin/* 其余段无前缀冲突;GET 方法在此段唯一。 + router.get('/api/admin/node-backup/status', adminAuth, controller.admin.getNodeBackupStatus); + // 分数管理 router.get('/api/admin/scores/:group', adminAuth, controller.admin.getScores); router.put('/api/admin/scores/:group', adminAuth, controller.admin.setScores); @@ -169,6 +173,12 @@ module.exports = app => { router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers); // 考生答卷导出(R28):3 段路径,与上方 2 段列表、下方 PUT /:uid/scores 均不冲突 router.get('/api/admin/big-answers/:group/export', adminAuth, controller.admin.exportBigAnswers); + // 成绩 Excel 导入(R172):两段式(预览零写入 → 确认导入),均为 3 段路径,与 PUT /:uid/scores 不冲突。 + // ⚠️ 具体段(import-template / import-preview / import-scores)不得与将来的 `/:uid` 通配段冲突 ⇒ + // 保持与 export 同等注册位置(在 PUT 之前)。 + router.get('/api/admin/big-answers/:group/import-template', adminAuth, controller.admin.downloadBigScoreTemplate); + router.post('/api/admin/big-answers/:group/import-preview', adminAuth, controller.admin.previewBigScoreImport); + router.post('/api/admin/big-answers/:group/import-scores', adminAuth, controller.admin.importBigScores); router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores); // 备赛资料管理 diff --git a/app/service/admin.js b/app/service/admin.js index ba99c68..31a6c1a 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -787,6 +787,28 @@ class AdminService extends Service { const wordMap = {}; examSubs.forEach(w => { wordMap[String(w.contestant_id)] = w; }); + // 🔴 R166 修复「已答 N / N 题」恒真显示:原 `totalQuestions: answeredCount` + // ⇒ 分子分母恒相等,该列零信息量(连「只答了 3 题」的考生也显示「已答 3 / 3 题」)。 + // 真实分母 = 该考生被抽到的**总题数**,口径必须与抽题实现(service/exam.js:85-101)同源: + // 实抽 = min(配置抽取数, 题库实际容量) 逐题型相加 + // ⚠️ 为什么不能直接用 choice_count + judge_count:那是**上限**, + // 题库不足时 `limit` 会被封顶(controller/exam.js:263-266 的 available* 就是为此暴露), + // 直接相加会把「实际 40 题」显示成「70 题」⇒ 分母大于真实题数,比恒真更误导。 + // ⚠️ 为什么不能从逐题表反推总数:`subs.length` 只统计**已答**行, + // 未答的抽题不入表 ⇒ 反推出来仍是 answeredCount(同恒真)。 + const [timeRow, availableChoice, availableJudge] = await Promise.all([ + ctx.model.CompetitionTime.findOne({ + where: { group }, + attributes: ['choice_count', 'judge_count'], + raw: true, + }), + ctx.model.ChoiceQuestion.count({ where: { group } }), + ctx.model.JudgeQuestion.count({ where: { group } }), + ]); + const totalQuestions = + Math.min(timeRow && timeRow.choice_count != null ? timeRow.choice_count : 50, availableChoice) + + Math.min(timeRow && timeRow.judge_count != null ? timeRow.judge_count : 20, availableJudge); + return contestants.map(c => { // 归集键(含歧义消解后的兜底键);首元素必为主键 const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)]; @@ -807,8 +829,8 @@ class AdminService extends Service { idNumber: c.id_number, phone: c.phone, group: c.group, - answeredCount, - totalQuestions: answeredCount, +answeredCount, + totalQuestions, autoScore, wordScore: c.word_score ?? 0, // 总分 = 客观题自动评分合计 + 综合大题阅卷分(与「卷面分 / 大题分」口径一致); @@ -1584,6 +1606,28 @@ class AdminService extends Service { choice_count: choiceCount, judge_count: judgeCount, }); + + // ---- 节点备份重排(考试开始前 30 分钟 / 考试结束后 10 分钟)---- + // 🔴 R167:管理员每次保存/修改考试时间都要重排该组的两个时间节点。 + // · 幂等键含 fireAt ⇒ 改时间 = 新计划(该再备一次);重复点保存同一时间 + // = 同一 fireAt = 覆盖同一计划(不重复触发)。 + // · ⛔ 备份失败绝不影响本方法返回 —— 定时器是「到点才跑」,此处只排程, + // 且调度器内部对 enabled=false 直接返回(非生产不排)。 + try { + const scheduler = this.app.nodeBackupScheduler; + if (scheduler && scheduler.enabled) { + scheduler.rescheduleGroupTimes([{ + group, + start_time: startTime ? new Date(startTime) : null, + end_time: endTime ? new Date(endTime) : null, + }]); + } + } catch (bkErr) { + this.ctx.logger.error( + '[setTimes] 节点备份重排失败(不影响时间设置):group=%s err=%s', + group, bkErr && bkErr.message + ); + } } } @@ -2107,6 +2151,25 @@ class AdminService extends Service { if (!group) { await this._setConfig('score_published', value, transaction); } + + // ---- 节点备份:成绩「发布」后立即触发(撤销不触发)---- + // ⚠️ 用户裁定③:撤销发布时备份到的是**已发布过的数据**,对回退诉求无意义 ⇒ + // 只在 publish === true 时触发。⛔ 必须排在事务之外(此处 transaction 已提交), + // 否则备份 dump 到的可能是未提交快照。 + // ⛔ 备份失败不得让「发布成绩」这个管理动作失败 ⇒ 独立 try/catch 只记日志。 + if (publish) { + try { + const scheduler = this.app.nodeBackupScheduler; + if (scheduler && scheduler.enabled) { + // ⛔ await 但内部为 spawn 异步 + 不抛错 ⇒ 只多花一次 spawn 的时间 + scheduler.triggerScorePublished(group).catch(bkErr => { + this.ctx.logger.error('[publishScores] 节点备份触发失败:%s', bkErr && bkErr.message); + }); + } + } catch (bkErr) { + this.ctx.logger.error('[publishScores] 节点备份触发失败(不影响发布):%s', bkErr && bkErr.message); + } + } } /** diff --git a/app/service/bigQuestion.js b/app/service/bigQuestion.js index f8d2f58..e1706c3 100644 --- a/app/service/bigQuestion.js +++ b/app/service/bigQuestion.js @@ -7,6 +7,11 @@ const Service = require('egg').Service; // `@e965/xlsx`(0.20.3,含两处修复,零依赖,API 与 xlsx 完全一致)。 const XLSX = require('@e965/xlsx'); +// 成绩导入的**纯判定层**(R172 建 / R174 改三小问四列制):小问取值校验 / 逐行判定 / 表头别名解析。 +// ⚠️ 必须走这个 lib,不得在本文件内联第二套算法 —— 否则 `--self-test` 自证的是副本, +// 主流程仍可能是错的(恒绿假自证,R167 M1 血证)。 +const bigScoreImport = require('../lib/bigScoreImport'); + /** * 综合大题服务 - BigQuestionService * @@ -337,15 +342,24 @@ class BigQuestionService extends Service { order: [['id', 'ASC']], }); + // R172:补「小组编号」。小组身份键 = (组别 group, 队伍号 teamUid), + // ⛔ 绝不可单用 team_uid —— 运行库中不同组会复用同一数值(如 benke 与 zhongzhi 都有 team_uid=1)。 + // 解析优先级与 admin.getContestants 同源:aggregation.team_uid(报名聚合,权威)→ contestants.team_id(兜底)。 + const teamMeta = await this._loadTeamMeta(group, contestants.map(c => c.id)); + return contestants.map(c => { const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null; + const tm = teamMeta.byContestant[String(c.id)] || null; return { contestantId: c.id, aggregationUid: rec ? rec.aggregation_uid : c.id, name: c.name, idNumber: c.id_number, phone: c.phone, + // teamId 保留为**整数队伍号**(历史字段,勿改语义);teamNumber 才是人类可读的小组编号 teamId: c.team_id, + teamUid: tm ? tm.teamUid : null, + teamNumber: tm ? tm.teamNumber : null, drawn: !!rec, bigQuestionUid: rec ? rec.big_question_uid : null, questionSnapshot: rec ? this.parseSnapshot(rec.question_snapshot) : null, @@ -363,6 +377,74 @@ class BigQuestionService extends Service { }); } + /** + * 批量解析「考生 → 小组编号」(R172)。 + * + * 小组身份键 = (group, team_uid)。⛔ 不可只用 team_uid:不同组会复用同一数值, + * 单键解析会把别组的小组编号贴到本组考生身上(与铁律「team_uid 仅组内唯一」同源)。 + * + * 来源优先级: + * ① `aggregation.team_uid`(报名表聚合写入,权威) + * ② `contestants.team_id`(导入流程填充,兼容旧数据 / 单人无队伍时为 NULL) + * + * @param {string} group + * @param {Array} contestantIds + * @returns {Promise<{byContestant: Object}>} cid → { teamUid, teamNumber } + */ + async _loadTeamMeta(group, contestantIds) { + const { ctx } = this; + const ids = (contestantIds || []).filter(v => v !== null && v !== undefined); + const out = { byContestant: {} }; + if (!ids.length) return out; + + const Op = ctx.model.Sequelize.Op; + const contestants = await ctx.model.Contestant.findAll({ + where: { id: { [Op.in]: ids }, group }, + attributes: ['id', 'team_id'], + raw: true, + }); + const aggRows = await ctx.model.Aggregation.findAll({ + where: { account_uid: { [Op.in]: ids }, group }, + attributes: ['account_uid', 'team_uid'], + raw: true, + }); + + const aggTeamByCid = {}; + for (const a of aggRows) { + // 同一 account 可能有多条聚合行(多竞赛报名)⇒ 取第一个非空,保持确定性 + if (a.team_uid && !aggTeamByCid[a.account_uid]) aggTeamByCid[a.account_uid] = a.team_uid; + } + + const teamUidByCid = {}; + for (const c of contestants) { + const uid = aggTeamByCid[c.id] || c.team_id || null; + if (uid) teamUidByCid[c.id] = Number(uid); + } + + const uids = Array.from(new Set(Object.values(teamUidByCid))); + if (!uids.length) return out; + + // ⚠️ 必带 group_name:team_uid 跨组重号,不带组会把别组队号当成本组的 + const teams = await ctx.model.Team.findAll({ + where: { team_uid: { [Op.in]: uids }, group_name: group }, + attributes: ['team_uid', 'team_id', 'team_number'], + raw: true, + }); + const teamByUid = {}; + for (const t of teams) teamByUid[t.team_uid] = t; + + for (const cid of Object.keys(teamUidByCid)) { + const uid = teamUidByCid[cid]; + const t = teamByUid[uid]; + out.byContestant[cid] = { + teamUid: uid, + // 人类可读小组编号优先 team_number(形如 BRICS2026-ST-027-91001),回退 team_id + teamNumber: t ? (t.team_number || t.team_id || null) : null, + }; + } + return out; + } + /** * 管理端按三小问给分。 * @@ -454,6 +536,371 @@ class BigQuestionService extends Service { return 10; } + // ==================== 成绩 Excel 导入(R172) ==================== + // + // 使用场景:阅卷完成后,评卷人手上只有一张「小组编号 → 三小问得分」的 Excel。 + // 逐人在管理端点开评分弹窗录入几百遍既慢又易错,故提供批量导入。 + // + // 四条硬约束(改动前务必先读): + // 1) **写入仍只走 setSubScores** —— 即仍经 service.admin.updateScores 同步 + // contestants.word_score / score。「成绩发布门控」「总分公式」因此不受影响, + // 本功能绝不新增第二条写成绩的通路。 + // 2) **定位键 = (group, 小组编号)**。team_uid 跨组重号 ⇒ 必须带 group 限定; + // 流程为 小组编号 → teams.team_uid → 组内成员 → **唯一的抽题者**。 + // 3) **已评分默认拒绝**,需显式 overwrite 才覆盖(用户裁定)。 + // 4) **三小问是逐项取值的,不做均分**(R174 用户裁定)。留空即整行拒绝, + // 缺列即整份拒绝,旧两列模板整份拒绝 ⛔ 不得为「兼容」加均分回退分支。 + + /** + * 生成「小组编号 + 三小问」成绩导入模板(xlsx)。 + * sheet 顺序是契约:第 1 页 `成绩导入`(数据,首行表头),第 2 页 `填写说明`。 + * + * 🔴 R174:模板由「小组编号 + 分数」两列改为**四列**(小组编号 + 第1/2/3小问)。 + * 用户裁定「只认三小问模板」⇒ 总分不再由用户填,改由系统对三项求和。 + * ⛔ 不得再往表里加「总分」列 —— 那会立刻产生「总分与三项之和不一致该信谁」的歧义。 + * + * @param {string} group + * @returns {Promise} + */ + async buildScoreTemplate(group) { + const groupLabel = GROUP_LABELS[group] || group; + const header = ['小组编号', '第1小问', '第2小问', '第3小问']; + // 示例刻意用「9 / 8.67 / 8.67」而不是三个整数:让评卷人一眼看到可填小数 + const example = ['BRICS2026-ST-027-91001', 9, 8.67, 8.67]; + + const ws = XLSX.utils.aoa_to_sheet([header, example]); + ws['!cols'] = [{ wch: 32 }, { wch: 11 }, { wch: 11 }, { wch: 11 }]; + + const notes = [ + ['综合大题 成绩导入模板 — 填写说明'], + [], + [`当前模板适用组别:${groupLabel}(导入时以管理端选中的组别为准,本表不含组别列)`], + [], + ['列名', '是否必填', '说明'], + ['小组编号', '必填', '与「选手管理 → 选手列表」显示的小组编号完全一致,例如 BRICS2026-ST-027-91001。' + + '前后空格会自动去掉,但字母大小写必须一致。'], + ['第1小问', '必填', '该小组第 1 小问得分,范围 0 ~ 10,可含两位小数(例如 8.67)。'], + ['第2小问', '必填', '该小组第 2 小问得分,范围 0 ~ 10,可含两位小数。'], + ['第3小问', '必填', '该小组第 3 小问得分,范围 0 ~ 10,可含两位小数。'], + [], + ['【组别说明】'], + ['本文件不需要填写组别。导入时以「管理端当前选中的组别」为准,', + '若某小组编号属于其他组别,该行会被拒绝并提示切换到对应页签。'], + [], + ['【★ 为什么要用小组编号,而不是姓名 / 身份证号】'], + ['一个小组只会有一人真正参加考试(抽题作答),但成绩是**组内共享**的:', + '导入后只写在参赛者名下,同组其他成员登录查分时看到的是同一份成绩。', + '因此按小组编号导入即可,无需逐个成员填写。'], + [], + ['【★ 三列小问必须全部填写,不能留空】'], + ['三小问**逐项填写**,系统只做求和,不做任何均分或补齐:', + '例:9 / 8.67 / 8.67 → 大题总分 26.34(系统自动算,表中不需要填总分)。', + '⚠️ 只要有一问留空,**整行会被拒绝并报错**(例如「第 2 小问未填写」)。', + ' 系统不会把留空当 0 分 —— 那样会静默抹掉评卷人已经给出的分数。', + '⚠️ 三列都要保留:不要删除任何一列,也不要改列名,否则整份文件会被拒绝。'], + [], + ['【★ 旧版「小组编号 + 分数」两列模板已不再支持】'], + ['若你手上还是老的「小组编号 / 分数」两列表格,导入会被整份拒绝并提示', + '「检测到旧版两列模板」。请在管理端重新下载本模板,把总分拆成三小问后再导入。'], + [], + ['【★ 已评分的小组会被拒绝】'], + ['默认不覆盖既有评分:若该考生已有大题成绩,该行会被跳过并提示。', + '确需覆盖时,在导入对话框里勾选「覆盖已评分」后再导入。'], + [], + ['【常见被拒原因(导入时会逐行报错)】'], + ['1. 小组编号为空,或在本组 teams 表中不存在(核对空格 / 是否漏填前缀)'], + ['2. 小组编号属于其他组别 → 请切换到对应组别页签再导入'], + ['3. 同一小组编号在本文件中出现多次 → 全部拒绝(无法判断以哪行为准)'], + ['4. 某个小问留空 → 整行拒绝(三问必须齐全)'], + ['5. 某个小问不是数值、为负数、或超过 10 分'], + ['6. 该小组尚无人抽取综合大题 → 说明还没考试或还没进入作答页,暂不能录分'], + ['7. 该小组有多人抽取了大题 → 与「一组一人参赛」不符,需人工先核对'], + ['8. 该考生已评分,但未勾选「覆盖已评分」'], + [], + ['【Q & A】'], + ['Q1:小组编号里有纯数字(如 1、2、3),Excel 会不会转成数值导致匹配不上?'], + ['A:不会。系统读取时会转成文本再比对。但若编号带前导零(如 007),', + ' Excel 会吞掉前导零 → 请在单元格格式里把该列设为「文本」后再填。'], + ['Q2:同一个小组成员要不要每人都填一行?'], + ['A:不需要。一个小组填一行即可,成绩自动全组共享。'], + ['Q3:表中要不要写总分?'], + ['A:不要。总分由系统对三小问求和得到,表里没有也不需要总分列。'], + ['Q4:导入后还能改吗?'], + ['A:可以。勾选「覆盖已评分」重新导入,或在管理端逐人手工改分。'], + ['Q5:导错了怎么办?'], + ['A:重新导入正确分数并勾选「覆盖已评分」即可;不会产生重复行(一个考生只有一行大题记录)。'], + ]; + const notesWs = XLSX.utils.aoa_to_sheet(notes); + notesWs['!cols'] = [{ wch: 16 }, { wch: 10 }, { wch: 92 }]; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, '成绩导入'); + XLSX.utils.book_append_sheet(wb, notesWs, '填写说明'); + return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); + } + + /** + * 解析并**预览**成绩导入(零写入)。 + * + * 每个 Excel 行都会被判定成一个明确状态;前端据此展示「可导入 / 将覆盖 / 被拒绝(原因)」, + * 管理员确认后才执行写入。⛔ 不做「上传即写库」——成绩误写不可撤销。 + * + * @param {string} group + * @param {Buffer} buffer - xlsx 二进制(内存解析,不落盘) + * @returns {Promise} + */ + async previewScoreImport(group, buffer) { + const { ctx } = this; + if (!this.isEnabledGroup(group)) { + return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' }; + } + + let aoa; + try { + const wb = XLSX.read(buffer, { type: 'buffer' }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + // header:1 ⇒ 返回二维数组,保留真实行号;⛔ 不用 sheet_to_json 的对象键, + // 因为表头可能不在第 1 行、且用户表头写法不固定(统一走 bigScoreImport 的别名匹配) + aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true }); + } catch (e) { + return { ok: false, code: 'PARSE_FAILED', message: 'Excel 解析失败:' + e.message }; + } + + const parsed = bigScoreImport.parseScoreRows(aoa); + if (!parsed.ok) return { ok: false, code: parsed.code, message: parsed.message }; + + // ---- 上下文预查(一次查完,避免逐行打库)---- + const Op = ctx.model.Sequelize.Op; + const teamNumbers = Array.from(new Set(parsed.rows.map(r => r.teamNumber).filter(Boolean))); + + // ① 本组 teams:team_number 与 team_id 都可能被当作「小组编号」,两者都查 + const teamsInGroup = await ctx.model.Team.findAll({ + where: { + group_name: group, + [Op.or]: [ + { team_number: { [Op.in]: teamNumbers } }, + { team_id: { [Op.in]: teamNumbers } }, + ], + }, + attributes: ['team_uid', 'team_id', 'team_number'], + raw: true, + }); + // ② 他组 teams:用于区分「不存在」与「属于其他组」 + const teamsInOther = await ctx.model.Team.findAll({ + where: { + group_name: { [Op.ne]: group }, + [Op.or]: [ + { team_number: { [Op.in]: teamNumbers } }, + { team_id: { [Op.in]: teamNumbers } }, + ], + }, + attributes: ['team_uid', 'group_name', 'team_id', 'team_number'], + raw: true, + }); + + const indexTeam = (rows) => { + const m = {}; + for (const t of rows) { + for (const key of [t.team_number, t.team_id]) { + const k = bigScoreImport.normalizeTeamNumber(key); + if (k && !m[k]) m[k] = t; + } + } + return m; + }; + const teamByNumber = indexTeam(teamsInGroup); + const otherByNumber = indexTeam(teamsInOther); + + // ③ 各处室上下文:小组成员、抽题者、现有成绩 + const teamUids = Array.from(new Set(teamsInGroup.map(t => t.team_uid))); + const members = teamUids.length + ? await ctx.model.Contestant.findAll({ + where: { group, team_id: { [Op.in]: teamUids } }, + attributes: ['id', 'name', 'id_number', 'team_id'], + raw: true, + }) + : []; + // 报名聚合里的 team_uid 也算成员(权威来源),与 contestants.team_id 取并集 + const aggMembers = teamUids.length + ? await ctx.model.Aggregation.findAll({ + where: { group, team_uid: { [Op.in]: teamUids } }, + attributes: ['account_uid', 'team_uid'], + raw: true, + }) + : []; + const memberUidByCid = {}; + for (const m of members) memberUidByCid[m.id] = Number(m.team_id); + for (const a of aggMembers) { + if (a.account_uid && !memberUidByCid[a.account_uid]) memberUidByCid[a.account_uid] = Number(a.team_uid); + } + const allCids = Object.keys(memberUidByCid).map(Number); + const contestantInfo = allCids.length + ? await ctx.model.Contestant.findAll({ + where: { id: { [Op.in]: allCids }, group }, + attributes: ['id', 'name', 'id_number'], + raw: true, + }) + : []; + const infoByCid = {}; + for (const c of contestantInfo) infoByCid[c.id] = c; + + const answers = allCids.length + ? await ctx.model.BigQuestionAnswer.findAll({ + where: { group, contestant_id: { [Op.in]: allCids } }, + attributes: [ + 'id', 'contestant_id', 'aggregation_uid', 'big_question_id', + 'score_1', 'score_2', 'score_3', 'total_score', + ], + raw: true, + }) + : []; + const answerByCid = {}; + for (const a of answers) if (a.contestant_id) answerByCid[a.contestant_id] = a; + + // ④ 逐行判定(全部走 lib 的纯函数,与自证同源) + const rows = []; + for (const r of parsed.rows) { + const team = r.teamNumber ? teamByNumber[r.teamNumber] : null; + const other = r.teamNumber ? otherByNumber[r.teamNumber] : null; + const teamUid = team ? Number(team.team_uid) : null; + + const cidsInTeam = teamUid + ? Object.keys(memberUidByCid).filter(cid => memberUidByCid[cid] === teamUid).map(Number) + : []; + const operators = cidsInTeam.filter(cid => !!answerByCid[cid]); + const operatorCid = operators.length === 1 ? operators[0] : null; + const rec = operatorCid ? answerByCid[operatorCid] : null; + + const judged = bigScoreImport.classifyScoreRow({ + teamNumber: r.teamNumber, + subRaws: r.subRaws, + dupCount: r.dupCount, + foundInGroup: !!team, + foundInOtherGroup: !!other, + otherGroupLabel: other ? (GROUP_LABELS[other.group_name] || other.group_name) : '', + operatorCount: operators.length, + currentTotal: rec ? rec.total_score : null, + subCount: SUB_QUESTION_COUNT, + perSub: rec ? await this.resolveScorePerSub(group, rec) : bigScoreImport.DEFAULT_PER_SUB, + }); + + const opInfo = operatorCid ? infoByCid[operatorCid] : null; + rows.push({ + row: r.row, + teamNumber: r.teamNumber, + subRaws: r.subRaws, + // 三小问填报值(判定通过时才有);前端据此显示「第1/2/3小问」与合计 + subs: judged.subs || null, + total: judged.total === undefined ? null : judged.total, + status: judged.status, + message: judged.message, + blocking: bigScoreImport.BLOCKING_STATUS.indexOf(judged.status) >= 0, + // 命中信息(前端展示用;⛔ 不含身份证号以外的敏感字段) + contestantId: operatorCid, + contestantName: opInfo ? opInfo.name : null, + contestantIdNumber: opInfo ? opInfo.id_number : null, + teamUid, + memberCount: cidsInTeam.length, + operatorCount: operators.length, + currentScores: rec + ? { score1: rec.score_1, score2: rec.score_2, score3: rec.score_3, total: rec.total_score } + : null, + }); + } + + const count = s => rows.filter(x => x.status === s).length; + return { + ok: true, + group, + total: rows.length, + importable: count(bigScoreImport.ROW_STATUS.OK), + overwritable: count(bigScoreImport.ROW_STATUS.OVERWRITE), + blocked: rows.filter(x => x.blocking).length, + skippedBlank: count(bigScoreImport.ROW_STATUS.EMPTY_ROW), + rows, + }; + } + + /** + * 执行成绩导入(先 preview,再逐行写)。 + * + * @param {string} group + * @param {Buffer} buffer + * @param {Object} options - { overwrite:boolean } + * @returns {Promise} { ok, imported, skipped, errors, preview } + */ + async importScores(group, buffer, options = {}) { + const overwrite = options.overwrite === true; + const preview = await this.previewScoreImport(group, buffer); + if (!preview.ok) return preview; + + const S = bigScoreImport.ROW_STATUS; + const result = { ok: true, imported: 0, skipped: 0, overwritten: 0, errors: [], preview }; + + for (const r of preview.rows) { + // 空行不计错误 + if (r.status === S.EMPTY_ROW) continue; + + // 已评分:未勾选覆盖 ⇒ 跳过(不是失败,但要能看见) + if (r.status === S.OVERWRITE && !overwrite) { + result.skipped++; + result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: r.message }); + continue; + } + // 其余阻断态一律跳过 + if (r.blocking) { + result.skipped++; + result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: r.message }); + continue; + } + // 到这里只剩 OK / OVERWRITE(且已确认) + if (!r.contestantId) { + result.skipped++; + result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '未解析到参赛考生,已跳过' }); + continue; + } + + // 三小问逐项写入(⛔ 不再均分;值直接来自判定层 scoreSubs,与预览展示完全一致) + const subs = Array.isArray(r.subs) ? r.subs : []; + if (subs.length !== SUB_QUESTION_COUNT) { + result.skipped++; + result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '三小问得分不完整,请重新预览后再导入' }); + continue; + } + const rec = await this._findAnswerByContestant(group, r.contestantId); + if (!rec) { + result.skipped++; + result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '大题作答记录已消失,请重新预览' }); + continue; + } + + // 🔴 写入走唯一评分路径(setSubScores → service.admin.updateScores) + const written = await this.setSubScores(group, rec.aggregation_uid, { + score_1: subs[0], + score_2: subs[1], + score_3: subs[2], + }); + if (!written.ok) { + result.skipped++; + result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: written.message }); + continue; + } + result.imported++; + if (r.status === S.OVERWRITE) result.overwritten++; + } + + return result; + } + + /** 按考生取大题作答记录(组内限定,防跨组同名聚合 uid) */ + async _findAnswerByContestant(group, contestantId) { + const { ctx } = this; + return ctx.model.BigQuestionAnswer.findOne({ + where: { group, contestant_id: contestantId }, + attributes: ['id', 'contestant_id', 'aggregation_uid', 'total_score'], + }); + } + // ==================== 题库管理(管理端) ==================== /** @@ -845,6 +1292,16 @@ class BigQuestionService extends Service { runs: [{ text: `${groupLabel}综合大题阅卷表`, bold: true, size: 32 }], }); blocks.push({ type: 'p', runs: [{ text: `姓名:${s.name || '--'}  身份证号:${s.idNumber || '--'}  手机号:${s.phone || '--'}` }] }); + // R172(用户要求):**在 Word 文件内用红色字体标注小组编号**。 + // 阅卷人按小组编号分发 / 归档 / 核对纸卷,需一眼可辨,故独立一行、红色加粗。 + // ⚠️ 没有小组编号时显式印「(无)」而**不是省略该行**:省略会让人误判成「导出漏了字段」。 + blocks.push({ + type: 'p', + runs: [ + { text: '小组编号:', bold: true, color: 'FF0000' }, + { text: s.teamNumber || '(无)', bold: true, color: 'FF0000' }, + ], + }); blocks.push({ type: 'p', runs: [{ text: `抽到题目:${snap.title || '--'}(${s.bigQuestionUid || '--'})` }] }); blocks.push({ type: 'p', runs: [{ text: `提交时间:${fmtTime(s.submitTime)}  已答小问:${s.answeredCount} / ${SUB_QUESTION_COUNT}` }] }); diff --git a/app/service/upload.js b/app/service/upload.js index 9492c5b..8dc0c00 100644 --- a/app/service/upload.js +++ b/app/service/upload.js @@ -291,10 +291,13 @@ class UploadService extends Service { score: null, // 重新上传后清除评分 }); - if (stalePath) { - // best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果 - const r = this.removePublicFile(stalePath, 'overwrite-stale'); - if (r !== 'removed') { +if (stalePath) { + // best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果 + const r = this.removePublicFile(stalePath, 'overwrite-stale'); + // 🔴 R157:登记计数(⛔ 不抛错,保持 best-effort),使「旧文件清理失败」 + // 在运行期可被读取,而不只沉在日志里。 + this._noteDeleteResult(r, stalePath, 'overwrite-stale'); + if (r !== 'removed') { this.app.logger.warn( `[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}` ); @@ -374,8 +377,14 @@ class UploadService extends Service { // 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx) // 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径, // 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。 + // + // 🔴 R157:删除结果**不再丢弃**。此前返回值被完全忽略 ⇒ 删除失败只进日志, + // 运行期无计数、无暴露,孤儿文件在服务侧完全不可见(只能靠离线扫描发现, + // 而离线扫描报出的孤儿无法回溯「是哪一次删除失败的」)。 + // 修法:累计到**服务实例计数器 + 暴露 getter**,⛔ 不抛错(保持 best-effort 语义)。 if (record.file_path) { - this.removePublicFile(record.file_path, 'deleteSubmission'); + const r = this.removePublicFile(record.file_path, 'deleteSubmission'); + this._noteDeleteResult(r, record.file_path, 'deleteSubmission'); } await record.destroy(); @@ -391,6 +400,7 @@ class UploadService extends Service { * 一旦删除失败就静默留下一份无人认领的文件(孤儿)。 * 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。 * + /** * 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件: * - DB 里的 file_path 天然是 `works//`;前缀白名单可挡住 * 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/` @@ -404,6 +414,48 @@ class UploadService extends Service { * @param {string} [label] - 日志场景名 * @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错) */ + /** + * 【R157】登记一次删除结果 —— 把「删除失败」从**只有日志**变成**可计数、可读取**。 + * + * 【为什么不抛错】删除是副作用,失败不得污染主业务结果(best-effort 语义见 + * extend/helper.js:safeCleanup)。但「不抛错」⛔ 不等于「不可见」—— + * 此前 removed/skipped/missing 三态被调用方直接丢弃,导致孤儿文件在运行期 + * 完全不可见。这里累计计数并暴露 `delStats` / `getDeleteStats()`, + * 供健康检查、探针与门禁读取。 + * + * @param {'removed'|'missing'|'skipped'} r - removePublicFile 的返回值 + * @param {string} rel - 相对路径(仅用于日志定位) + * @param {string} label - 场景名 + * @returns {{removed:number,skipped:number,missing:number,failed:number}} 累计计数(快照) + */ + _noteDeleteResult(r, rel, label = 'removePublicFile') { + if (!this.delStats) { + this.delStats = { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null }; + } + const s = this.delStats; + if (r === 'removed') s.removed += 1; + else if (r === 'skipped') { s.skipped += 1; s.failed += 1; s.lastErrorPath = rel; } + else if (r === 'missing') s.missing += 1; + + if (s.failed > 0) { + const logger = (this.ctx && this.ctx.logger) || (this.app && this.app.logger); + if (logger && logger.warn) { + logger.warn( + `[delStats] 累计删除异常 ${s.failed} 次` + + `(removed=${s.removed} skipped=${s.skipped} missing=${s.missing})` + + ` 最近失败路径=${s.lastErrorPath} ⇒ 疑似残留孤儿文件,` + + ` 可用 scan_orphan_files.py 离线复核` + ); + } + } + return { ...s }; + } + + /** 读取累计删除统计(健康检查 / 探针 / 门禁用;⛔ 不含任何副作用)。 */ + getDeleteStats() { + return { ...(this.delStats || { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null }) }; + } + removePublicFile(relativePath, label = 'removePublicFile') { const { ctx, app } = this; const logger = (ctx && ctx.logger) || app.logger; diff --git a/config/config.default.js b/config/config.default.js index d0ba739..ca3751d 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -499,6 +499,71 @@ module.exports = (appInfo) => { maxDays: 14, }; + // ----------------------------------------------------------------- + // 节点备份调度(考试开始前 / 考试结束后 / 成绩发布后) + // ----------------------------------------------------------------- + // 需求来源:用户 2026-10-16「系统在考试开始前,考试结束后,成绩发布后这三个 + // 节点应该执行数据备份」。触发层用**后端钩子 + Node 定时器**(用户裁定①: + // 巡检脚本会占额外内存),实现见 app/lib/nodeBackupScheduler.js。 + // + // 🔴 生产隔离:备份目标是**生产库**(用户裁定②)⇒ 本段仅在生产环境生效, + // 非生产 enabled=false 且调度器构造时即封死。这是与上方 + // DEPLOY_ENV_LOCAL_SKIP_PREFIXES=['DB_',...] 同一纪律的延伸: + // 非生产进程不得持有生产连接凭据、不得去 dump 生产库。 + // + // ⛔ 与 scripts/backup_daily.sh 的分工:那个脚本被自身硬门禁锁死在**本地库** + // (端口须 ∈ {3306,3307,33060}、BACKUP_ENV_FILE 匹配 *production* 一律拒绝), + // 故本节点备份**不复用它的入口**,只复用它底层的 scripts/backup_db.py + // (同一份 mysqldump + gzip + 完整性校验 + 保留期治理,避免两套校验口径漂移)。 + // ⛔ 绝不「放宽 backup_daily.sh 的门禁来复用」—— 那是把本地防线拆给生产用。 + // 🔴 路径纪律:生产机的目录布局**与本地仓库不一致**,⛔ 不许用 `__dirname` 推导 + // (R168 实测踩坑)。取证依据(2026-08-10 / 2026-09-24 生产实证): + // 后端 baseDir = /home/root/production/agent-code/back-end/competition-website + // (注意是 `back-end` 连字符,不是仓库里的 `backend`) + // 脚本目录 = /home/root/production/agent-code/scripts/ ← 与 deploy/ 同级 + // 环境文件 = /home/root/production/agent-code/deploy/competition-website/.env.production + // (systemd EnvironmentFile,见 deploy/competition-backend.service) + // ⇒ `repoRoot = resolve(__dirname,'../..')` 在本地指向仓库根、在生产指向 + // `.../agent-code/back-end` ⇒ `join(root,'deploy','competition-website','scripts')` + // 在生产解析成 `.../back-end/deploy/competition-website/scripts` =**不存在**。 + // 解法 = 全部走环境变量、可显式覆盖,默认值只覆盖本地开发: + // BACKUP_SCRIPT / BACKUP_ENV_FILE / BACKUP_OUT_DIR / BACKUP_LOG_DIR。 + // ⚠️ 这四项须在 deploy/.env.production 中显式声明(部署检查清单会逐条核对), + // 否则生产会落到下面的默认值 ⇒ **路径错 ⇒ 备份静默失败**(P1 级)。 + const nbPath = require('path'); + const localRepo = nbPath.resolve(__dirname, '..', '..'); + config.nodeBackup = { + enabled: true, + // 触发缓冲(用户裁定①的「留出缓冲」口径) + beforeStartBufferMin: Number(process.env.BACKUP_BEFORE_START_MIN) || 30, // 考试开始前 30 分钟 + afterEndDelayMin: Number(process.env.BACKUP_AFTER_END_MIN) || 10, // 考试结束后 10 分钟 + // 🔴 备份脚本路径:⛔ 本地默认值在生产不存在 ⇒ 生产必须由 BACKUP_SCRIPT 覆盖 + scriptPath: process.env.BACKUP_SCRIPT + || nbPath.join(localRepo, 'scripts', 'backup_db.py'), + // 环境文件(生产库凭据来源) + envFile: process.env.BACKUP_ENV_FILE + || nbPath.join(localRepo, 'deploy', '.env.production'), + // 节点备份独立目录:⛔ 不复用 backups/db —— 否则会被每日备份的 keep-days + // 清理误删(每日 14 天 vs 节点备份需长期留存以备「赛后复盘/申诉」回溯) + outDir: process.env.BACKUP_OUT_DIR + || nbPath.join(localRepo, 'backups', 'db-nodes'), + logDir: process.env.BACKUP_LOG_DIR + || nbPath.join(localRepo, 'logs', 'backup', 'nodes'), + keepDays: Number(process.env.BACKUP_KEEP_DAYS) || 90, + // 跨进程锁 TTL(秒):残留锁(kill -9)会永久挡住后续备份 ⇒ 需能接管。 + // 取 2h:远大于一次 dump 的正常耗时(分钟级),又不会让崩溃残留锁永久生效。 + lockTtlSec: Number(process.env.BACKUP_LOCK_TTL_SEC) || 7200, + // 服务器 Py 3.6.8 无 pymysql/openpyxl ⇒ 既有约定走 /opt/pytools/lib + pythonPath: process.env.BACKUP_PYTHON_PATH || '/opt/pytools/lib', + mysqldump: process.env.BACKUP_MYSQLDUMP || '', + // ⚠️ spawn 的 cwd:只影响相对路径解析,backup_db.py 内部路径均基于 __dirname + repoRoot: process.env.BACKUP_CWD || localRepo, + }; + // 非生产强制关闭(即使误设 enabled=true 也不生效) + if (!config.isProduction) { + config.nodeBackup.enabled = false; + } + // ----------------------------------------------------------------- // 生产环境安全守卫:禁止使用内置默认密钥上线 // (由 APP_ENV=production 触发) diff --git a/test/lib.test.js b/test/lib.test.js new file mode 100644 index 0000000..38e06cd --- /dev/null +++ b/test/lib.test.js @@ -0,0 +1,168 @@ +'use strict'; + +/** + * backend 最小可跑单测集(P1 ㉚a) + * ================================================================ + * 背景:`backend/package.json` 声明了 `test-local: egg-bin test`,但 `backend/test/` + * 长期是**空目录** ⇒ 「配置存在、用例为零」——跑 `npm run test-local` 恒绿(0 个用例), + * 属 R95 根因①「最危险假绿族」。本文件填充**真实、无外部依赖(无 DB / 无网络 / + * 无 egg 启动)、有判别力**的最小单测集,使该 script 不再空转。 + * + * 取材原则(为什么选这三个纯函数模块): + * ① 业务核心:aggregationKey = 铁律13/14 归集键 SoT;idCard = 导入通道判据 SoT; + * filename = R108g 上传落盘名清洗。 + * ② **判别力**:三者的关键判据都有「退化后必然出错」的形态—— + * · aggregationKey 若退回「无条件双键归集」(历史 bug)⇒ 跨编号空间重叠用例必红; + * · idCard 若校验位算法改动 ⇒ 已知公开有效号 `11010519491231002X` 用例必红; + * · filename 若漏控制字符 / 路径分隔符清洗 ⇒ 穿越与 ENOENT 用例必红。 + * ③ 零副作用:全部为纯函数,不触盘、不连库。 + * + * ⚠️ 运行:`cd backend && node_modules/.bin/egg-bin test`(或裸 mocha 直跑本文件)。 + */ + +const assert = require('assert'); +const aggKey = require('../app/lib/aggregationKey'); +const idCard = require('../app/lib/idCard'); +const { sanitizeUploadFileName } = require('../app/utils/filename'); + +describe('app/lib/aggregationKey —— 归集键 SoT(铁律13/14)', () => { + it('考试组:主键 = contestant.id,兜底键 = 真实聚合 uid', () => { + assert.strictEqual(aggKey.primaryKeyOf('gaozhi', 5, 9), '5'); + assert.strictEqual(aggKey.primaryKeyOf('zhongzhi', 5, 9), '5'); + assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, 9), '9'); + }); + + it('考试组:缺真实聚合 uid ⇒ 无兜底键(null),而非拼一个假键', () => { + assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, null), null); + assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, ''), null); + }); + + it('作品组:主键 = 真实聚合 uid,兜底键 = cid', () => { + assert.strictEqual(aggKey.primaryKeyOf('benke', 5, 9), '9'); + assert.strictEqual(aggKey.fallbackKeyOf('benke', 5, 9), '5'); + }); + + it('作品组:缺真实聚合 uid ⇒ 主键退化为 cid(不再引入第二个键)', () => { + assert.strictEqual(aggKey.primaryKeyOf('benke', 5, null), '5'); + assert.strictEqual(aggKey.primaryKeyOf('benke', 5, ''), '5'); + }); + + it('isExamGroup 只认 zhongzhi / gaozhi', () => { + assert.strictEqual(aggKey.isExamGroup('zhongzhi'), true); + assert.strictEqual(aggKey.isExamGroup('gaozhi'), true); + assert.strictEqual(aggKey.isExamGroup('benke'), false); + assert.strictEqual(aggKey.isExamGroup('jiaoshi'), false); + }); + + // ---- 判别力核心:跨编号空间重叠 ---- + // 生产实测(2026-10-08)283 对碰撞的根因就是「无条件双键归集」。 + it('buildAggregationKeyMap:兜底键撞上同组他人主键 ⇒ 必须被丢弃', () => { + // 考试组:cid=1 的真实聚合 uid = 2,而 cid=2 的主键恰为 '2' ⇒ 二者重叠 + const contestants = [{ id: 1 }, { id: 2 }]; + const realAgg = { 1: 2 }; + const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg); + assert.deepStrictEqual(map['1'], ['1'], 'cid=1 的兜底键 2 会命中 cid=2 ⇒ 必须丢弃'); + assert.deepStrictEqual(map['2'], ['2']); + }); + + it('buildAggregationKeyMap:安全兜底键须保留(不因噎废食)', () => { + const contestants = [{ id: 1 }, { id: 2 }]; + const realAgg = { 1: 7 }; // 7 未被任何人认领主键 + const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg); + assert.deepStrictEqual(map['1'], ['1', '7']); + assert.deepStrictEqual(map['2'], ['2']); + }); + + it('buildAggregationKeyMap:空输入 / 非数组不抛错', () => { + assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', null, null), {}); + assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', [], {}), {}); + }); + + it('buildAggregationKeyMap:首元素恒为主键且键不重复', () => { + const contestants = [{ id: 3 }]; + const map = aggKey.buildAggregationKeyMap('benke', contestants, { 3: 3 }); // 兜底=cid=主键 + assert.deepStrictEqual(map['3'], ['3'], '主键与兜底相等时不得重复'); + }); +}); + +describe('app/lib/idCard —— 导入通道判据 SoT', () => { + it('normalizeIdNumber:trim + 末位 x 归一为大写 X', () => { + assert.strictEqual(idCard.normalizeIdNumber(' 11010519491231002x '), '11010519491231002X'); + assert.strictEqual(idCard.normalizeIdNumber(null), ''); + assert.strictEqual(idCard.normalizeIdNumber(undefined), ''); + }); + + it('expectedCheckCode:对 GB11643 公开样例号算出正确校验位(独立已知答案)', () => { + // `11010519491231002X` 为公开引用的有效样例;校验位算法若被改动此处必红。 + assert.strictEqual(idCard.expectedCheckCode('11010519491231002'), 'X'); + // 入参不足 17 位或含非数字 ⇒ 返回 '' + assert.strictEqual(idCard.expectedCheckCode('123'), ''); + assert.strictEqual(idCard.expectedCheckCode('1101051949123100A'), ''); + }); + + it('inspectIdNumber:合法号 ⇒ importable=true 且 level=null', () => { + const r = idCard.inspectIdNumber('11010519491231002X'); + assert.strictEqual(r.importable, true); + assert.strictEqual(r.level, null); + }); + + it('inspectIdNumber:空 ⇒ error 且不可导入', () => { + const r = idCard.inspectIdNumber(''); + assert.strictEqual(r.importable, false); + assert.strictEqual(r.level, 'error'); + }); + + it('inspectIdNumber:长度≠18 ⇒ error 且不可导入(模型层硬约束前置为可读错误)', () => { + const r = idCard.inspectIdNumber('11010519491231002'); // 17 位 + assert.strictEqual(r.importable, false); + assert.strictEqual(r.level, 'error'); + assert.ok(r.message.includes('18')); + }); + + it('inspectIdNumber:18 位但校验位不符 ⇒ warn(不阻断,防误伤在册考生)', () => { + const r = idCard.inspectIdNumber('110105194912310020'); // 末位应为 X + assert.strictEqual(r.importable, true); + assert.strictEqual(r.level, 'warn'); + assert.ok(r.message.includes('校验位')); + }); + + it('inspectIdNumber:18 位但含非法字符 ⇒ warn(模型只查长度)', () => { + const r = idCard.inspectIdNumber('1101051949123100AX'); + assert.strictEqual(r.importable, true); + assert.strictEqual(r.level, 'warn'); + }); +}); + +describe('app/utils/filename —— 上传落盘名清洗(R108g)', () => { + it('去除控制字符(防 stat 失败 / ENOENT)', () => { + assert.strictEqual(sanitizeUploadFileName('a\x00b\x1fc.txt'), 'abc.txt'); + }); + + it('路径分隔符替换为下划线(防目录穿越)', () => { + assert.strictEqual(sanitizeUploadFileName('a/b\\c.txt'), 'a_b_c.txt'); + }); + + it('折叠多余空白并 trim(修「前导空格 + 乱码」类 ENOENT)', () => { + assert.strictEqual(sanitizeUploadFileName(' a b .txt'), 'a b .txt'); + }); + + it('去除首尾点(防 .name / .. 类)', () => { + assert.strictEqual(sanitizeUploadFileName('.hidden'), 'hidden'); + }); + + it('超长名保留扩展名并截断到上限(120)', () => { + const out = sanitizeUploadFileName('a'.repeat(200) + '.txt'); + assert.strictEqual(out.length, 120); + assert.ok(out.endsWith('.txt'), '扩展名必须保留'); + }); + + it('客户端预编码(%XX)先解码', () => { + assert.strictEqual(sanitizeUploadFileName('%E5%89%8D.txt'), '前.txt'); + }); + + it('空值 / 非字符串 ⇒ 返回空串(调用方回退时间戳名)', () => { + assert.strictEqual(sanitizeUploadFileName(''), ''); + assert.strictEqual(sanitizeUploadFileName(null), ''); + assert.strictEqual(sanitizeUploadFileName(123), ''); + }); +});