Files
competition-website/app/service/bigQuestion.js
qubiaobiao 8ef18da395 feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道
## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」

按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答
综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。

- 新增表(迁移 023/024)
  · `big_questions`        —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径)
  · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分)
  两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。
- 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js`
- 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑)
- 考生端接口(`app/controller/exam.js`)
  · `GET  /api/exam/:group/big-question` —— 首次抽题并落库题干快照,
    后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷)
  · `POST /api/exam/:group/big-answer`   —— 保存三小问答案,带考试窗口门控
- 管理端接口(`app/controller/admin.js`)
  · 题库 CRUD:`/api/admin/big-questions/:group[/:id]`
  · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间)
  · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores`
- 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口
  一并拒绝(前端入口已移除,但直调 API 仍可绕过)。

**所有新增读写仅落在新增的两张表,不触碰任何既有表结构。**

## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口)

原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接
回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩,
而同期 `GET /api/score` 正确返回 `null`。

- `POST /api/auth/login`         —— 实测未发布态回传 `score`
- `GET  /api/auth/current-user`  —— 全字段 `toJSON`,连带下发分项
- `POST /api/auth/refresh`       —— 全字段 `toJSON`,且前端 `doSilentRefresh()`
  会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道;
  实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段

修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛,
禁止各自直接 `toJSON`。口径:
- 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认)
- `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发**
  (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路)

门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项,
新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止
"永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。

## 三、移除已弃用的 Word 模板

中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`)
随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。

---
验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、
窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00

579 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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;
/**
* 综合大题服务 - 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'];
const SCORE_FIELDS = ['score_1', 'score_2', 'score_3'];
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: '该组别未启用综合大题' };
}
const record = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: aggregationUid },
});
if (!record) {
return { ok: false, code: 'NOT_FOUND', message: '该考生尚未抽取大题,无法评分' };
}
// 逐问校验 0–10 整数(分值口径来自题库 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} 的数值` };
}
parsed.push(Math.round(n));
}
const total = parsed.reduce((a, b) => a + b, 0);
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>}
*/
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,
}));
}
/**
* 新建大题
* @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));
}
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,
scorePerSub: Math.round(scorePerSub),
sortOrder,
status,
};
}
// ==================== 内部工具 ====================
/** 构造题干快照(抽题时刻固化,题目后续被改不影响存证) */
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;