feat(big-question): 综合大题 Excel 模板下载 + 批量导入(R73)

新增 GET /api/admin/big-questions/:group/template —— 生成自解释模板:
数据 sheet 排第 1(导入侧按 SheetNames[0] 解析,是承重契约)+「填写说明」sheet,
含列名含义/状态建议/常见被拒原因,让测试人员下载即会用,不依赖外部文档。

新增 POST /api/admin/big-questions/:group/import-excel —— 逐行调用
service.bigQuestion.createQuestion,标题长度/三小问/分值/泄题检测(detectAnswerLeak)
全部自动继承,与手工录入同一入口同一校验;批量导入绝不成为绕过防呆的后门。
组别以 URL :group 为准,忽略表格里的组别列;同组内标题重复即跳过并报错;
状态缺省 0(停用),批量录入的题未经人工校对不直接进抽题池。

客观题模板 buildQuestionTemplate 同步加「填写说明」sheet:数据 sheet 仍排第 1、
原 8 列不变,故 importQuestionsFromExcel 的 SheetNames[0] 解析不受影响(已离线验证)。
路由顺序硬约束:含具体段的 template / import-excel 必须排在通配段 /:id 之前。
This commit is contained in:
2026-10-08 10:08:41 +08:00
parent d44d466a90
commit 154338e5dd
4 changed files with 324 additions and 0 deletions

View File

@@ -1638,6 +1638,30 @@ class AdminService extends Service {
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '客观题');
// ⚠️ sheet 顺序是**契约**:导入侧(importQuestionsFromExcel)按 `SheetNames[0]` 解析,
// 因此数据 sheet 必须排第 1,「填写说明」只能排第 2(删掉也不影响导入)。
// R73:测试人员反馈「不知道导入模板长什么样」⇒ 让模板自解释,不必依赖外部文档。
const notes = [
['客观题导入模板 — 填写说明'],
[],
['列名', '是否必填', '说明'],
['题型', '必填', '只能填「选择题」或「判断题」(英文 choice / judge 也能识别)'],
['题目', '必填', '题干内容'],
['选项A', '选填', '选项内容。选择题至少需 2 个非空选项'],
['选项B', '选填', ''],
['选项C', '选填', ''],
['选项D', '选填', ''],
['正确答案', '必填', '选择题填选项字母(如 C,多选如 AC);判断题填「对」或「错」'],
['分值', '已废弃', '客观题分值口径为「每小题固定 1 分」,本列会被忽略,无需填写'],
[],
['【注意事项】'],
['· 表头必须位于第 1 行,请勿删除或改写表头文字。'],
['· 一行一道题;判断题请把选项列(A..D)留空。'],
['· 导入完成后请在题库列表中核对数量是否与预期一致。'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notes);
notesWs['!cols'] = [{ wch: 14 }, { wch: 10 }, { wch: 66 }];
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}

View File

@@ -1,6 +1,7 @@
'use strict';
const Service = require('egg').Service;
const XLSX = require('xlsx');
/**
* 综合大题服务 - BigQuestionService
@@ -35,6 +36,57 @@ const MAX_ANSWER_CHARS = 2000;
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'];
/** 组别中文名(导出文档抬头 / 文件名用) */
@@ -405,6 +457,173 @@ class BigQuestionService extends Service {
* @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 = 启用(参与随机抽题);0 = 停用'],
[],
['【组别说明】'],
['本文件不需要填写组别。导入时以「管理端当前选择的组别」为准,表格里的组别一律忽略。'],
'',
['【★ 关于「状态」列的强烈建议】'],
['建议全部填 0(停用)。导入后题目会进入题库但不参与抽题;'],
['待管理员人工校对无误后,再在管理端逐条启用。'],
['原因:考生抽到题目后题目会被快照固化,事后再改题库不会影响已抽到的考生。'],
[],
['【常见被拒原因(导入时会逐行报错)】'],
['1. 标题为空,或超过 200 字'],
['2. 同一组内标题重复 → 跳过并报错'],
['3. 三个小问未填全'],
['4. 每小题分值不是大于 0 的数字'],
['5. 题干或小问中出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样 → 判定为泄题并拒绝'],
['6. 题干中直接给出可照抄的成品答案(如带具体参数的指令示例)→ 判定为泄题并拒绝'],
[' 说明:题干提供「指令格式定义」是允许的,给出「能照抄的成品」才构成泄题。'],
];
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;
}
if (!row.sub1 || !row.sub2 || !row.sub3) {
result.skipped++;
result.errors.push({ row: rowIndex, message: '三个小问必须全部填写' });
continue;
}
// 题干: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({