diff --git a/app/controller/admin.js b/app/controller/admin.js index 33eb2b9..9a634e5 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -1906,6 +1906,83 @@ class AdminController extends Controller { ctx.body = ctx.helper.error(500, '大题评分失败'); } } + + // ==================== 成绩复核 ==================== + // 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。 + // ⚠️ 复核明细会回传 question.answer(正确答案),**只能**挂 admin 路由 + // (若被选手端复用即为成绩泄露,见 scripts/verify_score_leak_all_channels.py)。 + + /** + * 检索考生 + * GET /api/admin/review/search?keyword=xxx[&group=zhongzhi] + * + * keyword 命中「姓名 / 身份证号 / 电话」任一(模糊匹配)。不传 group 则全组别检索 —— + * 复核时管理员通常只拿到一个身份证号,不该被迫先猜组别。 + */ + async reviewSearch() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const keyword = ctx.query.keyword; + const group = ctx.query.group || undefined; + + // 传了 group 就必须合法;不传表示全组别检索。 + if (group && !this._validateGroup(group, true)) return; + + const result = await service.admin.reviewSearch(keyword, group); + if (!result.ok) { + ctx.body = ctx.helper.error(400, result.message); + return; + } + + ctx.body = ctx.helper.success({ + list: result.list, + total: result.list.length, + truncated: result.truncated, + }, '检索完成'); + } catch (err) { + ctx.logger.error('复核检索失败:', err); + ctx.body = ctx.helper.error(500, '复核检索失败'); + } + } + + /** + * 考生全卷明细(复核) + * GET /api/admin/review/score/:group/:id + * + * :id 为 contestants.id。:group 仅用于**校验**(与考生实际组别不符则拒绝), + * 真正的取数组别以库中考生的 group 为准 —— 防止前端传错组别导致照错人出证据。 + */ + async contestantScore() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group, id } = ctx.params; + if (!this._validateGroup(group, true)) return; + + const cid = parseInt(id, 10); + if (isNaN(cid)) { + ctx.body = ctx.helper.error(400, '考生 ID 非法'); + return; + } + + const result = await service.admin.getContestantScore(cid, group); + if (!result.ok) { + // 404 资源不存在;409 资源存在但与请求上下文冲突(组别不符) + const status = result.code === 'NOT_FOUND' ? 404 + : (result.code === 'GROUP_MISMATCH' ? 409 : 400); + ctx.body = ctx.helper.error(status, result.message); + return; + } + + ctx.body = ctx.helper.success(result, '获取考生全卷明细成功'); + } catch (err) { + ctx.logger.error('获取考生全卷明细失败:', err); + ctx.body = ctx.helper.error(500, '获取考生全卷明细失败'); + } + } } module.exports = AdminController; diff --git a/app/router.js b/app/router.js index 67b607a..7966740 100644 --- a/app/router.js +++ b/app/router.js @@ -187,6 +187,14 @@ module.exports = app => { router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag); router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData); + // ==================== 成绩复核(需管理员认证) ==================== + // 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。 + // ⚠️ 本组会回传客观题正确答案,务必保持 adminAuth —— 选手端一旦可达即为成绩泄露。 + // 注册顺序:两条路径段数不同(3 段 / 5 段),不存在通配段互相吞并的问题; + // 仍按「固定段在前」的惯例把 search 放前面。 + router.get('/api/admin/review/search', adminAuth, controller.admin.reviewSearch); + router.get('/api/admin/review/score/:group/:id', adminAuth, controller.admin.contestantScore); + // ==================== 成绩查询(需认证) ==================== router.get('/api/score', auth, controller.score.index); diff --git a/app/service/admin.js b/app/service/admin.js index 7488df0..992837a 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -1896,51 +1896,333 @@ class AdminService extends Service { await ctx.model.Carousel.destroy({ where: { id } }); } - // ==================== 成绩查询 ==================== - // ==================== 成绩查询 ==================== + // ==================== 成绩复核 ==================== + // 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。 + // 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。 /** - * 获取选手个人成绩详情 - * @param {number} aggregationUid - 聚合报名 ID - * @param {string} group - 组别 - * @returns {Promise} 成绩详情 + * 检索考生(复核入口) + * + * 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** —— + * 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 group 供界面展示。 + * + * @param {string} keyword - 检索词(姓名 / 身份证号 / 电话片段) + * @param {string} [group] - 可选:限定组别 + * @returns {Promise<{ok:boolean, code?:string, message?:string, list?:Array, truncated?:boolean}>} */ - async getContestantScore(aggregationUid, group) { + async reviewSearch(keyword, group) { const { ctx } = this; - const result = {}; + const kw = String(keyword == null ? '' : keyword).trim(); - if (group === 'zhongzhi' || group === 'gaozhi') { - const SubmissionModel = group === 'zhongzhi' - ? ctx.model.ZhongzhiSubmission - : ctx.model.GaozhiSubmission; - - const submissions = await SubmissionModel.findAll({ - where: { aggregation_uid: aggregationUid }, - attributes: ['id', 'question_uid', 'answer_data', 'is_correct', 'score', 'word_file_path'], - }); - - const totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0); - result.score = totalScore; - result.totalQuestions = submissions.length; - result.correctCount = submissions.filter(s => s.is_correct).length; - result.details = submissions; - result.hasWordFile = submissions.some(s => s.word_file_path); - } else { - const SubmissionModel = group === 'benke' - ? ctx.model.BenkeSubmission - : ctx.model.JiaoshiSubmission; - - const submissions = await SubmissionModel.findAll({ - where: { aggregation_uid: aggregationUid }, - attributes: ['id', 'work_name', 'file_name', 'file_path', 'file_size', 'upload_time', 'score'], - order: [['upload_time', 'DESC']], - }); - - result.score = submissions.reduce((sum, s) => sum + (s.score || 0), 0); - result.submissions = submissions; + // 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。 + if (!kw) return { ok: true, list: [], truncated: false }; + if (kw.length > 64) { + return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' }; } - return result; + const { Op } = ctx.model.Sequelize; + const like = { [Op.like]: `%${kw}%` }; + const where = { [Op.or]: [{ name: like }, { id_number: like }, { phone: like }] }; + if (group) where.group = group; + + const LIMIT = 50; + const rows = await ctx.model.Contestant.findAll({ + where, + attributes: ['id', 'name', 'id_number', 'phone', 'group'], + order: [['group', 'ASC'], ['id', 'ASC']], + limit: LIMIT, + raw: true, + }); + + return { + ok: true, + list: rows.map(c => ({ + contestantId: c.id, + name: c.name || '', + idNumber: c.id_number || '', + phone: c.phone || '', + group: c.group, + // 客观题只存在于中职/高职;本科/教师的「卷面」是作品文件。界面据此切换展示与提示。 + objectiveApplicable: c.group === 'zhongzhi' || c.group === 'gaozhi', + })), + // 命中被截断时如实上报,引导管理员补充更多字符(而非静默只展示前 50 条)。 + truncated: rows.length >= LIMIT, + }; + } + + /** + * 成绩复核:取某考生的**全卷**明细。 + * + * 输出 = 客观题逐题(题干 / 选项 / 考生答案 / 正确答案 / 对错 / 得分 / 满分) + * + 综合大题(题干快照 + 三小问作答 + 三小问得分) + * + 本科/教师组的作品文件清单。 + * + * 设计约束(每条都对应一个已发生过的坑): + * 1. **归集口径必须与 getExamSubmissions 完全一致**(双键:contestant.id 与真实 aggregation_uid)。 + * 否则会出现「列表页说已答 70 题、复核页只有 0 题」—— 管理员会先怀疑数据被篡改。 + * 2. **组别以考生自身的 group 为准**,并校验入参 group:防跨组串查。 + * 3. 题目按 **exam_submissions.question_order(考生固化的抽题顺序)** 排列,复现他当时所见的题序; + * 无固化顺序的历史数据回退为「选择在前、判断在后,各自按题号升序」。 + * 4. `question.answer` **必须回传**(复核场景用户明确要求给出正确答案)。 + * ⚠️ 因此本方法**只能挂 admin 路由**:一旦被选手端复用即为成绩泄露 + * (参见 scripts/verify_score_leak_all_channels.py 所守的三条通道)。 + * 也正因如此这里**不做** isScorePublished 门控 —— admin 是可信边界, + * 而内部核对往往发生在发布之前;发布态只在返回值里**如实上报**供界面提示。 + * 5. 题库中已删除 / 改组导致取不到题目时标 `questionMissing`,而非静默留空 —— + * 复核要能区分「考生没答」与「题没了」。 + * + * @param {number} contestantId - contestants.id + * @param {string} [group] - 调用方声明的组别(须与考生实际组别一致) + * @returns {Promise} { ok, ... } ok=false 时带 code/message(不抛异常) + */ + async getContestantScore(contestantId, group) { + const { ctx } = this; + const { Op } = ctx.model.Sequelize; + + const cid = parseInt(contestantId, 10); + if (!Number.isFinite(cid)) { + return { ok: false, code: 'BAD_ID', message: '考生 ID 非法' }; + } + + const contestant = await ctx.model.Contestant.findOne({ + where: { id: cid }, + attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'], + raw: true, + }); + if (!contestant) return { ok: false, code: 'NOT_FOUND', message: '考生不存在' }; + if (group && group !== contestant.group) { + return { ok: false, code: 'GROUP_MISMATCH', message: `组别不符:该考生属于「${contestant.group}」组` }; + } + + const g = contestant.group; + const isExamGroup = g === 'zhongzhi' || g === 'gaozhi'; + + const base = { + contestantId: cid, + name: contestant.name || '', + idNumber: contestant.id_number || '', + phone: contestant.phone || '', + group: g, + objectiveApplicable: isExamGroup, + scorePublished: await this.isScorePublished(g), // 只上报,不门控(见方法头第 4 条) + }; + + if (!isExamGroup) { + // 本科/教师:无客观题,「卷面」即提交的作品文件(Word / PPT)。 + return { + ok: true, + ...base, + objective: [], + summary: { answered: 0, total: 0, correctCount: 0, autoScore: 0 }, + bigQuestion: null, + works: await this._reviewWorks(cid, g), + warnings: [], + }; + } + + // ---- 1) 双键归集(与 getExamSubmissions 完全同口径) ---- + const agg = await ctx.model.Aggregation.findOne({ + where: { account_uid: cid }, + attributes: ['aggregation_uid'], + raw: true, + }); + const keys = [String(cid)]; + if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(cid)) { + keys.push(String(agg.aggregation_uid)); + } + + const SubmissionModel = g === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission; + const subs = await SubmissionModel.findAll({ + where: { aggregation_uid: { [Op.in]: keys.map(Number) } }, + attributes: ['id', 'aggregation_uid', 'question_uid', 'question_type', 'answer_data', 'is_correct', 'score'], + order: [['id', 'ASC']], + raw: true, + }); + + const warnings = []; + const usedKeys = [ ...new Set(subs.map(s => String(s.aggregation_uid))) ]; + if (usedKeys.length > 1) { + warnings.push('该考生的作答记录同时落在两种聚合键下(' + usedKeys.join(' / ') + + '),已按与「考生答题记录」一致的口径合并归集;若题数异常请人工核对。'); + } + + // ---- 2) 固化题序(复现考生当时所见次序) ---- + const examSub = await ctx.model.ExamSubmission.findOne({ + where: { contestant_id: cid }, + attributes: ['question_order', 'submit_time'], + raw: true, + }); + const rank = new Map(); + if (examSub && examSub.question_order) { + try { + const parsed = JSON.parse(examSub.question_order); + if (Array.isArray(parsed)) parsed.forEach((u, i) => rank.set(String(u), i)); + } catch (e) { + // 脏数据不抛异常:回退默认排序(复核页不应因一条脏记录整体打不开) + ctx.logger.warn('[getContestantScore] question_order 解析失败 cid=%s', cid); + } + } + + // ---- 3) 批量取题干(按题型分表;必须带 group 限定,防跨组同号题串台) ---- + const idsByType = { choice: [], judge: [] }; + for (const s of subs) { + const t = s.question_type; + // 白名单判定,避免 'constructor' 之类字符串命中 Object 原型链 + if (!Object.prototype.hasOwnProperty.call(idsByType, t)) continue; + const n = Number(s.question_uid); + if (Number.isFinite(n)) idsByType[t].push(n); + } + const qMap = new Map(); + for (const t of [ 'choice', 'judge' ]) { + const ids = idsByType[t]; + if (!ids.length) continue; + const Model = t === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion; + const rows = await Model.findAll({ where: { id: { [Op.in]: ids }, group: g }, raw: true }); + rows.forEach(r => qMap.set(`${t}:${r.id}`, r)); + } + + // ---- 4) 拼装逐题明细 ---- + const norm = v => String(v == null ? '' : v).trim().toLowerCase(); + const objective = subs.map(s => { + const t = s.question_type; + const n = Number(s.question_uid); + const q = qMap.get(`${t}:${n}`) || null; + const userAnswer = s.answer_data == null ? '' : String(s.answer_data); + const correct = q ? String(q.answer == null ? '' : q.answer) : null; + const orderKey = `${t}_${n}`; + const seq = rank.has(orderKey) ? rank.get(orderKey) + : (rank.has(String(n)) ? rank.get(String(n)) : null); + return { + seq, + questionType: t, + questionUid: n, + question: q ? q.question : null, + options: q ? (q.options || null) : null, + // 原始值:选择 'A'/'AB';判断 'true'/'false'(由前端做「正确/错误」展示映射,后端不丢事实) + correctAnswer: correct, + maxScore: q && q.score != null ? Number(q.score) : null, + answer: userAnswer, + answered: userAnswer.trim() !== '', + isCorrect: s.is_correct == null ? null : !!s.is_correct, + score: s.score == null ? null : Number(s.score), + // 交叉校验:答案一致却判为错(或反之)即判分异常 —— 复核时最先要看的正是这个。 + // ⚠️ 未作答必须给 null 而非 false:'' 与标准答案比对天然不等, + // 若返回 false,「未答」在界面上会被误读成「答错」。 + answerMatches: (q && userAnswer.trim() !== '') ? (norm(userAnswer) === norm(correct)) : null, + questionMissing: !q, + }; + }); + + objective.sort((a, b) => { + const ra = a.seq == null ? Number.MAX_SAFE_INTEGER : a.seq; + const rb = b.seq == null ? Number.MAX_SAFE_INTEGER : b.seq; + if (ra !== rb) return ra - rb; + if (a.questionType !== b.questionType) return a.questionType === 'choice' ? -1 : 1; + return a.questionUid - b.questionUid; + }); + + // 小数按 1 位收敛(与 DECIMAL(6,1) 及全站评分口径一致,避免 0.30000000000000004) + const round1 = v => Math.round(v * 10) / 10; + const autoScore = round1(objective.reduce((sum, o) => sum + (o.score || 0), 0)); + + // ---- 5) 综合大题(题干取抽题快照,保证考生当时所见可举证) ---- + let bigQuestion = null; + const bigRow = await ctx.model.BigQuestionAnswer.findOne({ + where: { aggregation_uid: cid }, + raw: true, + }); + if (bigRow) { + const snap = this.service.bigQuestion.parseSnapshot(bigRow.question_snapshot) || {}; + const subPrompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : []; + bigQuestion = { + drawn: true, + bigQuestionId: bigRow.big_question_id || null, + bigQuestionUid: bigRow.big_question_uid || null, + title: snap.title || '', + description: snap.description || '', + subPrompts, + scorePerSub: snap.score_per_sub != null ? Number(snap.score_per_sub) : null, + maxTotal: snap.total_score != null ? Number(snap.total_score) : null, + // 固定 3 个槽位,与库表 answer_1..3 对齐(未作答为 '') + answers: [ 1, 2, 3 ].map(i => { + const a = bigRow[`answer_${i}`] == null ? '' : String(bigRow[`answer_${i}`]); + const sc = bigRow[`score_${i}`]; + return { + index: i, + prompt: subPrompts[i - 1] || '', + answer: a, + answered: a.trim() !== '', + score: sc == null ? null : Number(sc), + }; + }), + totalScore: bigRow.total_score == null ? null : Number(bigRow.total_score), + snapshotAt: snap.snapshot_at || null, + submitTime: bigRow.submit_time || null, + }; + } + + return { + ok: true, + ...base, + objective, + summary: { + answered: objective.length, + total: objective.length, + correctCount: objective.filter(o => o.isCorrect === true).length, + autoScore, + wordScore: contestant.word_score == null ? 0 : Number(contestant.word_score), + totalScore: round1(autoScore + (contestant.word_score == null ? 0 : Number(contestant.word_score))), + }, + // 固化题序存在与否决定上图顺序的可解释性,一并上报(便于核对与排障) + questionOrderFixed: rank.size > 0, + matchedKeys: usedKeys, + bigQuestion, + works: [], + warnings, + }; + } + + /** + * 本科/教师组的作品文件清单(复核用,只读) + * + * 不返回 file_path(服务端磁盘路径不该出网),只给下载所需的 submission id; + * 下载走既有端点 GET /api/admin/submission-file/:group/:id。 + * + * @param {number} contestantId - contestants.id + * @param {string} group - benke / jiaoshi + * @returns {Promise} + */ + async _reviewWorks(contestantId, group) { + const { ctx } = this; + const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; + + // benke/jiaoshi_submissions.aggregation_uid 存的是「聚合报名 ID」,与 contestants.id 不是同一序列, + // 需经 aggregation.account_uid 映射(与 getSubmissions 同口径)。 + const agg = await ctx.model.Aggregation.findOne({ + where: { account_uid: contestantId }, + attributes: ['aggregation_uid'], + raw: true, + }); + const keys = [String(contestantId)]; + if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(contestantId)) { + keys.push(String(agg.aggregation_uid)); + } + + const rows = await SubmissionModel.findAll({ + where: { aggregation_uid: { [ctx.model.Sequelize.Op.in]: keys.map(Number) } }, + attributes: ['id', 'work_name', 'file_name', 'file_size', 'upload_time', 'score'], + order: [['upload_time', 'DESC']], + raw: true, + }); + + return rows.map(r => ({ + id: r.id, + workName: r.work_name || '', + fileName: r.file_name || '', + fileSize: r.file_size == null ? null : Number(r.file_size), + uploadTime: r.upload_time || null, + score: r.score == null ? null : Number(r.score), + })); } }