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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,11 @@ const Service = require('egg').Service;
|
||||
// `@e965/xlsx`(0.20.3,含两处修复,零依赖,API 与 xlsx 完全一致)。
|
||||
const XLSX = require('@e965/xlsx');
|
||||
|
||||
// 成绩导入的**纯判定层**(R172 建 / R174 改三小问四列制):小问取值校验 / 逐行判定 / 表头别名解析。
|
||||
// ⚠️ 必须走这个 lib,不得在本文件内联第二套算法 —— 否则 `--self-test` 自证的是副本,
|
||||
// 主流程仍可能是错的(恒绿假自证,R167 M1 血证)。
|
||||
const bigScoreImport = require('../lib/bigScoreImport');
|
||||
|
||||
/**
|
||||
* 综合大题服务 - BigQuestionService
|
||||
*
|
||||
@@ -337,15 +342,24 @@ class BigQuestionService extends Service {
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
// R172:补「小组编号」。小组身份键 = (组别 group, 队伍号 teamUid),
|
||||
// ⛔ 绝不可单用 team_uid —— 运行库中不同组会复用同一数值(如 benke 与 zhongzhi 都有 team_uid=1)。
|
||||
// 解析优先级与 admin.getContestants 同源:aggregation.team_uid(报名聚合,权威)→ contestants.team_id(兜底)。
|
||||
const teamMeta = await this._loadTeamMeta(group, contestants.map(c => c.id));
|
||||
|
||||
return contestants.map(c => {
|
||||
const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null;
|
||||
const tm = teamMeta.byContestant[String(c.id)] || null;
|
||||
return {
|
||||
contestantId: c.id,
|
||||
aggregationUid: rec ? rec.aggregation_uid : c.id,
|
||||
name: c.name,
|
||||
idNumber: c.id_number,
|
||||
phone: c.phone,
|
||||
// teamId 保留为**整数队伍号**(历史字段,勿改语义);teamNumber 才是人类可读的小组编号
|
||||
teamId: c.team_id,
|
||||
teamUid: tm ? tm.teamUid : null,
|
||||
teamNumber: tm ? tm.teamNumber : null,
|
||||
drawn: !!rec,
|
||||
bigQuestionUid: rec ? rec.big_question_uid : null,
|
||||
questionSnapshot: rec ? this.parseSnapshot(rec.question_snapshot) : null,
|
||||
@@ -363,6 +377,74 @@ class BigQuestionService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解析「考生 → 小组编号」(R172)。
|
||||
*
|
||||
* 小组身份键 = (group, team_uid)。⛔ 不可只用 team_uid:不同组会复用同一数值,
|
||||
* 单键解析会把别组的小组编号贴到本组考生身上(与铁律「team_uid 仅组内唯一」同源)。
|
||||
*
|
||||
* 来源优先级:
|
||||
* ① `aggregation.team_uid`(报名表聚合写入,权威)
|
||||
* ② `contestants.team_id`(导入流程填充,兼容旧数据 / 单人无队伍时为 NULL)
|
||||
*
|
||||
* @param {string} group
|
||||
* @param {Array<number>} contestantIds
|
||||
* @returns {Promise<{byContestant: Object}>} cid → { teamUid, teamNumber }
|
||||
*/
|
||||
async _loadTeamMeta(group, contestantIds) {
|
||||
const { ctx } = this;
|
||||
const ids = (contestantIds || []).filter(v => v !== null && v !== undefined);
|
||||
const out = { byContestant: {} };
|
||||
if (!ids.length) return out;
|
||||
|
||||
const Op = ctx.model.Sequelize.Op;
|
||||
const contestants = await ctx.model.Contestant.findAll({
|
||||
where: { id: { [Op.in]: ids }, group },
|
||||
attributes: ['id', 'team_id'],
|
||||
raw: true,
|
||||
});
|
||||
const aggRows = await ctx.model.Aggregation.findAll({
|
||||
where: { account_uid: { [Op.in]: ids }, group },
|
||||
attributes: ['account_uid', 'team_uid'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const aggTeamByCid = {};
|
||||
for (const a of aggRows) {
|
||||
// 同一 account 可能有多条聚合行(多竞赛报名)⇒ 取第一个非空,保持确定性
|
||||
if (a.team_uid && !aggTeamByCid[a.account_uid]) aggTeamByCid[a.account_uid] = a.team_uid;
|
||||
}
|
||||
|
||||
const teamUidByCid = {};
|
||||
for (const c of contestants) {
|
||||
const uid = aggTeamByCid[c.id] || c.team_id || null;
|
||||
if (uid) teamUidByCid[c.id] = Number(uid);
|
||||
}
|
||||
|
||||
const uids = Array.from(new Set(Object.values(teamUidByCid)));
|
||||
if (!uids.length) return out;
|
||||
|
||||
// ⚠️ 必带 group_name:team_uid 跨组重号,不带组会把别组队号当成本组的
|
||||
const teams = await ctx.model.Team.findAll({
|
||||
where: { team_uid: { [Op.in]: uids }, group_name: group },
|
||||
attributes: ['team_uid', 'team_id', 'team_number'],
|
||||
raw: true,
|
||||
});
|
||||
const teamByUid = {};
|
||||
for (const t of teams) teamByUid[t.team_uid] = t;
|
||||
|
||||
for (const cid of Object.keys(teamUidByCid)) {
|
||||
const uid = teamUidByCid[cid];
|
||||
const t = teamByUid[uid];
|
||||
out.byContestant[cid] = {
|
||||
teamUid: uid,
|
||||
// 人类可读小组编号优先 team_number(形如 BRICS2026-ST-027-91001),回退 team_id
|
||||
teamNumber: t ? (t.team_number || t.team_id || null) : null,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端按三小问给分。
|
||||
*
|
||||
@@ -454,6 +536,371 @@ class BigQuestionService extends Service {
|
||||
return 10;
|
||||
}
|
||||
|
||||
// ==================== 成绩 Excel 导入(R172) ====================
|
||||
//
|
||||
// 使用场景:阅卷完成后,评卷人手上只有一张「小组编号 → 三小问得分」的 Excel。
|
||||
// 逐人在管理端点开评分弹窗录入几百遍既慢又易错,故提供批量导入。
|
||||
//
|
||||
// 四条硬约束(改动前务必先读):
|
||||
// 1) **写入仍只走 setSubScores** —— 即仍经 service.admin.updateScores 同步
|
||||
// contestants.word_score / score。「成绩发布门控」「总分公式」因此不受影响,
|
||||
// 本功能绝不新增第二条写成绩的通路。
|
||||
// 2) **定位键 = (group, 小组编号)**。team_uid 跨组重号 ⇒ 必须带 group 限定;
|
||||
// 流程为 小组编号 → teams.team_uid → 组内成员 → **唯一的抽题者**。
|
||||
// 3) **已评分默认拒绝**,需显式 overwrite 才覆盖(用户裁定)。
|
||||
// 4) **三小问是逐项取值的,不做均分**(R174 用户裁定)。留空即整行拒绝,
|
||||
// 缺列即整份拒绝,旧两列模板整份拒绝 ⛔ 不得为「兼容」加均分回退分支。
|
||||
|
||||
/**
|
||||
* 生成「小组编号 + 三小问」成绩导入模板(xlsx)。
|
||||
* sheet 顺序是契约:第 1 页 `成绩导入`(数据,首行表头),第 2 页 `填写说明`。
|
||||
*
|
||||
* 🔴 R174:模板由「小组编号 + 分数」两列改为**四列**(小组编号 + 第1/2/3小问)。
|
||||
* 用户裁定「只认三小问模板」⇒ 总分不再由用户填,改由系统对三项求和。
|
||||
* ⛔ 不得再往表里加「总分」列 —— 那会立刻产生「总分与三项之和不一致该信谁」的歧义。
|
||||
*
|
||||
* @param {string} group
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async buildScoreTemplate(group) {
|
||||
const groupLabel = GROUP_LABELS[group] || group;
|
||||
const header = ['小组编号', '第1小问', '第2小问', '第3小问'];
|
||||
// 示例刻意用「9 / 8.67 / 8.67」而不是三个整数:让评卷人一眼看到可填小数
|
||||
const example = ['BRICS2026-ST-027-91001', 9, 8.67, 8.67];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([header, example]);
|
||||
ws['!cols'] = [{ wch: 32 }, { wch: 11 }, { wch: 11 }, { wch: 11 }];
|
||||
|
||||
const notes = [
|
||||
['综合大题 成绩导入模板 — 填写说明'],
|
||||
[],
|
||||
[`当前模板适用组别:${groupLabel}(导入时以管理端选中的组别为准,本表不含组别列)`],
|
||||
[],
|
||||
['列名', '是否必填', '说明'],
|
||||
['小组编号', '必填', '与「选手管理 → 选手列表」显示的小组编号完全一致,例如 BRICS2026-ST-027-91001。'
|
||||
+ '前后空格会自动去掉,但字母大小写必须一致。'],
|
||||
['第1小问', '必填', '该小组第 1 小问得分,范围 0 ~ 10,可含两位小数(例如 8.67)。'],
|
||||
['第2小问', '必填', '该小组第 2 小问得分,范围 0 ~ 10,可含两位小数。'],
|
||||
['第3小问', '必填', '该小组第 3 小问得分,范围 0 ~ 10,可含两位小数。'],
|
||||
[],
|
||||
['【组别说明】'],
|
||||
['本文件不需要填写组别。导入时以「管理端当前选中的组别」为准,',
|
||||
'若某小组编号属于其他组别,该行会被拒绝并提示切换到对应页签。'],
|
||||
[],
|
||||
['【★ 为什么要用小组编号,而不是姓名 / 身份证号】'],
|
||||
['一个小组只会有一人真正参加考试(抽题作答),但成绩是**组内共享**的:',
|
||||
'导入后只写在参赛者名下,同组其他成员登录查分时看到的是同一份成绩。',
|
||||
'因此按小组编号导入即可,无需逐个成员填写。'],
|
||||
[],
|
||||
['【★ 三列小问必须全部填写,不能留空】'],
|
||||
['三小问**逐项填写**,系统只做求和,不做任何均分或补齐:',
|
||||
'例:9 / 8.67 / 8.67 → 大题总分 26.34(系统自动算,表中不需要填总分)。',
|
||||
'⚠️ 只要有一问留空,**整行会被拒绝并报错**(例如「第 2 小问未填写」)。',
|
||||
' 系统不会把留空当 0 分 —— 那样会静默抹掉评卷人已经给出的分数。',
|
||||
'⚠️ 三列都要保留:不要删除任何一列,也不要改列名,否则整份文件会被拒绝。'],
|
||||
[],
|
||||
['【★ 旧版「小组编号 + 分数」两列模板已不再支持】'],
|
||||
['若你手上还是老的「小组编号 / 分数」两列表格,导入会被整份拒绝并提示',
|
||||
'「检测到旧版两列模板」。请在管理端重新下载本模板,把总分拆成三小问后再导入。'],
|
||||
[],
|
||||
['【★ 已评分的小组会被拒绝】'],
|
||||
['默认不覆盖既有评分:若该考生已有大题成绩,该行会被跳过并提示。',
|
||||
'确需覆盖时,在导入对话框里勾选「覆盖已评分」后再导入。'],
|
||||
[],
|
||||
['【常见被拒原因(导入时会逐行报错)】'],
|
||||
['1. 小组编号为空,或在本组 teams 表中不存在(核对空格 / 是否漏填前缀)'],
|
||||
['2. 小组编号属于其他组别 → 请切换到对应组别页签再导入'],
|
||||
['3. 同一小组编号在本文件中出现多次 → 全部拒绝(无法判断以哪行为准)'],
|
||||
['4. 某个小问留空 → 整行拒绝(三问必须齐全)'],
|
||||
['5. 某个小问不是数值、为负数、或超过 10 分'],
|
||||
['6. 该小组尚无人抽取综合大题 → 说明还没考试或还没进入作答页,暂不能录分'],
|
||||
['7. 该小组有多人抽取了大题 → 与「一组一人参赛」不符,需人工先核对'],
|
||||
['8. 该考生已评分,但未勾选「覆盖已评分」'],
|
||||
[],
|
||||
['【Q & A】'],
|
||||
['Q1:小组编号里有纯数字(如 1、2、3),Excel 会不会转成数值导致匹配不上?'],
|
||||
['A:不会。系统读取时会转成文本再比对。但若编号带前导零(如 007),',
|
||||
' Excel 会吞掉前导零 → 请在单元格格式里把该列设为「文本」后再填。'],
|
||||
['Q2:同一个小组成员要不要每人都填一行?'],
|
||||
['A:不需要。一个小组填一行即可,成绩自动全组共享。'],
|
||||
['Q3:表中要不要写总分?'],
|
||||
['A:不要。总分由系统对三小问求和得到,表里没有也不需要总分列。'],
|
||||
['Q4:导入后还能改吗?'],
|
||||
['A:可以。勾选「覆盖已评分」重新导入,或在管理端逐人手工改分。'],
|
||||
['Q5:导错了怎么办?'],
|
||||
['A:重新导入正确分数并勾选「覆盖已评分」即可;不会产生重复行(一个考生只有一行大题记录)。'],
|
||||
];
|
||||
const notesWs = XLSX.utils.aoa_to_sheet(notes);
|
||||
notesWs['!cols'] = [{ wch: 16 }, { wch: 10 }, { wch: 92 }];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '成绩导入');
|
||||
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并**预览**成绩导入(零写入)。
|
||||
*
|
||||
* 每个 Excel 行都会被判定成一个明确状态;前端据此展示「可导入 / 将覆盖 / 被拒绝(原因)」,
|
||||
* 管理员确认后才执行写入。⛔ 不做「上传即写库」——成绩误写不可撤销。
|
||||
*
|
||||
* @param {string} group
|
||||
* @param {Buffer} buffer - xlsx 二进制(内存解析,不落盘)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async previewScoreImport(group, buffer) {
|
||||
const { ctx } = this;
|
||||
if (!this.isEnabledGroup(group)) {
|
||||
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||
}
|
||||
|
||||
let aoa;
|
||||
try {
|
||||
const wb = XLSX.read(buffer, { type: 'buffer' });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
// header:1 ⇒ 返回二维数组,保留真实行号;⛔ 不用 sheet_to_json 的对象键,
|
||||
// 因为表头可能不在第 1 行、且用户表头写法不固定(统一走 bigScoreImport 的别名匹配)
|
||||
aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true });
|
||||
} catch (e) {
|
||||
return { ok: false, code: 'PARSE_FAILED', message: 'Excel 解析失败:' + e.message };
|
||||
}
|
||||
|
||||
const parsed = bigScoreImport.parseScoreRows(aoa);
|
||||
if (!parsed.ok) return { ok: false, code: parsed.code, message: parsed.message };
|
||||
|
||||
// ---- 上下文预查(一次查完,避免逐行打库)----
|
||||
const Op = ctx.model.Sequelize.Op;
|
||||
const teamNumbers = Array.from(new Set(parsed.rows.map(r => r.teamNumber).filter(Boolean)));
|
||||
|
||||
// ① 本组 teams:team_number 与 team_id 都可能被当作「小组编号」,两者都查
|
||||
const teamsInGroup = await ctx.model.Team.findAll({
|
||||
where: {
|
||||
group_name: group,
|
||||
[Op.or]: [
|
||||
{ team_number: { [Op.in]: teamNumbers } },
|
||||
{ team_id: { [Op.in]: teamNumbers } },
|
||||
],
|
||||
},
|
||||
attributes: ['team_uid', 'team_id', 'team_number'],
|
||||
raw: true,
|
||||
});
|
||||
// ② 他组 teams:用于区分「不存在」与「属于其他组」
|
||||
const teamsInOther = await ctx.model.Team.findAll({
|
||||
where: {
|
||||
group_name: { [Op.ne]: group },
|
||||
[Op.or]: [
|
||||
{ team_number: { [Op.in]: teamNumbers } },
|
||||
{ team_id: { [Op.in]: teamNumbers } },
|
||||
],
|
||||
},
|
||||
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const indexTeam = (rows) => {
|
||||
const m = {};
|
||||
for (const t of rows) {
|
||||
for (const key of [t.team_number, t.team_id]) {
|
||||
const k = bigScoreImport.normalizeTeamNumber(key);
|
||||
if (k && !m[k]) m[k] = t;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const teamByNumber = indexTeam(teamsInGroup);
|
||||
const otherByNumber = indexTeam(teamsInOther);
|
||||
|
||||
// ③ 各处室上下文:小组成员、抽题者、现有成绩
|
||||
const teamUids = Array.from(new Set(teamsInGroup.map(t => t.team_uid)));
|
||||
const members = teamUids.length
|
||||
? await ctx.model.Contestant.findAll({
|
||||
where: { group, team_id: { [Op.in]: teamUids } },
|
||||
attributes: ['id', 'name', 'id_number', 'team_id'],
|
||||
raw: true,
|
||||
})
|
||||
: [];
|
||||
// 报名聚合里的 team_uid 也算成员(权威来源),与 contestants.team_id 取并集
|
||||
const aggMembers = teamUids.length
|
||||
? await ctx.model.Aggregation.findAll({
|
||||
where: { group, team_uid: { [Op.in]: teamUids } },
|
||||
attributes: ['account_uid', 'team_uid'],
|
||||
raw: true,
|
||||
})
|
||||
: [];
|
||||
const memberUidByCid = {};
|
||||
for (const m of members) memberUidByCid[m.id] = Number(m.team_id);
|
||||
for (const a of aggMembers) {
|
||||
if (a.account_uid && !memberUidByCid[a.account_uid]) memberUidByCid[a.account_uid] = Number(a.team_uid);
|
||||
}
|
||||
const allCids = Object.keys(memberUidByCid).map(Number);
|
||||
const contestantInfo = allCids.length
|
||||
? await ctx.model.Contestant.findAll({
|
||||
where: { id: { [Op.in]: allCids }, group },
|
||||
attributes: ['id', 'name', 'id_number'],
|
||||
raw: true,
|
||||
})
|
||||
: [];
|
||||
const infoByCid = {};
|
||||
for (const c of contestantInfo) infoByCid[c.id] = c;
|
||||
|
||||
const answers = allCids.length
|
||||
? await ctx.model.BigQuestionAnswer.findAll({
|
||||
where: { group, contestant_id: { [Op.in]: allCids } },
|
||||
attributes: [
|
||||
'id', 'contestant_id', 'aggregation_uid', 'big_question_id',
|
||||
'score_1', 'score_2', 'score_3', 'total_score',
|
||||
],
|
||||
raw: true,
|
||||
})
|
||||
: [];
|
||||
const answerByCid = {};
|
||||
for (const a of answers) if (a.contestant_id) answerByCid[a.contestant_id] = a;
|
||||
|
||||
// ④ 逐行判定(全部走 lib 的纯函数,与自证同源)
|
||||
const rows = [];
|
||||
for (const r of parsed.rows) {
|
||||
const team = r.teamNumber ? teamByNumber[r.teamNumber] : null;
|
||||
const other = r.teamNumber ? otherByNumber[r.teamNumber] : null;
|
||||
const teamUid = team ? Number(team.team_uid) : null;
|
||||
|
||||
const cidsInTeam = teamUid
|
||||
? Object.keys(memberUidByCid).filter(cid => memberUidByCid[cid] === teamUid).map(Number)
|
||||
: [];
|
||||
const operators = cidsInTeam.filter(cid => !!answerByCid[cid]);
|
||||
const operatorCid = operators.length === 1 ? operators[0] : null;
|
||||
const rec = operatorCid ? answerByCid[operatorCid] : null;
|
||||
|
||||
const judged = bigScoreImport.classifyScoreRow({
|
||||
teamNumber: r.teamNumber,
|
||||
subRaws: r.subRaws,
|
||||
dupCount: r.dupCount,
|
||||
foundInGroup: !!team,
|
||||
foundInOtherGroup: !!other,
|
||||
otherGroupLabel: other ? (GROUP_LABELS[other.group_name] || other.group_name) : '',
|
||||
operatorCount: operators.length,
|
||||
currentTotal: rec ? rec.total_score : null,
|
||||
subCount: SUB_QUESTION_COUNT,
|
||||
perSub: rec ? await this.resolveScorePerSub(group, rec) : bigScoreImport.DEFAULT_PER_SUB,
|
||||
});
|
||||
|
||||
const opInfo = operatorCid ? infoByCid[operatorCid] : null;
|
||||
rows.push({
|
||||
row: r.row,
|
||||
teamNumber: r.teamNumber,
|
||||
subRaws: r.subRaws,
|
||||
// 三小问填报值(判定通过时才有);前端据此显示「第1/2/3小问」与合计
|
||||
subs: judged.subs || null,
|
||||
total: judged.total === undefined ? null : judged.total,
|
||||
status: judged.status,
|
||||
message: judged.message,
|
||||
blocking: bigScoreImport.BLOCKING_STATUS.indexOf(judged.status) >= 0,
|
||||
// 命中信息(前端展示用;⛔ 不含身份证号以外的敏感字段)
|
||||
contestantId: operatorCid,
|
||||
contestantName: opInfo ? opInfo.name : null,
|
||||
contestantIdNumber: opInfo ? opInfo.id_number : null,
|
||||
teamUid,
|
||||
memberCount: cidsInTeam.length,
|
||||
operatorCount: operators.length,
|
||||
currentScores: rec
|
||||
? { score1: rec.score_1, score2: rec.score_2, score3: rec.score_3, total: rec.total_score }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
const count = s => rows.filter(x => x.status === s).length;
|
||||
return {
|
||||
ok: true,
|
||||
group,
|
||||
total: rows.length,
|
||||
importable: count(bigScoreImport.ROW_STATUS.OK),
|
||||
overwritable: count(bigScoreImport.ROW_STATUS.OVERWRITE),
|
||||
blocked: rows.filter(x => x.blocking).length,
|
||||
skippedBlank: count(bigScoreImport.ROW_STATUS.EMPTY_ROW),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行成绩导入(先 preview,再逐行写)。
|
||||
*
|
||||
* @param {string} group
|
||||
* @param {Buffer} buffer
|
||||
* @param {Object} options - { overwrite:boolean }
|
||||
* @returns {Promise<Object>} { ok, imported, skipped, errors, preview }
|
||||
*/
|
||||
async importScores(group, buffer, options = {}) {
|
||||
const overwrite = options.overwrite === true;
|
||||
const preview = await this.previewScoreImport(group, buffer);
|
||||
if (!preview.ok) return preview;
|
||||
|
||||
const S = bigScoreImport.ROW_STATUS;
|
||||
const result = { ok: true, imported: 0, skipped: 0, overwritten: 0, errors: [], preview };
|
||||
|
||||
for (const r of preview.rows) {
|
||||
// 空行不计错误
|
||||
if (r.status === S.EMPTY_ROW) continue;
|
||||
|
||||
// 已评分:未勾选覆盖 ⇒ 跳过(不是失败,但要能看见)
|
||||
if (r.status === S.OVERWRITE && !overwrite) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: r.message });
|
||||
continue;
|
||||
}
|
||||
// 其余阻断态一律跳过
|
||||
if (r.blocking) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: r.message });
|
||||
continue;
|
||||
}
|
||||
// 到这里只剩 OK / OVERWRITE(且已确认)
|
||||
if (!r.contestantId) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '未解析到参赛考生,已跳过' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 三小问逐项写入(⛔ 不再均分;值直接来自判定层 scoreSubs,与预览展示完全一致)
|
||||
const subs = Array.isArray(r.subs) ? r.subs : [];
|
||||
if (subs.length !== SUB_QUESTION_COUNT) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '三小问得分不完整,请重新预览后再导入' });
|
||||
continue;
|
||||
}
|
||||
const rec = await this._findAnswerByContestant(group, r.contestantId);
|
||||
if (!rec) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '大题作答记录已消失,请重新预览' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 🔴 写入走唯一评分路径(setSubScores → service.admin.updateScores)
|
||||
const written = await this.setSubScores(group, rec.aggregation_uid, {
|
||||
score_1: subs[0],
|
||||
score_2: subs[1],
|
||||
score_3: subs[2],
|
||||
});
|
||||
if (!written.ok) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: written.message });
|
||||
continue;
|
||||
}
|
||||
result.imported++;
|
||||
if (r.status === S.OVERWRITE) result.overwritten++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 按考生取大题作答记录(组内限定,防跨组同名聚合 uid) */
|
||||
async _findAnswerByContestant(group, contestantId) {
|
||||
const { ctx } = this;
|
||||
return ctx.model.BigQuestionAnswer.findOne({
|
||||
where: { group, contestant_id: contestantId },
|
||||
attributes: ['id', 'contestant_id', 'aggregation_uid', 'total_score'],
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 题库管理(管理端) ====================
|
||||
|
||||
/**
|
||||
@@ -845,6 +1292,16 @@ class BigQuestionService extends Service {
|
||||
runs: [{ text: `${groupLabel}综合大题阅卷表`, bold: true, size: 32 }],
|
||||
});
|
||||
blocks.push({ type: 'p', runs: [{ text: `姓名:${s.name || '--'} 身份证号:${s.idNumber || '--'} 手机号:${s.phone || '--'}` }] });
|
||||
// R172(用户要求):**在 Word 文件内用红色字体标注小组编号**。
|
||||
// 阅卷人按小组编号分发 / 归档 / 核对纸卷,需一眼可辨,故独立一行、红色加粗。
|
||||
// ⚠️ 没有小组编号时显式印「(无)」而**不是省略该行**:省略会让人误判成「导出漏了字段」。
|
||||
blocks.push({
|
||||
type: 'p',
|
||||
runs: [
|
||||
{ text: '小组编号:', bold: true, color: 'FF0000' },
|
||||
{ text: s.teamNumber || '(无)', bold: true, color: 'FF0000' },
|
||||
],
|
||||
});
|
||||
blocks.push({ type: 'p', runs: [{ text: `抽到题目:${snap.title || '--'}(${s.bigQuestionUid || '--'})` }] });
|
||||
blocks.push({ type: 'p', runs: [{ text: `提交时间:${fmtTime(s.submitTime)} 已答小问:${s.answeredCount} / ${SUB_QUESTION_COUNT}` }] });
|
||||
|
||||
|
||||
@@ -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