Files
competition-website/app/lib/bigScoreImport.js
qubiaobiao 5392345078 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)
2026-10-19 13:03:48 +08:00

430 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string>} headerRow
* @param {Array<string>} 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<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,
};