Files
competition-website/app/router.js
qubiaobiao cbc05ed4e7 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
2026-09-28 10:15:42 +08:00

202 lines
11 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';
/**
* 路由配置 - Router
*
* 通信与导航应用技术创新赛 - 线上选拔赛网站后端路由定义
*
* 路由分组说明:
* - /api/auth/* : 认证相关(登录、激活、验证码等)
* - /api/news/* : 新闻通知(公开访问)
* - /api/carousel : 轮播图(公开访问)
* - /api/materials/* : 资料下载(需认证)
* - /api/exam/* : 比赛答题(需认证)
* - /api/upload/* : 文件上传(需认证)
* - /api/admin/* : 管理后台(需管理员认证)
* - /api/score/* : 成绩查询(需认证)
*/
module.exports = app => {
const { router, controller, middleware } = app;
// 获取 JWT 认证中间件实例
const auth = middleware.auth();
// 管理员认证中间件(需要 admin 角色)
const adminAuth = middleware.auth({ adminOnly: true });
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
// 交卷限流(2026-09-21 并发压测结论)
//
// 🔴 只挂在 submit-all 这**单条**路由上,绝不整段挂 /api/exam:
// 逐题保存 submit-answers 是「每答一题一次请求」(一场约 59 次),若整段挂
// exam:30/分钟,正常考生在答题过程中就会被自己的保存请求打爆(429),
// 直接造成丢答案 —— 这比不限流更糟。
//
// 🔴 必须排在 auth **之后**:rateLimit 的限流键优先取 ctx.state.currentUser.uid
// (见 middleware/rateLimit.js getClientId),而 currentUser 由 auth 写入。
// 若不排在 auth 之后,键会退化为 ip:<ctx.ip> —— 考场/学校普遍共用出口 IP,
// 会把整间考场的考生一起限流拒掉,属灾难级误伤。
//
// 阈值取 LIMITS.exam = 30 次/60 秒(正常交卷 1 次 + 前端自动重试数次,余量充足)。
const examSubmitLimit = middleware.rateLimit({ level: 'exam' });
// ==================== 公开接口(无需认证) ====================
// 健康检查(容器探针 / 负载均衡探活,无需认证)
router.get('/api/health', controller.health.check);
// 认证相关 - 公开接口
router.post('/api/auth/captcha', controller.auth.captcha);
router.post('/api/auth/sms', controller.auth.sms);
router.post('/api/auth/activate', controller.auth.activate);
router.post('/api/auth/login', loginRateLimit, controller.auth.login);
router.post('/api/auth/admin-login', loginRateLimit, controller.auth.adminLogin);
router.post('/api/auth/forgot-password', controller.auth.forgotPassword);
router.post('/api/auth/reset-password', controller.auth.resetPassword);
// ==================== 选手认证接口(需要 JWT,不需要 admin 角色) ====================
// 认证相关 - 受保护接口
router.get('/api/auth/current-user', auth, controller.auth.currentUser);
router.post('/api/auth/logout', auth, controller.auth.logout);
// 静默刷新令牌(不挂 auth 中间件,因为携带的 token 可能已过期)
router.post('/api/auth/refresh', controller.auth.refresh);
// ==================== 公开内容接口 ====================
// 新闻通知
router.get('/api/news', controller.news.index);
router.get('/api/news/counts', controller.news.counts);
router.get('/api/news/:id', controller.news.show);
// 轮播图
router.get('/api/carousel', controller.carousel.index);
// ==================== 资料下载(需认证) ====================
router.get('/api/materials', auth, controller.materials.index);
router.get('/api/materials/:group/:fileName/download', auth, controller.materials.download);
// ==================== 比赛答题(需认证) ====================
router.get('/api/exam/:group/status', auth, controller.exam.status);
router.get('/api/exam/:group/questions', auth, controller.exam.questions);
router.get('/api/exam/:group/answers', auth, controller.exam.getAnswers);
router.get('/api/exam/:group/word-template', controller.exam.wordTemplate);
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
// 交卷:全局闸门(config.middleware 的 concurrencyGuard 已纳入 submit-all,
// 过载返回 429 + Retry-After)→ auth → 交卷限流(按 user,见上方 examSubmitLimit)
router.post('/api/exam/:group/submit-all', auth, examSubmitLimit, controller.exam.submitAll);
// 综合大题(仅中职/高职组;2026-09-21 起替代原 Word 文件上传题)
// 首次 GET 抽题并落库题干快照,后续 GET 一律返回同一道题(幂等防纠纷)。
router.get('/api/exam/:group/big-question', auth, controller.exam.bigQuestion);
router.post('/api/exam/:group/big-answer', auth, controller.exam.saveBigAnswer);
// ==================== 文件上传(需认证) ====================
router.post('/api/upload/:group', auth, controller.upload.upload);
router.get('/api/upload/:group/status', auth, controller.upload.status);
router.get('/api/upload/:group/submissions', auth, controller.upload.submissions);
router.delete('/api/upload/:group/:id', auth, controller.upload.delete);
// 分片上传(需认证)
router.post('/api/upload/:group/chunk', auth, controller.upload.uploadChunk);
router.post('/api/upload/:group/merge', auth, controller.upload.mergeChunks);
router.get('/api/upload/:group/chunk-status', auth, controller.upload.chunkStatus);
// ==================== 管理后台(需管理员认证) ====================
// 数据统计
router.get('/api/admin/stats', adminAuth, controller.admin.stats);
// 账号管理
router.get('/api/admin/accounts', adminAuth, controller.admin.accounts);
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel);
// 提交管理
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);
router.get('/api/admin/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
// 时间设置
router.get('/api/admin/times', adminAuth, controller.admin.getTimes);
router.put('/api/admin/times', adminAuth, controller.admin.setTimes);
// 分数管理
router.get('/api/admin/scores/:group', adminAuth, controller.admin.getScores);
router.put('/api/admin/scores/:group', adminAuth, controller.admin.setScores);
// 试题管理
router.get('/api/admin/exam/:group/questions', adminAuth, controller.admin.getQuestions);
router.put('/api/admin/exam/:group/questions', adminAuth, controller.admin.setQuestions);
router.post('/api/admin/exam/:group/questions/import-excel', adminAuth, controller.admin.importQuestionsExcel);
router.get('/api/admin/exam/:group/questions/template', adminAuth, controller.admin.downloadQuestionTemplate);
router.put('/api/admin/exam/:group/word-template', adminAuth, controller.admin.uploadWordTemplate);
router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate);
router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate);
// 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分
router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions);
router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion);
// Word 导出:?ids=1,2,3 批量;缺省 = 该组全部。1 道 → .docx;多道 → .zip(内含多个 .docx)
// ⚠️ 必须注册在 `:group/:id` 之前:Egg 按注册顺序匹配,否则一旦将来新增
// `GET /:group/:id`,`export` 就会被当作 :id 吞掉(实测 2026-09-23 复核时
// 本行曾被放在 :group/:id 之后,当时因 :group/:id 仅注册 PUT/DELETE 而未暴露问题)
router.get('/api/admin/big-questions/:group/export', adminAuth, controller.admin.exportBigQuestions);
router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion);
router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion);
router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores);
// 备赛资料管理
router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials);
router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial);
router.delete('/api/admin/materials/:group/:fileName', adminAuth, controller.admin.deleteMaterial);
// 成绩发布
router.put('/api/admin/publish-scores', adminAuth, controller.admin.publishScores);
router.get('/api/admin/publish-scores', adminAuth, controller.admin.getPublishStatus);
// 新闻管理
router.get('/api/admin/news', adminAuth, controller.admin.listNews);
router.post('/api/admin/news', adminAuth, controller.admin.createNews);
router.put('/api/admin/news/order', adminAuth, controller.admin.updateNewsOrder);
router.put('/api/admin/news/:id', adminAuth, controller.admin.updateNews);
router.delete('/api/admin/news/:id', adminAuth, controller.admin.deleteNews);
// 轮播图管理
router.get('/api/admin/carousel', adminAuth, controller.admin.listCarousel);
router.post('/api/admin/carousel', adminAuth, controller.admin.addCarousel);
router.put('/api/admin/carousel/:id', adminAuth, controller.admin.updateCarousel);
router.delete('/api/admin/carousel/:id', adminAuth, controller.admin.deleteCarousel);
router.put('/api/admin/carousel/order', adminAuth, controller.admin.updateCarouselOrder);
// 图片上传(轮播图等)
router.post('/api/admin/upload/image', adminAuth, controller.admin.uploadImage);
router.post('/api/admin/upload/news-image', adminAuth, controller.admin.uploadNewsImage);
// 下载考生答案 Word 文件
router.get('/api/admin/submissions/:group/:id/word', adminAuth, controller.admin.downloadWord);
// 测试数据标记与重置
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);
};