From 154338e5dda39ebd2015b2a8c0da675dea43f93f Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Thu, 8 Oct 2026 10:08:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(big-question):=20=E7=BB=BC=E5=90=88?= =?UTF-8?q?=E5=A4=A7=E9=A2=98=20Excel=20=E6=A8=A1=E6=9D=BF=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=20+=20=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=EF=BC=88R7?= =?UTF-8?q?3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 之前。 --- app/controller/admin.js | 77 +++++++++++++ app/router.js | 4 + app/service/admin.js | 24 ++++ app/service/bigQuestion.js | 219 +++++++++++++++++++++++++++++++++++++ 4 files changed, 324 insertions(+) diff --git a/app/controller/admin.js b/app/controller/admin.js index f8b3e72..184f7c8 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -1783,6 +1783,83 @@ class AdminController extends Controller { * ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法, * 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。 */ + /** + * 下载综合大题 Excel 导入模板(R73) + * GET /api/admin/big-questions/:group/template + */ + async downloadBigQuestionTemplate() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + const buffer = await service.bigQuestion.buildTemplate(); + ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + // ⚠️ 只调 ctx.attachment(原名):koa 自动做 RFC5987 编码,自行 encodeURIComponent 会双重编码 + ctx.attachment(`综合大题导入模板_${group}.xlsx`); + ctx.body = buffer; + } catch (err) { + ctx.logger.error('下载大题导入模板失败:', err); + ctx.body = ctx.helper.error(500, '生成模板失败'); + } + } + + /** + * 综合大题 Excel 批量导入(R73) + * POST /api/admin/big-questions/:group/import-excel + * multipart/form-data: file 字段为 .xlsx/.xls + * sheet:首个 sheet 为数据表(表头在第 1 行),可选携带「填写说明」sheet + */ + async importBigQuestionsExcel() { + const { ctx, service, app } = this; + if (!this._ensureAdmin()) return; + + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + let tempFilePath = null; + try { + const stream = await ctx.getFileStream(); + if (!stream) { + ctx.body = ctx.helper.error(400, '未接收到文件'); + return; + } + const ext = path.extname(stream.filename).toLowerCase(); + if (!['.xlsx', '.xls'].includes(ext)) { + ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式'); + return; + } + + const tempDir = path.join(app.baseDir, 'app/public/tmp/excel'); + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); + tempFilePath = path.join(tempDir, `big_questions_import_${Date.now()}${ext}`); + const writeStream = fs.createWriteStream(tempFilePath); + + await new Promise((resolve, reject) => { + stream.pipe(writeStream); + writeStream.on('finish', resolve); + writeStream.on('error', reject); + stream.on('error', reject); + }); + + const result = await service.bigQuestion.importFromExcel(tempFilePath, { group }); + if (!result.ok) { + ctx.body = ctx.helper.error(400, result.message); + return; + } + ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`); + } catch (err) { + ctx.logger.error('综合大题 Excel 导入失败:', err); + const msg = (err && err.message) ? err.message : '未知错误'; + ctx.body = ctx.helper.error(500, '导入失败:' + msg); + } finally { + // 无论成功/失败都清临时文件:题目内容属敏感数据,不留副本 + if (tempFilePath) { try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ } } + } + } + async exportBigQuestions() { const { ctx, service } = this; if (!this._ensureAdmin()) return; diff --git a/app/router.js b/app/router.js index 384daa8..4ffde52 100644 --- a/app/router.js +++ b/app/router.js @@ -151,6 +151,10 @@ module.exports = app => { // `GET /:group/:id`,`export` 就会被当作 :id 吞掉(实测 2026-09-23 复核时 // 本行曾被放在 :group/:id 之后,当时因 :group/:id 仅注册 PUT/DELETE 而未暴露问题) router.get('/api/admin/big-questions/:group/export', adminAuth, controller.admin.exportBigQuestions); + // ⚠️ 注册顺序是硬约束:Egg 按注册序匹配,下面两条含**具体段**(template / import-excel), + // 必须排在通配段 `/:id` 之前,否则会被它抢走(GET .../template → id='template')。 + router.get('/api/admin/big-questions/:group/template', adminAuth, controller.admin.downloadBigQuestionTemplate); + router.post('/api/admin/big-questions/:group/import-excel', adminAuth, controller.admin.importBigQuestionsExcel); router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion); router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion); router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers); diff --git a/app/service/admin.js b/app/service/admin.js index bfedabc..39ed62e 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -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' }); } diff --git a/app/service/bigQuestion.js b/app/service/bigQuestion.js index 8c98144..e0e8c81 100644 --- a/app/service/bigQuestion.js +++ b/app/service/bigQuestion.js @@ -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} */ + /** + * 生成综合大题 Excel 导入模板(R73)。 + * + * sheet 顺序是**契约**:`综合大题`(数据)必须在第 1 个 —— 导入侧按 + * `SheetNames[0]` 解析(与客观题 importQuestionsFromExcel 同口径); + * `填写说明` 放第 2 个,测试人员下载后点底部标签即可查看,删掉也不影响导入。 + * + * @returns {Promise} 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 单元格编辑。例:

系统由 3 部分组成

。留空则使用「题干」纯文本'], + ['小问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} { 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 列优先;未填时用纯文本包

+ const html = row.descriptionHtml; + const description = html || (row.description ? '

' + row.description + '

' : ''); + + 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({