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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user