Files
competition-website/app/lib/bigScoreImport.js

430 lines
17 KiB
JavaScript
Raw Normal View History

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
'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,
};