feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复

成绩导入(R173 落地 → R174 改为三小问四列制)
- app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件)
  · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值
  · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE
  · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝
  · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号
- app/service/bigQuestion.js
  · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持")
  · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores
  · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores
- app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence)
- app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores
  · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载)

节点备份调度器(R168/R170)
- app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间
  · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻
  · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源)
- config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地
  (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导)
- app.js:启动构造单例 + hydrate 未到期计划

缺陷修复
- app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理
- app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount)
- app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注

测试
- .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js,
  使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
This commit is contained in:
2026-10-19 13:03:48 +08:00
parent 192b43bc67
commit 5392345078
13 changed files with 2400 additions and 20 deletions

View File

@@ -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/<group>/<file>`;前缀白名单可挡住
* 「脏数据 / 手工改库 / 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;