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