Files
competition-website/app/service/bigQuestion.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

1605 lines
69 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
const Service = require('egg').Service;
// 2026-10-10 换库:npm 上的 `xlsx` 永久冻结在 0.18.5(含 CVE-2023-30533 原型污染 +
// CVE-2024-22363 ReDoS,且 npm 无修复版本)。SheetJS 官方把修复版只发自家 CDN,
// 而 cdn.sheetjs.com 在部署环境不可达 ⇒ 改用 npm 上忠实转发官方 CDN 构建的
// `@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
*
* 2026-09-21 需求变更:中职组(zhongzhi) / 高职组(gaozhi) 取消「Word 文件上传题」,
* 改为从题库随机抽取一道大题在线作答。大题 = 文字描述 + 富文本表格(HTML) + 三小问,
* 每小问 10 分,大题总分 30 分。
*
* ── 隔离边界(硬约束:除本次功能外不得影响其他功能)─────────────────────────
* · 本服务**只读写新增的 big_questions / big_question_answers**;
* · 不触碰 zhongzhi_submissions / gaozhi_submissions(客观题逐题表);
* · 不触碰 benke_submissions / jiaoshi_submissions(本科/教师组作品提交);
* · 不触碰 choice_questions / judge_questions(客观题题库);
* · 评分**复用既有唯一写路径** service.admin.updateScores(见 setSubScores),
* 保证 contestants.word_score / score 的写入口径与「成绩发布门控」不出现第二条通路。
*
* ── 关键存储事实 ─────────────────────────────────────────────────────────
* controller/exam.js:90 `_getUserAggregation()` 返回 `aggregation_uid: contestant.id`,
* 故中职/高职组本表 aggregation_uid 即 contestants.id(与 zhongzhi/gaozhi_submissions
* 的 aggregation_uid 同口径),管理端查证可按 contestants.id 直接映射。
*
* MariaDB 5.5 无 JSON 列类型,题干快照以 JSON 文本存 MEDIUMTEXT。
*/
/** 启用「综合大题」的组别(其余组别走原有作品/Word 提交通道,不受影响) */
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi'];
/** 每小问作答上限(字符数,与前端 textarea maxlength 同口径,前后端一致) */
const MAX_ANSWER_CHARS = 2000;
/** 大题固定含 3 小问 */
const SUB_QUESTION_COUNT = 3;
const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3'];
/** 大题 Excel 导入:逻辑字段 → 表头别名(R73)。
* 归一化思路与客观题导入同源:测试人员的表头常带前后空格 / 全角空格 / BOM,
* 或用近义词;不归一化就会出现「列名取不到 ⇒ 整表 0 导入」的老问题
* (参照 service/admin.js importQuestionsFromExcel 的 keyAlias)。
*/
const BIG_Q_COLUMNS = {
title: ['标题', '题目标题', '大题标题', 'title'],
description: ['题干', '题目描述', 'description'],
descriptionHtml: ['题干HTML', '题干html', '题干富文本', 'descriptionHtml', 'description_html'],
sub1: ['小问1', '第1小问', '小问一', 'sub_prompt_1', 'subPrompt1'],
sub2: ['小问2', '第2小问', '小问二', 'sub_prompt_2', 'subPrompt2'],
sub3: ['小问3', '第3小问', '小问三', 'sub_prompt_3', 'subPrompt3'],
scorePerSub: ['每小题分值', '每问分值', '小问分值', 'score_per_sub', 'scorePerSub'],
sortOrder: ['排序', '排序值', 'sort_order', 'sortOrder'],
status: ['状态', '启用状态', 'status'],
};
/** 大题 Excel 模板的列顺序(首行表头,必须落在第 1 行) */
const BIG_Q_HEADER = [
'标题', '题干', '题干HTML', '小问1', '小问2', '小问3',
'每小题分值', '排序', '状态',
];
/** 归一化表头:去首尾空白 / 全角空格 / BOM,并与别名表对齐 */
function normalizeBigQRow(raw) {
const out = {};
for (const k of Object.keys(raw || {})) {
const nk = String(k).replace(/[\s ]+/g, '');
out[nk] = raw[k];
}
const pick = (field) => {
const aliases = BIG_Q_COLUMNS[field];
for (const a of aliases) {
const v = out[a];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
};
return {
title: pick('title'),
description: pick('description'),
descriptionHtml: pick('descriptionHtml'),
sub1: pick('sub1'),
sub2: pick('sub2'),
sub3: pick('sub3'),
scorePerSub: pick('scorePerSub'),
sortOrder: pick('sortOrder'),
status: pick('status'),
};
}
const SCORE_FIELDS = ['score_1', 'score_2', 'score_3'];
/** 组别中文名(导出文档抬头 / 文件名用) */
const GROUP_LABELS = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
/** 导出文件名里的非法字符(Windows 禁止 \ / : * ? " < > |,另剔除控制字符) */
function safeFilePart(str) {
return String(str == null ? '' : str)
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 80) || 'untitled';
}
class BigQuestionService extends Service {
/** 该组别是否启用综合大题 */
isEnabledGroup(group) {
return BIG_QUESTION_GROUPS.includes(group);
}
/** 每小问字数上限(供 controller 回传前端) */
answerMaxChars() {
return MAX_ANSWER_CHARS;
}
/** 小问数量 */
subQuestionCount() {
return SUB_QUESTION_COUNT;
}
// ==================== 考生端 ====================
/**
* 取回本次抽中的大题 + 已保存的作答(供考生端渲染)。
*
* 幂等语义:同一考生重复进入只会得到**同一道题**(首次进入时抽题并落库快照,
* 之后一律复用该行),这是「避免考生纠纷」的基础保证。
*
* ⚠️ 返回值**不含任何分数**:成绩经发布门控后仅由 GET /api/score 单通道下发
* (铁律15 成绩泄露四通道封堵)。
*
* @param {number} aggregationUid - 聚合报名ID(中职/高职组 == contestants.id)
* @param {string} group - 组别
* @param {number} [contestantId] - 选手ID(冗余存证用,缺省同 aggregationUid)
* @returns {Promise<Object>} { ok, code, message, question, answers, submitTime, ... }
*/
async getForCandidate(aggregationUid, group, contestantId) {
const drawn = await this.drawOrGet(aggregationUid, group, contestantId);
if (!drawn.ok) return drawn;
const record = drawn.record;
return {
ok: true,
reused: drawn.reused,
question: this.parseSnapshot(record.question_snapshot),
answers: {
answer_1: record.answer_1 || '',
answer_2: record.answer_2 || '',
answer_3: record.answer_3 || '',
},
submitTime: record.submit_time || null,
maxChars: MAX_ANSWER_CHARS,
subCount: SUB_QUESTION_COUNT,
};
}
/**
* 抽题或取回已抽中的题(并发安全,一考生一行)。
* @param {number} aggregationUid
* @param {string} group
* @param {number} [contestantId]
* @returns {Promise<Object>} { ok, record, reused } 或 { ok:false, code, message }
*/
async drawOrGet(aggregationUid, group, contestantId) {
const { ctx } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
if (!aggregationUid) {
return { ok: false, code: 'NO_AGGREGATION', message: '未找到您的报名信息' };
}
const Answer = ctx.model.BigQuestionAnswer;
// 已抽过 → 直接复用(题目确定性 = 防纠纷)
const existed = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
if (existed) return { ok: true, record: existed, reused: true };
// 首次进入:从该组启用中的题库随机抽 1 道
const Question = ctx.model.BigQuestion;
const where = { group, status: 1 };
const total = await Question.count({ where });
if (!total) {
return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' };
}
// 以 count + 随机 offset 抽样(不依赖特定方言的 RAND(),MariaDB 5.5 亦安全)
const offset = Math.floor(Math.random() * total);
const picked = await Question.findOne({
where,
order: [['sort_order', 'ASC'], ['id', 'ASC']],
offset,
limit: 1,
});
if (!picked) {
return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' };
}
const snapshot = this.buildSnapshot(picked);
const defaults = {
aggregation_uid: aggregationUid,
contestant_id: contestantId || aggregationUid,
group,
big_question_id: picked.id,
big_question_uid: picked.question_uid,
question_snapshot: JSON.stringify(snapshot),
answer_1: '',
answer_2: '',
answer_3: '',
submit_time: null,
};
try {
const created = await Answer.create(defaults);
return { ok: true, record: created, reused: false };
} catch (e) {
// 唯一键竞争(同一考生并发首抽):回读已落库的那一行,
// 保证「一考生一行」且题目只有一个权威版本(另一并发请求已写入快照)。
if (this.isDupKey(e)) {
const winner = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
if (winner) return { ok: true, record: winner, reused: true };
}
throw e;
}
}
/**
* 保存三小问作答(后端同步做长度校验,防前端绕过 maxlength)。
* @param {number} aggregationUid
* @param {string} group
* @param {Object} answers - { answer_1, answer_2, answer_3 }
* @returns {Promise<Object>} { ok, filled, record } 或 { ok:false, code, message }
*/
async saveAnswers(aggregationUid, group, answers) {
const { ctx } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
// 长度校验:逐问超限即整批拒绝(不静默截断——截断会造成考生答案与页面所见不一致)
const normalized = [];
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
const raw = answers ? answers[ANSWER_FIELDS[i]] : undefined;
const val = raw === undefined || raw === null ? '' : String(raw);
if (val.length > MAX_ANSWER_CHARS) {
return {
ok: false,
code: 'ANSWER_TOO_LONG',
message: `第 ${i + 1} 小问作答超过 ${MAX_ANSWER_CHARS} 字上限(当前 ${val.length} 字)`,
};
}
normalized.push(val);
}
const Answer = ctx.model.BigQuestionAnswer;
const record = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
if (!record) {
// 未抽题就作答 → 明确拒绝,避免产生「无题目来源」的孤儿作答
return { ok: false, code: 'NOT_DRAWN', message: '尚未抽取题目,请先进入考试页面抽取题目' };
}
await record.update({
answer_1: normalized[0],
answer_2: normalized[1],
answer_3: normalized[2],
});
const filled = normalized.filter(v => v.trim().length > 0).length;
return { ok: true, filled, record };
}
/**
* 随整卷正式提交时补写 submit_time(已写则不覆盖,保留首次提交时间)。
* @param {number} aggregationUid
* @returns {Promise<Object|null>} 更新后的记录(无大题作答行则返回 null)
*/
async markSubmitted(aggregationUid) {
const { ctx } = this;
if (!aggregationUid) return null;
const record = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: aggregationUid },
});
if (!record) return null;
if (!record.submit_time) {
await record.update({ submit_time: new Date() });
}
return record;
}
/**
* 判断大题是否已答满(三小问均有非空内容)。
* @param {Object} record
* @returns {boolean}
*/
isCompleted(record) {
if (!record) return false;
return ANSWER_FIELDS.every(f => String(record[f] || '').trim().length > 0);
}
// ==================== 管理端 ====================
/**
* 管理端查证视图:按组返回「每位考生 → 抽到的题目(快照)→ 三小问作答 → 提交时间」。
*
* 包含该组**全部考生**(未抽题者 drawn=false),便于管理员核对缺考/未作答情况。
* 题目取**题干快照**而非当前题库内容 —— 考生看到的即当时那版,防止题目被改后无法举证。
*
* @param {string} group
* @returns {Promise<Array>} 查证列表
*/
async listAnswersForAdmin(group) {
const { ctx } = this;
const answers = await ctx.model.BigQuestionAnswer.findAll({
where: { group },
order: [['id', 'ASC']],
});
// aggregation_uid 与 contestant_id 双键索引(铁律14:跨键须双向映射防单键错配)
const byAgg = {};
const byContestant = {};
for (const a of answers) {
byAgg[String(a.aggregation_uid)] = a;
if (a.contestant_id != null) byContestant[String(a.contestant_id)] = a;
}
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'team_id', 'word_score'],
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,
answer1: rec ? (rec.answer_1 || '') : '',
answer2: rec ? (rec.answer_2 || '') : '',
answer3: rec ? (rec.answer_3 || '') : '',
answeredCount: rec ? ANSWER_FIELDS.filter(f => String(rec[f] || '').trim().length > 0).length : 0,
score1: rec ? rec.score_1 : null,
score2: rec ? rec.score_2 : null,
score3: rec ? rec.score_3 : null,
bigScore: rec ? rec.total_score : null,
wordScore: c.word_score != null ? Number(c.word_score) : null,
submitTime: rec ? rec.submit_time : null,
};
});
}
/**
* 批量解析「考生 → 小组编号」(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;
}
/**
* 管理端按三小问给分。
*
* 双写但**不新增成绩通路**:
* 1) 三小问得分 + 合计落 big_question_answers(存证 / 复核依据);
* 2) 合计分通过既有唯一评分路径 service.admin.updateScores 同步 contestants.word_score
* → 与「成绩发布门控」「总分公式(中职/高职 = exam_score + word_score)」完全一致。
*
* @param {string} group
* @param {number} aggregationUid
* @param {Object} scores - { score_1, score_2, score_3 }
* @returns {Promise<Object>} { ok, total } 或 { ok:false, code, message }
*/
async setSubScores(group, aggregationUid, scores) {
const { ctx, service } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
// ⚠️ 必须带 group(与读取路径 listAnswersForAdmin 同源):只按 aggregation_uid 定位时,
// 用任意组别的路径即可改写他组考生成绩,且下方 updateScores(..., group) 会用错组
// 去同步总分 ⇒ 组别隔离在写入侧被绕过(2026-09-23 实测:
// PUT /big-answers/gaozhi/<中职考生uid>/scores 返回 code=0 并真实改写了中职行)。
const record = await ctx.model.BigQuestionAnswer.findOne({
where: { group, aggregation_uid: aggregationUid },
});
if (!record) {
// 区分「不属于本组」与「尚未抽题」:前者通常意味着前端残留了上一组的 uid,
// 文案要能直接暴露这类误操作,而不是把它伪装成「考生没抽题」。
const elsewhere = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: aggregationUid },
attributes: ['id'],
});
return {
ok: false,
code: 'NOT_FOUND',
message: elsewhere
? '该考生不属于当前组别,无法在本组评分'
: '该考生尚未抽取大题,无法评分',
};
}
// 逐问校验 0–per 的数值(可含一位小数;per 来自题库 score_per_sub,默认 10)
const per = await this.resolveScorePerSub(group, record);
const parsed = [];
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
const raw = scores ? scores[SCORE_FIELDS[i]] : undefined;
if (raw === undefined || raw === null || raw === '') {
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问必须填写得分(0-${per})` };
}
const n = Number(raw);
if (!Number.isFinite(n) || n < 0 || n > per) {
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值(可含两位小数)` };
}
// 2026-09-28:评分精确到小数点后**两位**(测试人员反馈)⇒ 收敛到 2 位小数,
// ⛔ 不可用 Math.round 抹成整数(会丢掉 8.25 这类分数)
parsed.push(Math.round(n * 100) / 100);
}
// 浮点求和再收敛一次,避免 0.1+0.2=0.30000000000000004 之类脏值落库/回显
const total = Math.round(parsed.reduce((a, b) => a + b, 0) * 100) / 100;
await record.update({
score_1: parsed[0],
score_2: parsed[1],
score_3: parsed[2],
total_score: total,
});
// 同步到既有成绩路径(contests.word_score / score + accounts + 作品分项),
// 复用 AdminService.updateScores,避免出现第二条写成绩的代码路径。
const contestantId = record.contestant_id || record.aggregation_uid;
await service.admin.updateScores([{ contestantId, wordScore: total }], group);
return { ok: true, total, perSubScore: per };
}
/** 取该大题每小题分值(优先题库当前配置,回退快照,再回退 10) */
async resolveScorePerSub(group, record) {
const { ctx } = this;
if (record && record.big_question_id) {
const q = await ctx.model.BigQuestion.findByPk(record.big_question_id, {
attributes: ['score_per_sub'],
});
if (q && q.score_per_sub) return Number(q.score_per_sub);
}
const snap = record ? this.parseSnapshot(record.question_snapshot) : null;
if (snap && snap.score_per_sub) return Number(snap.score_per_sub);
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'],
});
}
// ==================== 题库管理(管理端) ====================
/**
* 题库列表(按组)
* @param {string} group
* @returns {Promise<Array>}
*/
/**
* 生成综合大题 Excel 导入模板(R73)。
*
* sheet 顺序是**契约**:`综合大题`(数据)必须在第 1 个 —— 导入侧按
* `SheetNames[0]` 解析(与客观题 importQuestionsFromExcel 同口径);
* `填写说明` 放第 2 个,测试人员下载后点底部标签即可查看,删掉也不影响导入。
*
* @returns {Promise<Buffer>} xlsx 二进制
*/
async buildTemplate() {
const example = [
'卫星导航系统组成与定位原理',
'简述北斗三号全球卫星导航系统的组成,并说明其基本定位原理。',
'',
'北斗三号由哪三大部分组成?各部分的主要功能是什么?',
'说明单点定位与差分定位的基本原理差异。',
'分析卫星信号传播延时对定位精度的影响。',
10,
0,
0,
];
const ws = XLSX.utils.aoa_to_sheet([BIG_Q_HEADER, example]);
ws['!cols'] = [
{ wch: 28 }, { wch: 46 }, { wch: 40 }, { wch: 34 }, { wch: 34 },
{ wch: 34 }, { wch: 12 }, { wch: 8 }, { wch: 8 },
];
const notes = [
['综合大题导入模板 — 填写说明'],
[],
['列名', '是否必填', '说明'],
['标题', '必填', '大题标题,不超过 200 字。同一组内标题不可重复(重复的行会被跳过并报错)'],
['题干', '选填', '纯文本题干。若题目需要表格/加粗等富文本,请把 HTML 填进右侧「题干HTML」列;两列都填时以 HTML 为准'],
['题干HTML', '选填', '富文本 HTML,可用 Excel 单元格编辑。例:<p>系统由 <b>3</b> 部分组成</p>。留空则使用「题干」纯文本'],
['小问1', '选填', '第 1 小问题干(考生作答区上方的提示文字)。可留空,但建议三问都写全'],
['小问2', '选填', '第 2 小问题干。可留空,但建议三问都写全'],
['小问3', '选填', '第 3 小问题干。可留空,但建议三问都写全'],
['每小题分值', '选填', '每道小问的分值,默认 10。大题总分 = 每小题分值 × 3'],
['排序', '选填', '大题在管理列表里的显示顺序:**数值越小越靠前**。缺省 0;想让某题排在前面就填 1、2、3'],
['状态', '选填', '★ **1 = 启用(参与考生随机抽题)· 0 = 停用(留在题库但不参与抽题)**。本模板建议填 0,理由见下方【常见疑问】'],
[],
['【组别说明】'],
['本文件不需要填写组别。导入时以「管理端当前选择的组别」为准,表格里的组别一律忽略。'],
'',
['【★ 关于「状态」列的强烈建议】'],
['建议全部填 0(停用)。导入后题目会进入题库但不参与抽题;'],
['待管理员人工校对无误后,再在管理端逐条启用。'],
['原因:考生抽到题目后题目会被快照固化,事后再改题库不会影响已抽到的考生。'],
[],
['【常见被拒原因(导入时会逐行报错)】'],
['1. 标题为空,或超过 200 字'],
['2. 同一组内标题重复 → 跳过并报错'],
['3. 每小题分值不是大于 0 的数字'],
['4. 题干或小问中出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样 → 判定为泄题并拒绝'],
['5. 题干中直接给出可照抄的成品答案(如带具体参数的指令示例)→ 判定为泄题并拒绝'],
[' 说明:题干提供「指令格式定义」是允许的,给出「能照抄的成品」才构成泄题。'],
[],
['【常见疑问】'],
['Q1:「排序」和「状态」都填 0,是填错了吗?'],
['A:不是填错。排序 0 = 默认显示顺序(数值越小越靠前,可按需改成 1、2、3);'],
[' 状态 0 = 停用,即题目已入库但暂不参与抽题。'],
['Q2:为什么建议「状态」填 0 而不是 1?'],
['A:这是有意设计的保险。考生抽到题目时,题目会被「快照固化」,事后再怎么改题库,'],
[' 也影响不到已经抽到该题的考生。所以未经人工校对的题不应该直接进考卷 ——'],
[' 先以停用状态入库,校对无误后再启用。'],
['Q3:导入后题目会不会一直不参与考试?'],
['A:不会。到管理端「题库管理 → 综合大题」列表点「编辑」,'],
[' 把「状态」开关拨到「启用」并保存,该题即刻参与抽题。'],
['Q4:「题干」和「题干HTML」填哪个?'],
['A:普通文字填「题干」即可;需要表格、加粗等排版时填「题干HTML」(写 HTML 代码,'],
[' 建议写成一行避免换行被拆断)。两列都填时以「题干HTML」为准。'],
[' 例:<p>北斗三号由 <b>空间段</b>、<b>地面段</b>、<b>用户段</b> 三部分组成。</p>'],
['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 批量导入(R73)。
*
* 设计要点:
* 1. **逐行调用 createQuestion** —— 标题长度、三小问、分值、**泄题检测**
* (detectAnswerLeak)全部自动继承,与管理端手工录入**同一入口、同一校验**。
* 批量导入绝不成为绕过防呆的后门(唯一守点就在录入入口)。
* 2. **组别以入参 group 为准**,忽略表格里的组别列,避免误导到别的组。
* 3. **判重 = 同组内标题**(同组标题重复即视为同一道题),跳过并报错。
* 4. 状态缺省 **0(停用)**:批量录入的题未经人工校对,不直接进抽题池。
*
* @param {string} filePath - xlsx 路径
* @param {Object} options - { group }
* @returns {Promise<Object>} { ok, imported, skipped, errors: [{ row, message }] }
*/
async importFromExcel(filePath, options = {}) {
const { ctx } = this;
const group = options.group;
if (!BIG_QUESTION_GROUPS.includes(group)) {
return {
ok: false,
code: 'GROUP_NOT_SUPPORTED',
message: '综合大题仅支持 ' + BIG_QUESTION_GROUPS.join(' / ') + ' 组,当前:' + group,
};
}
let rows;
try {
const wb = XLSX.readFile(filePath);
const sheet = wb.Sheets[wb.SheetNames[0]];
rows = XLSX.utils.sheet_to_json(sheet);
} catch (e) {
return { ok: false, code: 'PARSE_FAILED', message: 'Excel 解析失败:' + e.message };
}
if (!Array.isArray(rows) || rows.length === 0) {
return { ok: false, code: 'EMPTY_FILE', message: 'Excel 内容为空(请确认上传的是「综合大题」sheet,且首行是表头)' };
}
const result = { imported: 0, skipped: 0, errors: [] };
// 判重集合:库中已有标题 + 本次已导入标题(防同一文件内重复)
const existing = await ctx.model.BigQuestion.findAll({
where: { group },
attributes: ['title'],
raw: true,
});
const titleSet = new Set(existing.map(r => String(r.title || '').trim()));
for (let i = 0; i < rows.length; i++) {
const row = normalizeBigQRow(rows[i]);
const rowIndex = i + 2; // Excel 行号(首行是表头)
if (!row.title) {
// 整行为空 ⇒ 静默跳过(模板示例后的空行常见),不计入错误
const raw = rows[i];
const hasAny = Object.keys(raw || {}).some(k => String(raw[k] || '').trim() !== '');
if (!hasAny) continue;
result.skipped++;
result.errors.push({ row: rowIndex, message: '标题为空' });
continue;
}
if (titleSet.has(row.title)) {
result.skipped++;
result.errors.push({ row: rowIndex, message: `标题「${row.title}」在本组已存在,跳过(避免重复题)` });
continue;
}
// 小问允许留空:与后台手工录入保持一致(弹窗标签写明「可留空」,且
// validateQuestionPayload 对小问无非空校验),故 Excel 导入也不在此处拦截。
// 说明 sheet 中仍提示「建议三问都写全」,由录入人自行把握。
// 题干:HTML 列优先;未填时用纯文本包 <p>
const html = row.descriptionHtml;
const description = html || (row.description ? '<p>' + row.description + '</p>' : '');
const payload = {
title: row.title,
description,
sub_prompt_1: row.sub1,
sub_prompt_2: row.sub2,
sub_prompt_3: row.sub3,
score_per_sub: row.scorePerSub || 10,
sort_order: row.sortOrder || 0,
status: row.status === '' ? 0 : Number(row.status),
};
const created = await this.createQuestion(group, payload);
if (!created.ok) {
result.skipped++;
result.errors.push({ row: rowIndex, message: `${row.title}:${created.message}` });
continue;
}
titleSet.add(row.title);
result.imported++;
}
return Object.assign({ ok: true }, result);
}
async listQuestions(group) {
const { ctx } = this;
const rows = await ctx.model.BigQuestion.findAll({
where: { group },
order: [['sort_order', 'ASC'], ['id', 'ASC']],
});
return rows.map(r => ({
id: r.id,
questionUid: r.question_uid,
group: r.group,
title: r.title,
description: r.description || '',
subPrompt1: r.sub_prompt_1 || '',
subPrompt2: r.sub_prompt_2 || '',
subPrompt3: r.sub_prompt_3 || '',
scorePerSub: r.score_per_sub,
totalScore: r.total_score,
sortOrder: r.sort_order,
status: r.status,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
/**
* 导出大题为 Word(.docx)(2026-09-23 需求:单独 / 批量 / 全部导出中职·高职大题)
*
* 行为:
* · 指定 ids(单个或多个)→ 只导出这些题;
* · 不传 ids → 导出该组**全部**;
* · 命中 1 道 → 直接返回 .docx;
* · 命中多道 → 返回「多个 .docx 打成的一个 .zip」(每道题一个独立 Word 文件,
* 便于分别打印/分发;而不是把多道题塞进一个长文档)。
*
* 只读操作:不写库、不改任何状态。
*
* @param {string} group - 组别(zhongzhi / gaozhi)
* @param {Array<number>|string} [ids] - 大题 ID 列表(数组或逗号分隔字符串)
* @returns {Promise<Object>} { ok:true, fileName, buffer, count, isZip? } 或 { ok:false, code, message }
*/
async exportQuestions(group, ids) {
const { ctx } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
const where = { group };
if (ids !== undefined && ids !== null && ids !== '') {
const raw = Array.isArray(ids) ? ids : String(ids).split(',');
const idList = raw
.map(v => Number(String(v).trim()))
.filter(v => Number.isInteger(v) && v > 0);
if (!idList.length) {
return { ok: false, code: 'INVALID_IDS', message: '导出的大题 ID 列表非法' };
}
// Sequelize:数组值自动生成 IN (...) 条件,无需 Op
where.id = idList;
}
const rows = await ctx.model.BigQuestion.findAll({
where,
order: [['sort_order', 'ASC'], ['id', 'ASC']],
});
if (!rows.length) {
return { ok: false, code: 'NOT_FOUND', message: '该组别下没有可导出的大题' };
}
const docx = require('../lib/docx');
const groupLabel = GROUP_LABELS[group] || group;
const files = [];
for (const r of rows) {
const blocks = [];
// 抬头
blocks.push({
type: 'p',
align: 'center',
runs: [{ text: r.title || '(无标题)', bold: true, size: 32 }],
});
blocks.push({
type: 'p',
runs: [{
text: `组别:${groupLabel}  分值:每小题 ${r.score_per_sub} 分 × ${SUB_QUESTION_COUNT} 小题 = ${r.total_score} 分`,
bold: false,
}],
});
// 题干(富文本 HTML → 段落 + 真实 Word 表格)
blocks.push({ type: 'p', runs: [] });
blocks.push({ type: 'p', runs: [{ text: '【题目】', bold: true, size: 26 }] });
blocks.push(...docx.htmlToBlocks(r.description || ''));
// 三小问
for (let i = 1; i <= SUB_QUESTION_COUNT; i++) {
blocks.push({ type: 'p', runs: [] });
blocks.push({
type: 'p',
runs: [{ text: `【第 ${i} 小问】(${r.score_per_sub} 分)`, bold: true, size: 26 }],
});
blocks.push(...docx.textToBlocks(r[`sub_prompt_${i}`] || ''));
}
const buffer = await docx.buildDocx(blocks);
// 文件名用 question_uid(稳定标识)而非标题:标题可能含空格/标点,且允许重名
files.push({
name: `${groupLabel}_${safeFilePart(r.question_uid || `big_${r.id}`)}.docx`,
buffer,
});
}
if (files.length === 1) {
return { ok: true, fileName: files[0].name, buffer: files[0].buffer, count: 1 };
}
const buffer = await docx.zipDocxFiles(files);
return {
ok: true,
isZip: true,
fileName: `${groupLabel}_综合大题_${files.length}道.zip`,
buffer,
count: files.length,
};
}
/**
* 导出「试题 + 考生作答」阅卷用 Word(2026-09-28 测试人员需求)
*
* 与 exportQuestions 的区别(⛔ 勿混淆):
* · exportQuestions = 导出**题库题目**(供留存 / 打印 / 分发),不含任何考生信息;
* · exportAnswers = 导出**考生答卷**(供离线阅卷 / 打印 / 归档),内容 =
* 考生身份 + 抽题时刻题干快照 + 三小问作答 + 逐问得分 + 合计。
*
* 粒度(测试人员明确要求,勿改):
* · 命中 1 位考生 → 直接返回 .docx;
* · 命中多位 / 不传 ids(该组全部已抽题考生)→ **每人一个独立 .docx** 再打包为 .zip;
* · ⛔ 绝不把多位考生塞进同一个 Word —— 否则无法分别打印 / 分发 / 归档。
*
* 只读操作:不写库、不改任何状态。
*
* @param {string} group
* @param {Array<number>|string} [ids] - 考生 ID(contestantId)数组或逗号分隔串
* @returns {Promise<Object>} { ok:true, fileName, buffer, count, isZip? } 或 { ok:false, code, message }
*/
async exportAnswers(group, ids) {
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
const all = await this.listAnswersForAdmin(group);
let picked = all;
if (ids !== undefined && ids !== null && ids !== '') {
const raw = Array.isArray(ids) ? ids : String(ids).split(',');
const idList = raw
.map(v => Number(String(v).trim()))
.filter(v => Number.isInteger(v) && v > 0);
if (!idList.length) {
return { ok: false, code: 'INVALID_IDS', message: '导出的考生 ID 列表非法' };
}
const wanted = new Set(idList);
picked = all.filter(x => wanted.has(Number(x.contestantId)));
if (!picked.length) {
return { ok: false, code: 'NOT_FOUND', message: '选中的考生不属于该组别或已不存在' };
}
}
// 只导出「已抽题」考生:未抽题者没有题面与作答,导出只会得到空白页
const exportable = picked.filter(x => x.drawn);
if (!exportable.length) {
return { ok: false, code: 'NOT_FOUND', message: '该范围内没有已抽取大题的考生,无可导出的答卷' };
}
const docx = require('../lib/docx');
const groupLabel = GROUP_LABELS[group] || group;
const pad = n => String(n).padStart(2, '0');
const fmtTime = v => {
if (!v) return '未提交';
const d = new Date(v);
if (isNaN(d.getTime())) return String(v);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
// 评分统一显示两位小数(与 DECIMAL(6,2) 口径一致)
const fmtScore = v => (v == null || v === '' ? '(未评分)' : Number(v).toFixed(2));
const files = [];
for (const s of exportable) {
const snap = s.questionSnapshot || {};
const prompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
const per = snap.score_per_sub != null ? Number(snap.score_per_sub) : null;
const answers = [s.answer1, s.answer2, s.answer3];
const scores = [s.score1, s.score2, s.score3];
const blocks = [];
// ---- 抬头:身份信息(阅卷核对用,与成绩复核同口径) ----
blocks.push({
type: 'p',
align: 'center',
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}` }] });
// ---- 试题(抽题时刻快照:题目后续被改不影响存证) ----
blocks.push({ type: 'p', runs: [] });
blocks.push({ type: 'p', runs: [{ text: '【试题】(抽题时刻快照,防纠纷)', bold: true, size: 26 }] });
blocks.push(...docx.htmlToBlocks(snap.description || ''));
// ---- 三小问:小问题目 + 考生作答 + 得分 ----
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
blocks.push({ type: 'p', runs: [] });
blocks.push({
type: 'p',
runs: [{
text: `【第 ${i + 1} 小问】${prompts[i] ? ':' + prompts[i] : ''}${per != null ? `(${per} 分)` : ''}`,
bold: true,
size: 26,
}],
});
const a = String(answers[i] || '').trim();
blocks.push({ type: 'p', runs: [{ text: '考生作答:', bold: true }] });
blocks.push(...docx.textToBlocks(a || '(未作答)'));
blocks.push({ type: 'p', runs: [{ text: `得分:${fmtScore(scores[i])}${per != null ? ` / ${Number(per).toFixed(2)}` : ''} 分` }] });
}
// ---- 合计 ----
blocks.push({ type: 'p', runs: [] });
blocks.push({
type: 'p',
runs: [{
text: `本大题合计:${fmtScore(s.bigScore)}${per != null ? ` / ${(per * SUB_QUESTION_COUNT).toFixed(2)}` : ''} 分`,
bold: true,
}],
});
blocks.push({ type: 'p', runs: [{ text: `(导出时间:${fmtTime(new Date())})` }] });
const buffer = await docx.buildDocx(blocks);
files.push({
name: `${groupLabel}_${safeFilePart(s.name || '考生')}_${safeFilePart(s.idNumber || s.contestantId)}.docx`,
buffer,
});
}
if (files.length === 1) {
return { ok: true, fileName: files[0].name, buffer: files[0].buffer, count: 1 };
}
const buffer = await docx.zipDocxFiles(files);
return {
ok: true,
isZip: true,
fileName: `${groupLabel}_大题答卷_${files.length}人.zip`,
buffer,
count: files.length,
};
}
/**
* 新建大题
* @param {string} group
* @param {Object} data
* @returns {Promise<Object>} { ok, record } 或 { ok:false, code, message }
*/
async createQuestion(group, data) {
const { ctx } = this;
const checked = this.validateQuestionPayload(data);
if (!checked.ok) return checked;
const q = await ctx.model.BigQuestion.create({
// 唯一键占位:question_uid 期望形态为 big_<自增id>,需插入拿到 id 后回填;
// 占位值加时间戳+随机数,避免并发新建时占位互相冲突。
question_uid: `tmp_${Date.now()}_${Math.floor(Math.random() * 1000000)}`,
group,
title: checked.title,
description: checked.description,
sub_prompt_1: checked.subPrompts[0],
sub_prompt_2: checked.subPrompts[1],
sub_prompt_3: checked.subPrompts[2],
score_per_sub: checked.scorePerSub,
total_score: checked.scorePerSub * SUB_QUESTION_COUNT,
sort_order: checked.sortOrder,
status: checked.status,
});
await q.update({ question_uid: `big_${q.id}` });
return { ok: true, record: q };
}
/**
* 更新大题
* @param {string} group
* @param {number} id
* @param {Object} data
* @returns {Promise<Object>}
*/
async updateQuestion(group, id, data) {
const { ctx } = this;
const q = await ctx.model.BigQuestion.findOne({ where: { id, group } });
if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' };
const checked = this.validateQuestionPayload(data, q);
if (!checked.ok) return checked;
await q.update({
title: checked.title,
description: checked.description,
sub_prompt_1: checked.subPrompts[0],
sub_prompt_2: checked.subPrompts[1],
sub_prompt_3: checked.subPrompts[2],
score_per_sub: checked.scorePerSub,
total_score: checked.scorePerSub * SUB_QUESTION_COUNT,
sort_order: checked.sortOrder,
status: checked.status,
});
return { ok: true, record: q };
}
/**
* 删除大题。
* 已被考生抽取过的大题**拒绝硬删除**(否则管理端查证只剩快照、题库无法回溯),
* 引导改为「停用」(status=0,不再参与抽题,已有考生不受影响)。
* @param {string} group
* @param {number} id
* @returns {Promise<Object>}
*/
async deleteQuestion(group, id) {
const { ctx } = this;
const q = await ctx.model.BigQuestion.findOne({ where: { id, group } });
if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' };
const used = await ctx.model.BigQuestionAnswer.count({ where: { big_question_id: id } });
if (used > 0) {
return {
ok: false,
code: 'IN_USE',
message: `该大题已被 ${used} 名考生抽取,不可删除;如需下线请改为「停用」(不影响已抽到该题的考生)`,
};
}
await q.destroy();
return { ok: true };
}
/**
* 校验大题录入数据(新增 / 更新共用)
* @param {Object} data
* @param {Object} [existing] - 更新时用于字段缺省回退
* @returns {Object} { ok, title, description, subPrompts, scorePerSub, sortOrder, status }
*/
validateQuestionPayload(data, existing) {
const src = data || {};
const title = String(src.title !== undefined ? src.title : (existing ? existing.title : '')).trim();
if (!title) return { ok: false, code: 'TITLE_REQUIRED', message: '大题标题不能为空' };
if (title.length > 200) return { ok: false, code: 'TITLE_TOO_LONG', message: '大题标题不得超过 200 字' };
const description = String(
src.description !== undefined ? src.description : (existing ? existing.description : '') || ''
);
const subPrompts = [];
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
const key = `sub_prompt_${i + 1}`;
const camel = `subPrompt${i + 1}`;
let v;
if (src[key] !== undefined) v = src[key];
else if (src[camel] !== undefined) v = src[camel];
else v = existing ? existing[key] : '';
subPrompts.push(String(v == null ? '' : v));
}
// ── 防呆:题干不得混入「可被直接抄袭的答案性内容」(2026-09-28 R27 缺陷加固)──
// 背景:中职组某启用题的题干里给了三条指令**输入示例**(>OFFMSG,COM1*4A 等),
// 而该题第 3 小问恰是「列出配置指令」⇒ 答案被题干直接给出;又因抽题快照固化
// (question_snapshot 抽题即冻结)无法事后撤回,只能从**录入入口**前置拦截。
const leak = this.detectAnswerLeak({ title, description, subPrompts });
if (leak) return { ok: false, code: 'SUSPECT_ANSWER_LEAK', message: leak };
const perRaw = src.score_per_sub !== undefined ? src.score_per_sub
: (src.scorePerSub !== undefined ? src.scorePerSub
: (existing ? existing.score_per_sub : 10));
const scorePerSub = Number(perRaw);
if (!Number.isFinite(scorePerSub) || scorePerSub <= 0) {
return { ok: false, code: 'INVALID_SCORE', message: '每小题分值必须为大于 0 的数值' };
}
const sortRaw = src.sort_order !== undefined ? src.sort_order
: (src.sortOrder !== undefined ? src.sortOrder
: (existing ? existing.sort_order : 0));
const sortOrder = Number.isFinite(Number(sortRaw)) ? Math.round(Number(sortRaw)) : 0;
let status = existing ? Number(existing.status) : 1;
if (src.status !== undefined && src.status !== null && src.status !== '') {
status = Number(src.status) ? 1 : 0;
}
return {
ok: true,
title,
description,
subPrompts,
// 每小题分值 = 赛制口径(每问 10 分、大题 30 分),刻意保持整数;
// 人工评分的小数精度只作用于「考生得分」,见 setSubScores。
scorePerSub: Math.round(scorePerSub),
sortOrder,
status,
};
}
/**
* 防呆扫描:题干 / 小问里是否混入了「考生可直接抄袭的答案性内容」。
*
* 背景(2026-09-28 实测缺陷):中职组某启用题的 `description` 内嵌
* 「答题可能用到的指令集」,除格式定义外还给了**可直接使用的输入示例**:
* &gt;OFFMSG,COM1*4A / &gt;OUTMSG,COM1,GGA,1*3B / &gt;SAVECONFIG*0B
* 而该题第 3 小问正是「请列出对该接收机做串口输出配置需下发的指令」⇒ 答案白送。
* 前端只渲染 description、后端按快照原样下发 ⇒ 唯一守点是**录入入口**。
*
* 判定规则(命中即拒绝保存):
* 1) 出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样;
* 2) 出现**带具体字面参数**的指令示例(形如 &gt;CMD,字面值*两位十六进制校验码)。
*
* ⚠️ 刻意**不拦**「指令格式定义」(参数用方括号占位,如 &gt;OFFMSG[,Port[,Message]]*CS):
* 向考生提供指令**手册**是允许的,构成泄露的是**能照抄的成品**。
* —— 本规则与 2026-09-28 那次修复(只删示例、保留格式定义)同口径。
*
* @param {Object} parts - { title, description, subPrompts }
* @returns {string|null} 命中说明(直接展示给管理员);未命中返回 null
*/
detectAnswerLeak(parts) {
const targets = [
['大题标题', parts.title],
['题干', parts.description],
...((parts.subPrompts) || []).map((p, i) => [`第 ${i + 1} 小问`, p]),
];
const KEYWORDS = ['参考答案', '标准答案', '评分标准', '评分细则', '答案要点'];
// 带字面参数的指令示例:&gt;OFFMSG,COM1*4A
// 校验码限定「两位十六进制」⇒ 天然排除占位符 *CS(S 非十六进制字符)
// 兼容两种存储形态:富文本存 &gt;(实测库中形态)或裸 >
const CMD_SAMPLE = /(?:&gt;|>)\s*[A-Z][A-Z0-9]*(?:,[^<&\n]*)?\*[0-9A-Fa-f]{2}(?![0-9A-Fa-f])/g;
for (const [where, raw] of targets) {
const text = String(raw == null ? '' : raw);
if (!text) continue;
for (const kw of KEYWORDS) {
if (text.includes(kw)) {
return `${where}中出现「${kw}」字样,疑似把参考答案写进了题目,已拒绝保存。`;
}
}
CMD_SAMPLE.lastIndex = 0;
let m;
while ((m = CMD_SAMPLE.exec(text)) !== null) {
const hit = m[0];
// 方括号占位(如 [,Port])属「格式定义」,不算示例
if (hit.includes('[')) continue;
return `${where}中出现可直接抄袭的指令示例「${hit}」,已拒绝保存。`
+ '如确需向考生提供指令手册,请只写**格式定义**(参数用方括号占位,例如:'
+ '&gt;OUTMSG[,Port],Message,Period*CS);带具体参数值的示例等同于给出答案。';
}
}
return null;
}
// ==================== 内部工具 ====================
/** 构造题干快照(抽题时刻固化,题目后续被改不影响存证) */
buildSnapshot(q) {
return {
big_question_uid: q.question_uid,
title: q.title,
description: q.description || '',
sub_prompts: [q.sub_prompt_1 || '', q.sub_prompt_2 || '', q.sub_prompt_3 || ''],
score_per_sub: q.score_per_sub,
total_score: q.total_score,
snapshot_at: new Date().toISOString(),
};
}
/** 解析题干快照(容错:脏数据不抛异常,返回 null) */
parseSnapshot(text) {
if (!text) return null;
if (typeof text === 'object') return text;
try {
return JSON.parse(text);
} catch (e) {
return null;
}
}
/** 唯一键冲突判定(并发首抽竞争) */
isDupKey(e) {
if (!e) return false;
if (e.name === 'SequelizeUniqueConstraintError') return true;
if (e.code === 'ER_DUP_ENTRY') return true;
if (e.parent && e.parent.code === 'ER_DUP_ENTRY') return true;
return /ER_DUP_ENTRY/.test(String(e.message || ''));
}
}
module.exports = BigQuestionService;