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