Files
competition-website/app/router.js

172 lines
9.0 KiB
JavaScript
Raw Normal View History

'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 });
// ==================== 公开接口(无需认证) ====================
// 健康检查(容器探针 / 负载均衡探活,无需认证)
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);
router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll);
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
// 综合大题(仅中职/高职组;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);
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
// 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分
router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions);
router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion);
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);
// ==================== 成绩查询(需认证) ====================
router.get('/api/score', auth, controller.score.index);
};