Files
competition-website/app/router.js
qubiaobiao c15b46a0f9 feat(score): 成绩排名表导出 R220(客观题分排名 + 三档底色 + 前80红框)+ 修公示表参赛单位
中职/高职按小组客观题分降序全量导出,一行 = 一个小组,6 列:
名次 / 小组编号 / 学校 / 参赛选手 / 人数 / 客观题分(不含大题分与总分)。

- 整行按客观题分区间上底色:>=60 绿 / 50-59.99 黄 / <50 红
- 前 min(80, 行数) 行用**一个红色外框整体圈住**(首行画上边、末行画下边、
  行间不画横线),红框范围与统计块「前80名」同源,恒等于 min(topN, 行数)
- 表尾三区间小组数统计:全量 + 全量排名前 80 两列(是"全部小组降序后连续取
  前 80",不是各分数区间分别取 80)
- 零新增运行时依赖:CE 版 @e965/xlsx 写盘会丢弃全部单元格样式 => zip 层手改
  styles.xml 追加 fills/fonts/borders/cellXfs,再重建 sheetData 写入 s=,
  复用 publicitySheet 的 readZip/writeZip(Node 内置 zlib)
- 分数走 getScores(includeIdle:true) 实时口径(autoScore+wordScore),
  不读 contestants.score;includeIdle 必须传,否则"从未参赛"者被隐藏
- 组归属只认 resolveTeamLabels;代表成员按客观题分最高者选取
- 上色/红框失败自动降级为无色版(文件名加「-无底色」),自检从产物反查
  红边索引逐行核对,错误不会静默流到用户手里
- 路由登记 adminPermissions(scores 模块);未登记会被"默认拒绝"判 403

修复 buildPublicityXlsx 参赛单位取值(R220):
where:{group} -> account_uid IN (contestants.id)。
生产库 aggregation.group 中职/高职 161 行全 NULL,旧写法导出的公示表
「参赛单位名称」整列空白;改后非空率 100%。
2026-10-22 17:25:22 +08:00

268 lines
18 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);
// ==================== 管理后台(需管理员认证) ====================
// ---------- 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);
// 成绩排名表导出(R220):中职/高职按小组**客观题分**降序**全量**导出,整行按客观题分区间上底色。
// ⚠️ 第三个产物,⛔ 不可与上面两个互换:export-excel = 内部核查表(含身份证号/手机号)、
// export-publicity = 对外公示表(模板半成品、获奖等级留空)、
// 本接口 = 排名速览表(一小组一行、含区间底色与区间统计、无任何敏感字段)。
router.get('/api/admin/export-score-rank/:group', adminAuth, controller.admin.exportScoreRank);
// 赛项信息(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);
};