成绩导入(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)
229 lines
14 KiB
JavaScript
229 lines
14 KiB
JavaScript
'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);
|
||
};
|