feat(admin): 成绩复核——按姓名/身份证/电话检索考生并查看全卷客观题逐题作答与正确答案(R26)

- service.admin: 新增 reviewSearch / getContestantScore / _reviewWorks;移除同名的死代码旧实现
- 归集双键合并 [contestant.id] ∪ [真实 aggregation_uid],与 getExamSubmissions 口径逐字一致
- 题序还原:优先 exam_submissions.question_order,缺失则回退「选择→判断 + 题号升序」并回传 questionOrderFixed=false
- 未作答行 answerMatches 返回 null(非 false),避免界面把「未答」读成「答错」
- 组别以库中考生 group 为准,路由 :group 不符返回 409,防前端传错组照错人
- 异常可区分:questionMissing(题被删/改组)、answerMatches≠isCorrect(判分矛盾)
- 因回传正确答案,两条路由均挂 adminAuth —— 选手端可达即成绩泄露(与 verify_score_leak_all_channels.py 三通道门禁一致)
- 不改动 schema、不改动既有接口行为

controller.admin: 新增 reviewSearch / contestantScore(NOT_FOUND→404 / GROUP_MISMATCH→409 / 其它→400)
router.js: 注册 GET /api/admin/review/search 与 /api/admin/review/score/:group/:id,均 adminAuth
This commit is contained in:
2026-09-28 10:15:42 +08:00
parent 079c1b31c9
commit cbc05ed4e7
3 changed files with 405 additions and 38 deletions

View File

@@ -1906,6 +1906,83 @@ class AdminController extends Controller {
ctx.body = ctx.helper.error(500, '大题评分失败'); 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; module.exports = AdminController;

View File

@@ -187,6 +187,14 @@ module.exports = app => {
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag); router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData); 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); router.get('/api/score', auth, controller.score.index);

View File

@@ -1896,51 +1896,333 @@ class AdminService extends Service {
await ctx.model.Carousel.destroy({ where: { id } }); await ctx.model.Carousel.destroy({ where: { id } });
} }
// ==================== 成绩查询 ==================== // ==================== 成绩复核 ====================
// ==================== 成绩查询 ==================== // 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。
/** /**
* 获取选手个人成绩详情 * 检索考生(复核入口)
* @param {number} aggregationUid - 聚合报名 ID *
* @param {string} group - 组别 * 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** ——
* @returns {Promise<Object>} 成绩详情 * 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 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 { ctx } = this;
const result = {}; const kw = String(keyword == null ? '' : keyword).trim();
if (group === 'zhongzhi' || group === 'gaozhi') { // 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。
const SubmissionModel = group === 'zhongzhi' if (!kw) return { ok: true, list: [], truncated: false };
? ctx.model.ZhongzhiSubmission if (kw.length > 64) {
: ctx.model.GaozhiSubmission; return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' };
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;
} }
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<Object>} { 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<Array>}
*/
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),
}));
} }
} }