diff --git a/app/controller/admin.js b/app/controller/admin.js index 327a8c6..33eb2b9 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -467,11 +467,8 @@ class AdminController extends Controller { if (action === 'add') { for (const q of questions) { - // 分值必须为大于 0 的合法数值;0 分题属非法业务数据 - if (q.score == null || !isFinite(Number(q.score)) || Number(q.score) <= 0) { - ctx.body = ctx.helper.error(400, '题目分值(score)必须为大于 0 的合法数值'); - return; - } + // 客观题(choice/judge)分值由 service 层强制为 1(赛制口径),此处不再要求 + // 调用方传 score;word 题型的合法性由 service 层 _resolveQuestionScore 校验。 await service.admin.addQuestion(q, group, qType); } ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功'); @@ -494,14 +491,12 @@ class AdminController extends Controller { } ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功'); } else if (action === 'setTypeScore') { - // 批量按题型设置分值:将该组该题型全部题目统一设为指定分值 - if (score == null || isNaN(parseInt(score, 10))) { - ctx.body = ctx.helper.error(400, '批量设分需要合法的 score 数值'); - return; - } - const updated = await service.admin.setTypeScore(group, qType, parseInt(score, 10)); + // 分值口径已统一(2026-09-23):客观题(choice/judge)恒为 1 分, + // 传入的 score 被忽略;仅综合性任务(word)按传入值设置(service 层校验)。 + const updated = await service.admin.setTypeScore(group, qType, score); const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务'); - ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${score} 分`); + const shown = (qType === 'choice' || qType === 'judge') ? 1 : score; + ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${shown} 分`); } else if (action === 'bulkDelete') { // 批量删除题目:question_uids 数组 const ids = (ctx.request.body.question_uids || []) @@ -1700,6 +1695,50 @@ class AdminController extends Controller { } } + /** + * 导出大题为 Word(2026-09-23 需求) + * GET /api/admin/big-questions/:group/export?ids=1,2,3 + * · 传 ids → 单独 / 批量导出;不传 → 导出该组全部 + * · 1 道 → 返回 .docx;多道 → 返回「多个 .docx 打包的 .zip」 + * + * ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法, + * 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。 + */ + async exportBigQuestions() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + const ids = ctx.query.ids; + const result = await service.bigQuestion.exportQuestions(group, ids); + + if (!result.ok) { + const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400; + ctx.body = ctx.helper.error(httpStatus, result.message); + return; + } + + // ⚠️ 只调 ctx.attachment(原名):koa 会自动做 RFC5987 编码。 + // 自行 encodeURIComponent 会**双重编码**,中文文件名变成乱码(同源缺陷族)。 + ctx.set( + 'Content-Type', + result.isZip + ? 'application/zip' + : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); + ctx.attachment(result.fileName); + // 前端需要知道「本次导出了几道题」,非简单请求下无法从 body 读(body 是二进制) + ctx.set('X-Export-Count', String(result.count)); + ctx.body = result.buffer; + } catch (err) { + ctx.logger.error('导出大题 Word 失败:', err); + ctx.body = ctx.helper.error(500, '导出大题 Word 失败'); + } + } + /** * 新增大题 * POST /api/admin/big-questions/:group diff --git a/app/lib/decimal_score.js b/app/lib/decimal_score.js new file mode 100644 index 0000000..5e1e735 --- /dev/null +++ b/app/lib/decimal_score.js @@ -0,0 +1,39 @@ +'use strict'; + +/** + * 分数列类型工厂(2026-09-23:大题人工评分允许小数点后一位) + * + * 用法(在 model 文件内): + * const decimalScore = require('../lib/decimal_score')(app); + * ... + * word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 1 位小数)'), + * + * ⚠️ 三处必须同时成立,小数才不会被截断或变形: + * ① DB 列 = DECIMAL + * → database/migrations/025-scores-decimal-one-place.sql + * ② model 声明 = DECIMAL(本文件) + * → 若仍写 INTEGER,Sequelize 读取时会走 INTEGER.parse(parseInt), + * 把 7.5 **静默截成 7**,且不报任何错 + * ③ mysql2 驱动配 decimalNumbers=true(config/config.default.js) + * → 否则 DECIMAL 经驱动读出为**字符串**('7.5'),前端 `+` 会变成拼接 + * + * getter 再兜底转一次 Number,保证 raw / 非 raw 查询路径都拿到数值类型。 + * + * @param {Egg.Application} app Egg 应用实例(用于取 app.Sequelize) + * @returns {(field: string, comment: string, extra?: object) => object} + */ +module.exports = app => { + const { DECIMAL } = app.Sequelize; + + return function decimalScore(field, comment, extra = {}) { + return Object.assign({ + type: DECIMAL(6, 1), // 上限 99999.9,足够覆盖总分/单题分 + allowNull: true, + comment, + get() { + const v = this.getDataValue(field); + return (v === null || v === undefined) ? v : Number(v); + }, + }, extra); + }; +}; diff --git a/app/lib/docx.js b/app/lib/docx.js new file mode 100644 index 0000000..802fb7b --- /dev/null +++ b/app/lib/docx.js @@ -0,0 +1,469 @@ +'use strict'; + +/** + * 极简 WordprocessingML(.docx) 生成器(2026-09-23) + * + * 用途:把综合大题(`big_questions`)导出为**真正的 .docx**,供管理员留存 / 打印 / 分发。 + * + * 为什么不用第三方 docx 库:后端已有 `archiver`(service/admin.js 的批量下载在用), + * 而 .docx 本质就是一个 ZIP + 几个 XML part ⇒ 直接手写 OOXML,**新增零依赖**、 + * 也避免引入未经验证的库影响既有交卷链路。 + * + * 生成的包结构(最小合法 OOXML): + * [Content_Types].xml + * _rels/.rels + * word/document.xml + * word/styles.xml + * word/_rels/document.xml.rels + * + * 支持的 HTML 子集(实测题干只用到这些:p / table / tbody / tr / td / strong / span / a / br): + * 块级:p、div、h1–h6、li、ul/ol、blockquote、pre、hr + * 表格:table(含 tbody/thead/tfoot 包裹)、tr、td/th(th 加粗) + * 行内:strong/b(加粗)、em/i(斜体)、u、span、a(只取文字)、br + * 样式:仅解析 `text-align`(对齐)与 `text-indent`(首行缩进,pt→twips) + * 实体:  < > & " ' ' + * 忽略:script / style / img(图片不外链,避免生成"打不开的图片占位") + * + * ⛔ 已知取舍:嵌套表格会被扁平化;图片不导出。 + */ + +// ==================== XML / 实体 ==================== + +function decodeEntities(str) { + return String(str == null ? '' : str) + .replace(/ /gi, ' ') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/�*39;/g, "'") + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (m, d) => { + const code = Number(d); + return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m; + }) + // ⚠️ & 必须放最后:否则 `&lt;` 会被二次解码成 `<` + .replace(/&/gi, '&'); +} + +/** XML 文本转义(在 decodeEntities 之后调用,顺序不可颠倒) */ +function esc(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ==================== 极简 HTML 解析(容错栈式) ==================== + +const VOID_TAGS = new Set(['br', 'hr', 'img', 'meta', 'link', 'input', 'col', 'area', 'base', 'source', 'wbr']); + +/** + * 词法正则。 + * ⚠️ 必须用**具名捕获组**:早期版本用「按出现顺序数下标」(m[1]..m[8]), + * 但多个 `|` 分支各自贡献捕获组,实际只有 5 组 ⇒ 下标全部错位 ⇒ + * 所有标签被当成文本、块序列恒为空(静默产出空文档,无任何报错)。 + * 具名组不受分支数量影响,从根上消除这类错位。 + */ +const TOKEN_RE = /|]*>|<\?[\s\S]*?\?>|<\/\s*(?[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?[a-zA-Z][a-zA-Z0-9:-]*)(?(?:"[^"]*"|'[^']*'|[^>"'])*?)(?\/?)>|(?[^<]+)/g; + +const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g; + +function parseAttrs(raw) { + const attrs = {}; + if (!raw) return attrs; + ATTR_RE.lastIndex = 0; + let m; + while ((m = ATTR_RE.exec(raw))) { + attrs[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]); + } + return attrs; +} + +/** + * 把 HTML 片段解析为节点树。 + * ⛔ 容错优先:标签不闭合 / 交叉嵌套时按栈就近回退,绝不抛异常 + * (题干是管理员手工粘贴的富文本,脏 HTML 不能导致整次导出失败)。 + */ +function parseHtml(html) { + const root = { type: 'node', tag: '#root', attrs: {}, children: [] }; + const stack = [root]; + const src = String(html == null ? '' : html); + TOKEN_RE.lastIndex = 0; + let m; + while ((m = TOKEN_RE.exec(src))) { + const g = m.groups || {}; + if (g.text !== undefined) { + stack[stack.length - 1].children.push({ type: 'text', value: g.text }); + continue; + } + if (g.close !== undefined) { + // 闭标签:从栈顶向下找同名节点;找不到就忽略(脏 HTML 不抛异常) + const name = g.close.toLowerCase(); + for (let i = stack.length - 1; i > 0; i--) { + if (stack[i].tag === name) { + stack.length = i; + break; + } + } + continue; + } + if (g.open !== undefined) { + const tag = g.open.toLowerCase(); + const node = { type: 'node', tag, attrs: parseAttrs(g.attrs), children: [] }; + stack[stack.length - 1].children.push(node); + if (!VOID_TAGS.has(tag) && g.self !== '/') stack.push(node); + continue; + } + // 注释 / doctype / 处理指令:忽略 + } + return root; +} + +// ==================== 节点树 → 中间块序列 ==================== + +const BLOCK_TAGS = new Set([ + 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol', + 'blockquote', 'pre', 'section', 'article', 'hr', 'dl', 'dt', 'dd', +]); +const SKIP_TAGS = new Set(['script', 'style', 'head', 'meta', 'link', 'title', 'img']); + +/** 标题级别 → 半磅字号(21 半磅 = 10.5pt = 五号,正文基准) */ +const HEADING_SIZE = { h1: 32, h2: 28, h3: 26, h4: 24, h5: 22, h6: 21 }; +const HEADING_BOLD = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); + +const INLINE_BOLD = new Set(['strong', 'b', 'th']); +const INLINE_ITALIC = new Set(['em', 'i']); + +function styleOf(node) { + return (node && node.attrs && node.attrs.style) || ''; +} + +/** 从 style 里取段落对齐(仅映射 Word 支持的四种) */ +function alignOf(node) { + const s = styleOf(node); + const m = /text-align\s*:\s*(left|right|center|justify)/i.exec(s); + return m ? m[1].toLowerCase() : ''; +} + +/** 从 style 里取首行缩进(pt → twips,1pt = 20twips) */ +function firstLineTwips(node) { + const s = styleOf(node); + const m = /text-indent\s*:\s*(-?\d+(?:\.\d+)?)\s*pt/i.exec(s); + if (!m) return 0; + const pt = Number(m[1]); + return Number.isFinite(pt) && pt > 0 ? Math.round(pt * 20) : 0; +} + +/** + * 深度优先遍历,产出「块」序列: + * { type:'p', runs:[...], align, firstLine } + * { type:'table', rows:[{ cells:[{ blocks:[...] }] }] } + * run 形态:{ text, bold, italic } 或 { br: true } + */ +function walk(nodes, fmt, out) { + const flush = () => { + if (out.cur.length) { + out.blocks.push(Object.assign({ type: 'p', runs: out.cur }, out.curMeta)); + out.cur = []; + out.curMeta = {}; + } + }; + + for (const n of nodes) { + if (n.type === 'text') { + // HTML 源码里的换行/缩进属排版噪声,须丢弃; + // 但 ` ` 是作者有意的占位空格(题干排版大量使用),必须保留 ⇒ 用原始值判别 + if (!/\S/.test(n.value) && !/ /i.test(n.value)) continue; + // 原始换行/制表压成单空格:真实换行只由
与段落边界产生 + const text = decodeEntities(n.value).replace(/[\r\n\t]+/g, ' '); + if (text === '') continue; + out.cur.push({ text, bold: !!fmt.bold, italic: !!fmt.italic }); + continue; + } + + const tag = n.tag; + if (SKIP_TAGS.has(tag)) continue; + if (tag === 'br') { + out.cur.push({ br: true }); + continue; + } + if (tag === 'table') { + flush(); + out.blocks.push({ type: 'table', rows: extractRows(n) }); + continue; + } + if (tag === 'hr') { + flush(); + out.blocks.push({ type: 'p', runs: [] }); + continue; + } + if (tag === 'li') { + flush(); + out.curMeta = Object.assign({}, out.curMeta, { align: alignOf(n) || out.curMeta.align, firstLine: 0 }); + out.cur.push({ text: '• ', bold: false, italic: false }); + walk(n.children, fmt, out); + // 若不 flush,下一条 li 会并入同一段 ⇒ 显式收尾 + const last = out.blocks[out.blocks.length - 1]; + if (last && last.type === 'p' && last.runs.length && last.runs[0].text === '• ') flush(); + continue; + } + if (BLOCK_TAGS.has(tag)) { + flush(); + const nextFmt = HEADING_BOLD.has(tag) + ? Object.assign({}, fmt, { bold: true, size: HEADING_SIZE[tag] }) + : fmt; + out.curMeta = { align: alignOf(n), firstLine: firstLineTwips(n) }; + walk(n.children, nextFmt, out); + flush(); + continue; + } + // 行内标签 + const nextFmt = Object.assign({}, fmt); + if (INLINE_BOLD.has(tag)) nextFmt.bold = true; + if (INLINE_ITALIC.has(tag)) nextFmt.italic = true; + walk(n.children, nextFmt, out); + } + flush(); +} + +/** 提取表格行/单元格(容忍 tbody / thead / tfoot 包裹;嵌套表格扁平化到单元格文本) */ +function extractRows(tableNode) { + const rows = []; + const collect = (nodes) => { + for (const n of nodes) { + if (n.type !== 'node') continue; + if (n.tag === 'tr') { + const cells = []; + for (const c of n.children) { + if (c.type !== 'node') continue; + if (c.tag !== 'td' && c.tag !== 'th') continue; + const cellOut = { blocks: [], cur: [], curMeta: {} }; + walk(c.children, { bold: c.tag === 'th', italic: false }, cellOut); + cells.push({ blocks: cellOut.blocks, header: c.tag === 'th' }); + } + rows.push({ cells }); + } else if (n.tag === 'tbody' || n.tag === 'thead' || n.tag === 'tfoot' || n.tag === 'table') { + collect(n.children); + } + } + }; + collect(tableNode.children); + return rows; +} + +/** HTML → 块序列(对外唯一入口) */ +function htmlToBlocks(html) { + const out = { blocks: [], cur: [], curMeta: {} }; + walk(parseHtml(html).children, { bold: false, italic: false }, out); + return out.blocks; +} + +/** 纯文本 → 单块(自动按空行/换行切段) */ +function textToBlocks(text) { + const s = String(text == null ? '' : text); + if (!s.trim()) return []; + return s + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line !== '') + .map(line => ({ type: 'p', runs: [{ text: line, bold: false, italic: false }] })); +} + +// ==================== 块 → WordprocessingML ==================== + +const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; + +function rPrOf(run) { + const parts = []; + if (run.bold) parts.push(''); + if (run.italic) parts.push(''); + parts.push(''); + const sz = Number(run.size) > 0 ? Number(run.size) : 21; + parts.push(``); + return `${parts.join('')}`; +} + +function renderRun(run) { + if (run.br) return ''; + const text = String(run.text == null ? '' : run.text); + if (text === '') return ''; + return `${rPrOf(run)}${esc(text)}`; +} + +function pPrOf(block, extra) { + const parts = []; + const align = block.align || ''; + if (align) parts.push(``); + const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0; + if (fl) parts.push(``); + if (extra) parts.push(extra); + return parts.length ? `${parts.join('')}` : ''; +} + +/** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */ +function renderParagraph(block) { + const runs = (block.runs || []).map(renderRun).join(''); + return `${pPrOf(block)}${runs}`; +} + +const TBL_BORDERS = '' + + ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] + .map(side => ``) + .join('') + + ''; + +function renderTable(block) { + const rows = block.rows || []; + if (!rows.length) return ''; + const cellCount = Math.max(...rows.map(r => r.cells.length), 1); + const trs = rows.map(r => { + const tcs = []; + for (let i = 0; i < cellCount; i++) { + const cell = r.cells[i]; + let inner; + if (!cell || !cell.blocks.length) { + inner = ''; + } else { + inner = cell.blocks.map(b => (b.type === 'table' + // 嵌套表扁平化为文本,避免非法嵌套 + ? renderParagraph({ runs: flattenTableText(b) }) + : renderParagraph(b))).join(''); + } + tcs.push(`${inner}`); + } + return `${tcs.join('')}`; + }).join(''); + return `${TBL_BORDERS}${trs}`; +} + +function flattenTableText(tableBlock) { + const buf = []; + for (const r of tableBlock.rows || []) { + const cells = []; + for (const c of r.cells || []) { + const texts = []; + for (const b of c.blocks || []) { + if (b.type === 'p') texts.push((b.runs || []).map(x => (x.br ? ' ' : x.text)).join('')); + } + cells.push(texts.join(' ').trim()); + } + buf.push({ br: false, text: cells.join(' | ') }); + } + const runs = []; + buf.forEach((x, i) => { + if (i > 0) runs.push({ br: true }); + runs.push({ text: x.text, bold: false, italic: false }); + }); + return runs; +} + +function buildDocumentXml(blocks) { + const parts = []; + for (const b of blocks) { + if (b.type === 'table') { + const t = renderTable(b); + if (t) { + parts.push(t); + // 表后补空段:Word 中「表格紧邻表格/节尾」易被判异常 + parts.push(''); + } + } else { + parts.push(renderParagraph(b)); + } + } + if (!parts.length) parts.push(''); + parts.push(''); + return '' + + `` + + `${parts.join('')}`; +} + +// ==================== 打包为 .docx ==================== + +const CONTENT_TYPES = '' + + '' + + '' + + '' + + '' + + '' + + ''; + +const ROOT_RELS = '' + + '' + + '' + + ''; + +const DOC_RELS = '' + + '' + + '' + + ''; + +const STYLES_XML = '' + + `` + + '' + + '' + + '' + + '' + + '' + + ''; + +/** + * 把若干 part 打成一个 ZIP Buffer。 + * ⚠️ archiver v8 为 ESM:沿用 service/admin.js:754 已验证的 `const { ZipArchive } = require('archiver')` 写法。 + */ +function zipParts(parts) { + return new Promise((resolve, reject) => { + let ZipArchive; + try { + ({ ZipArchive } = require('archiver')); + } catch (e) { + reject(new Error('缺少 archiver 依赖,无法生成 .docx')); + return; + } + const chunks = []; + const archive = new ZipArchive({ zlib: { level: 9 } }); + archive.on('error', reject); + archive.on('data', c => chunks.push(c)); + archive.on('end', () => resolve(Buffer.concat(chunks))); + for (const p of parts) archive.append(Buffer.from(p.data, 'utf8'), { name: p.name }); + archive.finalize(); + }); +} + +/** + * 生成单个 .docx + * @param {Array} blocks - htmlToBlocks / textToBlocks 的产物(可多个数组按顺序拼接) + * @returns {Promise} + */ +async function buildDocx(blocks) { + const list = ([]).concat(blocks || []).filter(Boolean); + const flat = []; + for (const b of list) { + if (Array.isArray(b)) flat.push(...b); + else flat.push(b); + } + return zipParts([ + { name: '[Content_Types].xml', data: CONTENT_TYPES }, + { name: '_rels/.rels', data: ROOT_RELS }, + { name: 'word/document.xml', data: buildDocumentXml(flat) }, + { name: 'word/styles.xml', data: STYLES_XML }, + { name: 'word/_rels/document.xml.rels', data: DOC_RELS }, + ]); +} + +/** 把多个 .docx 打成一个 ZIP(批量/全部导出用),返回 Buffer */ +async function zipDocxFiles(entries) { + return zipParts(entries.map(e => ({ name: e.name, data: e.buffer }))); +} + +module.exports = { + buildDocx, + zipDocxFiles, + htmlToBlocks, + textToBlocks, + decodeEntities, + esc, + // 导出内部件便于单测 + _internal: { parseHtml, buildDocumentXml, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS }, +}; diff --git a/app/model/account.js b/app/model/account.js index 8b5476a..161eb14 100644 --- a/app/model/account.js +++ b/app/model/account.js @@ -10,6 +10,9 @@ module.exports = app => { const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const Account = app.model.define('account', { // 主键,自增 uid: { @@ -128,12 +131,7 @@ module.exports = app => { comment: '账号激活状态', }, // 总分 - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '账号总分', - }, + score: decimalScore('score', '账号总分', { defaultValue: null }), // 记录创建时间 created_at: { type: DATE, diff --git a/app/model/benkeSubmission.js b/app/model/benkeSubmission.js index 55b70b3..d053c25 100644 --- a/app/model/benkeSubmission.js +++ b/app/model/benkeSubmission.js @@ -10,6 +10,9 @@ module.exports = app => { const { STRING, INTEGER, DATE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const BenkeSubmission = app.model.define('benke_submission', { // 主键 ID,自增 id: { @@ -71,12 +74,7 @@ module.exports = app => { comment: '上传时间', }, // 评分 - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '评分', - }, + score: decimalScore('score', '评分(可含 1 位小数)', { defaultValue: null }), // 记录创建时间 created_at: { type: DATE, diff --git a/app/model/bigQuestion.js b/app/model/bigQuestion.js index 6f29cc6..911e524 100644 --- a/app/model/bigQuestion.js +++ b/app/model/bigQuestion.js @@ -14,6 +14,9 @@ module.exports = app => { const { STRING, INTEGER, DATE, TEXT } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + return app.model.define('BigQuestion', { id: { type: INTEGER, @@ -56,18 +59,8 @@ module.exports = app => { allowNull: true, comment: '第3小问题干', }, - score_per_sub: { - type: INTEGER, - allowNull: false, - defaultValue: 10, - comment: '每小题分值', - }, - total_score: { - type: INTEGER, - allowNull: false, - defaultValue: 30, - comment: '大题总分', - }, + score_per_sub: decimalScore('score_per_sub', '每小题分值', { allowNull: false, defaultValue: 10 }), + total_score: decimalScore('total_score', '大题总分', { allowNull: false, defaultValue: 30 }), sort_order: { type: INTEGER, allowNull: false, diff --git a/app/model/bigQuestionAnswer.js b/app/model/bigQuestionAnswer.js index bb60284..d1326f2 100644 --- a/app/model/bigQuestionAnswer.js +++ b/app/model/bigQuestionAnswer.js @@ -14,6 +14,9 @@ module.exports = app => { const { STRING, INTEGER, DATE, TEXT } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + return app.model.define('BigQuestionAnswer', { id: { type: INTEGER, @@ -66,26 +69,10 @@ module.exports = app => { allowNull: true, comment: '第3小问作答(上限 2000 字)', }, - score_1: { - type: INTEGER, - allowNull: true, - comment: '第1小问得分(0-10)', - }, - score_2: { - type: INTEGER, - allowNull: true, - comment: '第2小问得分(0-10)', - }, - score_3: { - type: INTEGER, - allowNull: true, - comment: '第3小问得分(0-10)', - }, - total_score: { - type: INTEGER, - allowNull: true, - comment: '三小问得分合计', - }, + score_1: decimalScore('score_1', '第1小问得分(0-10,可含 1 位小数)'), + score_2: decimalScore('score_2', '第2小问得分(0-10,可含 1 位小数)'), + score_3: decimalScore('score_3', '第3小问得分(0-10,可含 1 位小数)'), + total_score: decimalScore('total_score', '三小问得分合计(可含 1 位小数)'), submit_time: { type: DATE, allowNull: true, diff --git a/app/model/contestant.js b/app/model/contestant.js index 5e061c0..f570bcc 100644 --- a/app/model/contestant.js +++ b/app/model/contestant.js @@ -10,6 +10,9 @@ module.exports = app => { const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const Contestant = app.model.define('contestant', { // 主键 ID,自增 id: { @@ -64,33 +67,13 @@ module.exports = app => { comment: '账号激活状态', }, // 总分(管理员打分后更新,NULL 表示尚未评分) - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '选手总分(exam_score + word_score)', - }, + score: decimalScore('score', '选手总分(exam_score + word_score)'), // 卷面分(选择题+判断题自动计算) - exam_score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '卷面考试成绩', - }, - // Word题分数(管理员手动打分) - word_score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: 'Word题得分', - }, + exam_score: decimalScore('exam_score', '卷面考试成绩'), + // 综合大题 / Word 题分数(管理员手动打分,可含 1 位小数) + word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 1 位小数)'), // PPT分数(本科/教师组管理员手动打分) - ppt_score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: 'PPT得分(本科/教师组)', - }, + ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 1 位小数)'), // 所属团队 ID(外键关联 teams 表) team_id: { type: INTEGER, diff --git a/app/model/examSubmission.js b/app/model/examSubmission.js index 5c44e02..2c9377c 100644 --- a/app/model/examSubmission.js +++ b/app/model/examSubmission.js @@ -11,6 +11,9 @@ module.exports = app => { const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const ExamSubmission = app.model.define('exam_submission', { // 主键 ID,自增 id: { @@ -64,12 +67,7 @@ module.exports = app => { comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)', }, // 得分(管理员评分后更新,NULL 表示尚未评分) - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '试卷得分', - }, + score: decimalScore('score', '试卷得分', { defaultValue: null }), // 创建时间 created_at: { type: DATE, diff --git a/app/model/gaozhiSubmission.js b/app/model/gaozhiSubmission.js index d03a67c..d7e1b46 100644 --- a/app/model/gaozhiSubmission.js +++ b/app/model/gaozhiSubmission.js @@ -10,6 +10,9 @@ module.exports = app => { const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const GaozhiSubmission = app.model.define('gaozhi_submission', { // 主键 ID,自增 id: { @@ -54,12 +57,7 @@ module.exports = app => { comment: '是否判为正确', }, // 得分 - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '得分', - }, + score: decimalScore('score', '得分(可含 1 位小数)', { defaultValue: null }), // Word 文件存储路径 word_file_path: { type: STRING(500), diff --git a/app/model/jiaoshiSubmission.js b/app/model/jiaoshiSubmission.js index 5a1fc7b..177b358 100644 --- a/app/model/jiaoshiSubmission.js +++ b/app/model/jiaoshiSubmission.js @@ -10,6 +10,9 @@ module.exports = app => { const { STRING, INTEGER, DATE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const JiaoshiSubmission = app.model.define('jiaoshi_submission', { // 主键 ID,自增 id: { @@ -71,12 +74,7 @@ module.exports = app => { comment: '上传时间', }, // 评分 - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '评分', - }, + score: decimalScore('score', '评分(可含 1 位小数)', { defaultValue: null }), // 记录创建时间 created_at: { type: DATE, diff --git a/app/model/workSubmission.js b/app/model/workSubmission.js index 5091f0f..a7e04e4 100644 --- a/app/model/workSubmission.js +++ b/app/model/workSubmission.js @@ -11,6 +11,9 @@ module.exports = app => { const { STRING, INTEGER, DATE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const WorkSubmission = app.model.define('work_submission', { // 主键 ID,自增 id: { @@ -63,12 +66,7 @@ module.exports = app => { comment: '提交时间', }, // 得分(管理员评分后更新,NULL 表示尚未评分) - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '作品得分', - }, + score: decimalScore('score', '作品得分', { defaultValue: null }), // 创建时间 created_at: { type: DATE, diff --git a/app/model/zhongzhiSubmission.js b/app/model/zhongzhiSubmission.js index bcff88f..c04eda3 100644 --- a/app/model/zhongzhiSubmission.js +++ b/app/model/zhongzhiSubmission.js @@ -10,6 +10,9 @@ module.exports = app => { const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize; + // 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js) + const decimalScore = require('../lib/decimal_score')(app); + const ZhongzhiSubmission = app.model.define('zhongzhi_submission', { // 主键 ID,自增 id: { @@ -54,12 +57,7 @@ module.exports = app => { comment: '是否判为正确', }, // 得分 - score: { - type: INTEGER, - allowNull: true, - defaultValue: null, - comment: '得分', - }, + score: decimalScore('score', '得分(可含 1 位小数)', { defaultValue: null }), // Word 文件存储路径 word_file_path: { type: STRING(500), diff --git a/app/router.js b/app/router.js index 5e6fbae..67b607a 100644 --- a/app/router.js +++ b/app/router.js @@ -144,6 +144,11 @@ module.exports = app => { // 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分 router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions); router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion); + // Word 导出:?ids=1,2,3 批量;缺省 = 该组全部。1 道 → .docx;多道 → .zip(内含多个 .docx) + // ⚠️ 必须注册在 `:group/:id` 之前:Egg 按注册顺序匹配,否则一旦将来新增 + // `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); 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 e9145e4..7488df0 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -5,6 +5,18 @@ const path = require('path'); const fs = require('fs'); const XLSX = require('xlsx'); +/** + * 客观题(选择题 / 判断题)每小题固定分值。 + * + * 赛制口径(2026-09-23 统一):中职 / 高职组的选择题与判断题**每小题 1 分**, + * 客观题满分 = 抽题数(50 + 20 = 70)。 + * 此前分值可由管理端任意设置、且 Excel 导入缺省 5 分,一旦「5 分题的答案 + * 恰好全是 A / true」,考生全选 A 即可白拿高分(实测高职判断题得分率 95.3%)。 + * 综合性任务(word)不在本约束范围内。 + */ +const OBJECTIVE_SCORE_PER_QUESTION = 1; +const OBJECTIVE_TYPES = ['choice', 'judge']; + /** * 管理后台服务 - AdminService * @@ -960,6 +972,27 @@ class AdminService extends Service { } } + /** + * 计算题目的落库分值。 + * + * 客观题(choice / judge)一律强制为 `OBJECTIVE_SCORE_PER_QUESTION`(=1), + * 忽略调用方传入的任何分值;综合性任务(word)保留传入分值(须为正数)。 + * + * @param {string} questionType - choice / judge / word + * @param {*} rawScore - 调用方传入的分值(客观题会忽略) + * @returns {number} 实际落库分值 + */ + _resolveQuestionScore(questionType, rawScore) { + if (OBJECTIVE_TYPES.includes(questionType)) { + return OBJECTIVE_SCORE_PER_QUESTION; + } + const n = Number(rawScore); + if (!isFinite(n) || n <= 0) { + throw new Error('综合性任务分值(score)必须为大于 0 的合法数值'); + } + return n; + } + /** * 管理考试题目(替换操作:先清空再创建) * @param {Array} questions - 题目数组 @@ -982,11 +1015,8 @@ class AdminService extends Service { // 批量插入新题目 const questionData = questions.map((q, index) => { - // 分值必须为大于 0 的合法数值;0 分题属非法业务数据,整批替换时直接拒绝 - const scoreNum = Number(q.score); - if (!isFinite(scoreNum) || scoreNum <= 0) { - throw new Error(`题目分值(score)必须为大于 0 的合法数值(第 ${index + 1} 题)`); - } + // 客观题(choice/judge)分值由服务层强制为 1(赛制口径),忽略传入值 + const scoreNum = this._resolveQuestionScore(questionType, q.score); const base = { group, score: scoreNum, @@ -1037,12 +1067,8 @@ class AdminService extends Service { async addQuestion(questionData, group, questionType) { const QuestionModel = this._getQuestionModel(questionType); - // 分值必须为大于 0 的合法数值;0 分题属非法业务数据 - // (抽中该题的考生答对也得 0 分,且不同人满分不一致)。缺失/非法分值直接拒绝。 - const scoreNum = Number(questionData.score); - if (!isFinite(scoreNum) || scoreNum <= 0) { - throw new Error('题目分值(score)必须为大于 0 的合法数值'); - } + // 客观题(choice/judge)一律 1 分(赛制口径);word 题型保留传入分值 + const scoreNum = this._resolveQuestionScore(questionType, questionData.score); const data = { group, @@ -1081,7 +1107,10 @@ class AdminService extends Service { } const updateData = { - score: questionData.score !== undefined ? questionData.score : question.score, + // 客观题分值恒为 1(忽略传入值,防止管理端改回 5 分);word 题型沿用原逻辑 + score: OBJECTIVE_TYPES.includes(questionType) + ? OBJECTIVE_SCORE_PER_QUESTION + : (questionData.score !== undefined ? Number(questionData.score) : question.score), sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order, }; @@ -1373,8 +1402,10 @@ class AdminService extends Service { async setTypeScore(group, questionType, score) { const { ctx } = this; const QuestionModel = this._getQuestionModel(questionType); + // 客观题只能被设为 1 分(赛制口径);word 题型按传入值设置 + const resolved = this._resolveQuestionScore(questionType, score); const [updated] = await QuestionModel.update( - { score }, + { score: resolved }, { where: { group } } ); return updated; @@ -1477,9 +1508,9 @@ class AdminService extends Service { } } - const scoreRaw = row['分值']; - let score = parseInt(scoreRaw, 10); - if (isNaN(score) || score < 1) score = 5; + // 客观题分值恒为 1(赛制口径)。Excel 的「分值」列一律忽略 —— + // 旧逻辑在缺列/非法时缺省 5 分,正是「高分题」污染题库的来源之一。 + const score = OBJECTIVE_SCORE_PER_QUESTION; const data = { group, question, answer, score, sort_order: 0 }; if (qType === 'choice') data.options = options; @@ -1503,11 +1534,12 @@ class AdminService extends Service { * @returns {Promise} xlsx 文件 Buffer */ async buildQuestionTemplate() { + // 「分值」列已废弃(客观题恒为 1 分),保留列名仅为兼容旧模板与旧脚本 const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值']; - const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 5]; + const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 1]; const ws = XLSX.utils.aoa_to_sheet([header, example]); // 判断题示例行(另起一行,便于参考) - XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 5]], { origin: -1 }); + XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 }); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, '客观题'); return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); diff --git a/app/service/bigQuestion.js b/app/service/bigQuestion.js index de25d57..f3a6cd9 100644 --- a/app/service/bigQuestion.js +++ b/app/service/bigQuestion.js @@ -37,6 +37,18 @@ const SUB_QUESTION_COUNT = 3; const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3']; 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) { @@ -315,14 +327,30 @@ class BigQuestionService extends Service { 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: { aggregation_uid: aggregationUid }, + where: { group, aggregation_uid: aggregationUid }, }); if (!record) { - return { ok: false, code: 'NOT_FOUND', message: '该考生尚未抽取大题,无法评分' }; + // 区分「不属于本组」与「尚未抽题」:前者通常意味着前端残留了上一组的 uid, + // 文案要能直接暴露这类误操作,而不是把它伪装成「考生没抽题」。 + const elsewhere = await ctx.model.BigQuestionAnswer.findOne({ + where: { aggregation_uid: aggregationUid }, + attributes: ['id'], + }); + return { + ok: false, + code: 'NOT_FOUND', + message: elsewhere + ? '该考生不属于当前组别,无法在本组评分' + : '该考生尚未抽取大题,无法评分', + }; } - // 逐问校验 0–10 整数(分值口径来自题库 score_per_sub,默认 10) + // 逐问校验 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++) { @@ -332,11 +360,13 @@ class BigQuestionService extends Service { } const n = Number(raw); if (!Number.isFinite(n) || n < 0 || n > per) { - return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值` }; + return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值(可含一位小数)` }; } - parsed.push(Math.round(n)); + // 2026-09-23:人工评分允许小数点后一位 ⇒ 收敛到 1 位小数,⛔ 不可用 Math.round 抹成整数 + parsed.push(Math.round(n * 10) / 10); } - const total = parsed.reduce((a, b) => a + b, 0); + // 浮点求和再收敛一次,避免 0.1+0.2=0.30000000000000004 之类脏值落库/回显 + const total = Math.round(parsed.reduce((a, b) => a + b, 0) * 10) / 10; await record.update({ score_1: parsed[0], @@ -398,6 +428,107 @@ class BigQuestionService extends Service { })); } + /** + * 导出大题为 Word(.docx)(2026-09-23 需求:单独 / 批量 / 全部导出中职·高职大题) + * + * 行为: + * · 指定 ids(单个或多个)→ 只导出这些题; + * · 不传 ids → 导出该组**全部**; + * · 命中 1 道 → 直接返回 .docx; + * · 命中多道 → 返回「多个 .docx 打成的一个 .zip」(每道题一个独立 Word 文件, + * 便于分别打印/分发;而不是把多道题塞进一个长文档)。 + * + * 只读操作:不写库、不改任何状态。 + * + * @param {string} group - 组别(zhongzhi / gaozhi) + * @param {Array|string} [ids] - 大题 ID 列表(数组或逗号分隔字符串) + * @returns {Promise} { 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, + }; + } + /** * 新建大题 * @param {string} group @@ -533,6 +664,8 @@ class BigQuestionService extends Service { title, description, subPrompts, + // 每小题分值 = 赛制口径(每问 10 分、大题 30 分),刻意保持整数; + // 人工评分的小数精度只作用于「考生得分」,见 setSubScores。 scorePerSub: Math.round(scorePerSub), sortOrder, status, diff --git a/config/config.default.js b/config/config.default.js index f16610d..47b26df 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -236,6 +236,12 @@ module.exports = (appInfo) => { // 由上方 define.collate 负责。此处传 collate 会触发 // "Ignoring invalid configuration option passed to Connection: collate" 警告。 charset: 'utf8mb4', + // 🔴 DECIMAL 必须显式转 Number(2026-09-23,一位小数评分) + // mysql2 默认把 DECIMAL 读成**字符串**,会导致:① 前端出现 + // "7.5" + 0 → "7.50" 之类的字符串拼接;② `n > per` 退化为字符串比较。 + // 开启后 DECIMAL 以 Number 返回,与 INTEGER 行为一致;全库原无 DECIMAL 列 + // (迁移 025 首次引入),故对既有功能零影响。 + decimalNumbers: true, }, pool: { // 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」 diff --git a/database/migrations/025-scores-decimal-one-place.sql b/database/migrations/025-scores-decimal-one-place.sql new file mode 100644 index 0000000..4e8bb0f --- /dev/null +++ b/database/migrations/025-scores-decimal-one-place.sql @@ -0,0 +1,99 @@ +-- ============================================================================ +-- 025 一位小数评分支持:分数列 int(11) → DECIMAL(6,1) +-- +-- 背景(2026-09-23) +-- 需求:中职 / 高职「综合大题」的人工评分允许保留小数点后一位(如 7.5)。 +-- 现状:全库 23 个 score 类列**全部**为 int(11) ⇒ 小数落库即被静默截断; +-- 且 service/bigQuestion.js:337 另有一处 Math.round() 主动取整。 +-- 本迁移把「得分」语义的列放宽为 DECIMAL(6,1)(上限 99999.9,保留 1 位小数)。 +-- +-- 范围(17 列) +-- 改(得分 / 分数落点): +-- big_question_answers.score_1 / score_2 / score_3 / total_score +-- contestants.exam_score / word_score / ppt_score / score +-- accounts.score +-- zhongzhi_submissions.score / gaozhi_submissions.score / exam_submissions.score +-- benke_submissions.score / jiaoshi_submissions.score / work_submissions.score +-- big_questions.score_per_sub / total_score(大题分值配置,随评分口径) +-- 不改: +-- choice_questions.score / judge_questions.score +-- —— 客观题口径已固定为每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION), +-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1; +-- word_questions.score、exam_questions/zhongzhi_questions/gaozhi_questions.score +-- —— 属题目分值配置或历史遗留题表,无小数需求。 +-- +-- 兼容性 +-- INT → DECIMAL(6,1) 是**放宽**:既有整数值原样保留,读写语义不变。 +-- ⚠️ 应用侧必须同步: +-- ① config/config.default.js 的 sequelize.dialectOptions.decimalNumbers = true +-- (否则 mysql2 把 DECIMAL 读成**字符串**); +-- ② 相关 model 字段类型声明为 DECIMAL,且 getter 转 Number +-- (否则 Sequelize 的 INTEGER.parse 会把 7.5 二次截断成 7)。 +-- +-- 执行(在 database/ 目录下): +-- mysql -uroot -p competition_db < migrations/025-scores-decimal-one-place.sql +-- 幂等:MODIFY 到同一类型可重复执行。 +-- ============================================================================ + +-- ---------------------------------------------------------------- 大题作答 +ALTER TABLE `big_question_answers` + MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 1 位小数)', + MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 1 位小数)', + MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 1 位小数)', + MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '大题合计得分(可含 1 位小数)'; + +-- ---------------------------------------------------------------- 大题配置 +ALTER TABLE `big_questions` + MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10 COMMENT '每小题分值', + MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数'; + +-- ---------------------------------------------------------------- 选手成绩 +ALTER TABLE `contestants` + MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面考试成绩', + MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 1 位小数)', + MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 1 位小数)', + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)'; + +-- ---------------------------------------------------------------- 账号分(历史兼容) +ALTER TABLE `accounts` + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)'; + +-- ---------------------------------------------------------------- 提交表分项 +ALTER TABLE `zhongzhi_submissions` + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分'; +ALTER TABLE `gaozhi_submissions` + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分'; +ALTER TABLE `exam_submissions` + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面得分'; +ALTER TABLE `benke_submissions` + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分'; +ALTER TABLE `jiaoshi_submissions` + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分'; +ALTER TABLE `work_submissions` + MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分'; + +-- ============================================================================ +-- 回滚(⚠️ DECIMAL → INT 会**丢失**既有小数位,执行前务必确认无小数值: +-- SELECT COUNT(*) FROM contestants WHERE score <> FLOOR(score); -- 应为 0) +-- +-- ALTER TABLE `big_question_answers` +-- MODIFY COLUMN `score_1` INT(11) NULL DEFAULT NULL, +-- MODIFY COLUMN `score_2` INT(11) NULL DEFAULT NULL, +-- MODIFY COLUMN `score_3` INT(11) NULL DEFAULT NULL, +-- MODIFY COLUMN `total_score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `big_questions` +-- MODIFY COLUMN `score_per_sub` INT(11) NOT NULL DEFAULT 10, +-- MODIFY COLUMN `total_score` INT(11) NOT NULL DEFAULT 30; +-- ALTER TABLE `contestants` +-- MODIFY COLUMN `exam_score` INT(11) NULL DEFAULT NULL, +-- MODIFY COLUMN `word_score` INT(11) NULL DEFAULT NULL, +-- MODIFY COLUMN `ppt_score` INT(11) NULL DEFAULT NULL, +-- MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `accounts` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL; +-- ============================================================================