Files
competition-website/app/service/bigQuestion.js
qubiaobiao 95ad1adca4 fix(deps): xlsx → @e965/xlsx@0.20.3(消除两处已知 high 供应链漏洞)
背景:npm 上的 xlsx 永久冻结在 0.18.5(SheetJS 已停止向 npm 发布),该版本含
CVE-2023-30533(原型污染,0.19.3 修复)与 CVE-2024-22363(ReDoS,0.20.2 修复)两处 high,
而 npm 上**不存在**修复版本——官方修复版只发自家 CDN。

选型判据=CDN 可达性:本机实测 cdn.sheetjs.com 不可达(curl rc=35 schannel 吊销离线;
--ssl-no-revoke 后 rc=28 超时),采用官方 CDN tarball 会把「依赖能否装上」变成部署单点。
故改用 npm 上忠实转发官方 CDN 构建的 @e965/xlsx(0.20.3,含上述两处修复、零依赖、
API 与 xlsx 全等)。未选 exceljs:其 API 完全不同,11 个调用点需全量重写且写入变异步,
风险直接落在承载生产导入路径的代码上。

- package.json:xlsx@^0.18.0 → @e965/xlsx@^0.20.3
- app/service/admin.js(成绩导出 / 客观题模板 / 报名表导入 / 客观题导入)
  app/service/bigQuestion.js(综合大题模板 / 综合大题导入):仅改 require 包名,
  业务逻辑零改动(共 6 条路径 / 11 个调用点)
- package-lock.json:移除 xlsx 及其 7 个传递依赖(cfb/codepage/ssf/wmf/word/adler-32/frac),
  净 −7 包

验证:API 面 + 写读往返 PASS;差分测试 24/24(含畸形尺寸样本与 201 行模板);
真实 HTTP 回归 6 条路径 PASS(写 3/3 均 PK 头、读 3/3 正效应且落库计数吻合);
跨库往返 6/6;npm audit 中 xlsx 命中【无】(backend high 23 → 22)。
2026-10-10 15:15:37 +08:00

1148 lines
48 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');
/**
* 综合大题服务 - 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']],
});
return contestants.map(c => {
const rec = byContestant[String(c.id)] || byAgg[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: c.team_id,
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,
};
});
}
/**
* 管理端按三小问给分。
*
* 双写但**不新增成绩通路**:
* 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;
}
// ==================== 题库管理(管理端) ====================
/**
* 题库列表(按组)
* @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 || '--'}` }] });
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;