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, '大题评分失败');
}
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// ⚠️ 复核明细会回传 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;