'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: —— 考场/学校普遍共用出口 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); // ==================== 管理后台(需管理员认证) ==================== // ---------- R178 多管理员 + 权限 ---------- // 这些路由与下面所有 /api/admin/* 一样只挂 adminAuth: // 权限判定在 middleware/auth.js 的 adminOnly 分支内完成(单一真相源 // lib/adminPermissions.js 的 ROUTE_TABLE,未登记路由默认拒绝)。 // ⛔ 不要在这里逐条写权限参数——那会与映射表形成第二套真相源。 router.get('/api/admin/me', adminAuth, controller.adminUser.me); router.get('/api/admin/admin-users', adminAuth, controller.adminUser.list); router.post('/api/admin/admin-users', adminAuth, controller.adminUser.create); router.put('/api/admin/admin-users/:id/permissions', adminAuth, controller.adminUser.updatePermissions); router.put('/api/admin/admin-users/:id/password', adminAuth, controller.adminUser.resetPassword); router.put('/api/admin/admin-users/:id/status', adminAuth, controller.adminUser.setStatus); router.delete('/api/admin/admin-users/:id', adminAuth, controller.adminUser.remove); // 数据统计 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); // 指导教师回填(R176):报名表 → contestants.teacher_name。 // 🔴 与 import-excel 是**两条不同通路**:导入对已存在身份证号一律 skip, // 无法用于"重导一次补字段";本接口只写 teacher_name,不碰账号/队伍/成绩。 // ⚠️ 路径段固定(backfill-teacher 为字面量),且与下方 PUT /:id 方法不同,无匹配冲突。 // 破坏面定级:非破坏性(白名单单列写入、按组限定、可重复执行且幂等) router.post('/api/admin/contestants/backfill-teacher', adminAuth, controller.admin.backfillTeachers); // 选手信息编辑(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); // 成绩公示表导出(R176):按大赛参考模板生成 xlsx 半成品(获奖等级留空待人工填)。 // ⚠️ 与 export-excel 是两个不同产物:export-excel 是**内部核查表**(含身份证号/手机号), // 本接口是**对外公示表**(不含任何身份证号/手机号),⛔ 两者不可互相替代。 router.get('/api/admin/export-publicity/:group', adminAuth, controller.admin.exportPublicity); // 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。 // ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。 router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo); router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo); router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload); // 单小组作品压缩包(R190):zip 本身即小组目录,包内平铺该组文件。 // ⚠️ 必须注册在 `/api/admin/works/:group` 之前不是硬要求(路径段数不同,不冲突), // 但保持「同一业务相邻登记」,避免后来者以为是两条无关通道。 router.get('/api/admin/team-zip/:group', adminAuth, controller.admin.teamZip); 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); // 新闻附件上传(R176):成绩公示表以附件形式挂在新闻里,而不是把链接写进正文 // (正文会被人工作编辑,手写链接极易被删)。与 news-image 同目录、同访问前缀。 router.post('/api/admin/upload/news-file', adminAuth, controller.admin.uploadNewsFile); // 下载考生答案 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); };