Files
competition-website/app/router.js
qubiaobiao 5392345078 feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复
成绩导入(R173 落地 → R174 改为三小问四列制)
- app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件)
  · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值
  · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE
  · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝
  · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号
- app/service/bigQuestion.js
  · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持")
  · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores
  · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores
- app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence)
- app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores
  · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载)

节点备份调度器(R168/R170)
- app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间
  · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻
  · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源)
- config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地
  (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导)
- app.js:启动构造单例 + hydrate 未到期计划

缺陷修复
- app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理
- app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount)
- app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注

测试
- .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js,
  使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00

229 lines
14 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);
// 小组(团队)激活状态清单(R84 续六):查看已激活 / 未激活小组,并携带所属学校名称。
// ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。
router.get('/api/admin/teams', adminAuth, controller.admin.teams);
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel);
// 选手信息编辑(R108):让「导入之后就改不了」的历史限制成为过去 —— 身份证号 / 姓名 /
// 手机号可事后纠正(典型场景:报名表证号录入错误,人工核实后修正)。
// ⚠️ 路径段固定(/contestants/:id),与既有路由无匹配冲突;PUT 方法在 admin 段内唯一。
// 破坏面定级:非破坏性(白名单字段编辑、无批量清除语义、改动可逆且带脱敏审计日志)
// ⇒ 不登记进 DESTRUCTIVE_ENDPOINTS.json;登记册口径见该文件 note。
router.put('/api/admin/contestants/:id', adminAuth, controller.admin.updateContestant);
// 提交管理
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);
// R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable)
router.get('/api/admin/export-excel/:group', adminAuth, controller.admin.exportExcel);
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);
// 节点备份调度器状态(P1「生产备份静默失败」可见性):只读,不触发备份。
// ⚠️ 路由段 node-backup 与 /api/admin/* 其余段无前缀冲突;GET 方法在此段唯一。
router.get('/api/admin/node-backup/status', adminAuth, controller.admin.getNodeBackupStatus);
// 分数管理
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);
// ⚠️ 注册顺序是硬约束:Egg 按注册序匹配,下面两条含**具体段**(template / import-excel),
// 必须排在通配段 `/:id` 之前,否则会被它抢走(GET .../template → id='template')。
router.get('/api/admin/big-questions/:group/template', adminAuth, controller.admin.downloadBigQuestionTemplate);
router.post('/api/admin/big-questions/:group/import-excel', adminAuth, controller.admin.importBigQuestionsExcel);
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);
// 考生答卷导出(R28):3 段路径,与上方 2 段列表、下方 PUT /:uid/scores 均不冲突
router.get('/api/admin/big-answers/:group/export', adminAuth, controller.admin.exportBigAnswers);
// 成绩 Excel 导入(R172):两段式(预览零写入 → 确认导入),均为 3 段路径,与 PUT /:uid/scores 不冲突。
// ⚠️ 具体段(import-template / import-preview / import-scores)不得与将来的 `/:uid` 通配段冲突 ⇒
// 保持与 export 同等注册位置(在 PUT 之前)。
router.get('/api/admin/big-answers/:group/import-template', adminAuth, controller.admin.downloadBigScoreTemplate);
router.post('/api/admin/big-answers/:group/import-preview', adminAuth, controller.admin.previewBigScoreImport);
router.post('/api/admin/big-answers/:group/import-scores', adminAuth, controller.admin.importBigScores);
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);
};