Compare commits

...

14 Commits

Author SHA1 Message Date
31da9580e5 fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9)
R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。

顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件
⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。

- app/service/upload.js
  · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件
    (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿)
  · deleteSubmission:裸 fs.unlinkSync → 复用统一入口
  · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀
    (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..`
    + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup)
- app/controller/upload.js
  · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志,
    catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件
- app/controller/admin.js
  · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi),
    tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved

验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13
      check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过
配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
e8a5b34d42 fix(backend): 补齐单次上传端点的并发护栏(BACKLOG C7)
- middleware/concurrency_guard.js: HEAVY_PATTERNS 增加单段上传正则
  /^\/api\/upload\/[^/]+$/i —— 单段锚定([^/]+$)保证不误伤"两段"的轻路由:
  GET /status、GET /submissions、DELETE /:id(放宽成前缀匹配会让「查状态也 429」)
- controller/upload.js: upload() 补 await acquireFileIo() +
  finally { if (ioAcquired) releaseFileIo(); }
  ⚠️ 不用 uploadChunk 的无条件 release:本方法 429 分支是 acquire 失败后 return,
     无条件释放会把"别人的"配额放掉 => 计数下溢 => 护栏静默失效

背景:修复前该端点是**唯一在用**的作品上传路径(Competition.vue:477),却既不在
      concurrencyGuard.HEAVY_PATTERNS(表内 chunk/merge 已成死路由)、也不调 file_io_guard
      => 零护栏。因服务器入带宽仅 ≈1.25MB/s 天然节流,当前风险有限,但按 C3 升入带宽
      后即变实质风险 => 与 C3 绑定,先补护栏。

验证:
- verify-guard-r43.mjs 33/33(单元:路径判别力 + 满载 429 + 配额隔离 + 释放语义 + 变异自证)
- verify-guard-r43-live.mjs 8/9(真接口:30 路 5MB 并发上传 => 20 成功 / 10 个 429 +
  Retry-After: 2;15 个轻路由探针在并发中全 200;唯一失败为清理步骤,根因是
  saveSubmissionRecord 的 upsert 语义,非缺陷)
- 重启后 master PID 23684;[AppBoot][pool] 40 <= 151
2026-09-29 13:17:53 +08:00
070195a22c docs(backend): 同步 worker 数注释(生产 4 -> 2,与 2 核匹配)
仅注释变更,**无行为变更**。EGG_WORKERS 实际由 deploy/.env.production 注入 +
deploy/start-backend.sh 兜底决定,本次把注释里写死的旧口径同步为 2,避免
「注释与配置漂移」再次误导后人。

- app.js:
  · 「生产显式设为 4」-> 2
  · 「4×20=80(安全)」->「2×20=40(安全)」
  · 「本地 dev 也会加载 .env.production,其中写着 4」-> 2
  · 「dev 下真 worker=1 却报 4×20=80」-> 2×20=40
- config/config.default.js:
  · 「生产值写在 deploy/.env.production(EGG_WORKERS=4)」-> 2(并注明 2026-09-29
    R40 由 4 校正为 2)
  · 「4 × 20 = 80」->「2 × 20 = 40」
  · pool 段「worker 数由 EGG_WORKERS 显式控制(生产 = 4)」-> 2
  · 「本机/多数服务器为 8 核 → 8 个 worker」->「核数越多起得越多(开发机 8 核 → 8 个)」
    (原文把"8 核"当成多数服务器的事实,与实测的 2 核服务器不符)

背景(R40 · BACKLOG C2):worker 数 ≈ CPU 核数。worker 数 > 核数对 CPU 型路径
(bcrypt 登录)**零收益** —— 加 worker 不加核(native bcrypt 走 libuv threadpool),
只增加 context switch 与内存(单 worker 空闲 RSS ≈108MB)。登录容量由物理核数
决定(实测 ≈12.6 次/核·秒)⇒ 2 与 4 的登录容量相同。连接池 2×20=40 ≤ 151-20 ✅

验证:node --check app.js / config/config.default.js 通过;
verify-c1c2.py 含「五处文档/代码注释已无旧口径「4」残留」断言(24/24 通过)。
2026-09-29 12:34:38 +08:00
0531131f0e feat(admin): 报名表导入补列名别名 + 只导参赛选手 + 判重口径(R32)
- COLUMN_ALIAS/pick():兼容上游列名(身份证号码/全名/队伍ID),上游导出表免改表头
- 只导身份=参赛选手:其余计入 result.ignored/ignoredBy(≠skipped),建团队同步过滤
- 判重改以身份证号为准(同证即同人);错误消息附「队伍/姓名/手机号」便于同名定位
- 修 teamUidCache 复用库内团队时写入 maxTeamUid 而非真实 uid 的 bug
- phone 空值写 ''(列 NOT NULL)
- 实测:1225 行 -> 成功 591 / 跳过 3(身份证非18位)/ 忽略 631 / 建队 297
2026-09-29 09:40:51 +08:00
e74778ee08 feat(admin): 成绩导出改为 Excel + 板块迁移配套接口(R29)
- service/admin.js: 抽出 buildScoreExportTable(group) 作单一数据源(CSV/XLSX 共用,消除表头漂移)
  + 新增 exportToXlsx()(SheetJS aoa_to_sheet + write buffer + !cols 列宽)
- controller/admin.js: 新增 exportExcel()(xlsx MIME + ctx.attachment 中文文件名编码)
- router.js: 新增 GET /api/admin/export-excel/:group(adminAuth)
- export-csv 路由刻意保留:verify_full_e2e.py B10/D10、verify_upload_download.py B2 依赖其非空断言
2026-09-28 16:42:36 +08:00
2ab0991310 fix(admin): 复核页选择题选项渲染修复 + 阅卷导出 Word + 评分两位小数(R28)
针对测试人员反馈的四项:
1) 复核明细「选择题大量空白」:options 为 text 列存 JSON 字符串,原先被原样透传,
   前端 v-for 对字符串逐字符迭代(op.key/op.text 皆 undefined)⇒ 每字符渲染一行。
   新增 parseOptions()(仅 string 才 JSON.parse,失败降级 null)。
2) 选项与答案不随机:仅题目用 RAND() 抽题,normalizeQuestion 无 shuffle,字母无歧义。
3) 评分精度:DECIMAL(6,1) -> DECIMAL(6,2),migration 026 覆盖 17 列(与 025 同范围),
   lib/decimal_score.js 与 11 个 model 注释同步;汇总收敛 round2(×100/100)。
4) 阅卷导出:GET /api/admin/big-answers/:group/export?ids=
   单选考生 -> 直接 .docx;多位/全部 -> 每位考生单独 .docx 打包 .zip。
   路由为 3 段,注册在 :group 通配段之前。

验证:verify_big_question 51 PASS/1 FAIL(唯一 FAIL=测试人员新数据,非本次改动)、
verify_score_leak_all_channels 24/24、verify_question_no_answer_leak 4/3/0/1。
2026-09-28 15:50:46 +08:00
37d07907a1 feat(big-question): 录入侧防呆——题干不得含「可直抄的答案」(R27)
背景(2026-09-28 测试人员实测反馈):
  「答题时大题部分有参考答案。」初判怀疑前端未区分 / 评分系统未隔离,
  实测两条均被证伪:
    · 前端 Competition.vue 仅渲染 description / sub_prompts,无答案区块;
    · 后端 buildSnapshot() / getForCandidate() 返回字段无 answer;
    · 建表 DDL 023-024 的 big_questions 无答案列;
    · 真实联调 question 字段 = [big_question_uid, description, score_per_sub,
      snapshot_at, sub_prompts, title, total_score]。
  ⇒ 泄露 100% 来自**题库录入内容**:中职组启用题 id=38 的题干内嵌
    「指令输入示例」(>OFFMSG,COM1*4A 等三条),而第 3 小问正是问这些指令。

为什么守点是录入入口:
  抽题快照 question_snapshot **抽题即固化**(防纠纷设计)⇒ 事后改题库
  **撤不回**已抽考生看到的版本;下发链路本身无泄漏点 ⇒ 只能在录入时拦。

改动:
  · 新增 detectAnswerLeak():扫 title / description / sub_prompt_1..3
      ① 「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」字样;
      ② 带**字面参数**的指令示例(>CMD,值*两位十六进制校验码)。
  · validateQuestionPayload() 内调用 ⇒ create 与 update **同源**拦截;
    命中返回 code=SUSPECT_ANSWER_LEAK,message 直接教会管理员怎么改。
  · 兼容 &gt; 与裸 > 两种存储形态。

边界(刻意为之,勿收紧):
  格式定义(参数用方括号占位,如 >OFFMSG[,Port[,Message]]*CS)**不拦** ——
  校验码限定「两位十六进制」天然排除占位符 *CS(S 非 hex)。
  向考生提供指令**手册**是允许的;等同于答案的**成品**才不允许。
  —— 与同日数据修复口径一致(只删示例、保留格式定义)。

实测(后端已重启加载新码,5/5 PASS):
  ① 合法格式定义题 → 放行(code=0,新增 1 行)
  ② 含指令示例 → 拒绝 HTTP200 code=400,**未落库**
  ③ 含「参考答案」字样 → 拒绝,未落库
  ④ PUT 更新路径 → 同样拒绝,且原内容未被改写
  ⑤ 清理复原:行数与 MAX(id) 回到基线
2026-09-28 11:58:14 +08:00
cbc05ed4e7 feat(admin): 成绩复核——按姓名/身份证/电话检索考生并查看全卷客观题逐题作答与正确答案(R26)
- service.admin: 新增 reviewSearch / getContestantScore / _reviewWorks;移除同名的死代码旧实现
- 归集双键合并 [contestant.id] ∪ [真实 aggregation_uid],与 getExamSubmissions 口径逐字一致
- 题序还原:优先 exam_submissions.question_order,缺失则回退「选择→判断 + 题号升序」并回传 questionOrderFixed=false
- 未作答行 answerMatches 返回 null(非 false),避免界面把「未答」读成「答错」
- 组别以库中考生 group 为准,路由 :group 不符返回 409,防前端传错组照错人
- 异常可区分:questionMissing(题被删/改组)、answerMatches≠isCorrect(判分矛盾)
- 因回传正确答案,两条路由均挂 adminAuth —— 选手端可达即成绩泄露(与 verify_score_leak_all_channels.py 三通道门禁一致)
- 不改动 schema、不改动既有接口行为

controller.admin: 新增 reviewSearch / contestantScore(NOT_FOUND→404 / GROUP_MISMATCH→409 / 其它→400)
router.js: 注册 GET /api/admin/review/search 与 /api/admin/review/score/:group/:id,均 adminAuth
2026-09-28 10:15:42 +08:00
079c1b31c9 R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word
A1/A2 客观题:
- 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分
- 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力)
- ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁

B 大题评分一位小数:
- 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口
  「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立
- 10 个 model 的 score 字段改走该工厂
- database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int)
- service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数

C 页面状态刷新:
- 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空
  ⇒ 永不重新拉取)
- 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表)
- 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」
  判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫

D 大题导出 Word(单题 / 批量 / 全部):
- 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/
  span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前
- service.bigQuestion.exportQuestions + controller.exportBigQuestions +
  GET /api/admin/big-questions/:group/export
- 前端导出选中/全部两按钮 + 表格多选(reserve-selection)

计划外发现并修复(已在报告中披露):
- 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径
  改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有)
  ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案
- 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而
  未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患

验证(全部实测,非仅看退出码):
- ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0
- 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS
- 前端构建 VITE_EXIT=0

未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
a019df510d chore(security): 处置已入库的本地 DB 口令(P1-P3;P4 改历史不做)
背景(R14 登记的 §16.3):
  git grep -l "74191221" HEAD 曾命中 7 个文件,全为本地 MariaDB root 口令
  (已核实无生产凭据)。本次按登记项执行 P1-P3;P4(git filter-repo 改写
  历史)明确不做。

P1  database/config.json(dev/test 两段明文口令)
  - 加入 .gitignore
  - 新增 database/config.json.example(口令为 CHANGE_ME 占位符)
  - git rm --cached(保留磁盘文件,本地 sequelize-cli 不受影响)
  - 注意:这是对共享仓库的文件移除。push 后其他克隆需
    cp database/config.json.example database/config.json 并填入口令。

P2  五个脚本口令环境变量化(去明文兜底)
  - add_word_cols.js / add_wx_link.js / show_tables.js / seed-carousel.js
    内联 '74191221' 改为 process.env.DB_PASSWORD(另支持 DB_HOST/DB_USER/DB_NAME)
  - scripts/diagnose_upload_download.cjs:process.env.DB_PASS || '74191221'
    改为 process.env.DB_PASS;用法注释中的示例口令改为占位符
  - 有意的行为变更:未注入 DB_PASSWORD 时明确失败,不再静默使用公开口令

P3  config/config.default.js 消除「生产静默回退公开口令」
  - sequelize.password 去掉 || '74191221'
  - redis.client.password 去掉 || '74191221'
  - 生产安全守卫新增 DB_PASSWORD 缺失阻断(与 JWT_SECRET/APP_KEYS 同源同形)
  - deploy/.env.production 本就含 DB_PASSWORD ⇒ 现网零影响

验证(全部实测):
  - git grep 74191221 工作树零命中(HEAD 仍有,属 P4 不做范围)
  - 裸启动(无任何 DB 变量)成功:本地口令由 config.local.js 提供
  - 端到端:health db:ok + admin-login code=0
  - MySQL 侧独立取证:processlist = root @ competition_db
  - 变异测试(改坏 config.local.js 口令)⇒ 报 1045 且用户名仍为 root
    ⇒ 同证 P3 兜底确已移除 + R18 的生产账号过滤仍生效
  - P2 双向验证:无 DB_PASSWORD 失败 / 注入后正常列 27 张表
  - 门禁四连:verify_scoring_e2e 17/0、verify_exam_submit_integrity 46/0、
    verify_big_question --cleanup(清理+窗口还原)、check_no_proxy_guard 5 变异全检出
2026-09-23 16:34:08 +08:00
4f1e28d643 fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底)
问题(2026-09-23 用户报障):
  本地 `npm run dev` 启动即崩:
  SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES)
  → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。

根因:
  config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把
  deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号,
  而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。

改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库):
  仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。
  合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()`
  对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。
  本文件只覆盖 sequelize.username / password 两个字段,
  dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。
  生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。

改动 B —— config/config.default.js:
  ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定);
  ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键
     (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。
  生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据:
     a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV='production'`;
     b) deploy/.env.production:17 NODE_ENV=production,且 start-backend.sh 以
        `set -a; source "$ENV_FILE"` 注入 shell 后再 exec node;
     c) start.js:126 注释:argv → EGG_SERVER_ENV → 兜底 egg 取 prod。
  ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。

改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。

验证(全部实测):
  · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入;
    合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。
  · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0;
    MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。
  · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root
    ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。
  · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、
    verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。

不变量(本次未改,勿顺手改):
  · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库;
  · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js;
  · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
e7af00beb9 fix(admin): CSV 表头「Word分」改为「大题分」+ 移除 getExamSubmissions 重复 examScore 键
改动(3 处):
  ① getExamSubmissions CSV 表头(L631):
       「...,客观题分,Word分,总分」→「...,客观题分,大题分,总分」
  ② 移除 getExamSubmissions 返回对象中重复定义的 examScore 键(原 L445):
       该键与 L456 的 `examScore: autoScore` 重名,对象字面量重复键「后者静默胜出」,
       L445 实为死代码。当前因 updateScores 会同步写 contestants.exam_score
       而「结果碰巧一致」,属隐患,故删除并加注释说明。
  ③ 同步两处注释口径:Word 阅卷分 → 综合大题阅卷分(getScores isExam 分支)。

不改动(保持契约):updateScores 入参名 wordScore、路由
  /api/admin/submissions/:group/:id/word、contestants.word_score 列名。
  (列名为历史沿用,语义已是「综合大题得分」,仅文案口径调整。)

影响面:仅中职/高职(isExam)分支;本科/教师组 CSV 与成绩口径不变。

验证:CSV 表头端到端实测 = 选手ID,身份证号,姓名,手机号,是否提交,客观题分,大题分,总分;
     verify_scoring_e2e.py 17/0;verify_big_question.py --cleanup 52/0;零回归。
2026-09-22 13:05:35 +08:00
23604f2718 feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8)
R4 登录/交卷背压闸门
- app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式,
  不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2'
  (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。
  配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。
- config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。

R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt
- 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。
- bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。
- app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧
  SHA256 注释修正)。

答题链路与综合大题
- app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer,
  逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。
- app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。
- app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口,
  未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。
- app/router.js: 新增大题与管理端路由。
- app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。
- app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。

验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9);
真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
8ef18da395 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
48 changed files with 4186 additions and 311 deletions

9
.gitignore vendored
View File

@@ -23,6 +23,15 @@ run/
.env* .env*
!.env.example !.env.example
# 本地开发配置(Egg 仅在 serverEnv=local 时加载,生产不加载)
# 含本地 DB 口令,故排除入库;如需团队共享请提交不含口令的 config.local.example.js
config/config.local.js
# sequelize-cli 数据库配置(含本地 DB 口令)—— 2026-09-23 入库口令处置 P1
# 仓库内保留不含口令的 database/config.json.example;使用前复制并填入口令:
# cp database/config.json.example database/config.json
database/config.json
# Coverage / cache # Coverage / cache
coverage/ coverage/
.cache/ .cache/

View File

@@ -1,7 +1,12 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
(async () => { (async () => {
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
// DB_PASSWORD='<本机 DB 口令>' node add_word_cols.js
const c = await mysql.createConnection({ const c = await mysql.createConnection({
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db' host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'competition_db',
}); });
try { try {
await c.query("ALTER TABLE exam_submissions ADD COLUMN word_file_path VARCHAR(500) NULL DEFAULT NULL COMMENT 'Word答案文件路径' AFTER answers"); await c.query("ALTER TABLE exam_submissions ADD COLUMN word_file_path VARCHAR(500) NULL DEFAULT NULL COMMENT 'Word答案文件路径' AFTER answers");

View File

@@ -1,7 +1,12 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
(async () => { (async () => {
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
// DB_PASSWORD='<本机 DB 口令>' node add_wx_link.js
const c = await mysql.createConnection({ const c = await mysql.createConnection({
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db' host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'competition_db',
}); });
try { try {
await c.query("ALTER TABLE news ADD COLUMN wx_link VARCHAR(500) NULL DEFAULT NULL COMMENT '微信公众号文章链接' AFTER content"); await c.query("ALTER TABLE news ADD COLUMN wx_link VARCHAR(500) NULL DEFAULT NULL COMMENT '微信公众号文章链接' AFTER content");

44
app.js
View File

@@ -61,23 +61,53 @@ class AppBootHook {
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})` `[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
); );
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):egg-cluster 按 os.cpus() 起多 worker, // 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。
// 总连接数 ≈ worker 数 × pool.max。若超过 MySQL max_connections(默认 151), // 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire
// 高峰期会偶发 "connection acquire timeout" / "too many connections"。 // timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。
// 启动期只做 WARN(不阻断启动),提醒运维调大 DB 或调小 DB_POOL_MAX。 //
// 🔴 2026-09-21 修正(并发压测结论):原实现用 `os.cpus().length` 估 worker 数,
// 这是**误估** —— 真实 worker 数由 egg-scripts 的 --workers / EGG_WORKERS 决定
// (生产显式设为 2,见 config.default.js「集群 worker 数」段),与 CPU 核数无必然关系。
// 误估会在 8 核机上把「2×20=40(安全)」误报成「8×20=160(越限)」,
// 既制造假告警、也会掩盖真实的越限,使这条断言失去作用。
// 改为优先读 EGG_WORKERS(worker 进程继承 master 的环境变量),回退才用 CPU 数,
// 并在日志中显式标注估算依据,便于排障时判断该行可信度。
try { try {
const os = require('os'); const os = require('os');
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20; const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT }); const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151; const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
const workers = os.cpus().length; const explicitWorkers = parseInt(process.env.EGG_WORKERS, 10);
const usingExplicit = Number.isFinite(explicitWorkers) && explicitWorkers > 0;
// 🔴 2026-09-21 二次修正:egg-bin dev / test 会**显式传** `workers: 1`
// (node_modules/egg-bin/scripts/start-cluster.js),此时即便环境里存在
// EGG_WORKERS(本地 dev 也会加载 deploy/.env.production,其中写着 2),
// 也不是真实 worker 数 —— 真实值是 1。故必须先按 app.config.env 判定
// 单 worker 的运行形态,否则本地仍会打出一条假数字日志(实测:dev 下
// 真 worker=1 却报 2×20=40)。
const envName = (this.app.config && this.app.config.env) || '';
const singleWorkerEnv = envName === 'local' || envName === 'unittest';
let workers;
let workersSource;
if (singleWorkerEnv) {
workers = 1;
workersSource = `egg env=${envName}(egg-bin 强制单 app worker)`;
} else if (usingExplicit) {
workers = explicitWorkers;
workersSource = `EGG_WORKERS=${explicitWorkers}(显式)`;
} else {
workers = os.cpus().length;
workersSource = `os.cpus()=${workers}(EGG_WORKERS 未设置,为回退估算)`;
}
const estimated = workers * poolMax; const estimated = workers * poolMax;
if (estimated > dbMax) { if (estimated > dbMax) {
this.app.logger.warn( this.app.logger.warn(
`[AppBoot][pool] 预估总连接数 ${estimated} (${workers} workers × ${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX` `[AppBoot][pool] 预估总连接数 ${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX / EGG_WORKERS`
); );
} else { } else {
this.app.logger.info(`[AppBoot][pool] 连接池容量 OK:${estimated} (${workers} × ${poolMax}) ≤ MySQL max_connections=${dbMax}`); this.app.logger.info(
`[AppBoot][pool] 连接池容量 OK:${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) ≤ MySQL max_connections=${dbMax}`
);
} }
} catch (e) { } catch (e) {
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message)); this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));

View File

@@ -170,7 +170,9 @@ class AdminController extends Controller {
// 清理临时文件 // 清理临时文件
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ } try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`); // 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条${ignoredTip}`);
} catch (err) { } catch (err) {
ctx.logger.error('Excel 导入失败:', err); ctx.logger.error('Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误'; const msg = (err && err.message) ? err.message : '未知错误';
@@ -261,6 +263,34 @@ class AdminController extends Controller {
} }
} }
/**
* 导出提交记录为 Excel(.xlsx)(R29)
* GET /api/admin/export-excel/:group
*
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时中文易乱码、
* 身份证号 / 手机号会被转成科学计数法。此处直接下发真正的 xlsx。
* ⚠️ 与 exportCsv 共用 service.admin.buildScoreExportTable(表头/口径单一数据源)。
*/
async exportExcel() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const buf = await service.admin.exportToXlsx(group);
const fileName = `${group}_scores_${new Date().toISOString().slice(0, 10)}.xlsx`;
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ctx.attachment(fileName);
ctx.body = buf;
} catch (err) {
ctx.logger.error('导出 Excel 失败:', err);
ctx.body = ctx.helper.error(500, '导出 Excel 失败');
}
}
/** /**
* 批量下载某组所有提交文件为 ZIP * 批量下载某组所有提交文件为 ZIP
* GET /api/admin/batch-download/:group * GET /api/admin/batch-download/:group
@@ -467,11 +497,8 @@ class AdminController extends Controller {
if (action === 'add') { if (action === 'add') {
for (const q of questions) { for (const q of questions) {
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据 // 客观题(choice/judge)分值由 service 层强制为 1(赛制口径),此处不再要求
if (q.score == null || !isFinite(Number(q.score)) || Number(q.score) <= 0) { // 调用方传 score;word 题型的合法性由 service 层 _resolveQuestionScore 校验。
ctx.body = ctx.helper.error(400, '题目分值(score)必须为大于 0 的合法数值');
return;
}
await service.admin.addQuestion(q, group, qType); await service.admin.addQuestion(q, group, qType);
} }
ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功'); ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功');
@@ -494,14 +521,12 @@ class AdminController extends Controller {
} }
ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功'); ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功');
} else if (action === 'setTypeScore') { } else if (action === 'setTypeScore') {
// 批量按题型设置分值:将该组该题型全部题目统一设为指定分值 // 分值口径已统一(2026-09-23):客观题(choice/judge)恒为 1 分,
if (score == null || isNaN(parseInt(score, 10))) { // 传入的 score 被忽略;仅综合性任务(word)按传入值设置(service 层校验)。
ctx.body = ctx.helper.error(400, '批量设分需要合法的 score 数值'); const updated = await service.admin.setTypeScore(group, qType, score);
return;
}
const updated = await service.admin.setTypeScore(group, qType, parseInt(score, 10));
const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务'); const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务');
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${score} 分`); const shown = (qType === 'choice' || qType === 'judge') ? 1 : score;
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${shown} 分`);
} else if (action === 'bulkDelete') { } else if (action === 'bulkDelete') {
// 批量删除题目:question_uids 数组 // 批量删除题目:question_uids 数组
const ids = (ctx.request.body.question_uids || []) const ids = (ctx.request.body.question_uids || [])
@@ -1566,6 +1591,27 @@ class AdminController extends Controller {
let gaozhiDeleted = 0; let gaozhiDeleted = 0;
let benkeDeleted = 0; let benkeDeleted = 0;
let jiaoshiDeleted = 0; let jiaoshiDeleted = 0;
// 🔴 C9 根因③ 修复(2026-09-29 · R45):清库只删行、不删物理文件 ⇒ 磁盘孤儿随时间累积
// (实证:`app/public/works` 已积到 1003 个 / 9.62 GB,另 `tmp` 6.6 GB)。
// 这里先在本事务内把待删记录引用的文件路径**收集起来**(只读),
// 待事务**提交成功之后**才真正删文件。
// ⚠️ 为什么不能直接在事务里删:文件删除**不可回滚**。一旦事务回滚,
// DB 记录还在、文件却已消失 ⇒ 下载 404(数据丢失,比留一个孤儿严重得多)。
// 先收集后删、且只删「行确已删除」的那批,是唯一安全的顺序。
const staleFilePaths = [];
// 只读收集器:按「模型 + 文件列 + 条件」把非空路径收集进 staleFilePaths
const collectPaths = async (Model, column, where) => {
const rows = await Model.findAll({
where,
attributes: [ column ],
raw: true,
transaction: tx,
});
for (const r of rows) {
const p = r[column];
if (p && String(p).trim()) staleFilePaths.push(String(p).trim());
}
};
try { try {
// 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」, // 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」,
// 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。 // 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。
@@ -1591,6 +1637,14 @@ class AdminController extends Controller {
// (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。) // (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。)
await service.admin.publishScores(false, undefined, tx); await service.admin.publishScores(false, undefined, tx);
// 1.2 收集「即将被删除」的记录所引用的物理文件路径(必须在 DESTROY 之前,否则路径已随行消失)。
// 列名以 model 定义为准:exam/zhongzhi/gaozhi 用 `word_file_path`,
// work/benke/jiaoshi 用 `file_path`(不可跨表套用,见 verify_* 门禁的同类教训)。
await collectPaths(ctx.model.ExamSubmission, 'word_file_path', { contestant_id: testIds });
await collectPaths(ctx.model.WorkSubmission, 'file_path', { contestant_id: testIds });
await collectPaths(ctx.model.ZhongzhiSubmission, 'word_file_path', { aggregation_uid: testIds });
await collectPaths(ctx.model.GaozhiSubmission, 'word_file_path', { aggregation_uid: testIds });
// 2. 删除 exam_submissions(仅测试数据选手;客观题答案内嵌其中,一并清除) // 2. 删除 exam_submissions(仅测试数据选手;客观题答案内嵌其中,一并清除)
examDeleted = await ctx.model.ExamSubmission.destroy({ examDeleted = await ctx.model.ExamSubmission.destroy({
where: { contestant_id: testIds }, where: { contestant_id: testIds },
@@ -1627,6 +1681,12 @@ class AdminController extends Controller {
}); });
const testAggUids = testAggRows.map(a => a.aggregation_uid); const testAggUids = testAggRows.map(a => a.aggregation_uid);
// 1.3 补收集本科/教师组作品路径(键为聚合 uid,须在上面的 destroy 之前收集)
if (testAggUids.length) {
await collectPaths(ctx.model.BenkeSubmission, 'file_path', { aggregation_uid: testAggUids });
await collectPaths(ctx.model.JiaoshiSubmission, 'file_path', { aggregation_uid: testAggUids });
}
benkeDeleted = testAggUids.length ? await ctx.model.BenkeSubmission.destroy({ benkeDeleted = testAggUids.length ? await ctx.model.BenkeSubmission.destroy({
where: { aggregation_uid: testAggUids }, where: { aggregation_uid: testAggUids },
transaction: tx, transaction: tx,
@@ -1643,6 +1703,20 @@ class AdminController extends Controller {
throw txErr; throw txErr;
} }
// 1.4 事务已成功提交 ⇒ 行确已删除,此时才删物理文件(C9 根因③)。
// 顺序仍是「先库后盘」:若在提交前删文件而事务回滚,会留下「记录在、文件没了」的 404。
// 删文件用 service.upload.removePublicFile —— 统一走「只有 works/、tmp/ 前缀才允许删
// + best-effort 不抛错」的安全路径;失败的文件不会静默消失,会被计数上报,
// 也可由兜底扫描 scripts/scan_orphan_files.py 捞出。
const uniqueStalePaths = [ ...new Set(staleFilePaths) ];
let orphanFilesRemoved = 0;
for (const p of uniqueStalePaths) {
if (service.upload.removePublicFile(p, 'resetTestData') === 'removed') orphanFilesRemoved++;
}
results.orphanFileCandidates = uniqueStalePaths.length;
results.orphanFilesRemoved = orphanFilesRemoved;
results.contestantsReset = contestantCount; results.contestantsReset = contestantCount;
results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位 results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位
results.examSubmissionsDeleted = examDeleted; results.examSubmissionsDeleted = examDeleted;
@@ -1659,6 +1733,378 @@ class AdminController extends Controller {
ctx.body = ctx.helper.error(500, '重置测试数据失败'); ctx.body = ctx.helper.error(500, '重置测试数据失败');
} }
} }
// ==================== 综合大题(中职组 / 高职组) ====================
//
// 2026-09-21 需求变更配套管理端能力:
// · 题库 CRUD(题干富文本含表格 + 三小问提示语 + 每小问 10 分 / 大题 30 分)
// · 作答查证(考生 → 抽到的题目快照 → 三小问答案 → 提交时间),用于避免考生纠纷
// · 按小问评分(合计分经既有唯一评分路径写入 contestants.word_score)
//
// 全部读写仅落在新增的 big_questions / big_question_answers,不触碰任何既有表。
/**
* 大题题库列表
* GET /api/admin/big-questions/:group
*/
async listBigQuestions() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const list = await service.bigQuestion.listQuestions(group);
ctx.body = ctx.helper.success({
list,
total: list.length,
scorePerSub: 10,
subCount: service.bigQuestion.subQuestionCount(),
maxChars: service.bigQuestion.answerMaxChars(),
}, '获取大题题库成功');
} catch (err) {
ctx.logger.error('获取大题题库失败:', err);
ctx.body = ctx.helper.error(500, '获取大题题库失败');
}
}
/**
* 导出大题为 Word(2026-09-23 需求)
* GET /api/admin/big-questions/:group/export?ids=1,2,3
* · 传 ids → 单独 / 批量导出;不传 → 导出该组全部
* · 1 道 → 返回 .docx;多道 → 返回「多个 .docx 打包的 .zip」
*
* ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法,
* 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。
*/
async exportBigQuestions() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const ids = ctx.query.ids;
const result = await service.bigQuestion.exportQuestions(group, ids);
if (!result.ok) {
const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400;
ctx.body = ctx.helper.error(httpStatus, result.message);
return;
}
// ⚠️ 只调 ctx.attachment(原名):koa 会自动做 RFC5987 编码。
// 自行 encodeURIComponent 会**双重编码**,中文文件名变成乱码(同源缺陷族)。
ctx.set(
'Content-Type',
result.isZip
? 'application/zip'
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
);
ctx.attachment(result.fileName);
// 前端需要知道「本次导出了几道题」,非简单请求下无法从 body 读(body 是二进制)
ctx.set('X-Export-Count', String(result.count));
ctx.body = result.buffer;
} catch (err) {
ctx.logger.error('导出大题 Word 失败:', err);
ctx.body = ctx.helper.error(500, '导出大题 Word 失败');
}
}
/**
* 导出考生大题答卷为 Word(2026-09-28 需求:阅卷评分需将「试题 + 考生答案」导出)
* GET /api/admin/big-answers/:group/export?ids=1,2,3
* · 传 ids → 导出指定考生的答卷;不传 → 该组**全部已抽题**考生
* · 命中 1 位 → 直接返回 .docx
* · 命中多位 → 返回「每人一个独立 .docx」打包的 .zip(⛔ 不塞进同一个 Word)
*
* ⚠️ 与 `PUT /big-answers/:group/:uid/scores` 方法不同、段数不同,不会互相吞并。
* ⚠️ 只调 ctx.attachment(原名):koa 自动做 RFC5987 编码;自行 encodeURIComponent 会
* 双重编码导致中文文件名乱码(同源缺陷族,见 exportBigQuestions 注释)。
*/
async exportBigAnswers() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const result = await service.bigQuestion.exportAnswers(group, ctx.query.ids);
if (!result.ok) {
const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400;
ctx.body = ctx.helper.error(httpStatus, result.message);
return;
}
ctx.set(
'Content-Type',
result.isZip
? 'application/zip'
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
);
ctx.attachment(result.fileName);
// 前端需知道本次导出了几位考生(响应体是二进制,读不到)
ctx.set('X-Export-Count', String(result.count));
ctx.body = result.buffer;
} catch (err) {
ctx.logger.error('导出考生大题答卷失败:', err);
ctx.body = ctx.helper.error(500, '导出考生大题答卷失败');
}
}
/**
* 新增大题
* POST /api/admin/big-questions/:group
* Body: { title, description, sub_prompt_1..3, score_per_sub, sort_order, status }
*/
async createBigQuestion() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const result = await service.bigQuestion.createQuestion(group, ctx.request.body || {});
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success({
id: result.record.id,
questionUid: result.record.question_uid,
}, '大题已新增');
} catch (err) {
ctx.logger.error('新增大题失败:', err);
ctx.body = ctx.helper.error(500, '新增大题失败');
}
}
/**
* 更新大题
* PUT /api/admin/big-questions/:group/:id
*/
async updateBigQuestion() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group)) return;
const qid = parseInt(id, 10);
if (isNaN(qid)) {
ctx.body = ctx.helper.error(400, '大题 ID 非法');
return;
}
const result = await service.bigQuestion.updateQuestion(group, qid, ctx.request.body || {});
if (!result.ok) {
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message);
return;
}
ctx.body = ctx.helper.success({ id: result.record.id }, '大题已更新');
} catch (err) {
ctx.logger.error('更新大题失败:', err);
ctx.body = ctx.helper.error(500, '更新大题失败');
}
}
/**
* 删除大题(已被考生抽取过的大题拒绝删除,引导改为「停用」)
* DELETE /api/admin/big-questions/:group/:id
*/
async deleteBigQuestion() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group)) return;
const qid = parseInt(id, 10);
if (isNaN(qid)) {
ctx.body = ctx.helper.error(400, '大题 ID 非法');
return;
}
const result = await service.bigQuestion.deleteQuestion(group, qid);
if (!result.ok) {
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 409, result.message);
return;
}
ctx.body = ctx.helper.success({ deleted: 1 }, '大题已删除');
} catch (err) {
ctx.logger.error('删除大题失败:', err);
ctx.body = ctx.helper.error(500, '删除大题失败');
}
}
/**
* 大题作答查证:按组返回「考生 → 抽到的题目(快照)→ 三小问答案 → 提交时间 → 得分」
* GET /api/admin/big-answers/:group
*
* 含该组全部考生(未抽题者 drawn=false),便于核对缺考/未作答。
* 题目取自**题干快照**而非当前题库,确保考生当时所见可举证。
*/
async getBigAnswers() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const list = await service.bigQuestion.listAnswersForAdmin(group);
ctx.body = ctx.helper.success({
list,
total: list.length,
drawnCount: list.filter(x => x.drawn).length,
}, '获取大题作答成功');
} catch (err) {
ctx.logger.error('获取大题作答失败:', err);
ctx.body = ctx.helper.error(500, '获取大题作答失败');
}
}
/**
* 按小问评分(三小问各 0-10,合计 0-30)
* PUT /api/admin/big-answers/:group/:uid/scores
* Body: { score_1, score_2, score_3 }
*
* 合计分经 service.admin.updateScores(既有唯一评分路径)同步 contestants.word_score /
* contestants.score / accounts,保证与「成绩发布门控」及总分公式完全一致,
* 不引入第二条写成绩的代码通路。
*/
async setBigAnswerScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, uid } = ctx.params;
if (!this._validateGroup(group)) return;
const aggregationUid = parseInt(uid, 10);
if (isNaN(aggregationUid)) {
ctx.body = ctx.helper.error(400, '考生聚合 ID 非法');
return;
}
const body = ctx.request.body || {};
const result = await service.bigQuestion.setSubScores(group, aggregationUid, {
score_1: body.score_1,
score_2: body.score_2,
score_3: body.score_3,
});
if (!result.ok) {
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message);
return;
}
ctx.body = ctx.helper.success({
total: result.total,
perSubScore: result.perSubScore,
}, `大题评分已保存(合计 ${result.total} 分)`);
} catch (err) {
ctx.logger.error('大题评分失败:', err);
ctx.body = ctx.helper.error(500, '大题评分失败');
}
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// ⚠️ 复核明细会回传 question.answer(正确答案),**只能**挂 admin 路由
// (若被选手端复用即为成绩泄露,见 scripts/verify_score_leak_all_channels.py)。
/**
* 检索考生
* GET /api/admin/review/search?keyword=xxx[&group=zhongzhi]
*
* keyword 命中「姓名 / 身份证号 / 电话」任一(模糊匹配)。不传 group 则全组别检索 ——
* 复核时管理员通常只拿到一个身份证号,不该被迫先猜组别。
*/
async reviewSearch() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const keyword = ctx.query.keyword;
const group = ctx.query.group || undefined;
// 传了 group 就必须合法;不传表示全组别检索。
if (group && !this._validateGroup(group, true)) return;
const result = await service.admin.reviewSearch(keyword, group);
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success({
list: result.list,
total: result.list.length,
truncated: result.truncated,
}, '检索完成');
} catch (err) {
ctx.logger.error('复核检索失败:', err);
ctx.body = ctx.helper.error(500, '复核检索失败');
}
}
/**
* 考生全卷明细(复核)
* GET /api/admin/review/score/:group/:id
*
* :id 为 contestants.id。:group 仅用于**校验**(与考生实际组别不符则拒绝),
* 真正的取数组别以库中考生的 group 为准 —— 防止前端传错组别导致照错人出证据。
*/
async contestantScore() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group, true)) return;
const cid = parseInt(id, 10);
if (isNaN(cid)) {
ctx.body = ctx.helper.error(400, '考生 ID 非法');
return;
}
const result = await service.admin.getContestantScore(cid, group);
if (!result.ok) {
// 404 资源不存在;409 资源存在但与请求上下文冲突(组别不符)
const status = result.code === 'NOT_FOUND' ? 404
: (result.code === 'GROUP_MISMATCH' ? 409 : 400);
ctx.body = ctx.helper.error(status, result.message);
return;
}
ctx.body = ctx.helper.success(result, '获取考生全卷明细成功');
} catch (err) {
ctx.logger.error('获取考生全卷明细失败:', err);
ctx.body = ctx.helper.error(500, '获取考生全卷明细失败');
}
}
} }
module.exports = AdminController; module.exports = AdminController;

View File

@@ -190,7 +190,7 @@ class AuthController extends Controller {
* 1. 校验身份证号、手机号、短信验证码 * 1. 校验身份证号、手机号、短信验证码
* 2. 通过身份证号查找选手 * 2. 通过身份证号查找选手
* 3. 检查账号是否已激活 * 3. 检查账号是否已激活
* 4. SHA256 哈希密码,更新 activated=1 * 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1
* 5. 清除 session 中的短信验证码 * 5. 清除 session 中的短信验证码
*/ */
async activate() { async activate() {
@@ -263,7 +263,8 @@ class AuthController extends Controller {
* 流程: * 流程:
* 1. 通过手机号查找选手 * 1. 通过手机号查找选手
* 2. 检查账号是否激活 * 2. 检查账号是否激活
* 3. SHA256(输入密码) 与数据库密码比对 * 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希,
* 并在验证成功后透明升级为 bcrypt)
* 4. 生成 JWT Token,返回用户信息和 Token * 4. 生成 JWT Token,返回用户信息和 Token
*/ */
async login() { async login() {
@@ -309,9 +310,9 @@ class AuthController extends Controller {
role: 'contestant', role: 'contestant',
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn); }, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
// 返回用户信息(排除密码) // 返回用户信息 —— 统一走 AuthService.toClientUser 出口:
const userData = contestant.toJSON(); // 剔除密码 + 按「成绩发布门控」决定是否携带 score(铁律15 全通道封堵,2026-09-21)
delete userData.password; const userData = await service.auth.toClientUser(contestant);
ctx.body = ctx.helper.success({ ctx.body = ctx.helper.success({
token, token,
@@ -324,7 +325,7 @@ class AuthController extends Controller {
* *
* 流程: * 流程:
* 1. 通过用户名从 admins 表查找管理员 * 1. 通过用户名从 admins 表查找管理员
* 2. 密码比对(SHA256) * 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希)
* 3. 生成管理员 JWT Token * 3. 生成管理员 JWT Token
*/ */
async adminLogin() { async adminLogin() {
@@ -392,7 +393,7 @@ class AuthController extends Controller {
* 获取当前登录用户信息(需认证) * 获取当前登录用户信息(需认证)
*/ */
async currentUser() { async currentUser() {
const { ctx } = this; const { ctx, service } = this;
// 从 JWT token 中获取用户信息 // 从 JWT token 中获取用户信息
const token = ctx.headers.authorization const token = ctx.headers.authorization
@@ -426,8 +427,9 @@ class AuthController extends Controller {
return; return;
} }
const userData = contestant.toJSON(); // 与 login 共用同一脱敏出口(同源族:两条 auth 通道口径必须一致,
delete userData.password; // 且此处原为全字段 toJSON —— 会连带下发 exam_score/word_score/ppt_score)
const userData = await service.auth.toClientUser(contestant);
ctx.body = ctx.helper.success(userData, '获取用户信息成功'); ctx.body = ctx.helper.success(userData, '获取用户信息成功');
} }
@@ -544,7 +546,7 @@ class AuthController extends Controller {
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。 * - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
*/ */
async refresh() { async refresh() {
const { ctx } = this; const { ctx, service } = this;
const authorization = ctx.headers.authorization || ''; const authorization = ctx.headers.authorization || '';
const token = authorization.replace(/^Bearer\s+/i, '') || null; const token = authorization.replace(/^Bearer\s+/i, '') || null;
@@ -597,8 +599,9 @@ class AuthController extends Controller {
: { id: account.id, phone: account.phone, role: 'contestant' }; : { id: account.id, phone: account.phone, role: 'contestant' };
const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn); const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
const userData = account.toJSON(); // 返回用户信息 —— 统一走 AuthService.toClientUser 出口
delete userData.password; // (与 login / currentUser 同源:禁止各自直接 toJSON,否则成绩未发布时 score 会从本通道漏出)
const userData = await service.auth.toClientUser(account);
ctx.body = ctx.helper.success({ ctx.body = ctx.helper.success({
token: newToken, token: newToken,

View File

@@ -456,6 +456,17 @@ class ExamController extends Controller {
const { group } = ctx.params; const { group } = ctx.params;
if (!this._validateGroup(group)) return; if (!this._validateGroup(group)) return;
// 🔴 中职组/高职组已取消 Word 文件上传题(改为在线作答「综合大题」)。
// 前端入口已移除,但直调 API 仍可绕过,故在服务端一并封堵,避免出现
// 「界面上没这道题、后台却收到 Word 附件」的越界数据(2026-09-21)。
if (['zhongzhi', 'gaozhi'].includes(group)) {
ctx.body = ctx.helper.error(
400,
'中职组/高职组已取消 Word 文件上传题,请在「综合大题」中在线作答'
);
return;
}
// 获取用户聚合信息 // 获取用户聚合信息
const aggregation = await this._getUserAggregation(); const aggregation = await this._getUserAggregation();
if (!aggregation) { if (!aggregation) {
@@ -609,6 +620,153 @@ class ExamController extends Controller {
} }
} }
/**
* 获取本次抽中的综合大题(仅中职组 / 高职组)
* GET /api/exam/:group/big-question
*
* 2026-09-21 需求变更:中职/高职取消 Word 文件上传题,改为随机抽取一道大题在线作答。
* 语义:**首次调用抽题并落库题干快照,之后一律返回同一道题**(幂等,防考生纠纷),
* 同时回传已保存的三小问作答,供重新进入考试时恢复。
*
* ⚠️ 不回传任何分数:成绩仅经 GET /api/score 单通道(发布门控后)下发,铁律15。
*/
async bigQuestion() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 组别归属校验(与 status / questions 同口径,防止跨组越权取题)
if (aggregation.team && aggregation.team.group_name
&& aggregation.team.group_name !== group) {
ctx.body = ctx.helper.error(403, '您不属于该组别');
return;
}
const result = await service.bigQuestion.getForCandidate(
aggregation.aggregation_uid,
group,
(ctx.state.currentUser && ctx.state.currentUser.id) || aggregation.aggregation_uid
);
if (!result.ok) {
// 组别未启用 → 400;无题可抽 → 4004(与「题目未配置」类业务码口径一致)
const code = result.code === 'NO_QUESTION' ? 4004 : 400;
ctx.body = ctx.helper.error(code, result.message);
return;
}
ctx.body = ctx.helper.success({
question: result.question,
answers: result.answers,
submitTime: result.submitTime,
maxChars: result.maxChars,
subCount: result.subCount,
reused: result.reused,
}, '获取综合大题成功');
} catch (err) {
ctx.logger.error('获取综合大题失败:', err);
ctx.body = ctx.helper.error(500, '获取综合大题失败');
}
}
/**
* 保存综合大题三小问作答(仅中职组 / 高职组)
* POST /api/exam/:group/big-answer
* Body: { answer_1, answer_2, answer_3, forceSubmit? }
*
* 每小问上限 2000 字,**后端同步校验**(前端 maxlength 只是体验优化,不可作为唯一防线)。
* forceSubmit 与 submit-all 同语义:仅「倒计时归零的自动提交」传入,允许在考试窗口
* 临界/已关闭时仍落库考生已写内容,避免整卷提交时丢失大题答案。
*/
async saveBigAnswer() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const body = ctx.request.body || {};
// 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构):
// 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体,
// 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答;
// 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。
// 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted),
// 绝不接受请求体内容;库内无作答行则明确拒绝。
const examStatus = await service.exam.getExamStatus(group);
const windowOpen = examStatus.isActive === true;
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
if (!windowOpen) {
// 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝)
const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
if (!settled) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
ctx.body = ctx.helper.success({
filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ]
.filter((v) => v && String(v).trim().length > 0).length,
subCount: service.bigQuestion.subQuestionCount(),
maxChars: service.bigQuestion.answerMaxChars(),
savedAt: new Date().toISOString(),
settled: true,
}, '考试已结束,已按已保存内容结算');
return;
}
const result = await service.bigQuestion.saveAnswers(
aggregation.aggregation_uid,
group,
{
answer_1: body.answer_1,
answer_2: body.answer_2,
answer_3: body.answer_3,
}
);
if (!result.ok) {
// 超长 → 400(参数非法);未抽题 → 4004(业务前置条件未满足)
const code = result.code === 'NOT_DRAWN' ? 4004 : 400;
ctx.body = ctx.helper.error(code, result.message);
return;
}
ctx.body = ctx.helper.success({
filled: result.filled,
subCount: service.bigQuestion.subQuestionCount(),
maxChars: service.bigQuestion.answerMaxChars(),
savedAt: new Date().toISOString(),
}, '综合大题答案已保存');
} catch (err) {
ctx.logger.error('保存综合大题答案失败:', err);
ctx.body = ctx.helper.error(500, '保存综合大题答案失败');
}
}
/** /**
* 一体提交:答案 + Word 文件 * 一体提交:答案 + Word 文件
* POST /api/exam/:group/submit-all * POST /api/exam/:group/submit-all
@@ -623,35 +781,93 @@ class ExamController extends Controller {
const { group } = ctx.params; const { group } = ctx.params;
if (!this._validateGroup(group)) return; if (!this._validateGroup(group)) return;
// forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、 // forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方
// 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库, // 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。
// 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。 //
// 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。
// 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体,
// 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。
// 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true; const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
// 验证考试是否活跃 // 验证考试是否活跃
const examStatus = await service.exam.getExamStatus(group); const examStatus = await service.exam.getExamStatus(group);
if (!examStatus.isActive && !forceSubmit) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
// 获取用户聚合信息 // 获取用户聚合信息(须先于窗口判定:结算通道需要按考生查已落库作答)
const aggregation = await this._getUserAggregation(); const aggregation = await this._getUserAggregation();
if (!aggregation) { if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息'); ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return; return;
} }
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交) // 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论)
const teamGate = await this._enterExamWithTeamLock(aggregation); //
if (!teamGate.allowed) { // 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒
ctx.body = ctx.helper.error(4005, '您的小组已进入考试'); // (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**;
return; // 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit,
// 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空,
// 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。
//
// 因此窗口关闭后开放**服务端结算通道**(settleOnly):
// - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交;
// - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后
// 继续作答刷分,公平性受损;
// - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。
const windowOpen = examStatus.isActive === true;
let settleOnly = false;
let storedAnswers = null;
if (!windowOpen) {
const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group);
storedAnswers = (existing || []).filter(
(r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== ''
);
if (!storedAnswers.length) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
settleOnly = true;
// 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→
// 直接返回成功并回吐真实 submit_time,不重复写库。
// 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
if (alreadySubmitted) {
const rec = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregation.aggregation_uid },
attributes: [ 'submit_time' ],
});
ctx.body = ctx.helper.success({
answeredCount: storedAnswers.length,
totalQuestions: storedAnswers.length,
hasWordFile: storedAnswers.some((s) => s.word_file_path),
savedCount: storedAnswers.length,
submitted: true,
settled: true,
submitTime: (rec && rec.submit_time)
? new Date(rec.submit_time).toISOString()
: new Date().toISOString(),
}, '提交成功(此前已提交)');
return;
}
} }
// 参数校验 // 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
// ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用,
// 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。
if (!settleOnly) {
const teamGate = await this._enterExamWithTeamLock(aggregation);
if (!teamGate.allowed) {
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
return;
}
}
// 参数校验(结算通道不接收请求体答案,故放宽为可空)
const rule = { const rule = {
answers: { type: 'array', required: true, min: 1 }, answers: settleOnly
? { type: 'array', required: false }
: { type: 'array', required: true, min: 1 },
}; };
try { try {
ctx.validate(rule); ctx.validate(rule);
@@ -660,6 +876,34 @@ class ExamController extends Controller {
return; return;
} }
// ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ----------
// 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时
// 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分,
// 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。
if (settleOnly) {
const snapshot = storedAnswers.map((r) => ({
question_uid: r.question_uid,
question_type: r.question_type,
answer_data: r.answer_data,
}));
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot);
// 大题作答同样补 submit_time(结算通道只补标记、不改内容)
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
ctx.logger.warn(
`[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}`
);
ctx.body = ctx.helper.success({
answeredCount: snapshot.length,
totalQuestions: snapshot.length,
hasWordFile: storedAnswers.some((s) => s.word_file_path),
savedCount: snapshot.length,
submitted: true,
settled: true,
submitTime: new Date().toISOString(),
}, '提交成功(考试已结束,按已保存答案结算)');
return;
}
const { answers } = ctx.request.body; const { answers } = ctx.request.body;
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分) // 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
@@ -735,6 +979,14 @@ class ExamController extends Controller {
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。 // 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData); await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
// 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。
// 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码),
// 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」
// → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时
// 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护,
// 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。 // 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。 // 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
ctx.body = ctx.helper.success({ ctx.body = ctx.helper.success({

View File

@@ -52,11 +52,41 @@ class UploadController extends Controller {
*/ */
async upload() { async upload() {
const { ctx, service } = this; const { ctx, service } = this;
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
let ioAcquired = false;
// 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。
// 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 ——
// 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。
let savedFileInfo = null;
let recordPersisted = false;
try { try {
const { group } = ctx.params; const { group } = ctx.params;
if (!this._validateGroup(group)) return; if (!this._validateGroup(group)) return;
// 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**)
//
// 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用),
// 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏,
// 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard
// 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。
//
// 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额,
// 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。
//
// ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s,
// 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**;
// 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」
// (故 C7 与 C3 绑定:先补护栏再升带宽)。
try {
await acquireFileIo();
ioAcquired = true;
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
// 验证截止时间 // 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group); const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) { if (!deadlineCheck.valid) {
@@ -104,6 +134,7 @@ class UploadController extends Controller {
// 保存文件 // 保存文件
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group); const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
// 获取作品名称(流式模式字段在 stream.fields 中) // 获取作品名称(流式模式字段在 stream.fields 中)
const workName = (stream.fields && stream.fields.work_name) || stream.filename; const workName = (stream.fields && stream.fields.work_name) || stream.filename;
@@ -118,6 +149,8 @@ class UploadController extends Controller {
workName, workName,
fileType fileType
); );
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({ ctx.body = ctx.helper.success({
id: record.id, id: record.id,
@@ -130,9 +163,25 @@ class UploadController extends Controller {
uploadTime: record.upload_time || new Date().toISOString(), uploadTime: record.upload_time || new Date().toISOString(),
}, '文件上传成功'); }, '文件上传成功');
} catch (err) { } catch (err) {
// 🔴 孤儿回滚(C9 · R45):文件已落盘但记录未入库 ⇒ 立即清掉这个新文件。
// 条件严格限定为 `savedFileInfo && !recordPersisted`:
// · 未落盘(校验/护栏阶段失败)→ savedFileInfo 为 null,无事可做;
// · 已入库(recordPersisted=true)→ 文件正被 DB 引用,**绝不能删**。
// 仍然 best-effort:清理失败只告警,不能把真正的错误信息顶掉(同 helper.safeCleanup 的约定)。
if (savedFileInfo && savedFileInfo.filePath && !recordPersisted) {
service.upload.removePublicFile(savedFileInfo.filePath, 'upload-rollback');
}
ctx.logger.error('文件上传失败:', err); ctx.logger.error('文件上传失败:', err);
const msg = (err && err.message) ? err.message : '未知错误'; const msg = (err && err.message) ? err.message : '未知错误';
ctx.body = ctx.helper.error(500, msg); ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
// 否则配额只减不增,若干轮后上传通道**永久 429**。
if (ioAcquired) releaseFileIo();
} }
} }
@@ -214,6 +263,9 @@ class UploadController extends Controller {
*/ */
async mergeChunks() { async mergeChunks() {
const { ctx, service, app } = this; const { ctx, service, app } = this;
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
let mergedRelPath = null;
let recordPersisted = false;
try { try {
const { group } = ctx.params; const { group } = ctx.params;
@@ -291,6 +343,7 @@ class UploadController extends Controller {
// 构建相对路径 // 构建相对路径
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/'); const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
// 保存提交记录 // 保存提交记录
const fileInfo = { const fileInfo = {
@@ -306,6 +359,8 @@ class UploadController extends Controller {
work_name || fileName, work_name || fileName,
file_type || 'word' file_type || 'word'
); );
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({ ctx.body = ctx.helper.success({
id: record.id, id: record.id,
@@ -321,6 +376,10 @@ class UploadController extends Controller {
releaseFileIo(); releaseFileIo();
} }
} catch (err) { } catch (err) {
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
if (mergedRelPath && !recordPersisted) {
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
}
ctx.logger.error('分片合并失败:', err); ctx.logger.error('分片合并失败:', err);
ctx.body = ctx.helper.error(500, '分片合并失败'); ctx.body = ctx.helper.error(500, '分片合并失败');
} }

View File

@@ -3,7 +3,23 @@
const crypto = require('crypto'); const crypto = require('crypto');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const fs = require('fs'); const fs = require('fs');
const bcrypt = require('bcryptjs'); // 🔴 R8(2026-09-21):原生 bcrypt(N-API),**不是** bcryptjs。
// 原实现 `require('bcryptjs')` 是纯 JS、**同步阻塞事件循环** —— 实测 16 并发 compare
// 耗时 1129ms,且期间**心跳达成率 0%**(主线程被冻结 1.1s,无法 accept 新连接),
// 这正是登录链路需要"并发闸门"兜底的根因。原生实现走 libuv 线程池:
// 同批 compare 265ms(**4.20×**)、心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
//
// ✅ 兼容性(已验证,drop-in,无需数据迁移):
// · API 同形:hash / compare / hashSync / compareSync 均已实测存在;
// · 哈希格式同形:产出仍为 `$2b$10$`,与存量 26/26 条完全一致,可互相比对;
// · 是 **N-API** 构建(prebuilds 文件名无 ABI 后缀)⇒ node 18(ABI 108) 与
// 22(ABI 127) 同一份二进制均可 load;包内自带 win32-x64 与
// linux-x64(glibc + musl)预编译 ⇒ 本地与 Linux 生产**均无需编译**。
//
// ⚠️ 生产部署影响:原生模块**不会**被 rsync 源码带走,服务器须重新 `npm install`
// (见交付报告「上线前置」)。⛔ 不要把 `bcryptjs` 残留为依赖 —— 留着只会
// 让后人误以为仍在用纯 JS 实现。
const bcrypt = require('bcrypt');
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点) // bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
const BCRYPT_ROUNDS = 10; const BCRYPT_ROUNDS = 10;

42
app/lib/decimal_score.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict';
/**
* 分数列类型工厂(2026-09-28:大题人工评分精确到小数点后**两位**)
*
* 用法(在 model 文件内):
* const decimalScore = require('../lib/decimal_score')(app);
* ...
* word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
*
* ⚠️ 三处必须同时成立,小数才不会被截断或变形:
* ① DB 列 = DECIMAL
* → database/migrations/025-scores-decimal-one-place.sql(INT(11) → DECIMAL(6,1))
* → database/migrations/026-scores-decimal-two-places.sql((6,1) → (6,2) ← 现行)
* ② model 声明 = DECIMAL(本文件)
* → 若仍写 INTEGER,Sequelize 读取时会走 INTEGER.parse(parseInt),
* 把 7.5 **静默截成 7**,且不报任何错
* ③ mysql2 驱动配 decimalNumbers=true(config/config.default.js)
* → 否则 DECIMAL 经驱动读出为**字符串**('7.5'),前端 `+` 会变成拼接
*
* getter 再兜底转一次 Number,保证 raw / 非 raw 查询路径都拿到数值类型。
*
* @param {Egg.Application} app Egg 应用实例(用于取 app.Sequelize)
* @returns {(field: string, comment: string, extra?: object) => object}
*/
module.exports = app => {
const { DECIMAL } = app.Sequelize;
return function decimalScore(field, comment, extra = {}) {
return Object.assign({
// 上限 9999.99,足够覆盖总分 / 单题分;两位小数满足阅卷评分精度
// ⚠️ 改这里必须同步 database/migrations/026-scores-decimal-two-places.sql
type: DECIMAL(6, 2),
allowNull: true,
comment,
get() {
const v = this.getDataValue(field);
return (v === null || v === undefined) ? v : Number(v);
},
}, extra);
};
};

469
app/lib/docx.js Normal file
View File

@@ -0,0 +1,469 @@
'use strict';
/**
* 极简 WordprocessingML(.docx) 生成器(2026-09-23)
*
* 用途:把综合大题(`big_questions`)导出为**真正的 .docx**,供管理员留存 / 打印 / 分发。
*
* 为什么不用第三方 docx 库:后端已有 `archiver`(service/admin.js 的批量下载在用),
* 而 .docx 本质就是一个 ZIP + 几个 XML part ⇒ 直接手写 OOXML,**新增零依赖**、
* 也避免引入未经验证的库影响既有交卷链路。
*
* 生成的包结构(最小合法 OOXML):
* [Content_Types].xml
* _rels/.rels
* word/document.xml
* word/styles.xml
* word/_rels/document.xml.rels
*
* 支持的 HTML 子集(实测题干只用到这些:p / table / tbody / tr / td / strong / span / a / br):
* 块级:p、div、h1–h6、li、ul/ol、blockquote、pre、hr
* 表格:table(含 tbody/thead/tfoot 包裹)、tr、td/th(th 加粗)
* 行内:strong/b(加粗)、em/i(斜体)、u、span、a(只取文字)、br
* 样式:仅解析 `text-align`(对齐)与 `text-indent`(首行缩进,pt→twips)
* 实体:&nbsp; &lt; &gt; &amp; &quot; &apos; &#39;
* 忽略:script / style / img(图片不外链,避免生成"打不开的图片占位")
*
* ⛔ 已知取舍:嵌套表格会被扁平化;图片不导出。
*/
// ==================== XML / 实体 ====================
function decodeEntities(str) {
return String(str == null ? '' : str)
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#0*39;/g, "'")
.replace(/&apos;/gi, "'")
.replace(/&#(\d+);/g, (m, d) => {
const code = Number(d);
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
})
// ⚠️ &amp; 必须放最后:否则 `&amp;lt;` 会被二次解码成 `<`
.replace(/&amp;/gi, '&');
}
/** XML 文本转义(在 decodeEntities 之后调用,顺序不可颠倒) */
function esc(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ==================== 极简 HTML 解析(容错栈式) ====================
const VOID_TAGS = new Set(['br', 'hr', 'img', 'meta', 'link', 'input', 'col', 'area', 'base', 'source', 'wbr']);
/**
* 词法正则。
* ⚠️ 必须用**具名捕获组**:早期版本用「按出现顺序数下标」(m[1]..m[8]),
* 但多个 `|` 分支各自贡献捕获组,实际只有 5 组 ⇒ 下标全部错位 ⇒
* 所有标签被当成文本、块序列恒为空(静默产出空文档,无任何报错)。
* 具名组不受分支数量影响,从根上消除这类错位。
*/
const TOKEN_RE = /<!--[\s\S]*?-->|<![^>]*>|<\?[\s\S]*?\?>|<\/\s*(?<close>[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?<open>[a-zA-Z][a-zA-Z0-9:-]*)(?<attrs>(?:"[^"]*"|'[^']*'|[^>"'])*?)(?<self>\/?)>|(?<text>[^<]+)/g;
const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
function parseAttrs(raw) {
const attrs = {};
if (!raw) return attrs;
ATTR_RE.lastIndex = 0;
let m;
while ((m = ATTR_RE.exec(raw))) {
attrs[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
}
return attrs;
}
/**
* 把 HTML 片段解析为节点树。
* ⛔ 容错优先:标签不闭合 / 交叉嵌套时按栈就近回退,绝不抛异常
* (题干是管理员手工粘贴的富文本,脏 HTML 不能导致整次导出失败)。
*/
function parseHtml(html) {
const root = { type: 'node', tag: '#root', attrs: {}, children: [] };
const stack = [root];
const src = String(html == null ? '' : html);
TOKEN_RE.lastIndex = 0;
let m;
while ((m = TOKEN_RE.exec(src))) {
const g = m.groups || {};
if (g.text !== undefined) {
stack[stack.length - 1].children.push({ type: 'text', value: g.text });
continue;
}
if (g.close !== undefined) {
// 闭标签:从栈顶向下找同名节点;找不到就忽略(脏 HTML 不抛异常)
const name = g.close.toLowerCase();
for (let i = stack.length - 1; i > 0; i--) {
if (stack[i].tag === name) {
stack.length = i;
break;
}
}
continue;
}
if (g.open !== undefined) {
const tag = g.open.toLowerCase();
const node = { type: 'node', tag, attrs: parseAttrs(g.attrs), children: [] };
stack[stack.length - 1].children.push(node);
if (!VOID_TAGS.has(tag) && g.self !== '/') stack.push(node);
continue;
}
// 注释 / doctype / 处理指令:忽略
}
return root;
}
// ==================== 节点树 → 中间块序列 ====================
const BLOCK_TAGS = new Set([
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol',
'blockquote', 'pre', 'section', 'article', 'hr', 'dl', 'dt', 'dd',
]);
const SKIP_TAGS = new Set(['script', 'style', 'head', 'meta', 'link', 'title', 'img']);
/** 标题级别 → 半磅字号(21 半磅 = 10.5pt = 五号,正文基准) */
const HEADING_SIZE = { h1: 32, h2: 28, h3: 26, h4: 24, h5: 22, h6: 21 };
const HEADING_BOLD = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
const INLINE_BOLD = new Set(['strong', 'b', 'th']);
const INLINE_ITALIC = new Set(['em', 'i']);
function styleOf(node) {
return (node && node.attrs && node.attrs.style) || '';
}
/** 从 style 里取段落对齐(仅映射 Word 支持的四种) */
function alignOf(node) {
const s = styleOf(node);
const m = /text-align\s*:\s*(left|right|center|justify)/i.exec(s);
return m ? m[1].toLowerCase() : '';
}
/** 从 style 里取首行缩进(pt → twips,1pt = 20twips) */
function firstLineTwips(node) {
const s = styleOf(node);
const m = /text-indent\s*:\s*(-?\d+(?:\.\d+)?)\s*pt/i.exec(s);
if (!m) return 0;
const pt = Number(m[1]);
return Number.isFinite(pt) && pt > 0 ? Math.round(pt * 20) : 0;
}
/**
* 深度优先遍历,产出「块」序列:
* { type:'p', runs:[...], align, firstLine }
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
* run 形态:{ text, bold, italic } 或 { br: true }
*/
function walk(nodes, fmt, out) {
const flush = () => {
if (out.cur.length) {
out.blocks.push(Object.assign({ type: 'p', runs: out.cur }, out.curMeta));
out.cur = [];
out.curMeta = {};
}
};
for (const n of nodes) {
if (n.type === 'text') {
// HTML 源码里的换行/缩进属排版噪声,须丢弃;
// 但 `&nbsp;` 是作者有意的占位空格(题干排版大量使用),必须保留 ⇒ 用原始值判别
if (!/\S/.test(n.value) && !/&nbsp;/i.test(n.value)) continue;
// 原始换行/制表压成单空格:真实换行只由 <br> 与段落边界产生
const text = decodeEntities(n.value).replace(/[\r\n\t]+/g, ' ');
if (text === '') continue;
out.cur.push({ text, bold: !!fmt.bold, italic: !!fmt.italic });
continue;
}
const tag = n.tag;
if (SKIP_TAGS.has(tag)) continue;
if (tag === 'br') {
out.cur.push({ br: true });
continue;
}
if (tag === 'table') {
flush();
out.blocks.push({ type: 'table', rows: extractRows(n) });
continue;
}
if (tag === 'hr') {
flush();
out.blocks.push({ type: 'p', runs: [] });
continue;
}
if (tag === 'li') {
flush();
out.curMeta = Object.assign({}, out.curMeta, { align: alignOf(n) || out.curMeta.align, firstLine: 0 });
out.cur.push({ text: '• ', bold: false, italic: false });
walk(n.children, fmt, out);
// 若不 flush,下一条 li 会并入同一段 ⇒ 显式收尾
const last = out.blocks[out.blocks.length - 1];
if (last && last.type === 'p' && last.runs.length && last.runs[0].text === '• ') flush();
continue;
}
if (BLOCK_TAGS.has(tag)) {
flush();
const nextFmt = HEADING_BOLD.has(tag)
? Object.assign({}, fmt, { bold: true, size: HEADING_SIZE[tag] })
: fmt;
out.curMeta = { align: alignOf(n), firstLine: firstLineTwips(n) };
walk(n.children, nextFmt, out);
flush();
continue;
}
// 行内标签
const nextFmt = Object.assign({}, fmt);
if (INLINE_BOLD.has(tag)) nextFmt.bold = true;
if (INLINE_ITALIC.has(tag)) nextFmt.italic = true;
walk(n.children, nextFmt, out);
}
flush();
}
/** 提取表格行/单元格(容忍 tbody / thead / tfoot 包裹;嵌套表格扁平化到单元格文本) */
function extractRows(tableNode) {
const rows = [];
const collect = (nodes) => {
for (const n of nodes) {
if (n.type !== 'node') continue;
if (n.tag === 'tr') {
const cells = [];
for (const c of n.children) {
if (c.type !== 'node') continue;
if (c.tag !== 'td' && c.tag !== 'th') continue;
const cellOut = { blocks: [], cur: [], curMeta: {} };
walk(c.children, { bold: c.tag === 'th', italic: false }, cellOut);
cells.push({ blocks: cellOut.blocks, header: c.tag === 'th' });
}
rows.push({ cells });
} else if (n.tag === 'tbody' || n.tag === 'thead' || n.tag === 'tfoot' || n.tag === 'table') {
collect(n.children);
}
}
};
collect(tableNode.children);
return rows;
}
/** HTML → 块序列(对外唯一入口) */
function htmlToBlocks(html) {
const out = { blocks: [], cur: [], curMeta: {} };
walk(parseHtml(html).children, { bold: false, italic: false }, out);
return out.blocks;
}
/** 纯文本 → 单块(自动按空行/换行切段) */
function textToBlocks(text) {
const s = String(text == null ? '' : text);
if (!s.trim()) return [];
return s
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line !== '')
.map(line => ({ type: 'p', runs: [{ text: line, bold: false, italic: false }] }));
}
// ==================== 块 → WordprocessingML ====================
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
function rPrOf(run) {
const parts = [];
if (run.bold) parts.push('<w:b/><w:bCs/>');
if (run.italic) parts.push('<w:i/><w:iCs/>');
parts.push('<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>');
const sz = Number(run.size) > 0 ? Number(run.size) : 21;
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
return `<w:rPr>${parts.join('')}</w:rPr>`;
}
function renderRun(run) {
if (run.br) return '<w:r><w:br/></w:r>';
const text = String(run.text == null ? '' : run.text);
if (text === '') return '';
return `<w:r>${rPrOf(run)}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
}
function pPrOf(block, extra) {
const parts = [];
const align = block.align || '';
if (align) parts.push(`<w:jc w:val="${align === 'justify' ? 'both' : align}"/>`);
const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0;
if (fl) parts.push(`<w:ind w:firstLine="${fl}"/>`);
if (extra) parts.push(extra);
return parts.length ? `<w:pPr>${parts.join('')}</w:pPr>` : '';
}
/** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */
function renderParagraph(block) {
const runs = (block.runs || []).map(renderRun).join('');
return `<w:p>${pPrOf(block)}${runs}</w:p>`;
}
const TBL_BORDERS = '<w:tblBorders>'
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map(side => `<w:${side} w:val="single" w:sz="4" w:space="0" w:color="auto"/>`)
.join('')
+ '</w:tblBorders>';
function renderTable(block) {
const rows = block.rows || [];
if (!rows.length) return '';
const cellCount = Math.max(...rows.map(r => r.cells.length), 1);
const trs = rows.map(r => {
const tcs = [];
for (let i = 0; i < cellCount; i++) {
const cell = r.cells[i];
let inner;
if (!cell || !cell.blocks.length) {
inner = '<w:p/>';
} else {
inner = cell.blocks.map(b => (b.type === 'table'
// 嵌套表扁平化为文本,避免非法嵌套
? renderParagraph({ runs: flattenTableText(b) })
: renderParagraph(b))).join('');
}
tcs.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${inner}</w:tc>`);
}
return `<w:tr>${tcs.join('')}</w:tr>`;
}).join('');
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${TBL_BORDERS}</w:tblPr>${trs}</w:tbl>`;
}
function flattenTableText(tableBlock) {
const buf = [];
for (const r of tableBlock.rows || []) {
const cells = [];
for (const c of r.cells || []) {
const texts = [];
for (const b of c.blocks || []) {
if (b.type === 'p') texts.push((b.runs || []).map(x => (x.br ? ' ' : x.text)).join(''));
}
cells.push(texts.join(' ').trim());
}
buf.push({ br: false, text: cells.join(' | ') });
}
const runs = [];
buf.forEach((x, i) => {
if (i > 0) runs.push({ br: true });
runs.push({ text: x.text, bold: false, italic: false });
});
return runs;
}
function buildDocumentXml(blocks) {
const parts = [];
for (const b of blocks) {
if (b.type === 'table') {
const t = renderTable(b);
if (t) {
parts.push(t);
// 表后补空段:Word 中「表格紧邻表格/节尾」易被判异常
parts.push('<w:p/>');
}
} else {
parts.push(renderParagraph(b));
}
}
if (!parts.length) parts.push('<w:p/>');
parts.push('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>');
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ `<w:document xmlns:w="${W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`
+ `<w:body>${parts.join('')}</w:body></w:document>`;
}
// ==================== 打包为 .docx ====================
const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
+ '<Default Extension="xml" ContentType="application/xml"/>'
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
+ '</Types>';
const ROOT_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
+ '</Relationships>';
const DOC_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
+ '</Relationships>';
const STYLES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ `<w:styles xmlns:w="${W_NS}">`
+ '<w:docDefaults><w:rPrDefault><w:rPr>'
+ '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>'
+ '<w:sz w:val="21"/><w:szCs w:val="21"/>'
+ '</w:rPr></w:rPrDefault></w:docDefaults>'
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
+ '</w:styles>';
/**
* 把若干 part 打成一个 ZIP Buffer。
* ⚠️ archiver v8 为 ESM:沿用 service/admin.js:754 已验证的 `const { ZipArchive } = require('archiver')` 写法。
*/
function zipParts(parts) {
return new Promise((resolve, reject) => {
let ZipArchive;
try {
({ ZipArchive } = require('archiver'));
} catch (e) {
reject(new Error('缺少 archiver 依赖,无法生成 .docx'));
return;
}
const chunks = [];
const archive = new ZipArchive({ zlib: { level: 9 } });
archive.on('error', reject);
archive.on('data', c => chunks.push(c));
archive.on('end', () => resolve(Buffer.concat(chunks)));
for (const p of parts) archive.append(Buffer.from(p.data, 'utf8'), { name: p.name });
archive.finalize();
});
}
/**
* 生成单个 .docx
* @param {Array} blocks - htmlToBlocks / textToBlocks 的产物(可多个数组按顺序拼接)
* @returns {Promise<Buffer>}
*/
async function buildDocx(blocks) {
const list = ([]).concat(blocks || []).filter(Boolean);
const flat = [];
for (const b of list) {
if (Array.isArray(b)) flat.push(...b);
else flat.push(b);
}
return zipParts([
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
{ name: '_rels/.rels', data: ROOT_RELS },
{ name: 'word/document.xml', data: buildDocumentXml(flat) },
{ name: 'word/styles.xml', data: STYLES_XML },
{ name: 'word/_rels/document.xml.rels', data: DOC_RELS },
]);
}
/** 把多个 .docx 打成一个 ZIP(批量/全部导出用),返回 Buffer */
async function zipDocxFiles(entries) {
return zipParts(entries.map(e => ({ name: e.name, data: e.buffer })));
}
module.exports = {
buildDocx,
zipDocxFiles,
htmlToBlocks,
textToBlocks,
decodeEntities,
esc,
// 导出内部件便于单测
_internal: { parseHtml, buildDocumentXml, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
};

View File

@@ -18,7 +18,29 @@
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。 // file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。 // 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
// 🔴 单次流式上传(2026-09-29 · R43 补齐,原为**零护栏**)
//
// 背景:R42 排查 C5(作品上传错峰)时发现 —— **在用的**上传端点 `POST /api/upload/:group`
// (router.js:101 → controller/upload.js 的 upload())**不在本表**,因此它既不经过本闸门,
// 当时也未调用 file_io_guard ⇒ 真正承载考生作品上传的唯一路径**完全无护栏**。
// 同表内 `chunk`/`merge` 反倒是**前端已无调用点的死路由**(Upload.vue 于 B2 删除后
// 失去唯一调用方,见 api/upload.js 的死代码清理),护栏加在了无人走的路上。
//
// 性质判定:单次上传是**重 IO**(长连接 + 大文件流式落盘 + 边写边算 md5),
// 与 chunk/merge 同类,故**共享同一配额 MAX**(不另开独立上限)——
// 配额的意义是「让 worker 有余力 accept 新连接」(见本文件顶部背景),
// 三者共享 20 比各自开 20 更能守住这个目的。
//
// ⚠️ 正则为何必须是**单段** `[^/]+$`(勿"顺手"放宽成 `/^\/api\/upload\//i`):
// 同一前缀下还有三条**轻**路由,路径同前缀但**多一段**:
// · GET /api/upload/:group/status (查状态,轻)
// · GET /api/upload/:group/submissions (列表,轻)
// · DELETE /api/upload/:group/:id (删作品,轻)
// `[^/]+$` 要求「/api/upload/」之后**恰好一段且到串尾**,故上面三条(两段)**天然不命中**;
// 放宽成前缀匹配会把它们一并计入配额,在考生端表现为「查状态也 429」——语义错误。
// 本表的 `chunk`/`merge` 同理带 `$` 锚定(既有写法,勿动)。
const HEAVY_PATTERNS = [ const HEAVY_PATTERNS = [
/^\/api\/upload\/[^/]+$/i,
/^\/api\/upload\/[^/]+\/chunk$/i, /^\/api\/upload\/[^/]+\/chunk$/i,
/^\/api\/upload\/[^/]+\/merge$/i, /^\/api\/upload\/[^/]+\/merge$/i,
/^\/api\/exam\/[^/]+\/word$/i, /^\/api\/exam\/[^/]+\/word$/i,
@@ -26,19 +48,172 @@ const HEAVY_PATTERNS = [
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i, /^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
]; ];
// 🔴 交卷闸门(2026-09-21 并发压测结论)
//
// 背景:submit-all 不在 HEAVY_PATTERNS 内,因此交卷高峰**完全无闸门**。压测实测:
// 400 并发同刻交卷 → 约 20% 考生在 TCP accept 层被拒(客户端 WinError 10061),
// 服务端零业务错误、零崩溃。根因是单 worker 的 accept 循环被重任务(一次交卷≈百次
// DB 往返)饿死 → 内核 listen 队列溢出 → **Windows 在队列满时回 RST**
// (Linux 是静默丢 SYN,所以本机现象与 Linux 经验相反,极易误判为「网络问题」)。
//
// 纳入闸门的意义:过载时由「静默 RST(考生不知道是否交上)」变为「明确 429
// + Retry-After(可识别、可重试)」,配合前端交卷自动重试即可收敛;
// 同时闸门在**读取请求体之前**拒绝,避免请求体缓冲进一步挤占内存。
//
// ⚠️ 交卷使用**独立上限**,不与上传共用 20:
// - 交卷是纯 DB 重任务、无文件流,与上传的大文件 IO 特性不同,共用会过紧;
// - 但上限的作用是「让 worker 有余力 accept 新连接」,**不是**提高吞吐 ——
// 实测单 worker 硬容量约 15 交卷/秒,把上限开到几百只会让每个请求都变慢,
// 反而更容易饿死 accept。默认 24 ≈ 略高于单 worker 舒适并发,形成良性背压。
// - 多 worker 部署时总上限 = worker 数 × SUBMIT_REQ_MAX(进程内计数)。
const SUBMIT_PATTERNS = [
/^\/api\/exam\/[^/]+\/submit-all$/i,
];
// 🔴 登录闸门(2026-09-21 并发压测**第二轮**结论)
//
// 背景:submit-all 已于同日纳入闸门(见上),但**登录链路当时仍无闸门** ——
// 压测实测(每组 400 人 = 800 人同刻登录):
// · 仅 484/800 取得 token → **39.5% 在 TCP accept 层被拒**(WinError 10061);
// · 连接级重试 1134 次;
// · **P50 = 9957ms、P95 = 24350ms** —— 考生点「登录」后要干等十秒以上,
// 且近四成直接失败(客户端只看到「无法连接」,不知道是否要重试)。
// 根因与交卷侧**同源**:单 worker 的 accept 循环被重任务饿死 → accept 队列溢出
// → Windows 回 RST(Linux 静默丢 SYN,故 Linux 生产下表现为客户端长时间超时)。
// 按「同源族一并修」原则纳入闸门。
//
// 为何登录需要**独立**上限(不与交卷共用 24):
// - 登录是 **CPU 型**重任务:单请求的绝大部分成本是口令哈希比对
// (BCRYPT_ROUNDS=10,见 extend/helper.js),另加 2~3 次 DB 往返
// (findByPhone + isScorePublished);
// - 交卷是 **IO 型**重任务(约 23 条 SQL 往返),等待 DB 期间可让位给 accept;
// CPU 密集任务的换手粒度更粗,对 accept 的挤压更直接;
// - 故默认取 16(低于交卷的 24)。上限的作用是**保命**(让 worker 有余力
// accept 新连接),**不是**提速 —— 把上限开大只会让每个请求都变慢。
//
// 🔴 R8(2026-09-21)后的状态变化 —— 闸门保留,但**理由已改变**(勿照旧文本理解):
// 口令哈希实现已由 `bcryptjs`(纯 JS,**同步阻塞主线程**)换为**原生 bcrypt**
// (N-API,走 libuv 线程池)。实测:16 并发 compare 由 1129ms → 265ms(4.20×);
// 改动前**心跳达成率 0%**(主线程被冻结 1.1s,期间无法 accept 任何连接),
// 改动后心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
// ⇒ "accept 被 CPU 计算饿死"这一**原始**理由已大幅减弱,但闸门**仍然必要**:
// ① libuv 线程池默认仅 **4** 线程,16 并发仍需排队;长时间满载会挤压**其它**
// 走线程池的工作(DNS / fs / crypto)—— 闸门把排队**限界**,避免无界堆积;
// ② 每个登录请求另有 2~3 次 DB 往返,是**连接池与 DB 的真实压力源**,
// 与哈希是否阻塞事件循环**无关**;
// ③ 过载时给出**可识别**的 429 + Retry-After,优于 TCP 层静默 RST
// (考生至少知道"要重试"而非"不知道登没登上")。
// ⛔ 因此**不要**因为"换了原生 bcrypt"就顺手删掉本闸门 —— 理由②③与哈希实现无关。
// - 多 worker 部署时总上限 = worker 数 × LOGIN_REQ_MAX(进程内计数)。
//
// ⛔ 为何**不**把 /api/auth/admin-login 一并纳入(重要设计决策,勿"顺手"补上):
// 管理后台登录是极低频瞬时动作(并发个位数),**从不构成 accept 压力源**,
// 因此它不属"同源缺陷族"(缺陷族 = 同一根因 **且** 同一压力形态);
// 而一旦纳入共用/独立计数,考生入场洪峰会挤占管理员配额 →
// 制造出「考试期间管理员登不进后台」这一**新**故障面,得不偿失。
// 其防爆破由 router 级 loginRateLimit 单独负责(该中间件同时挂在两条登录路由上)。
//
// 🔴 与 loginRateLimit 的执行顺序(本改动的关键红线,勿破坏):
// 本中间件是**全局**中间件(config/config.default.js 的 config.middleware),
// 而 loginRateLimit 是 **router 级**(router.js 挂在 /api/auth/login 单条路由上);
// Egg 的请求链为「全局中间件 → router 匹配 → router 级中间件」,
// 因此本闸门**先于** loginRateLimit 执行 —— 一旦在此处返回 429,
// loginRateLimit 的 `await next()` **根本不会执行**,其"按失败计数"逻辑不生效。
// 这是**必须**的性质:否则考生每被背压拒绝一次就被记一次失败,
// 重试 10 次即撞上 15 分钟账号锁(业务码 4290)—— 那比「静默 RST」更糟(锁死)。
// ⚠️ 后人若把本中间件降为 router 级、或把登录判断挪到 `await next()` 之后,
// 会立刻破坏该性质。门禁 scripts/verify_login_guard.py 对此有专项断言。
const LOGIN_PATTERNS = [
/^\/api\/auth\/login$/i,
];
module.exports = (options, app) => { module.exports = (options, app) => {
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10); const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
const SUBMIT_MAX = parseInt(
process.env.SUBMIT_REQ_MAX || (options && options.submitMax) || '24',
10
);
const LOGIN_MAX = parseInt(
process.env.LOGIN_REQ_MAX || (options && options.loginMax) || '16',
10
);
let active = 0; // 🔴 Retry-After 取值依据(2026-09-21 R8 实测,勿凭直觉调大)
//
// 原为**三处各自硬编码 `'2'`** 的魔数(无依据、无同源保证)。经实测给出依据:
//
// 一个「闸门满载窗口」的服务时间 = 并发上限 × 单请求 compare 耗时:
// · 改动前 bcryptjs(纯 JS,主线程**串行**):16 × ~85ms ≈ 1.11 s
// (实测 16 并发 compare = 1092~1133 ms,期间**心跳达成率 0%**,主线程完全冻结)
// · 改动后 原生 bcrypt(N-API + libuv 线程池):实测 265 ms
//
// 客户端应在**一个满载窗口之后**再回来:窗口结束时槽位集中释放,命中概率最高;
// 过早回来只会撞墙并制造无谓往返(臂2 实测登录侧请求 +14%、重试 +28%)。
//
// ⇒ 取 **2s**:≈1.8 个满载窗口(改动前)/ ≈7.5 个(改动后),足以覆盖一次拥挤周期,
// 又不过长以免挤掉前端后续的指数退避重试机会(前端 cap 15s / 总窗口 ~45s)。
//
// ⛔ **不要盲目调大(如 5–8s)**:本闸门是「**立即拒绝**」(无排队),槽位是**连续释放**的,
// 等待过久会**错过本可成功的时机**,并让总收敛时间超过前端重试窗口 ⇒ 反而更多失败。
// ⛔ **也不要 < 1s**:退化为忙等,把连接级背压变成新的请求风暴。
// ⛔ **三处必须同源同值**:登录/交卷/上传的差异只体现在**配额**(LOGIN_MAX / SUBMIT_MAX / MAX),
// 不体现在等待时长上。历史上此值散落三处,改一处漏两处即产生"同源族漂移"。
const RETRY_AFTER = '2';
let active = 0; // 大文件/重 IO 并发数
let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额)
let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同)
return async function concurrencyGuard(ctx, next) { return async function concurrencyGuard(ctx, next) {
if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) { const path = ctx.path;
// ---- 登录(独立配额,2026-09-21 第二轮压测新增)----
if (LOGIN_PATTERNS.some((re) => re.test(path))) {
if (loginActive >= LOGIN_MAX) {
// 连接级背压:把「TCP 层静默 RST(考生不知是否登录成功)」换为
// 「明确 429 + Retry-After(可识别、客户端可定时重试)」。
// 注意:此处直接 return,故 router 级 loginRateLimit 不执行 → 不计失败次数。
ctx.status = 429;
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(登录高峰)');
return;
}
loginActive++;
try {
await next();
} finally {
loginActive--;
}
return;
}
const isSubmit = SUBMIT_PATTERNS.some((re) => re.test(path));
if (isSubmit) {
if (submitActive >= SUBMIT_MAX) {
// 连接级背压:明确告知「服务器忙 + 多久后重试」,而非让请求堆积到 accept 队列溢出。
ctx.status = 429;
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(交卷高峰)');
return;
}
submitActive++;
try {
await next();
} finally {
submitActive--;
}
return;
}
if (!HEAVY_PATTERNS.some((re) => re.test(path))) {
return next(); return next();
} }
if (active >= MAX) { if (active >= MAX) {
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存 // 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
ctx.status = 429; ctx.status = 429;
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试'); ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
return; return;
} }

View File

@@ -10,6 +10,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize; const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const Account = app.model.define('account', { const Account = app.model.define('account', {
// 主键,自增 // 主键,自增
uid: { uid: {
@@ -66,7 +69,7 @@ module.exports = app => {
unique: true, unique: true,
comment: '身份证号', comment: '身份证号',
}, },
// 登录密码(SHA256 哈希存储) // 登录密码(bcrypt 加盐哈希存储;兼容存量 SHA256 旧哈希,登录成功后透明升级)
password: { password: {
type: STRING(100), type: STRING(100),
allowNull: false, allowNull: false,
@@ -128,12 +131,7 @@ module.exports = app => {
comment: '账号激活状态', comment: '账号激活状态',
}, },
// 总分 // 总分
score: { score: decimalScore('score', '账号总分', { defaultValue: null }),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '账号总分',
},
// 记录创建时间 // 记录创建时间
created_at: { created_at: {
type: DATE, type: DATE,

View File

@@ -10,6 +10,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize; const { STRING, INTEGER, DATE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const BenkeSubmission = app.model.define('benke_submission', { const BenkeSubmission = app.model.define('benke_submission', {
// 主键 ID,自增 // 主键 ID,自增
id: { id: {
@@ -71,12 +74,7 @@ module.exports = app => {
comment: '上传时间', comment: '上传时间',
}, },
// 评分 // 评分
score: { score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '评分',
},
// 记录创建时间 // 记录创建时间
created_at: { created_at: {
type: DATE, type: DATE,

92
app/model/bigQuestion.js Normal file
View File

@@ -0,0 +1,92 @@
'use strict';
/**
* 综合大题模型 - BigQuestion
*
* 对应 big_questions 表(2026-09-21 新增)。
* 中职组(zhongzhi) / 高职组(gaozhi) 取消 Word 文件上传题后,改为从本表随机抽取一道大题:
* 题干 = 文字描述 + 富文本表格(HTML),下设三小问,每小问 score_per_sub 分(默认 10),
* 大题总分 total_score(默认 30)。
*
* 说明:本模型为**新增隔离模型**,与既有 word_questions(已弃用)互不影响,
* 也不参与 choice_questions / judge_questions 客观题链路。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
return app.model.define('BigQuestion', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '大题ID',
},
question_uid: {
type: STRING(64),
allowNull: false,
comment: '大题稳定标识(全局唯一,形如 big_12)',
},
group: {
type: STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
title: {
type: STRING(200),
allowNull: false,
comment: '大题标题',
},
description: {
type: TEXT('medium'),
allowNull: true,
comment: '题干富文本 HTML(含表格)',
},
sub_prompt_1: {
type: TEXT,
allowNull: true,
comment: '第1小问题干',
},
sub_prompt_2: {
type: TEXT,
allowNull: true,
comment: '第2小问题干',
},
sub_prompt_3: {
type: TEXT,
allowNull: true,
comment: '第3小问题干',
},
score_per_sub: decimalScore('score_per_sub', '每小题分值', { allowNull: false, defaultValue: 10 }),
total_score: decimalScore('total_score', '大题总分', { allowNull: false, defaultValue: 30 }),
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
status: {
type: INTEGER,
allowNull: false,
defaultValue: 1,
comment: '状态:1=启用(参与抽题),0=停用',
},
created_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '创建时间',
},
updated_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '更新时间',
},
}, {
tableName: 'big_questions',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
};

View File

@@ -0,0 +1,97 @@
'use strict';
/**
* 综合大题作答模型 - BigQuestionAnswer
*
* 对应 big_question_answers 表(2026-09-21 新增)。
*
* 一对一行:UNIQUE(aggregation_uid) —— 一个考生一行数据(用户明确要求的存储形态),
* 同时与「逐题一行」的客观题表 zhongzhi_submissions / gaozhi_submissions 完全隔离。
*
* question_snapshot:抽题那一刻的题干快照(JSON 文本)。因 MariaDB 5.5 无 JSON 列类型,
* 以 MEDIUMTEXT 存储,由应用层 JSON.stringify / JSON.parse 处理。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
return app.model.define('BigQuestionAnswer', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
},
aggregation_uid: {
type: INTEGER,
allowNull: false,
comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
},
contestant_id: {
type: INTEGER,
allowNull: true,
comment: '选手ID(冗余,便于管理端查证与排障)',
},
group: {
type: STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
big_question_id: {
type: INTEGER,
allowNull: true,
comment: '抽中的大题ID',
},
big_question_uid: {
type: STRING(64),
allowNull: true,
comment: '抽中的大题稳定标识',
},
question_snapshot: {
type: TEXT('medium'),
allowNull: true,
comment: '抽题时刻的题干快照 JSON(防纠纷存证)',
},
answer_1: {
type: TEXT,
allowNull: true,
comment: '第1小问作答(上限 2000 字)',
},
answer_2: {
type: TEXT,
allowNull: true,
comment: '第2小问作答(上限 2000 字)',
},
answer_3: {
type: TEXT,
allowNull: true,
comment: '第3小问作答(上限 2000 字)',
},
score_1: decimalScore('score_1', '第1小问得分(0-10,可含 2 位小数)'),
score_2: decimalScore('score_2', '第2小问得分(0-10,可含 2 位小数)'),
score_3: decimalScore('score_3', '第3小问得分(0-10,可含 2 位小数)'),
total_score: decimalScore('total_score', '三小问得分合计(可含 2 位小数)'),
submit_time: {
type: DATE,
allowNull: true,
comment: '随整卷正式提交写入(未提交为 NULL)',
},
created_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '创建时间',
},
updated_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '更新时间',
},
}, {
tableName: 'big_question_answers',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
};

View File

@@ -10,6 +10,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize; const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const Contestant = app.model.define('contestant', { const Contestant = app.model.define('contestant', {
// 主键 ID,自增 // 主键 ID,自增
id: { id: {
@@ -64,33 +67,13 @@ module.exports = app => {
comment: '账号激活状态', comment: '账号激活状态',
}, },
// 总分(管理员打分后更新,NULL 表示尚未评分) // 总分(管理员打分后更新,NULL 表示尚未评分)
score: { score: decimalScore('score', '选手总分(exam_score + word_score)'),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '选手总分(exam_score + word_score)',
},
// 卷面分(选择题+判断题自动计算) // 卷面分(选择题+判断题自动计算)
exam_score: { exam_score: decimalScore('exam_score', '卷面考试成绩'),
type: INTEGER, // 综合大题 / Word 题分数(管理员手动打分,可含 2 位小数)
allowNull: true, word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
defaultValue: null,
comment: '卷面考试成绩',
},
// Word题分数(管理员手动打分)
word_score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: 'Word题得分',
},
// PPT分数(本科/教师组管理员手动打分) // PPT分数(本科/教师组管理员手动打分)
ppt_score: { ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 2 位小数)'),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: 'PPT得分(本科/教师组)',
},
// 所属团队 ID(外键关联 teams 表) // 所属团队 ID(外键关联 teams 表)
team_id: { team_id: {
type: INTEGER, type: INTEGER,

View File

@@ -11,6 +11,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize; const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const ExamSubmission = app.model.define('exam_submission', { const ExamSubmission = app.model.define('exam_submission', {
// 主键 ID,自增 // 主键 ID,自增
id: { id: {
@@ -64,12 +67,7 @@ module.exports = app => {
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)', comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
}, },
// 得分(管理员评分后更新,NULL 表示尚未评分) // 得分(管理员评分后更新,NULL 表示尚未评分)
score: { score: decimalScore('score', '试卷得分', { defaultValue: null }),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '试卷得分',
},
// 创建时间 // 创建时间
created_at: { created_at: {
type: DATE, type: DATE,

View File

@@ -10,6 +10,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize; const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const GaozhiSubmission = app.model.define('gaozhi_submission', { const GaozhiSubmission = app.model.define('gaozhi_submission', {
// 主键 ID,自增 // 主键 ID,自增
id: { id: {
@@ -54,12 +57,7 @@ module.exports = app => {
comment: '是否判为正确', comment: '是否判为正确',
}, },
// 得分 // 得分
score: { score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '得分',
},
// Word 文件存储路径 // Word 文件存储路径
word_file_path: { word_file_path: {
type: STRING(500), type: STRING(500),

View File

@@ -10,6 +10,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize; const { STRING, INTEGER, DATE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const JiaoshiSubmission = app.model.define('jiaoshi_submission', { const JiaoshiSubmission = app.model.define('jiaoshi_submission', {
// 主键 ID,自增 // 主键 ID,自增
id: { id: {
@@ -71,12 +74,7 @@ module.exports = app => {
comment: '上传时间', comment: '上传时间',
}, },
// 评分 // 评分
score: { score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '评分',
},
// 记录创建时间 // 记录创建时间
created_at: { created_at: {
type: DATE, type: DATE,

View File

@@ -11,6 +11,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize; const { STRING, INTEGER, DATE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const WorkSubmission = app.model.define('work_submission', { const WorkSubmission = app.model.define('work_submission', {
// 主键 ID,自增 // 主键 ID,自增
id: { id: {
@@ -63,12 +66,7 @@ module.exports = app => {
comment: '提交时间', comment: '提交时间',
}, },
// 得分(管理员评分后更新,NULL 表示尚未评分) // 得分(管理员评分后更新,NULL 表示尚未评分)
score: { score: decimalScore('score', '作品得分', { defaultValue: null }),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '作品得分',
},
// 创建时间 // 创建时间
created_at: { created_at: {
type: DATE, type: DATE,

View File

@@ -10,6 +10,9 @@
module.exports = app => { module.exports = app => {
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize; const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
const decimalScore = require('../lib/decimal_score')(app);
const ZhongzhiSubmission = app.model.define('zhongzhi_submission', { const ZhongzhiSubmission = app.model.define('zhongzhi_submission', {
// 主键 ID,自增 // 主键 ID,自增
id: { id: {
@@ -54,12 +57,7 @@ module.exports = app => {
comment: '是否判为正确', comment: '是否判为正确',
}, },
// 得分 // 得分
score: { score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '得分',
},
// Word 文件存储路径 // Word 文件存储路径
word_file_path: { word_file_path: {
type: STRING(500), type: STRING(500),

View File

View File

View File

@@ -26,6 +26,21 @@ module.exports = app => {
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败 // 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 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' });
// ==================== 公开接口(无需认证) ==================== // ==================== 公开接口(无需认证) ====================
// 健康检查(容器探针 / 负载均衡探活,无需认证) // 健康检查(容器探针 / 负载均衡探活,无需认证)
@@ -72,7 +87,14 @@ module.exports = app => {
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers); router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord); router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord); router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll); // 交卷:全局闸门(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);
// ==================== 文件上传(需认证) ==================== // ==================== 文件上传(需认证) ====================
@@ -99,6 +121,8 @@ module.exports = app => {
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions); 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/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv); 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/batch-download/:group', adminAuth, controller.admin.batchDownload);
router.get('/api/admin/works/:group', adminAuth, controller.admin.works); router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
@@ -119,6 +143,21 @@ module.exports = app => {
router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate); router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate);
router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate); 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);
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);
router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores);
// 备赛资料管理 // 备赛资料管理
router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials); router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials);
router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial); router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial);
@@ -152,6 +191,14 @@ module.exports = app => {
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag); router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData); 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); router.get('/api/score', auth, controller.score.index);

View File

@@ -5,6 +5,18 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const XLSX = require('xlsx'); const XLSX = require('xlsx');
/**
* 客观题(选择题 / 判断题)每小题固定分值。
*
* 赛制口径(2026-09-23 统一):中职 / 高职组的选择题与判断题**每小题 1 分**,
* 客观题满分 = 抽题数(50 + 20 = 70)。
* 此前分值可由管理端任意设置、且 Excel 导入缺省 5 分,一旦「5 分题的答案
* 恰好全是 A / true」,考生全选 A 即可白拿高分(实测高职判断题得分率 95.3%)。
* 综合性任务(word)不在本约束范围内。
*/
const OBJECTIVE_SCORE_PER_QUESTION = 1;
const OBJECTIVE_TYPES = ['choice', 'judge'];
/** /**
* 管理后台服务 - AdminService * 管理后台服务 - AdminService
* *
@@ -187,6 +199,35 @@ class AdminService extends Service {
return GROUP_ALIAS[s] || s; return GROUP_ALIAS[s] || s;
}; };
// 列名别名归一:上游赛事系统导出的「报名信息表」与后端内部列名契约不一致
// (「身份证号码」vs「身份证号」、「全名」vs「姓名」、「队伍ID」vs「队伍编号」),
// 直接导入会因取不到列而把整表判为「身份证号为空」全量跳过(R31 实测 0/1225)。
// 数组顺序即优先级;取首个非空值,使上游导出表无需人工改列名即可导入。
const COLUMN_ALIAS = {
身份证号: ['身份证号', '身份证号码', '身份证', 'id_number'],
姓名: ['姓名', '全名', 'name'],
队伍编号: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
组别: ['组别', 'group'],
身份: ['身份', 'identity'],
};
// 按别名顺序取首个非空值(统一 trim,返回字符串)
const pick = (row, key) => {
const aliases = COLUMN_ALIAS[key] || [key];
for (const k of aliases) {
const v = row[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
};
// 只导入「参赛选手」:报名审核表同一队伍内含报名人 / 指导教师(专家、教练) / 团队支持(领队) 等身份,
// 这些身份不建立参赛账号(不参与考试与作品提交)。归一化去空白后精确匹配,
// 避免因上游表格中的空白字符差异导致漏导。
const IMPORT_IDENTITY = '参赛选手';
const normalizeIdentity = (s) => String(s || '').replace(/\s+/g, '');
const isImportableIdentity = (s) => normalizeIdentity(s) === IMPORT_IDENTITY;
// 是否将本批次标记为测试数据(test_flag=1)。 // 是否将本批次标记为测试数据(test_flag=1)。
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据, // 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。 // 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
@@ -211,6 +252,9 @@ class AdminService extends Service {
imported: 0, imported: 0,
skipped: 0, skipped: 0,
teamsCreated: 0, teamsCreated: 0,
// 非「参赛选手」身份被主动忽略的行数(业务规则过滤,非异常,不计入 skipped/errors)
ignored: 0,
ignoredBy: {},
errors: [], errors: [],
}; };
@@ -243,8 +287,10 @@ class AdminService extends Service {
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim(); const teamIdRaw = pick(row, '队伍编号');
const group = normalizeGroup(row['组别'] || row['group']); const group = normalizeGroup(pick(row, '组别'));
// 只为参赛选手建队:报名人/指导教师/领队不参与建队,避免留下无人参赛的空团队
if (!isImportableIdentity(pick(row, '身份'))) continue;
if (!teamIdRaw) continue; if (!teamIdRaw) continue;
const cacheKey = teamIdRaw + '|' + group; const cacheKey = teamIdRaw + '|' + group;
if (teamUidCache[cacheKey] !== undefined) continue; if (teamUidCache[cacheKey] !== undefined) continue;
@@ -264,24 +310,36 @@ class AdminService extends Service {
}); });
result.teamsCreated++; result.teamsCreated++;
} }
teamUidCache[cacheKey] = maxTeamUid; // 写入团队自身的 team_uid:新建时为刚生成的 uid,库内复用时为真实 uid。
// (旧代码固定写 maxTeamUid,复用库内已存在团队时会把后续选手挂到错误的团队 uid)
teamUidCache[cacheKey] = team.get('team_uid');
} }
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
const rowIndex = i + 2; const rowIndex = i + 2;
// 身份过滤(事务之外):只导入参赛选手,其余身份计入 ignored 明细后跳过。
// 置于事务外可避免为被忽略行白开事务(本表约一半行属于此类)。
const identityRaw = pick(row, '身份');
if (!isImportableIdentity(identityRaw)) {
result.ignored++;
const label = identityRaw || '(空)';
result.ignoredBy[label] = (result.ignoredBy[label] || 0) + 1;
continue;
}
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚 // 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
const t = await ctx.model.transaction(); const t = await ctx.model.transaction();
try { try {
const idNumber = String(row['身份证号'] || row['id_number'] || '').trim(); const idNumber = pick(row, '身份证号');
const name = String(row['姓名'] || row['name'] || '').trim(); const name = pick(row, '姓名');
const phone = String(row['手机号'] || row['phone'] || '').trim(); const phone = pick(row, '手机号');
const group = normalizeGroup(row['组别'] || row['group']); const group = normalizeGroup(pick(row, '组别'));
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁) // 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim(); const teamIdRaw = pick(row, '队伍编号');
// 可选:身份(队长/队员),默认队员 // 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
const identity = String(row['身份'] || row['identity'] || '').trim() || '队员'; const identity = '选手';
if (!idNumber) { if (!idNumber) {
result.errors.push({ row: rowIndex, message: '身份证号为空' }); result.errors.push({ row: rowIndex, message: '身份证号为空' });
@@ -296,24 +354,29 @@ class AdminService extends Service {
continue; continue;
} }
// 检查身份证号是否已存在 // 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
let teamUid = null;
if (teamIdRaw) {
teamUid = teamUidCache[teamIdRaw + '|' + group];
}
// 重复判定:身份证号全局唯一,且业务上同一人不会分配两个身份证号
// ⇒ 身份证号相同即同一人(无需「队伍+姓名+手机号」组合键;同名不同证者各自导入)。
// 消息中附带「队伍 / 姓名 / 手机号」,便于人工在同名人员中定位到具体是哪一条。
const existing = await ctx.model.Contestant.findOne({ const existing = await ctx.model.Contestant.findOne({
where: { id_number: idNumber }, where: { id_number: idNumber },
transaction: t, transaction: t,
}); });
if (existing) { if (existing) {
result.errors.push({ row: rowIndex, message: `身份证号 ${idNumber} 已存在` }); result.errors.push({
row: rowIndex,
message: `身份证号 ${idNumber} 已存在(队伍 ${teamIdRaw || '(单人参赛)'} / ${name} / ${phone || '(无手机号)'})`,
});
result.skipped++; result.skipped++;
await t.rollback(); await t.rollback();
continue; continue;
} }
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
let teamUid = null;
if (teamIdRaw) {
teamUid = teamUidCache[teamIdRaw + '|' + group];
}
// 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用 // 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用
// 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。 // 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。
const password = importAsTest const password = importAsTest
@@ -324,7 +387,7 @@ class AdminService extends Service {
const contestant = await ctx.model.Contestant.create({ const contestant = await ctx.model.Contestant.create({
id_number: idNumber, id_number: idNumber,
name, name,
phone: phone || null, phone: phone || '',
group: group || 'zhongzhi', group: group || 'zhongzhi',
password, password,
activated: false, activated: false,
@@ -442,9 +505,8 @@ class AdminService extends Service {
answeredCount, answeredCount,
totalQuestions: answeredCount, totalQuestions: answeredCount,
autoScore, autoScore,
examScore: c.exam_score ?? 0,
wordScore: c.word_score ?? 0, wordScore: c.word_score ?? 0,
// 总分 = 客观题自动评分合计 + Word 阅卷分(与卷面分/Word 分口径一致); // 总分 = 客观题自动评分合计 + 综合大题阅卷分(与「卷面分 / 大题分」口径一致);
// 不读 contestants.score(未人工评分时恒为 0,会导致"客观题有分但总分=0")。 // 不读 contestants.score(未人工评分时恒为 0,会导致"客观题有分但总分=0")。
totalScore: autoScore + (c.word_score ?? 0), totalScore: autoScore + (c.word_score ?? 0),
hasWordFile: hasWord, hasWordFile: hasWord,
@@ -452,7 +514,9 @@ class AdminService extends Service {
wordFileName: (wordSub && wordSub.word_file_name) || null, wordFileName: (wordSub && wordSub.word_file_name) || null,
submitted: !!formalSubmitTime || answeredCount > 0, submitted: !!formalSubmitTime || answeredCount > 0,
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null), submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null),
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致 // 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。
// ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出);
// 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。
examScore: autoScore, examScore: autoScore,
}; };
}); });
@@ -613,60 +677,107 @@ class AdminService extends Service {
}); });
} }
// ==================== CSV 导出 ==================== // ==================== 成绩导出(CSV / Excel,R29) ====================
/** /**
* 导出某组提交记录为 CSV 字符串 * 成绩导出**单一数据源**:表头 + 数据行。
*
* CSV 与 Excel 两个出口都必须走这里 —— 若各写一套,迟早出现
* 「Excel 加了新列、CSV 没加」这类表头漂移(本项目已被同类问题咬过多次)。
*
* @param {string} group - 组别 * @param {string} group - 组别
* @returns {Promise<string>} CSV 内容 * @returns {Promise<{headers: string[], rows: Array<Array<string|number>>}>}
*/ */
async exportToCsv(group) { async buildScoreExportTable(group) {
const isExam = group === 'zhongzhi' || group === 'gaozhi'; const isExam = group === 'zhongzhi' || group === 'gaozhi';
const submissions = isExam
? await this.getExamSubmissions(group)
: await this.getSubmissions(group);
let csvContent = '';
if (isExam) { if (isExam) {
// 中职/高职组:客观题(系统) + Word(人工) // 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,客观题分,Word分,总分\n'; // 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
for (const item of submissions) { // 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
csvContent += [ const submissions = await this.getExamSubmissions(group);
return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'],
rows: submissions.map(item => [
item.contestantId, item.contestantId,
`"${item.idNumber || ''}"`, item.idNumber || '',
`"${item.userName || ''}"`, item.userName || '',
`"${item.phone || ''}"`, item.phone || '',
item.answeredCount > 0 ? '是' : '否', item.answeredCount > 0 ? '是' : '否',
item.examScore ?? 0, item.examScore ?? 0,
item.wordScore ?? 0, item.wordScore ?? 0,
item.totalScore ?? 0, item.totalScore ?? 0,
].join(',') + '\n'; ]),
} };
} else { }
// 本科/教师组:Word(人工) + PPT(人工)
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,文档文件名,文档大小,文档分,PPT文件名,PPT大小,PPT分\n'; // 本科/教师组:Word(人工) + PPT(人工)
for (const item of submissions) { const submissions = await this.getSubmissions(group);
return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '文档文件名', '文档大小', '文档分', 'PPT文件名', 'PPT大小', 'PPT分'],
rows: submissions.map(item => {
const account = item.account || {}; const account = item.account || {};
const wordSub = item.wordSubmission; const wordSub = item.wordSubmission;
const pptSub = item.pptSubmission; const pptSub = item.pptSubmission;
csvContent += [ return [
item.contestantId, item.contestantId,
`"${account.id_number || ''}"`, account.id_number || '',
`"${account.name_cn || ''}"`, account.name_cn || '',
`"${account.phone || ''}"`, account.phone || '',
item.submitted ? '是' : '否', item.submitted ? '是' : '否',
`"${wordSub ? wordSub.file_name : ''}"`, wordSub ? wordSub.file_name : '',
wordSub ? wordSub.file_size : '', wordSub ? wordSub.file_size : '',
wordSub && wordSub.score != null ? wordSub.score : '', wordSub && wordSub.score != null ? wordSub.score : '',
`"${pptSub ? pptSub.file_name : ''}"`, pptSub ? pptSub.file_name : '',
pptSub ? pptSub.file_size : '', pptSub ? pptSub.file_size : '',
pptSub && pptSub.score != null ? pptSub.score : '', pptSub && pptSub.score != null ? pptSub.score : '',
].join(',') + '\n'; ];
} }),
} };
}
return csvContent; /**
* 导出某组提交记录为 CSV 字符串
*
* ⚠️ 该出口**保留**:`scripts/verify_full_e2e.py`(B10 / D10)与
* `scripts/verify_upload_download.py`(B2)都以它做「导出链路非空」断言,
* 删掉会让门禁变红。管理端界面已改走 Excel(见 exportToXlsx)。
*
* @param {string} group - 组别
* @returns {Promise<string>} CSV 内容
*/
async exportToCsv(group) {
const { headers, rows } = await this.buildScoreExportTable(group);
// 字符串一律加引号(原文对身份证 / 姓名 / 手机号 / 文件名即如此),数值保持裸值
const cell = v => {
if (v == null) return '';
return typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v);
};
return [headers, ...rows].map(r => r.map(cell).join(',')).join('\n') + '\n';
}
/**
* 导出某组成绩为真正的 Excel(.xlsx)(R29 需求)。
*
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时,
* 中文易乱码、18 位身份证号 / 11 位手机号会被转成科学计数法,需手工「分列」。
* 直接用已装依赖 xlsx(SheetJS) 生成 xlsx,打开即为规整表格。
*
* @param {string} group - 组别
* @returns {Promise<Buffer>} xlsx 二进制
*/
async exportToXlsx(group) {
const { headers, rows } = await this.buildScoreExportTable(group);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
// 列宽:文件名较长、身份证号 18 位,给足宽度让导出即可读
ws['!cols'] = headers.map(h => {
if (/文件名/.test(h)) return { wch: 32 };
if (/身份证号/.test(h)) return { wch: 20 };
return { wch: 12 };
});
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '成绩单');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
} }
// ==================== 批量下载 ZIP ==================== // ==================== 批量下载 ZIP ====================
@@ -957,6 +1068,27 @@ class AdminService extends Service {
} }
} }
/**
* 计算题目的落库分值。
*
* 客观题(choice / judge)一律强制为 `OBJECTIVE_SCORE_PER_QUESTION`(=1),
* 忽略调用方传入的任何分值;综合性任务(word)保留传入分值(须为正数)。
*
* @param {string} questionType - choice / judge / word
* @param {*} rawScore - 调用方传入的分值(客观题会忽略)
* @returns {number} 实际落库分值
*/
_resolveQuestionScore(questionType, rawScore) {
if (OBJECTIVE_TYPES.includes(questionType)) {
return OBJECTIVE_SCORE_PER_QUESTION;
}
const n = Number(rawScore);
if (!isFinite(n) || n <= 0) {
throw new Error('综合性任务分值(score)必须为大于 0 的合法数值');
}
return n;
}
/** /**
* 管理考试题目(替换操作:先清空再创建) * 管理考试题目(替换操作:先清空再创建)
* @param {Array} questions - 题目数组 * @param {Array} questions - 题目数组
@@ -979,11 +1111,8 @@ class AdminService extends Service {
// 批量插入新题目 // 批量插入新题目
const questionData = questions.map((q, index) => { const questionData = questions.map((q, index) => {
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据,整批替换时直接拒绝 // 客观题(choice/judge)分值由服务层强制为 1(赛制口径),忽略传入值
const scoreNum = Number(q.score); const scoreNum = this._resolveQuestionScore(questionType, q.score);
if (!isFinite(scoreNum) || scoreNum <= 0) {
throw new Error(`题目分值(score)必须为大于 0 的合法数值(第 ${index + 1} 题)`);
}
const base = { const base = {
group, group,
score: scoreNum, score: scoreNum,
@@ -1034,12 +1163,8 @@ class AdminService extends Service {
async addQuestion(questionData, group, questionType) { async addQuestion(questionData, group, questionType) {
const QuestionModel = this._getQuestionModel(questionType); const QuestionModel = this._getQuestionModel(questionType);
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据 // 客观题(choice/judge)一律 1 分(赛制口径);word 题型保留传入分值
// (抽中该题的考生答对也得 0 分,且不同人满分不一致)。缺失/非法分值直接拒绝。 const scoreNum = this._resolveQuestionScore(questionType, questionData.score);
const scoreNum = Number(questionData.score);
if (!isFinite(scoreNum) || scoreNum <= 0) {
throw new Error('题目分值(score)必须为大于 0 的合法数值');
}
const data = { const data = {
group, group,
@@ -1078,7 +1203,10 @@ class AdminService extends Service {
} }
const updateData = { const updateData = {
score: questionData.score !== undefined ? questionData.score : question.score, // 客观题分值恒为 1(忽略传入值,防止管理端改回 5 分);word 题型沿用原逻辑
score: OBJECTIVE_TYPES.includes(questionType)
? OBJECTIVE_SCORE_PER_QUESTION
: (questionData.score !== undefined ? Number(questionData.score) : question.score),
sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order, sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order,
}; };
@@ -1370,8 +1498,10 @@ class AdminService extends Service {
async setTypeScore(group, questionType, score) { async setTypeScore(group, questionType, score) {
const { ctx } = this; const { ctx } = this;
const QuestionModel = this._getQuestionModel(questionType); const QuestionModel = this._getQuestionModel(questionType);
// 客观题只能被设为 1 分(赛制口径);word 题型按传入值设置
const resolved = this._resolveQuestionScore(questionType, score);
const [updated] = await QuestionModel.update( const [updated] = await QuestionModel.update(
{ score }, { score: resolved },
{ where: { group } } { where: { group } }
); );
return updated; return updated;
@@ -1474,9 +1604,9 @@ class AdminService extends Service {
} }
} }
const scoreRaw = row['分值']; // 客观题分值恒为 1(赛制口径)。Excel 的「分值」列一律忽略 ——
let score = parseInt(scoreRaw, 10); // 旧逻辑在缺列/非法时缺省 5 分,正是「高分题」污染题库的来源之一。
if (isNaN(score) || score < 1) score = 5; const score = OBJECTIVE_SCORE_PER_QUESTION;
const data = { group, question, answer, score, sort_order: 0 }; const data = { group, question, answer, score, sort_order: 0 };
if (qType === 'choice') data.options = options; if (qType === 'choice') data.options = options;
@@ -1500,11 +1630,12 @@ class AdminService extends Service {
* @returns {Promise<Buffer>} xlsx 文件 Buffer * @returns {Promise<Buffer>} xlsx 文件 Buffer
*/ */
async buildQuestionTemplate() { async buildQuestionTemplate() {
// 「分值」列已废弃(客观题恒为 1 分),保留列名仅为兼容旧模板与旧脚本
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值']; const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 5]; const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 1];
const ws = XLSX.utils.aoa_to_sheet([header, example]); const ws = XLSX.utils.aoa_to_sheet([header, example]);
// 判断题示例行(另起一行,便于参考) // 判断题示例行(另起一行,便于参考)
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 5]], { origin: -1 }); XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '客观题'); XLSX.utils.book_append_sheet(wb, ws, '客观题');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
@@ -1861,51 +1992,348 @@ class AdminService extends Service {
await ctx.model.Carousel.destroy({ where: { id } }); await ctx.model.Carousel.destroy({ where: { id } });
} }
// ==================== 成绩查询 ==================== // ==================== 成绩复核 ====================
// ==================== 成绩查询 ==================== // 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。
/** /**
* 获取选手个人成绩详情 * 检索考生(复核入口)
* @param {number} aggregationUid - 聚合报名 ID *
* @param {string} group - 组别 * 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** ——
* @returns {Promise<Object>} 成绩详情 * 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 group 供界面展示。
*
* @param {string} keyword - 检索词(姓名 / 身份证号 / 电话片段)
* @param {string} [group] - 可选:限定组别
* @returns {Promise<{ok:boolean, code?:string, message?:string, list?:Array, truncated?:boolean}>}
*/ */
async getContestantScore(aggregationUid, group) { async reviewSearch(keyword, group) {
const { ctx } = this; const { ctx } = this;
const result = {}; const kw = String(keyword == null ? '' : keyword).trim();
if (group === 'zhongzhi' || group === 'gaozhi') { // 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。
const SubmissionModel = group === 'zhongzhi' if (!kw) return { ok: true, list: [], truncated: false };
? ctx.model.ZhongzhiSubmission if (kw.length > 64) {
: ctx.model.GaozhiSubmission; return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' };
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['id', 'question_uid', 'answer_data', 'is_correct', 'score', 'word_file_path'],
});
const totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
result.score = totalScore;
result.totalQuestions = submissions.length;
result.correctCount = submissions.filter(s => s.is_correct).length;
result.details = submissions;
result.hasWordFile = submissions.some(s => s.word_file_path);
} else {
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['id', 'work_name', 'file_name', 'file_path', 'file_size', 'upload_time', 'score'],
order: [['upload_time', 'DESC']],
});
result.score = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
result.submissions = submissions;
} }
return result; const { Op } = ctx.model.Sequelize;
const like = { [Op.like]: `%${kw}%` };
const where = { [Op.or]: [{ name: like }, { id_number: like }, { phone: like }] };
if (group) where.group = group;
const LIMIT = 50;
const rows = await ctx.model.Contestant.findAll({
where,
attributes: ['id', 'name', 'id_number', 'phone', 'group'],
order: [['group', 'ASC'], ['id', 'ASC']],
limit: LIMIT,
raw: true,
});
return {
ok: true,
list: rows.map(c => ({
contestantId: c.id,
name: c.name || '',
idNumber: c.id_number || '',
phone: c.phone || '',
group: c.group,
// 客观题只存在于中职/高职;本科/教师的「卷面」是作品文件。界面据此切换展示与提示。
objectiveApplicable: c.group === 'zhongzhi' || c.group === 'gaozhi',
})),
// 命中被截断时如实上报,引导管理员补充更多字符(而非静默只展示前 50 条)。
truncated: rows.length >= LIMIT,
};
}
/**
* 成绩复核:取某考生的**全卷**明细。
*
* 输出 = 客观题逐题(题干 / 选项 / 考生答案 / 正确答案 / 对错 / 得分 / 满分)
* + 综合大题(题干快照 + 三小问作答 + 三小问得分)
* + 本科/教师组的作品文件清单。
*
* 设计约束(每条都对应一个已发生过的坑):
* 1. **归集口径必须与 getExamSubmissions 完全一致**(双键:contestant.id 与真实 aggregation_uid)。
* 否则会出现「列表页说已答 70 题、复核页只有 0 题」—— 管理员会先怀疑数据被篡改。
* 2. **组别以考生自身的 group 为准**,并校验入参 group:防跨组串查。
* 3. 题目按 **exam_submissions.question_order(考生固化的抽题顺序)** 排列,复现他当时所见的题序;
* 无固化顺序的历史数据回退为「选择在前、判断在后,各自按题号升序」。
* 4. `question.answer` **必须回传**(复核场景用户明确要求给出正确答案)。
* ⚠️ 因此本方法**只能挂 admin 路由**:一旦被选手端复用即为成绩泄露
* (参见 scripts/verify_score_leak_all_channels.py 所守的三条通道)。
* 也正因如此这里**不做** isScorePublished 门控 —— admin 是可信边界,
* 而内部核对往往发生在发布之前;发布态只在返回值里**如实上报**供界面提示。
* 5. 题库中已删除 / 改组导致取不到题目时标 `questionMissing`,而非静默留空 ——
* 复核要能区分「考生没答」与「题没了」。
*
* @param {number} contestantId - contestants.id
* @param {string} [group] - 调用方声明的组别(须与考生实际组别一致)
* @returns {Promise<Object>} { ok, ... } ok=false 时带 code/message(不抛异常)
*/
async getContestantScore(contestantId, group) {
const { ctx } = this;
const { Op } = ctx.model.Sequelize;
const cid = parseInt(contestantId, 10);
if (!Number.isFinite(cid)) {
return { ok: false, code: 'BAD_ID', message: '考生 ID 非法' };
}
const contestant = await ctx.model.Contestant.findOne({
where: { id: cid },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
raw: true,
});
if (!contestant) return { ok: false, code: 'NOT_FOUND', message: '考生不存在' };
if (group && group !== contestant.group) {
return { ok: false, code: 'GROUP_MISMATCH', message: `组别不符:该考生属于「${contestant.group}」组` };
}
const g = contestant.group;
const isExamGroup = g === 'zhongzhi' || g === 'gaozhi';
const base = {
contestantId: cid,
name: contestant.name || '',
idNumber: contestant.id_number || '',
phone: contestant.phone || '',
group: g,
objectiveApplicable: isExamGroup,
scorePublished: await this.isScorePublished(g), // 只上报,不门控(见方法头第 4 条)
};
if (!isExamGroup) {
// 本科/教师:无客观题,「卷面」即提交的作品文件(Word / PPT)。
return {
ok: true,
...base,
objective: [],
summary: { answered: 0, total: 0, correctCount: 0, autoScore: 0 },
bigQuestion: null,
works: await this._reviewWorks(cid, g),
warnings: [],
};
}
// ---- 1) 双键归集(与 getExamSubmissions 完全同口径) ----
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: cid },
attributes: ['aggregation_uid'],
raw: true,
});
const keys = [String(cid)];
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(cid)) {
keys.push(String(agg.aggregation_uid));
}
const SubmissionModel = g === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission;
const subs = await SubmissionModel.findAll({
where: { aggregation_uid: { [Op.in]: keys.map(Number) } },
attributes: ['id', 'aggregation_uid', 'question_uid', 'question_type', 'answer_data', 'is_correct', 'score'],
order: [['id', 'ASC']],
raw: true,
});
const warnings = [];
const usedKeys = [ ...new Set(subs.map(s => String(s.aggregation_uid))) ];
if (usedKeys.length > 1) {
warnings.push('该考生的作答记录同时落在两种聚合键下(' + usedKeys.join(' / ') +
'),已按与「考生答题记录」一致的口径合并归集;若题数异常请人工核对。');
}
// ---- 2) 固化题序(复现考生当时所见次序) ----
const examSub = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: cid },
attributes: ['question_order', 'submit_time'],
raw: true,
});
const rank = new Map();
if (examSub && examSub.question_order) {
try {
const parsed = JSON.parse(examSub.question_order);
if (Array.isArray(parsed)) parsed.forEach((u, i) => rank.set(String(u), i));
} catch (e) {
// 脏数据不抛异常:回退默认排序(复核页不应因一条脏记录整体打不开)
ctx.logger.warn('[getContestantScore] question_order 解析失败 cid=%s', cid);
}
}
// ---- 3) 批量取题干(按题型分表;必须带 group 限定,防跨组同号题串台) ----
const idsByType = { choice: [], judge: [] };
for (const s of subs) {
const t = s.question_type;
// 白名单判定,避免 'constructor' 之类字符串命中 Object 原型链
if (!Object.prototype.hasOwnProperty.call(idsByType, t)) continue;
const n = Number(s.question_uid);
if (Number.isFinite(n)) idsByType[t].push(n);
}
const qMap = new Map();
for (const t of [ 'choice', 'judge' ]) {
const ids = idsByType[t];
if (!ids.length) continue;
const Model = t === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion;
const rows = await Model.findAll({ where: { id: { [Op.in]: ids }, group: g }, raw: true });
rows.forEach(r => qMap.set(`${t}:${r.id}`, r));
}
// ---- 4) 拼装逐题明细 ----
const norm = v => String(v == null ? '' : v).trim().toLowerCase();
// 【R28 修复】options 在 DB 里是 text 列、存的是 JSON 字符串。
// 若原样透传给前端,`v-for="op in row.options"` 会按**字符**迭代 ⇒ 每个字符渲染成一行
// 空内容(op.key / op.text 皆 undefined),表现即「选择题大量换行导致空白」。
// 写法与 admin.js:1370 既有口径同源:仅在 typeof === 'string' 时 parse,失败降级 null。
const parseOptions = raw => {
if (raw == null) return null;
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (e) {
return null;
}
};
const objective = subs.map(s => {
const t = s.question_type;
const n = Number(s.question_uid);
const q = qMap.get(`${t}:${n}`) || null;
const userAnswer = s.answer_data == null ? '' : String(s.answer_data);
const correct = q ? String(q.answer == null ? '' : q.answer) : null;
const orderKey = `${t}_${n}`;
const seq = rank.has(orderKey) ? rank.get(orderKey)
: (rank.has(String(n)) ? rank.get(String(n)) : null);
return {
seq,
questionType: t,
questionUid: n,
question: q ? q.question : null,
options: q ? parseOptions(q.options) : null,
// 原始值:选择 'A'/'AB';判断 'true'/'false'(由前端做「正确/错误」展示映射,后端不丢事实)
correctAnswer: correct,
maxScore: q && q.score != null ? Number(q.score) : null,
answer: userAnswer,
answered: userAnswer.trim() !== '',
isCorrect: s.is_correct == null ? null : !!s.is_correct,
score: s.score == null ? null : Number(s.score),
// 交叉校验:答案一致却判为错(或反之)即判分异常 —— 复核时最先要看的正是这个。
// ⚠️ 未作答必须给 null 而非 false:'' 与标准答案比对天然不等,
// 若返回 false,「未答」在界面上会被误读成「答错」。
answerMatches: (q && userAnswer.trim() !== '') ? (norm(userAnswer) === norm(correct)) : null,
questionMissing: !q,
};
});
objective.sort((a, b) => {
const ra = a.seq == null ? Number.MAX_SAFE_INTEGER : a.seq;
const rb = b.seq == null ? Number.MAX_SAFE_INTEGER : b.seq;
if (ra !== rb) return ra - rb;
if (a.questionType !== b.questionType) return a.questionType === 'choice' ? -1 : 1;
return a.questionUid - b.questionUid;
});
// 小数按 2 位收敛(与 DECIMAL(6,2) 及全站评分口径一致,避免 0.30000000000000004)
const round2 = v => Math.round(v * 100) / 100;
const autoScore = round2(objective.reduce((sum, o) => sum + (o.score || 0), 0));
// ---- 5) 综合大题(题干取抽题快照,保证考生当时所见可举证) ----
let bigQuestion = null;
const bigRow = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: cid },
raw: true,
});
if (bigRow) {
const snap = this.service.bigQuestion.parseSnapshot(bigRow.question_snapshot) || {};
const subPrompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
bigQuestion = {
drawn: true,
bigQuestionId: bigRow.big_question_id || null,
bigQuestionUid: bigRow.big_question_uid || null,
title: snap.title || '',
description: snap.description || '',
subPrompts,
scorePerSub: snap.score_per_sub != null ? Number(snap.score_per_sub) : null,
maxTotal: snap.total_score != null ? Number(snap.total_score) : null,
// 固定 3 个槽位,与库表 answer_1..3 对齐(未作答为 '')
answers: [ 1, 2, 3 ].map(i => {
const a = bigRow[`answer_${i}`] == null ? '' : String(bigRow[`answer_${i}`]);
const sc = bigRow[`score_${i}`];
return {
index: i,
prompt: subPrompts[i - 1] || '',
answer: a,
answered: a.trim() !== '',
score: sc == null ? null : Number(sc),
};
}),
totalScore: bigRow.total_score == null ? null : Number(bigRow.total_score),
snapshotAt: snap.snapshot_at || null,
submitTime: bigRow.submit_time || null,
};
}
return {
ok: true,
...base,
objective,
summary: {
answered: objective.length,
total: objective.length,
correctCount: objective.filter(o => o.isCorrect === true).length,
autoScore,
wordScore: contestant.word_score == null ? 0 : Number(contestant.word_score),
totalScore: round2(autoScore + (contestant.word_score == null ? 0 : Number(contestant.word_score))),
},
// 固化题序存在与否决定上图顺序的可解释性,一并上报(便于核对与排障)
questionOrderFixed: rank.size > 0,
matchedKeys: usedKeys,
bigQuestion,
works: [],
warnings,
};
}
/**
* 本科/教师组的作品文件清单(复核用,只读)
*
* 不返回 file_path(服务端磁盘路径不该出网),只给下载所需的 submission id;
* 下载走既有端点 GET /api/admin/submission-file/:group/:id。
*
* @param {number} contestantId - contestants.id
* @param {string} group - benke / jiaoshi
* @returns {Promise<Array>}
*/
async _reviewWorks(contestantId, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
// benke/jiaoshi_submissions.aggregation_uid 存的是「聚合报名 ID」,与 contestants.id 不是同一序列,
// 需经 aggregation.account_uid 映射(与 getSubmissions 同口径)。
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
attributes: ['aggregation_uid'],
raw: true,
});
const keys = [String(contestantId)];
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(contestantId)) {
keys.push(String(agg.aggregation_uid));
}
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: { [ctx.model.Sequelize.Op.in]: keys.map(Number) } },
attributes: ['id', 'work_name', 'file_name', 'file_size', 'upload_time', 'score'],
order: [['upload_time', 'DESC']],
raw: true,
});
return rows.map(r => ({
id: r.id,
workName: r.work_name || '',
fileName: r.file_name || '',
fileSize: r.file_size == null ? null : Number(r.file_size),
uploadTime: r.upload_time || null,
score: r.score == null ? null : Number(r.score),
}));
} }
} }

View File

@@ -37,6 +37,58 @@ class AuthService extends Service {
}); });
} }
/**
* 构造「可下发给考生端」的用户信息(**唯一出口**)。
*
* 2026-09-21 修复(铁律15 成绩泄露全通道封堵 —— 登录通道缺口):
* 此前 `POST /api/auth/login` 与 `GET /api/auth/current-user` 直接回传
* `contestant.toJSON()`,其中 `score` 在成绩**未发布**状态下依然被下发
* (实测未发布态 `score=27`),而同期 `GET /api/score` 正确返回 `null`。
* 即门控只覆盖了 4 条已知通道,遗漏了 auth 这两条同源通道。
*
* 同源族(**三处**必须共用本出口,禁止各自直接 toJSON):
* · controller/auth.js login()
* · controller/auth.js currentUser()
* · controller/auth.js refresh() (`POST /api/auth/refresh`; 前端 doSilentRefresh 会把它写入 localStorage 与 Pinia)
*
* 口径(与 GET /api/score 一致,复用 service.admin.isScorePublished 单一判定):
* · 该组成绩**已发布** → 保留 score;
* · 该组成绩**未发布 / 查询异常** → 剔除 score(安全默认:宁可不显示,不可泄露);
* · 分项字段(exam_score / word_score / ppt_score)**任何情况下都不下发**
* —— 它们只是总分的组成部分,前端从不使用,留在响应里等于给出第二条推算通道。
*
* @param {Model|Object} contestant - Contestant 模型实例或其普通对象
* @returns {Promise<Object|null>} 已脱敏的用户信息
*/
async toClientUser(contestant) {
const { service } = this;
if (!contestant) return null;
const userData = typeof contestant.toJSON === 'function'
? contestant.toJSON()
: Object.assign({}, contestant);
delete userData.password;
// 成绩发布门控:查询失败按「未发布」处理,避免异常路径把分数漏出去
let published = false;
try {
published = await service.admin.isScorePublished(userData.group);
} catch (e) {
published = false;
}
if (!published) {
delete userData.score;
}
// 分项成绩一律不下发(前端不使用;保留会造成「总分虽隐藏、分项可加总」的旁路)
delete userData.exam_score;
delete userData.word_score;
delete userData.ppt_score;
return userData;
}
/** /**
* 根据用户名查找管理员 * 根据用户名查找管理员
* *
@@ -92,7 +144,8 @@ class AuthService extends Service {
* 将指定 id 的选手设置为已激活状态,并更新密码。 * 将指定 id 的选手设置为已激活状态,并更新密码。
* *
* @param {number} id - 选手 id * @param {number} id - 选手 id
* @param {string} password - SHA256 哈希后的密码 * @param {string} password - bcrypt 加盐哈希后的密码(由 helper.hashPassword 生成,
* BCRYPT_ROUNDS=10;调用方 controller/auth.js 的 activate() 已保证此口径)
* @returns {Promise<Model>} 更新后的 Contestant 模型对象 * @returns {Promise<Model>} 更新后的 Contestant 模型对象
*/ */
async activateAccount(id, password) { async activateAccount(id, password) {

909
app/service/bigQuestion.js Normal file
View File

@@ -0,0 +1,909 @@
'use strict';
const Service = require('egg').Service;
/**
* 综合大题服务 - BigQuestionService
*
* 2026-09-21 需求变更:中职组(zhongzhi) / 高职组(gaozhi) 取消「Word 文件上传题」,
* 改为从题库随机抽取一道大题在线作答。大题 = 文字描述 + 富文本表格(HTML) + 三小问,
* 每小问 10 分,大题总分 30 分。
*
* ── 隔离边界(硬约束:除本次功能外不得影响其他功能)─────────────────────────
* · 本服务**只读写新增的 big_questions / big_question_answers**;
* · 不触碰 zhongzhi_submissions / gaozhi_submissions(客观题逐题表);
* · 不触碰 benke_submissions / jiaoshi_submissions(本科/教师组作品提交);
* · 不触碰 choice_questions / judge_questions(客观题题库);
* · 评分**复用既有唯一写路径** service.admin.updateScores(见 setSubScores),
* 保证 contestants.word_score / score 的写入口径与「成绩发布门控」不出现第二条通路。
*
* ── 关键存储事实 ─────────────────────────────────────────────────────────
* controller/exam.js:90 `_getUserAggregation()` 返回 `aggregation_uid: contestant.id`,
* 故中职/高职组本表 aggregation_uid 即 contestants.id(与 zhongzhi/gaozhi_submissions
* 的 aggregation_uid 同口径),管理端查证可按 contestants.id 直接映射。
*
* MariaDB 5.5 无 JSON 列类型,题干快照以 JSON 文本存 MEDIUMTEXT。
*/
/** 启用「综合大题」的组别(其余组别走原有作品/Word 提交通道,不受影响) */
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi'];
/** 每小问作答上限(字符数,与前端 textarea maxlength 同口径,前后端一致) */
const MAX_ANSWER_CHARS = 2000;
/** 大题固定含 3 小问 */
const SUB_QUESTION_COUNT = 3;
const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3'];
const SCORE_FIELDS = ['score_1', 'score_2', 'score_3'];
/** 组别中文名(导出文档抬头 / 文件名用) */
const GROUP_LABELS = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
/** 导出文件名里的非法字符(Windows 禁止 \ / : * ? " < > |,另剔除控制字符) */
function safeFilePart(str) {
return String(str == null ? '' : str)
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 80) || 'untitled';
}
class BigQuestionService extends Service {
/** 该组别是否启用综合大题 */
isEnabledGroup(group) {
return BIG_QUESTION_GROUPS.includes(group);
}
/** 每小问字数上限(供 controller 回传前端) */
answerMaxChars() {
return MAX_ANSWER_CHARS;
}
/** 小问数量 */
subQuestionCount() {
return SUB_QUESTION_COUNT;
}
// ==================== 考生端 ====================
/**
* 取回本次抽中的大题 + 已保存的作答(供考生端渲染)。
*
* 幂等语义:同一考生重复进入只会得到**同一道题**(首次进入时抽题并落库快照,
* 之后一律复用该行),这是「避免考生纠纷」的基础保证。
*
* ⚠️ 返回值**不含任何分数**:成绩经发布门控后仅由 GET /api/score 单通道下发
* (铁律15 成绩泄露四通道封堵)。
*
* @param {number} aggregationUid - 聚合报名ID(中职/高职组 == contestants.id)
* @param {string} group - 组别
* @param {number} [contestantId] - 选手ID(冗余存证用,缺省同 aggregationUid)
* @returns {Promise<Object>} { ok, code, message, question, answers, submitTime, ... }
*/
async getForCandidate(aggregationUid, group, contestantId) {
const drawn = await this.drawOrGet(aggregationUid, group, contestantId);
if (!drawn.ok) return drawn;
const record = drawn.record;
return {
ok: true,
reused: drawn.reused,
question: this.parseSnapshot(record.question_snapshot),
answers: {
answer_1: record.answer_1 || '',
answer_2: record.answer_2 || '',
answer_3: record.answer_3 || '',
},
submitTime: record.submit_time || null,
maxChars: MAX_ANSWER_CHARS,
subCount: SUB_QUESTION_COUNT,
};
}
/**
* 抽题或取回已抽中的题(并发安全,一考生一行)。
* @param {number} aggregationUid
* @param {string} group
* @param {number} [contestantId]
* @returns {Promise<Object>} { ok, record, reused } 或 { ok:false, code, message }
*/
async drawOrGet(aggregationUid, group, contestantId) {
const { ctx } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
if (!aggregationUid) {
return { ok: false, code: 'NO_AGGREGATION', message: '未找到您的报名信息' };
}
const Answer = ctx.model.BigQuestionAnswer;
// 已抽过 → 直接复用(题目确定性 = 防纠纷)
const existed = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
if (existed) return { ok: true, record: existed, reused: true };
// 首次进入:从该组启用中的题库随机抽 1 道
const Question = ctx.model.BigQuestion;
const where = { group, status: 1 };
const total = await Question.count({ where });
if (!total) {
return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' };
}
// 以 count + 随机 offset 抽样(不依赖特定方言的 RAND(),MariaDB 5.5 亦安全)
const offset = Math.floor(Math.random() * total);
const picked = await Question.findOne({
where,
order: [['sort_order', 'ASC'], ['id', 'ASC']],
offset,
limit: 1,
});
if (!picked) {
return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' };
}
const snapshot = this.buildSnapshot(picked);
const defaults = {
aggregation_uid: aggregationUid,
contestant_id: contestantId || aggregationUid,
group,
big_question_id: picked.id,
big_question_uid: picked.question_uid,
question_snapshot: JSON.stringify(snapshot),
answer_1: '',
answer_2: '',
answer_3: '',
submit_time: null,
};
try {
const created = await Answer.create(defaults);
return { ok: true, record: created, reused: false };
} catch (e) {
// 唯一键竞争(同一考生并发首抽):回读已落库的那一行,
// 保证「一考生一行」且题目只有一个权威版本(另一并发请求已写入快照)。
if (this.isDupKey(e)) {
const winner = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
if (winner) return { ok: true, record: winner, reused: true };
}
throw e;
}
}
/**
* 保存三小问作答(后端同步做长度校验,防前端绕过 maxlength)。
* @param {number} aggregationUid
* @param {string} group
* @param {Object} answers - { answer_1, answer_2, answer_3 }
* @returns {Promise<Object>} { ok, filled, record } 或 { ok:false, code, message }
*/
async saveAnswers(aggregationUid, group, answers) {
const { ctx } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
// 长度校验:逐问超限即整批拒绝(不静默截断——截断会造成考生答案与页面所见不一致)
const normalized = [];
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
const raw = answers ? answers[ANSWER_FIELDS[i]] : undefined;
const val = raw === undefined || raw === null ? '' : String(raw);
if (val.length > MAX_ANSWER_CHARS) {
return {
ok: false,
code: 'ANSWER_TOO_LONG',
message: `第 ${i + 1} 小问作答超过 ${MAX_ANSWER_CHARS} 字上限(当前 ${val.length} 字)`,
};
}
normalized.push(val);
}
const Answer = ctx.model.BigQuestionAnswer;
const record = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
if (!record) {
// 未抽题就作答 → 明确拒绝,避免产生「无题目来源」的孤儿作答
return { ok: false, code: 'NOT_DRAWN', message: '尚未抽取题目,请先进入考试页面抽取题目' };
}
await record.update({
answer_1: normalized[0],
answer_2: normalized[1],
answer_3: normalized[2],
});
const filled = normalized.filter(v => v.trim().length > 0).length;
return { ok: true, filled, record };
}
/**
* 随整卷正式提交时补写 submit_time(已写则不覆盖,保留首次提交时间)。
* @param {number} aggregationUid
* @returns {Promise<Object|null>} 更新后的记录(无大题作答行则返回 null)
*/
async markSubmitted(aggregationUid) {
const { ctx } = this;
if (!aggregationUid) return null;
const record = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: aggregationUid },
});
if (!record) return null;
if (!record.submit_time) {
await record.update({ submit_time: new Date() });
}
return record;
}
/**
* 判断大题是否已答满(三小问均有非空内容)。
* @param {Object} record
* @returns {boolean}
*/
isCompleted(record) {
if (!record) return false;
return ANSWER_FIELDS.every(f => String(record[f] || '').trim().length > 0);
}
// ==================== 管理端 ====================
/**
* 管理端查证视图:按组返回「每位考生 → 抽到的题目(快照)→ 三小问作答 → 提交时间」。
*
* 包含该组**全部考生**(未抽题者 drawn=false),便于管理员核对缺考/未作答情况。
* 题目取**题干快照**而非当前题库内容 —— 考生看到的即当时那版,防止题目被改后无法举证。
*
* @param {string} group
* @returns {Promise<Array>} 查证列表
*/
async listAnswersForAdmin(group) {
const { ctx } = this;
const answers = await ctx.model.BigQuestionAnswer.findAll({
where: { group },
order: [['id', 'ASC']],
});
// aggregation_uid 与 contestant_id 双键索引(铁律14:跨键须双向映射防单键错配)
const byAgg = {};
const byContestant = {};
for (const a of answers) {
byAgg[String(a.aggregation_uid)] = a;
if (a.contestant_id != null) byContestant[String(a.contestant_id)] = a;
}
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'team_id', 'word_score'],
order: [['id', 'ASC']],
});
return contestants.map(c => {
const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null;
return {
contestantId: c.id,
aggregationUid: rec ? rec.aggregation_uid : c.id,
name: c.name,
idNumber: c.id_number,
phone: c.phone,
teamId: c.team_id,
drawn: !!rec,
bigQuestionUid: rec ? rec.big_question_uid : null,
questionSnapshot: rec ? this.parseSnapshot(rec.question_snapshot) : null,
answer1: rec ? (rec.answer_1 || '') : '',
answer2: rec ? (rec.answer_2 || '') : '',
answer3: rec ? (rec.answer_3 || '') : '',
answeredCount: rec ? ANSWER_FIELDS.filter(f => String(rec[f] || '').trim().length > 0).length : 0,
score1: rec ? rec.score_1 : null,
score2: rec ? rec.score_2 : null,
score3: rec ? rec.score_3 : null,
bigScore: rec ? rec.total_score : null,
wordScore: c.word_score != null ? Number(c.word_score) : null,
submitTime: rec ? rec.submit_time : null,
};
});
}
/**
* 管理端按三小问给分。
*
* 双写但**不新增成绩通路**:
* 1) 三小问得分 + 合计落 big_question_answers(存证 / 复核依据);
* 2) 合计分通过既有唯一评分路径 service.admin.updateScores 同步 contestants.word_score
* → 与「成绩发布门控」「总分公式(中职/高职 = exam_score + word_score)」完全一致。
*
* @param {string} group
* @param {number} aggregationUid
* @param {Object} scores - { score_1, score_2, score_3 }
* @returns {Promise<Object>} { ok, total } 或 { ok:false, code, message }
*/
async setSubScores(group, aggregationUid, scores) {
const { ctx, service } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
// ⚠️ 必须带 group(与读取路径 listAnswersForAdmin 同源):只按 aggregation_uid 定位时,
// 用任意组别的路径即可改写他组考生成绩,且下方 updateScores(..., group) 会用错组
// 去同步总分 ⇒ 组别隔离在写入侧被绕过(2026-09-23 实测:
// PUT /big-answers/gaozhi/<中职考生uid>/scores 返回 code=0 并真实改写了中职行)。
const record = await ctx.model.BigQuestionAnswer.findOne({
where: { group, aggregation_uid: aggregationUid },
});
if (!record) {
// 区分「不属于本组」与「尚未抽题」:前者通常意味着前端残留了上一组的 uid,
// 文案要能直接暴露这类误操作,而不是把它伪装成「考生没抽题」。
const elsewhere = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: aggregationUid },
attributes: ['id'],
});
return {
ok: false,
code: 'NOT_FOUND',
message: elsewhere
? '该考生不属于当前组别,无法在本组评分'
: '该考生尚未抽取大题,无法评分',
};
}
// 逐问校验 0–per 的数值(可含一位小数;per 来自题库 score_per_sub,默认 10)
const per = await this.resolveScorePerSub(group, record);
const parsed = [];
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
const raw = scores ? scores[SCORE_FIELDS[i]] : undefined;
if (raw === undefined || raw === null || raw === '') {
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问必须填写得分(0-${per})` };
}
const n = Number(raw);
if (!Number.isFinite(n) || n < 0 || n > per) {
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值(可含两位小数)` };
}
// 2026-09-28:评分精确到小数点后**两位**(测试人员反馈)⇒ 收敛到 2 位小数,
// ⛔ 不可用 Math.round 抹成整数(会丢掉 8.25 这类分数)
parsed.push(Math.round(n * 100) / 100);
}
// 浮点求和再收敛一次,避免 0.1+0.2=0.30000000000000004 之类脏值落库/回显
const total = Math.round(parsed.reduce((a, b) => a + b, 0) * 100) / 100;
await record.update({
score_1: parsed[0],
score_2: parsed[1],
score_3: parsed[2],
total_score: total,
});
// 同步到既有成绩路径(contests.word_score / score + accounts + 作品分项),
// 复用 AdminService.updateScores,避免出现第二条写成绩的代码路径。
const contestantId = record.contestant_id || record.aggregation_uid;
await service.admin.updateScores([{ contestantId, wordScore: total }], group);
return { ok: true, total, perSubScore: per };
}
/** 取该大题每小题分值(优先题库当前配置,回退快照,再回退 10) */
async resolveScorePerSub(group, record) {
const { ctx } = this;
if (record && record.big_question_id) {
const q = await ctx.model.BigQuestion.findByPk(record.big_question_id, {
attributes: ['score_per_sub'],
});
if (q && q.score_per_sub) return Number(q.score_per_sub);
}
const snap = record ? this.parseSnapshot(record.question_snapshot) : null;
if (snap && snap.score_per_sub) return Number(snap.score_per_sub);
return 10;
}
// ==================== 题库管理(管理端) ====================
/**
* 题库列表(按组)
* @param {string} group
* @returns {Promise<Array>}
*/
async listQuestions(group) {
const { ctx } = this;
const rows = await ctx.model.BigQuestion.findAll({
where: { group },
order: [['sort_order', 'ASC'], ['id', 'ASC']],
});
return rows.map(r => ({
id: r.id,
questionUid: r.question_uid,
group: r.group,
title: r.title,
description: r.description || '',
subPrompt1: r.sub_prompt_1 || '',
subPrompt2: r.sub_prompt_2 || '',
subPrompt3: r.sub_prompt_3 || '',
scorePerSub: r.score_per_sub,
totalScore: r.total_score,
sortOrder: r.sort_order,
status: r.status,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
/**
* 导出大题为 Word(.docx)(2026-09-23 需求:单独 / 批量 / 全部导出中职·高职大题)
*
* 行为:
* · 指定 ids(单个或多个)→ 只导出这些题;
* · 不传 ids → 导出该组**全部**;
* · 命中 1 道 → 直接返回 .docx;
* · 命中多道 → 返回「多个 .docx 打成的一个 .zip」(每道题一个独立 Word 文件,
* 便于分别打印/分发;而不是把多道题塞进一个长文档)。
*
* 只读操作:不写库、不改任何状态。
*
* @param {string} group - 组别(zhongzhi / gaozhi)
* @param {Array<number>|string} [ids] - 大题 ID 列表(数组或逗号分隔字符串)
* @returns {Promise<Object>} { ok:true, fileName, buffer, count, isZip? } 或 { ok:false, code, message }
*/
async exportQuestions(group, ids) {
const { ctx } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
const where = { group };
if (ids !== undefined && ids !== null && ids !== '') {
const raw = Array.isArray(ids) ? ids : String(ids).split(',');
const idList = raw
.map(v => Number(String(v).trim()))
.filter(v => Number.isInteger(v) && v > 0);
if (!idList.length) {
return { ok: false, code: 'INVALID_IDS', message: '导出的大题 ID 列表非法' };
}
// Sequelize:数组值自动生成 IN (...) 条件,无需 Op
where.id = idList;
}
const rows = await ctx.model.BigQuestion.findAll({
where,
order: [['sort_order', 'ASC'], ['id', 'ASC']],
});
if (!rows.length) {
return { ok: false, code: 'NOT_FOUND', message: '该组别下没有可导出的大题' };
}
const docx = require('../lib/docx');
const groupLabel = GROUP_LABELS[group] || group;
const files = [];
for (const r of rows) {
const blocks = [];
// 抬头
blocks.push({
type: 'p',
align: 'center',
runs: [{ text: r.title || '(无标题)', bold: true, size: 32 }],
});
blocks.push({
type: 'p',
runs: [{
text: `组别:${groupLabel}  分值:每小题 ${r.score_per_sub} 分 × ${SUB_QUESTION_COUNT} 小题 = ${r.total_score} 分`,
bold: false,
}],
});
// 题干(富文本 HTML → 段落 + 真实 Word 表格)
blocks.push({ type: 'p', runs: [] });
blocks.push({ type: 'p', runs: [{ text: '【题目】', bold: true, size: 26 }] });
blocks.push(...docx.htmlToBlocks(r.description || ''));
// 三小问
for (let i = 1; i <= SUB_QUESTION_COUNT; i++) {
blocks.push({ type: 'p', runs: [] });
blocks.push({
type: 'p',
runs: [{ text: `【第 ${i} 小问】(${r.score_per_sub} 分)`, bold: true, size: 26 }],
});
blocks.push(...docx.textToBlocks(r[`sub_prompt_${i}`] || ''));
}
const buffer = await docx.buildDocx(blocks);
// 文件名用 question_uid(稳定标识)而非标题:标题可能含空格/标点,且允许重名
files.push({
name: `${groupLabel}_${safeFilePart(r.question_uid || `big_${r.id}`)}.docx`,
buffer,
});
}
if (files.length === 1) {
return { ok: true, fileName: files[0].name, buffer: files[0].buffer, count: 1 };
}
const buffer = await docx.zipDocxFiles(files);
return {
ok: true,
isZip: true,
fileName: `${groupLabel}_综合大题_${files.length}道.zip`,
buffer,
count: files.length,
};
}
/**
* 导出「试题 + 考生作答」阅卷用 Word(2026-09-28 测试人员需求)
*
* 与 exportQuestions 的区别(⛔ 勿混淆):
* · exportQuestions = 导出**题库题目**(供留存 / 打印 / 分发),不含任何考生信息;
* · exportAnswers = 导出**考生答卷**(供离线阅卷 / 打印 / 归档),内容 =
* 考生身份 + 抽题时刻题干快照 + 三小问作答 + 逐问得分 + 合计。
*
* 粒度(测试人员明确要求,勿改):
* · 命中 1 位考生 → 直接返回 .docx;
* · 命中多位 / 不传 ids(该组全部已抽题考生)→ **每人一个独立 .docx** 再打包为 .zip;
* · ⛔ 绝不把多位考生塞进同一个 Word —— 否则无法分别打印 / 分发 / 归档。
*
* 只读操作:不写库、不改任何状态。
*
* @param {string} group
* @param {Array<number>|string} [ids] - 考生 ID(contestantId)数组或逗号分隔串
* @returns {Promise<Object>} { ok:true, fileName, buffer, count, isZip? } 或 { ok:false, code, message }
*/
async exportAnswers(group, ids) {
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
const all = await this.listAnswersForAdmin(group);
let picked = all;
if (ids !== undefined && ids !== null && ids !== '') {
const raw = Array.isArray(ids) ? ids : String(ids).split(',');
const idList = raw
.map(v => Number(String(v).trim()))
.filter(v => Number.isInteger(v) && v > 0);
if (!idList.length) {
return { ok: false, code: 'INVALID_IDS', message: '导出的考生 ID 列表非法' };
}
const wanted = new Set(idList);
picked = all.filter(x => wanted.has(Number(x.contestantId)));
if (!picked.length) {
return { ok: false, code: 'NOT_FOUND', message: '选中的考生不属于该组别或已不存在' };
}
}
// 只导出「已抽题」考生:未抽题者没有题面与作答,导出只会得到空白页
const exportable = picked.filter(x => x.drawn);
if (!exportable.length) {
return { ok: false, code: 'NOT_FOUND', message: '该范围内没有已抽取大题的考生,无可导出的答卷' };
}
const docx = require('../lib/docx');
const groupLabel = GROUP_LABELS[group] || group;
const pad = n => String(n).padStart(2, '0');
const fmtTime = v => {
if (!v) return '未提交';
const d = new Date(v);
if (isNaN(d.getTime())) return String(v);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
// 评分统一显示两位小数(与 DECIMAL(6,2) 口径一致)
const fmtScore = v => (v == null || v === '' ? '(未评分)' : Number(v).toFixed(2));
const files = [];
for (const s of exportable) {
const snap = s.questionSnapshot || {};
const prompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
const per = snap.score_per_sub != null ? Number(snap.score_per_sub) : null;
const answers = [s.answer1, s.answer2, s.answer3];
const scores = [s.score1, s.score2, s.score3];
const blocks = [];
// ---- 抬头:身份信息(阅卷核对用,与成绩复核同口径) ----
blocks.push({
type: 'p',
align: 'center',
runs: [{ text: `${groupLabel}综合大题阅卷表`, bold: true, size: 32 }],
});
blocks.push({ type: 'p', runs: [{ text: `姓名:${s.name || '--'}  身份证号:${s.idNumber || '--'}  手机号:${s.phone || '--'}` }] });
blocks.push({ type: 'p', runs: [{ text: `抽到题目:${snap.title || '--'}(${s.bigQuestionUid || '--'})` }] });
blocks.push({ type: 'p', runs: [{ text: `提交时间:${fmtTime(s.submitTime)}  已答小问:${s.answeredCount} / ${SUB_QUESTION_COUNT}` }] });
// ---- 试题(抽题时刻快照:题目后续被改不影响存证) ----
blocks.push({ type: 'p', runs: [] });
blocks.push({ type: 'p', runs: [{ text: '【试题】(抽题时刻快照,防纠纷)', bold: true, size: 26 }] });
blocks.push(...docx.htmlToBlocks(snap.description || ''));
// ---- 三小问:小问题目 + 考生作答 + 得分 ----
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
blocks.push({ type: 'p', runs: [] });
blocks.push({
type: 'p',
runs: [{
text: `【第 ${i + 1} 小问】${prompts[i] ? ':' + prompts[i] : ''}${per != null ? `(${per} 分)` : ''}`,
bold: true,
size: 26,
}],
});
const a = String(answers[i] || '').trim();
blocks.push({ type: 'p', runs: [{ text: '考生作答:', bold: true }] });
blocks.push(...docx.textToBlocks(a || '(未作答)'));
blocks.push({ type: 'p', runs: [{ text: `得分:${fmtScore(scores[i])}${per != null ? ` / ${Number(per).toFixed(2)}` : ''} 分` }] });
}
// ---- 合计 ----
blocks.push({ type: 'p', runs: [] });
blocks.push({
type: 'p',
runs: [{
text: `本大题合计:${fmtScore(s.bigScore)}${per != null ? ` / ${(per * SUB_QUESTION_COUNT).toFixed(2)}` : ''} 分`,
bold: true,
}],
});
blocks.push({ type: 'p', runs: [{ text: `(导出时间:${fmtTime(new Date())})` }] });
const buffer = await docx.buildDocx(blocks);
files.push({
name: `${groupLabel}_${safeFilePart(s.name || '考生')}_${safeFilePart(s.idNumber || s.contestantId)}.docx`,
buffer,
});
}
if (files.length === 1) {
return { ok: true, fileName: files[0].name, buffer: files[0].buffer, count: 1 };
}
const buffer = await docx.zipDocxFiles(files);
return {
ok: true,
isZip: true,
fileName: `${groupLabel}_大题答卷_${files.length}人.zip`,
buffer,
count: files.length,
};
}
/**
* 新建大题
* @param {string} group
* @param {Object} data
* @returns {Promise<Object>} { ok, record } 或 { ok:false, code, message }
*/
async createQuestion(group, data) {
const { ctx } = this;
const checked = this.validateQuestionPayload(data);
if (!checked.ok) return checked;
const q = await ctx.model.BigQuestion.create({
// 唯一键占位:question_uid 期望形态为 big_<自增id>,需插入拿到 id 后回填;
// 占位值加时间戳+随机数,避免并发新建时占位互相冲突。
question_uid: `tmp_${Date.now()}_${Math.floor(Math.random() * 1000000)}`,
group,
title: checked.title,
description: checked.description,
sub_prompt_1: checked.subPrompts[0],
sub_prompt_2: checked.subPrompts[1],
sub_prompt_3: checked.subPrompts[2],
score_per_sub: checked.scorePerSub,
total_score: checked.scorePerSub * SUB_QUESTION_COUNT,
sort_order: checked.sortOrder,
status: checked.status,
});
await q.update({ question_uid: `big_${q.id}` });
return { ok: true, record: q };
}
/**
* 更新大题
* @param {string} group
* @param {number} id
* @param {Object} data
* @returns {Promise<Object>}
*/
async updateQuestion(group, id, data) {
const { ctx } = this;
const q = await ctx.model.BigQuestion.findOne({ where: { id, group } });
if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' };
const checked = this.validateQuestionPayload(data, q);
if (!checked.ok) return checked;
await q.update({
title: checked.title,
description: checked.description,
sub_prompt_1: checked.subPrompts[0],
sub_prompt_2: checked.subPrompts[1],
sub_prompt_3: checked.subPrompts[2],
score_per_sub: checked.scorePerSub,
total_score: checked.scorePerSub * SUB_QUESTION_COUNT,
sort_order: checked.sortOrder,
status: checked.status,
});
return { ok: true, record: q };
}
/**
* 删除大题。
* 已被考生抽取过的大题**拒绝硬删除**(否则管理端查证只剩快照、题库无法回溯),
* 引导改为「停用」(status=0,不再参与抽题,已有考生不受影响)。
* @param {string} group
* @param {number} id
* @returns {Promise<Object>}
*/
async deleteQuestion(group, id) {
const { ctx } = this;
const q = await ctx.model.BigQuestion.findOne({ where: { id, group } });
if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' };
const used = await ctx.model.BigQuestionAnswer.count({ where: { big_question_id: id } });
if (used > 0) {
return {
ok: false,
code: 'IN_USE',
message: `该大题已被 ${used} 名考生抽取,不可删除;如需下线请改为「停用」(不影响已抽到该题的考生)`,
};
}
await q.destroy();
return { ok: true };
}
/**
* 校验大题录入数据(新增 / 更新共用)
* @param {Object} data
* @param {Object} [existing] - 更新时用于字段缺省回退
* @returns {Object} { ok, title, description, subPrompts, scorePerSub, sortOrder, status }
*/
validateQuestionPayload(data, existing) {
const src = data || {};
const title = String(src.title !== undefined ? src.title : (existing ? existing.title : '')).trim();
if (!title) return { ok: false, code: 'TITLE_REQUIRED', message: '大题标题不能为空' };
if (title.length > 200) return { ok: false, code: 'TITLE_TOO_LONG', message: '大题标题不得超过 200 字' };
const description = String(
src.description !== undefined ? src.description : (existing ? existing.description : '') || ''
);
const subPrompts = [];
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
const key = `sub_prompt_${i + 1}`;
const camel = `subPrompt${i + 1}`;
let v;
if (src[key] !== undefined) v = src[key];
else if (src[camel] !== undefined) v = src[camel];
else v = existing ? existing[key] : '';
subPrompts.push(String(v == null ? '' : v));
}
// ── 防呆:题干不得混入「可被直接抄袭的答案性内容」(2026-09-28 R27 缺陷加固)──
// 背景:中职组某启用题的题干里给了三条指令**输入示例**(>OFFMSG,COM1*4A 等),
// 而该题第 3 小问恰是「列出配置指令」⇒ 答案被题干直接给出;又因抽题快照固化
// (question_snapshot 抽题即冻结)无法事后撤回,只能从**录入入口**前置拦截。
const leak = this.detectAnswerLeak({ title, description, subPrompts });
if (leak) return { ok: false, code: 'SUSPECT_ANSWER_LEAK', message: leak };
const perRaw = src.score_per_sub !== undefined ? src.score_per_sub
: (src.scorePerSub !== undefined ? src.scorePerSub
: (existing ? existing.score_per_sub : 10));
const scorePerSub = Number(perRaw);
if (!Number.isFinite(scorePerSub) || scorePerSub <= 0) {
return { ok: false, code: 'INVALID_SCORE', message: '每小题分值必须为大于 0 的数值' };
}
const sortRaw = src.sort_order !== undefined ? src.sort_order
: (src.sortOrder !== undefined ? src.sortOrder
: (existing ? existing.sort_order : 0));
const sortOrder = Number.isFinite(Number(sortRaw)) ? Math.round(Number(sortRaw)) : 0;
let status = existing ? Number(existing.status) : 1;
if (src.status !== undefined && src.status !== null && src.status !== '') {
status = Number(src.status) ? 1 : 0;
}
return {
ok: true,
title,
description,
subPrompts,
// 每小题分值 = 赛制口径(每问 10 分、大题 30 分),刻意保持整数;
// 人工评分的小数精度只作用于「考生得分」,见 setSubScores。
scorePerSub: Math.round(scorePerSub),
sortOrder,
status,
};
}
/**
* 防呆扫描:题干 / 小问里是否混入了「考生可直接抄袭的答案性内容」。
*
* 背景(2026-09-28 实测缺陷):中职组某启用题的 `description` 内嵌
* 「答题可能用到的指令集」,除格式定义外还给了**可直接使用的输入示例**:
* &gt;OFFMSG,COM1*4A / &gt;OUTMSG,COM1,GGA,1*3B / &gt;SAVECONFIG*0B
* 而该题第 3 小问正是「请列出对该接收机做串口输出配置需下发的指令」⇒ 答案白送。
* 前端只渲染 description、后端按快照原样下发 ⇒ 唯一守点是**录入入口**。
*
* 判定规则(命中即拒绝保存):
* 1) 出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样;
* 2) 出现**带具体字面参数**的指令示例(形如 &gt;CMD,字面值*两位十六进制校验码)。
*
* ⚠️ 刻意**不拦**「指令格式定义」(参数用方括号占位,如 &gt;OFFMSG[,Port[,Message]]*CS):
* 向考生提供指令**手册**是允许的,构成泄露的是**能照抄的成品**。
* —— 本规则与 2026-09-28 那次修复(只删示例、保留格式定义)同口径。
*
* @param {Object} parts - { title, description, subPrompts }
* @returns {string|null} 命中说明(直接展示给管理员);未命中返回 null
*/
detectAnswerLeak(parts) {
const targets = [
['大题标题', parts.title],
['题干', parts.description],
...((parts.subPrompts) || []).map((p, i) => [`第 ${i + 1} 小问`, p]),
];
const KEYWORDS = ['参考答案', '标准答案', '评分标准', '评分细则', '答案要点'];
// 带字面参数的指令示例:&gt;OFFMSG,COM1*4A
// 校验码限定「两位十六进制」⇒ 天然排除占位符 *CS(S 非十六进制字符)
// 兼容两种存储形态:富文本存 &gt;(实测库中形态)或裸 >
const CMD_SAMPLE = /(?:&gt;|>)\s*[A-Z][A-Z0-9]*(?:,[^<&\n]*)?\*[0-9A-Fa-f]{2}(?![0-9A-Fa-f])/g;
for (const [where, raw] of targets) {
const text = String(raw == null ? '' : raw);
if (!text) continue;
for (const kw of KEYWORDS) {
if (text.includes(kw)) {
return `${where}中出现「${kw}」字样,疑似把参考答案写进了题目,已拒绝保存。`;
}
}
CMD_SAMPLE.lastIndex = 0;
let m;
while ((m = CMD_SAMPLE.exec(text)) !== null) {
const hit = m[0];
// 方括号占位(如 [,Port])属「格式定义」,不算示例
if (hit.includes('[')) continue;
return `${where}中出现可直接抄袭的指令示例「${hit}」,已拒绝保存。`
+ '如确需向考生提供指令手册,请只写**格式定义**(参数用方括号占位,例如:'
+ '&gt;OUTMSG[,Port],Message,Period*CS);带具体参数值的示例等同于给出答案。';
}
}
return null;
}
// ==================== 内部工具 ====================
/** 构造题干快照(抽题时刻固化,题目后续被改不影响存证) */
buildSnapshot(q) {
return {
big_question_uid: q.question_uid,
title: q.title,
description: q.description || '',
sub_prompts: [q.sub_prompt_1 || '', q.sub_prompt_2 || '', q.sub_prompt_3 || ''],
score_per_sub: q.score_per_sub,
total_score: q.total_score,
snapshot_at: new Date().toISOString(),
};
}
/** 解析题干快照(容错:脏数据不抛异常,返回 null) */
parseSnapshot(text) {
if (!text) return null;
if (typeof text === 'object') return text;
try {
return JSON.parse(text);
} catch (e) {
return null;
}
}
/** 唯一键冲突判定(并发首抽竞争) */
isDupKey(e) {
if (!e) return false;
if (e.name === 'SequelizeUniqueConstraintError') return true;
if (e.code === 'ER_DUP_ENTRY') return true;
if (e.parent && e.parent.code === 'ER_DUP_ENTRY') return true;
return /ER_DUP_ENTRY/.test(String(e.message || ''));
}
}
module.exports = BigQuestionService;

View File

@@ -255,6 +255,19 @@ class ExamService extends Service {
/** /**
* 批量自动判分 * 批量自动判分
*
* 🔴 批量化(2026-09-21 并发压测结论):原实现对每道答案 `await this.checkAnswer()`,
* 而 checkAnswer 内部是 `findByPk` —— 单次交卷 59~70 题即 59~70 次**串行** DB 往返。
* 这是「单 worker accept 循环被重任务饿死 → 监听队列溢出 → Windows 回 RST」的主因之一
* (压测实测:单 worker 硬容量仅 15 交卷/秒,400 并发下 20% 考生连接被拒)。
* 改为按题型分组,用**最多 2 次** whereIn 取回全部题目,再在内存中判分。
*
* 判分语义与逐题版**逐字等价**(不得漂移):
* - 答案比对:`String(x).trim().toLowerCase()` 严格相等;
* - 得分:答对取 question.score,答错取 0;
* - 题目不存在:跳过该题、**不进 details**(与 checkAnswer 返回 question=null 时一致);
* - details 的字段名与取值口径不变(question_id 保留调用方传入的原始 uid 形态)。
*
* @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }] * @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }]
* @returns {Promise<Object>} { autoScore, total, details } * @returns {Promise<Object>} { autoScore, total, details }
*/ */
@@ -263,24 +276,49 @@ class ExamService extends Service {
let autoScore = 0; let autoScore = 0;
let total = 0; let total = 0;
// 第一步:按题型收集原始自增 id(去重后一次查出)。
const idsByType = { choice: [], judge: [] };
for (const item of answers) { for (const item of answers) {
const result = await this.checkAnswer( const type = item.question_type;
item.question_id || item.question_uid, // 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。
item.question_type, // 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串
item.answer_data // 命中 Object 原型链而绕过题型判定。
); if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue;
if (result.question) { const rawId = this._extractRawId(item.question_id || item.question_uid);
total += result.question.maxScore; if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId));
autoScore += result.score; }
details.push({
question_id: item.question_id || item.question_uid, const questionMap = new Map(); // `${type}:${id}` → question 实例
question_type: item.question_type, for (const type of [ 'choice', 'judge' ]) {
answer_data: item.answer_data, const uniqueIds = Array.from(new Set(idsByType[type]));
is_correct: result.isCorrect, if (!uniqueIds.length) continue;
score: result.score, const QuestionModel = this._getQuestionModel(type);
max_score: result.question.maxScore, const rows = await QuestionModel.findAll({ where: { id: uniqueIds } });
}); for (const q of rows) questionMap.set(`${type}:${q.id}`, q);
} }
// 第二步:内存判分,保持与原实现相同的顺序(按 answers 入参顺序 push)。
for (const item of answers) {
const type = item.question_type;
const rawId = this._extractRawId(item.question_id || item.question_uid);
const question = questionMap.get(`${type}:${rawId}`);
if (!question) continue; // 题目不存在:与原 checkAnswer 行为一致
const correctAnswer = String(question.answer).trim().toLowerCase();
const userAnswer = String(item.answer_data).trim().toLowerCase();
const isCorrect = correctAnswer === userAnswer;
const score = isCorrect ? question.score : 0;
total += question.score;
autoScore += score;
details.push({
question_id: item.question_id || item.question_uid,
question_type: type,
answer_data: item.answer_data,
is_correct: isCorrect,
score,
max_score: question.score,
});
} }
return { autoScore, total, details }; return { autoScore, total, details };
@@ -394,31 +432,46 @@ class ExamService extends Service {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try { try {
await ctx.model.transaction(async (t) => { await ctx.model.transaction(async (t) => {
for (const answer of answers) { // 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert,
// 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束, // 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级,
// 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求 // 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生
// 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。 // 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate +
// // updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id, //
// 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行 // 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate,
// 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。 // Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列;
// // 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表,
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize // 否则会用 NULL 覆盖考生已上传的作品文件信息。
// 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉 //
// returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。 // ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
const rawQuestionId = this._extractRawId(answer.question_uid); // 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖
await SubmissionModel.upsert({ // (B13 修复:uid 全局唯一化引入的落库回归)。
aggregation_uid: aggregationUid, //
question_uid: rawQuestionId, // ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning,
question_type: answer.question_type, // 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
answer_data: answer.answer_data, const rows = answers.map((answer) => ({
is_correct: answer.is_correct, aggregation_uid: aggregationUid,
score: answer.score, question_uid: this._extractRawId(answer.question_uid),
}, { question_type: answer.question_type,
conflictFields: ['aggregation_uid', 'question_uid', 'question_type'], answer_data: answer.answer_data,
transaction: t, is_correct: answer.is_correct,
}); score: answer.score,
} }));
// 空数组守卫:bulkCreate([]) 在部分 dialect 下会抛错,而原 for 循环是空转成功。
if (!rows.length) return;
await SubmissionModel.bulkCreate(rows, {
updateOnDuplicate: [
'aggregation_uid',
'question_uid',
'question_type',
'answer_data',
'is_correct',
'score',
],
transaction: t,
});
}); });
return answers.length; return answers.length;
} catch (err) { } catch (err) {

View File

@@ -275,10 +275,31 @@ class UploadService extends Service {
}; };
if (existing) { if (existing) {
// 🔴 C9 根因① 修复(2026-09-29 · R45):重传覆盖前,先记住旧物理文件路径,
// 待 DB 更新**成功之后**再删旧文件 —— 否则每次重传都把旧文件永久留在磁盘上。
// 实证:本次清理的 1003 个孤儿(9.62 GB)中,绝大多数正是这样累积出来的。
//
// ⚠️ 顺序不可颠倒:若先删旧文件、而 update 又失败,DB 仍然指向**已被删除**的旧路径
// ⇒ 管理端/考生端下载 404(这是**数据丢失**,比留下一个孤儿严重得多)。
// 先落库后删文件,最坏结果只是多一个可被兜底扫描捞回来的孤儿。
const stalePath = (existing.file_path && existing.file_path !== fileInfo.filePath)
? existing.file_path
: null;
await existing.update({ await existing.update({
...payload, ...payload,
score: null, // 重新上传后清除评分 score: null, // 重新上传后清除评分
}); });
if (stalePath) {
// best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果
const r = this.removePublicFile(stalePath, 'overwrite-stale');
if (r !== 'removed') {
this.app.logger.warn(
`[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}`
);
}
}
return existing; return existing;
} }
@@ -339,7 +360,7 @@ class UploadService extends Service {
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false * @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
*/ */
async deleteSubmission(aggregationUid, group, id) { async deleteSubmission(aggregationUid, group, id) {
const { ctx, app } = this; const { ctx } = this;
const SubmissionModel = group === 'benke' const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission ? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission; : ctx.model.JiaoshiSubmission;
@@ -351,18 +372,57 @@ class UploadService extends Service {
if (!record) return false; if (!record) return false;
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx) // 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
if (record.file_path) { if (record.file_path) {
const absPath = path.join(app.baseDir, 'app/public', record.file_path); this.removePublicFile(record.file_path, 'deleteSubmission');
try {
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
} catch (e) {
ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message);
}
} }
await record.destroy(); await record.destroy();
return true; return true;
} }
/**
* 删除 app/public 下的物理文件(统一的安全删除入口)
*
* 【为什么需要它】C9 根因治理(2026-09-29 · R45):此前「删作品文件」这件事在
* service/upload.js、controller/admin.js、controller/exam.js 各写了一遍裸
* `fs.unlinkSync`,既没有路径白名单、失败也只在个别处记日志 ⇒
* 一旦删除失败就静默留下一份无人认领的文件(孤儿)。
* 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。
*
* 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件:
* - DB 里的 file_path 天然是 `works/<group>/<file>`;前缀白名单可挡住
* 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/`
* 等静态资源时被连带删除的风险。
* - 含 `..` 的路径一律拒绝(防目录穿越)。
*
* 【失败语义】best-effort,**永不抛错**:删除是副作用,不能污染主业务结果
* (详见 extend/helper.js:safeCleanup 的说明)。
*
* @param {string} relativePath - 相对 app/public 的路径,如 `works/benke/work_x.pdf`
* @param {string} [label] - 日志场景名
* @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错)
*/
removePublicFile(relativePath, label = 'removePublicFile') {
const { ctx, app } = this;
const logger = (ctx && ctx.logger) || app.logger;
try {
if (!relativePath || typeof relativePath !== 'string') return 'missing';
const norm = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (!/^(works|tmp)\//.test(norm) || norm.includes('..')) {
logger.warn(`[removePublicFile:${label}] 拒绝删除非作品区路径(安全闸门): ${relativePath}`);
return 'skipped';
}
const absPath = path.join(app.baseDir, 'app/public', norm);
return ctx.helper.safeCleanup(absPath, { label });
} catch (e) {
logger.warn(`[removePublicFile:${label}] 异常(已忽略,不影响主流程): ${relativePath} -> ${e && e.message}`);
return 'skipped';
}
}
} }
module.exports = UploadService; module.exports = UploadService;

View File

@@ -10,14 +10,46 @@
// 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时 // 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时
// 生效(外部注入优先),保证幂等。 // 生效(外部注入优先),保证幂等。
// 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。 // 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
//
// 🔴 2026-09-23 修复(本地启动崩溃 1045,详见 memory/2026-09-23.md):
// 原先本函数在【模块加载期】立即执行,且无条件把 .env.production 的全部键灌进
// process.env —— 于是本地 `npm run dev` 也会拿到**生产库账号**(见 deploy/.env.production
// 的 DB_USER),
// 而本机 MySQL 并无该账号 ⇒ SequelizeAccessDeniedError(1045) ⇒ agent_worker 退出
// ⇒ master 退出 ⇒ 7001 无监听。现改为两处收口:
// ① 【执行时机】推迟到 module.exports(appInfo) 内部 —— 此时 appInfo.env 已确定;
// ② 【作用范围】非生产环境跳过「连接类 / 凭据类 / 网关开关类」键(见下方常量)。
// 生产(appInfo.env === 'prod' 或 NODE_ENV=production)**不跳过任何键**,行为逐键不变。
// 生产必然满足判定的三重依据(2026-09-23 取证):
// a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV = 'production'`;
// b) deploy/.env.production:17 `NODE_ENV=production`,且 start-backend.sh 以
// `set -a; source "$ENV_FILE"` 注入 shell 后再 exec node;
// c) start.js:126 注释:argv -> EGG_SERVER_ENV -> 兜底 egg 取 `prod`。
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
(function loadDeployEnv() { const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
// 非生产环境额外跳过的键前缀:这些键一旦注入本机进程会造成**真实副作用**,
// 或把生产凭据带进本地进程。逐类取证依据(2026-09-23):
// DB_ —— 实证危害:注入生产库账号后本地必然 1045 崩溃(2026-09-23 已实测);
// REDIS_ —— 当前全库无 app.redis 使用点(grep 零命中),但一旦启用即连生产 Redis;
// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`),
// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面);
// SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。
// 仍保留注入的键:JWT_SECRET / APP_KEYS / EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_'];
function loadDeployEnv(appInfo) {
try { try {
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
const targets = []; const targets = [];
// 生产判定:appInfo.env 为权威(Egg getServerEnv 的结果),NODE_ENV 为兼容兜底。
// 两者皆不满足即视为非生产(本地/测试),采用**保守**方向跳过连接类键。
const isProdEnv =
process.env.NODE_ENV === 'production' ||
(appInfo && (appInfo.env === 'prod' || appInfo.env === 'production'));
// 1) 显式指定(最高优先级) // 1) 显式指定(最高优先级)
if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE); if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE);
@@ -39,6 +71,7 @@
} }
let loadedFrom = null; let loadedFrom = null;
let skipped = [];
for (const file of targets) { for (const file of targets) {
let text; let text;
try { try {
@@ -55,9 +88,13 @@
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1); val = val.slice(1, -1);
} }
if (key && !SKIP_KEYS.has(key) && process.env[key] === undefined) { if (!key || DEPLOY_ENV_SKIP_KEYS.has(key) || process.env[key] !== undefined) continue;
process.env[key] = val; // 非生产环境:跳过连接类 / 凭据类 / 网关开关类键,避免「本地进程连到生产库」
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_PREFIXES.some((p) => key.startsWith(p))) {
skipped.push(key);
continue;
} }
process.env[key] = val;
} }
loadedFrom = file; loadedFrom = file;
break; // 第一个存在的文件即为权威来源 break; // 第一个存在的文件即为权威来源
@@ -68,14 +105,30 @@
} else { } else {
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)'); console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
} }
// 非生产环境把「被跳过的键」显式打印出来。⚠️ 只打印键名,绝不打印值。
// 注意:本行出现 ≠ 那些键被注入(恰恰相反,是被跳过了),排查时勿误读。
if (skipped.length > 0) {
console.log(
'[deploy-env] 非生产环境(env=' +
((appInfo && appInfo.env) || process.env.NODE_ENV || 'unknown') +
'),已跳过 ' +
skipped.length +
' 个连接类/凭据类键(防止本地进程连到生产库或持有生产密钥):' +
skipped.join(', ')
);
}
} catch (e) { } catch (e) {
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message)); console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
} }
})(); }
module.exports = (appInfo) => { module.exports = (appInfo) => {
const config = (exports = {}); const config = (exports = {});
// 🔴 必须在下文任何 process.env.* 读取之前执行(原本是模块级 IIFE,见函数头注释)。
// 放在此处而非模块顶层,是为了拿到已确定的 appInfo.env 以区分生产/本地。
loadDeployEnv(appInfo);
const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod'; const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod';
// ----------------------------------------------------------------- // -----------------------------------------------------------------
@@ -114,8 +167,46 @@ module.exports = (appInfo) => {
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。 // 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
config.concurrencyGuard = { config.concurrencyGuard = {
max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20, max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20,
// 交卷独立上限(2026-09-21 并发压测结论):submit-all 纳入闸门后,过载由
// 「静默 RST(考生不知道是否交上)」变为「明确 429 + Retry-After(可重试)」。
// 取值理由(为何不与上传共用 20、也不放大到数百)见 middleware/concurrency_guard.js。
submitMax: parseInt(process.env.SUBMIT_REQ_MAX, 10) || 24,
// 登录独立上限(2026-09-21 并发压测**第二轮**结论):/api/auth/login 此前无闸门,
// 800 人同刻登录实测「仅 484/800 取得 token(39.5% 在 TCP accept 层被拒)、
// P50=9957ms / P95=24350ms、连接级重试 1134 次」。纳入闸门后过载由
// 「静默 RST(考生不知道要不要重试)」变为「明确 429 + Retry-After(可重试)」。
// 取 16(低于交卷的 24):登录是 CPU 型重任务(bcrypt 10 rounds),
// 换手粒度比 IO 型交卷更粗,对 accept 循环的挤压更直接。
// 为何不纳入 admin-login、以及与 loginRateLimit 的顺序红线,
// 见 middleware/concurrency_guard.js 的 LOGIN_PATTERNS 段注释。
loginMax: parseInt(process.env.LOGIN_REQ_MAX, 10) || 16,
}; };
// -----------------------------------------------------------------
// 🔴 集群 worker 数(2026-09-21 并发压测结论,必须显式设定)
// -----------------------------------------------------------------
// 背景:`egg-scripts start` 未传 --workers 时,egg-cluster 会按 os.cpus().length
// 起同等数量的 app worker —— 核数越多起得越多(开发机 8 核 → 8 个 worker)。
// 而每个 worker 的连接池上限是 DB_POOL_MAX(默认 20),8 × 20 = 160
// **已经超过** MySQL max_connections(151)。高峰期会出现 connection acquire
// timeout,在考生侧表现为「交卷失败」,且服务端不报业务错,极难定位。
//
// 落点:worker 数由环境变量 EGG_WORKERS 控制(egg-scripts start 把它作为
// --workers 的默认值,见 node_modules/egg-scripts/lib/cmd/start.js:31)。
// 生产值写在 deploy/.env.production(EGG_WORKERS=2,与服务器 2 核匹配;
// 2026-09-29 R40 由 4 校正为 2),并由 start-backend.sh 显式传参作双保险。
// 2 × 20 = 40 ≤ 151,为管理后台/统计等其余连接留足余量。
//
// ⚠️ 不要试图在此处写 config.cluster.workers —— egg-cluster 只读
// startCluster(options.workers)(options.js:24 → master.js:267),
// 该值在 master 进程由 egg-scripts 的命令行/EGG_WORKERS 决定;
// config 阶段的配置对 worker **数量**无效。此处仅作说明,避免后人误配。
//
// ⚠️ 多 worker 的两个已知副作用(均为进程内状态,非缺陷但需知悉):
// ① concurrencyGuard 的 active 是**进程内**计数,总上限 = worker 数 × max;
// ② loginRateLimit 的失败计数同为进程内 Map,实际阈值 = worker 数 × maxAttempts,
// 仅影响「防爆破」强度,不影响正确性(登录成功即清零该 key)。
// -----------------------------------------------------------------
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// sequelize - MariaDB/MySQL 数据库配置 // sequelize - MariaDB/MySQL 数据库配置
@@ -127,8 +218,10 @@ module.exports = (appInfo) => {
port: parseInt(process.env.DB_PORT, 10) || 3306, port: parseInt(process.env.DB_PORT, 10) || 3306,
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME // 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
username: process.env.DB_USERNAME || process.env.DB_USER || 'root', username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
// 【测试模拟值 '74191221',仅供本地/测试部署;生产请通过 DB_PASSWORD 环境变量注入真实密码】 // 🔴 口令不再内置明文兜底(2026-09-23 入库口令处置 P3):
password: process.env.DB_PASSWORD || '74191221', // 生产 —— 必须由 .env.production 注入 DB_PASSWORD(缺失则由下方生产安全守卫阻断启动);
// 本地 —— 由 config/config.local.js(已 gitignore)深合并覆盖为真实本机口令。
password: process.env.DB_PASSWORD,
timezone: '+08:00', timezone: '+08:00',
define: { define: {
underscored: true, underscored: true,
@@ -144,14 +237,21 @@ module.exports = (appInfo) => {
// 由上方 define.collate 负责。此处传 collate 会触发 // 由上方 define.collate 负责。此处传 collate 会触发
// "Ignoring invalid configuration option passed to Connection: collate" 警告。 // "Ignoring invalid configuration option passed to Connection: collate" 警告。
charset: 'utf8mb4', charset: 'utf8mb4',
// 🔴 DECIMAL 必须显式转 Number(2026-09-23,一位小数评分)
// mysql2 默认把 DECIMAL 读成**字符串**,会导致:① 前端出现
// "7.5" + 0 → "7.50" 之类的字符串拼接;② `n > per` 退化为字符串比较。
// 开启后 DECIMAL 以 Number 返回,与 INTEGER 行为一致;全库原无 DECIMAL 列
// (迁移 025 首次引入),故对既有功能零影响。
decimalNumbers: true,
}, },
pool: { pool: {
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」 // 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖), // 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。 // 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker, // 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 2,
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151, // 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 2 × 20 = 40,
// 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。 // 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言
// 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。
max: parseInt(process.env.DB_POOL_MAX, 10) || 20, max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
min: 2, min: 2,
acquire: 45000, acquire: 45000,
@@ -188,7 +288,8 @@ module.exports = (appInfo) => {
client: { client: {
port: parseInt(process.env.REDIS_PORT, 10) || 6379, port: parseInt(process.env.REDIS_PORT, 10) || 6379,
host: process.env.REDIS_HOST || '127.0.0.1', host: process.env.REDIS_HOST || '127.0.0.1',
password: process.env.REDIS_PASSWORD || '74191221', // 🔴 同上:不再内置明文兜底(2026-09-23 P3)。Redis 当前全库无使用点,留空为零影响。
password: process.env.REDIS_PASSWORD || '',
db: 0, db: 0,
}, },
}; };
@@ -351,6 +452,11 @@ module.exports = (appInfo) => {
const insecure = []; const insecure = [];
if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET'); if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET');
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS'); if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
// 🔴 2026-09-23(入库口令处置 P3):口令缺失同样阻断。
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
// 故现网永不触发本分支;一旦触发说明注入链路断了,明确阻断优于连不上库)。
if (!config.sequelize.password) insecure.push('DB_PASSWORD');
if (insecure.length > 0) { if (insecure.length > 0) {
// 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动 // 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动
throw new Error( throw new Error(

View File

@@ -1,7 +1,7 @@
{ {
"development": { "development": {
"username": "root", "username": "root",
"password": "74191221", "password": "CHANGE_ME_local_db_password",
"database": "competition_db", "database": "competition_db",
"host": "localhost", "host": "localhost",
"port": 3306, "port": 3306,
@@ -24,7 +24,7 @@
}, },
"test": { "test": {
"username": "root", "username": "root",
"password": "74191221", "password": "CHANGE_ME_local_db_password",
"database": "competition_db_test", "database": "competition_db_test",
"host": "localhost", "host": "localhost",
"port": 3306, "port": 3306,

View File

@@ -0,0 +1,80 @@
-- ============================================================================
-- 023 / 024 · 综合大题改造(中职组 / 高职组取消 Word 上传题 → 随机抽取大题在线作答)
-- 日期:2026-09-21
-- 目标库:MariaDB 5.5.68 competition_db (字符集 utf8mb4 / utf8mb4_general_ci)
--
-- 本文件是**服务器同步用**的可直接执行 DDL(与 023-*.js / 024-*.js 迁移等价)。
-- 本改造为纯 ADD TABLE:
-- · 不改动任何现有表结构
-- · 不动 zhongzhi_submissions / gaozhi_submissions(客观题链路)
-- · 不动 benke_submissions / jiaoshi_submissions(本科/教师组)
-- · 不动成绩发布门控与总分公式(中职/高职总分仍 = exam_score + word_score)
--
-- 注意:MariaDB 5.5 无 JSON 列类型(JSON 一律用 TEXT / MEDIUMTEXT 存文本);
-- DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳由应用层写入。
-- ============================================================================
-- ---------------------------------------------------------------------------
-- 023 · 综合大题题库
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `big_questions` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '大题ID',
`question_uid` varchar(64) NOT NULL COMMENT '大题稳定标识(全局唯一,形如 big_12)',
`group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi',
`title` varchar(200) NOT NULL COMMENT '大题标题',
`description` mediumtext DEFAULT NULL COMMENT '题干富文本 HTML(含表格)',
`sub_prompt_1` text DEFAULT NULL COMMENT '第1小问题干',
`sub_prompt_2` text DEFAULT NULL COMMENT '第2小问题干',
`sub_prompt_3` text DEFAULT NULL COMMENT '第3小问题干',
`score_per_sub` int(11) NOT NULL DEFAULT '10' COMMENT '每小题分值',
`total_score` int(11) NOT NULL DEFAULT '30' COMMENT '大题总分',
`sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序',
`status` int(11) NOT NULL DEFAULT '1' COMMENT '状态:1=启用(参与抽题),0=停用',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_big_question_uid` (`question_uid`),
KEY `idx_big_q_group_status` (`group`,`status`,`sort_order`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='综合大题题库(中职/高职组,替代原 Word 上传题)';
-- ---------------------------------------------------------------------------
-- 024 · 综合大题作答(一考生一行)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `big_question_answers` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`aggregation_uid` int(11) NOT NULL COMMENT '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
`contestant_id` int(11) DEFAULT NULL COMMENT '选手ID(冗余,便于管理端查证与排障)',
`group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi',
`big_question_id` int(11) DEFAULT NULL COMMENT '抽中的大题ID',
`big_question_uid` varchar(64) DEFAULT NULL COMMENT '抽中的大题稳定标识',
`question_snapshot` mediumtext DEFAULT NULL COMMENT '抽题时刻的题干快照 JSON(防纠纷存证)',
`answer_1` text DEFAULT NULL COMMENT '第1小问作答(上限2000字,后端同步校验)',
`answer_2` text DEFAULT NULL COMMENT '第2小问作答(上限2000字)',
`answer_3` text DEFAULT NULL COMMENT '第3小问作答(上限2000字)',
`score_1` int(11) DEFAULT NULL COMMENT '第1小问得分(0-10,人工阅卷)',
`score_2` int(11) DEFAULT NULL COMMENT '第2小问得分(0-10,人工阅卷)',
`score_3` int(11) DEFAULT NULL COMMENT '第3小问得分(0-10,人工阅卷)',
`total_score` int(11) DEFAULT NULL COMMENT '三小问得分合计(回写 contestants.word_score 的依据)',
`submit_time` datetime DEFAULT NULL COMMENT '随整卷正式提交写入(未提交为 NULL)',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_big_answer_agg` (`aggregation_uid`),
KEY `idx_big_answer_group` (`group`),
KEY `idx_big_answer_q` (`big_question_uid`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='综合大题作答(一考生一行,含题干快照)';
-- ============================================================================
-- 回滚(仅在确认弃用时执行)
-- DROP TABLE IF EXISTS `big_question_answers`;
-- DROP TABLE IF EXISTS `big_questions`;
--
-- 历史数据清理(2026-09-21 用户确认:已弃用,可全部删除)
-- DELETE FROM zhongzhi_submissions; -- 84 行(孤儿遗留,aggregation_uid 与现有选手不匹配)
-- DELETE FROM gaozhi_submissions; -- 480 行
-- UPDATE competition_times SET word_template = NULL WHERE `group` IN ('zhongzhi','gaozhi');
-- -- 并删除磁盘旧文件:app/public/answers/{zhongzhi,gaozhi}/、app/public/templates/{zhongzhi,gaozhi}/
-- -- ⚠️ 仅限中职/高职;本科/教师组的 word_template 与作品文件一律不得触碰。
-- ============================================================================

View File

@@ -0,0 +1,110 @@
'use strict';
/**
* 023 - 综合大题题库表 big_questions
*
* 背景(2026-09-21 需求变更)
* 中职组 / 高职组取消「Word 文件上传题」,改为从题库随机抽取一道大题在线作答;
* 大题含「文字描述 + 富文本表格」,下设三小问,每小问 10 分,大题总分 30 分。
*
* 隔离原则(硬约束:除本次功能外不得影响其他功能)
* 本表为**新增隔离表**,不改动任何现有表结构与既有客观题链路
* (choice_questions / judge_questions / zhongzhi_submissions / gaozhi_submissions
* 以及本科/教师组 benke_submissions / jiaoshi_submissions 全部零改动)。
*
* 环境说明
* 目标库为 MariaDB 5.5.68,**不支持 JSON 列类型**,故富文本 / 序列化数据一律用
* TEXT / MEDIUMTEXT 存储;亦不支持 DATETIME 的 DEFAULT CURRENT_TIMESTAMP,
* 时间戳列交由应用层(Sequelize timestamps)写入,与既有表(word_questions 等)一致。
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('big_questions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '大题ID',
},
question_uid: {
type: Sequelize.STRING(64),
allowNull: false,
comment: '大题稳定标识(全局唯一,形如 big_12)',
},
group: {
type: Sequelize.STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
title: {
type: Sequelize.STRING(200),
allowNull: false,
comment: '大题标题',
},
description: {
type: Sequelize.TEXT('medium'),
allowNull: true,
comment: '题干富文本 HTML(含表格)',
},
sub_prompt_1: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第1小问题干',
},
sub_prompt_2: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第2小问题干',
},
sub_prompt_3: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第3小问题干',
},
score_per_sub: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 10,
comment: '每小题分值',
},
total_score: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 30,
comment: '大题总分',
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
status: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 1,
comment: '状态:1=启用(参与抽题),0=停用',
},
created_at: {
type: Sequelize.DATE,
allowNull: true,
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: true,
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('big_questions', ['question_uid'], { name: 'uk_big_question_uid', unique: true }),
queryInterface.addIndex('big_questions', ['group', 'status', 'sort_order'], { name: 'idx_big_q_group_status' }),
]);
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('big_questions');
},
};

View File

@@ -0,0 +1,127 @@
'use strict';
/**
* 024 - 综合大题作答表 big_question_answers
*
* 背景(2026-09-21 需求变更)
* 中职组 / 高职组取消 Word 上传题,改为随机抽取一道大题、在页面内直接作答三小问。
*
* 设计要点
* 1. **一考生一行**:UNIQUE(aggregation_uid) —— 满足「一个考生一行数据保存」的目标,
* 且与题量配置解耦(客观题题量由 competition_times.choice_count/judge_count 配置,
* 属逐题表 zhongzhi/gaozhi_submissions,与本表互不干扰)。
* 2. **题干快照 question_snapshot**:抽题那一刻把题干(标题/描述/三小问/分值)序列化落库,
* 题目后续被管理员修改也不影响存证 → 防考生纠纷。
* 3. **不存储每题一列**:客观题 50+20=70 题且题量可配,逐题建列会导致配置每变更一次
* 就要 ALTER TABLE(反扩展性);大题固定 3 小问,故 answer_1/2/3 列式存储是安全的。
*
* 隔离原则
* 新增隔离表,zhongzhi_submissions / gaozhi_submissions 零改动、零迁移。
*
* 环境说明
* MariaDB 5.5.68:无 JSON 列类型(快照用 MEDIUMTEXT 存 JSON 文本);
* DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳交给应用层写入。
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('big_question_answers', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '主键ID',
},
aggregation_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
},
contestant_id: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '选手ID(冗余,便于管理端查证与排障)',
},
group: {
type: Sequelize.STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
big_question_id: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '抽中的大题ID',
},
big_question_uid: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '抽中的大题稳定标识',
},
question_snapshot: {
type: Sequelize.TEXT('medium'),
allowNull: true,
comment: '抽题时刻的题干快照 JSON(防纠纷存证)',
},
answer_1: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第1小问作答(上限 2000 字,后端同步校验)',
},
answer_2: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第2小问作答(上限 2000 字)',
},
answer_3: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第3小问作答(上限 2000 字)',
},
score_1: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '第1小问得分(0-10,人工阅卷)',
},
score_2: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '第2小问得分(0-10,人工阅卷)',
},
score_3: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '第3小问得分(0-10,人工阅卷)',
},
total_score: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '三小问得分合计(阅卷后回写 contestants.word_score 取数依据)',
},
submit_time: {
type: Sequelize.DATE,
allowNull: true,
comment: '随整卷正式提交写入(占位/未提交为 NULL)',
},
created_at: {
type: Sequelize.DATE,
allowNull: true,
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: true,
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('big_question_answers', ['aggregation_uid'], { name: 'uk_big_answer_agg', unique: true }),
queryInterface.addIndex('big_question_answers', ['group'], { name: 'idx_big_answer_group' }),
queryInterface.addIndex('big_question_answers', ['big_question_uid'], { name: 'idx_big_answer_q' }),
]);
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('big_question_answers');
},
};

View File

@@ -0,0 +1,99 @@
-- ============================================================================
-- 025 一位小数评分支持:分数列 int(11) → DECIMAL(6,1)
--
-- 背景(2026-09-23)
-- 需求:中职 / 高职「综合大题」的人工评分允许保留小数点后一位(如 7.5)。
-- 现状:全库 23 个 score 类列**全部**为 int(11) ⇒ 小数落库即被静默截断;
-- 且 service/bigQuestion.js:337 另有一处 Math.round() 主动取整。
-- 本迁移把「得分」语义的列放宽为 DECIMAL(6,1)(上限 99999.9,保留 1 位小数)。
--
-- 范围(17 列)
-- 改(得分 / 分数落点):
-- big_question_answers.score_1 / score_2 / score_3 / total_score
-- contestants.exam_score / word_score / ppt_score / score
-- accounts.score
-- zhongzhi_submissions.score / gaozhi_submissions.score / exam_submissions.score
-- benke_submissions.score / jiaoshi_submissions.score / work_submissions.score
-- big_questions.score_per_sub / total_score(大题分值配置,随评分口径)
-- 不改:
-- choice_questions.score / judge_questions.score
-- —— 客观题口径已固定为每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION),
-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1;
-- word_questions.score、exam_questions/zhongzhi_questions/gaozhi_questions.score
-- —— 属题目分值配置或历史遗留题表,无小数需求。
--
-- 兼容性
-- INT → DECIMAL(6,1) 是**放宽**:既有整数值原样保留,读写语义不变。
-- ⚠️ 应用侧必须同步:
-- ① config/config.default.js 的 sequelize.dialectOptions.decimalNumbers = true
-- (否则 mysql2 把 DECIMAL 读成**字符串**);
-- ② 相关 model 字段类型声明为 DECIMAL,且 getter 转 Number
-- (否则 Sequelize 的 INTEGER.parse 会把 7.5 二次截断成 7)。
--
-- 执行(在 database/ 目录下):
-- mysql -uroot -p competition_db < migrations/025-scores-decimal-one-place.sql
-- 幂等:MODIFY 到同一类型可重复执行。
-- ============================================================================
-- ---------------------------------------------------------------- 大题作答
ALTER TABLE `big_question_answers`
MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 1 位小数)',
MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 1 位小数)',
MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 1 位小数)',
MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '大题合计得分(可含 1 位小数)';
-- ---------------------------------------------------------------- 大题配置
ALTER TABLE `big_questions`
MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10 COMMENT '每小题分值',
MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数';
-- ---------------------------------------------------------------- 选手成绩
ALTER TABLE `contestants`
MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面考试成绩',
MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 1 位小数)',
MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 1 位小数)',
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)';
-- ---------------------------------------------------------------- 账号分(历史兼容)
ALTER TABLE `accounts`
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)';
-- ---------------------------------------------------------------- 提交表分项
ALTER TABLE `zhongzhi_submissions`
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
ALTER TABLE `gaozhi_submissions`
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
ALTER TABLE `exam_submissions`
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面得分';
ALTER TABLE `benke_submissions`
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
ALTER TABLE `jiaoshi_submissions`
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
ALTER TABLE `work_submissions`
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
-- ============================================================================
-- 回滚(⚠️ DECIMAL → INT 会**丢失**既有小数位,执行前务必确认无小数值:
-- SELECT COUNT(*) FROM contestants WHERE score <> FLOOR(score); -- 应为 0)
--
-- ALTER TABLE `big_question_answers`
-- MODIFY COLUMN `score_1` INT(11) NULL DEFAULT NULL,
-- MODIFY COLUMN `score_2` INT(11) NULL DEFAULT NULL,
-- MODIFY COLUMN `score_3` INT(11) NULL DEFAULT NULL,
-- MODIFY COLUMN `total_score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `big_questions`
-- MODIFY COLUMN `score_per_sub` INT(11) NOT NULL DEFAULT 10,
-- MODIFY COLUMN `total_score` INT(11) NOT NULL DEFAULT 30;
-- ALTER TABLE `contestants`
-- MODIFY COLUMN `exam_score` INT(11) NULL DEFAULT NULL,
-- MODIFY COLUMN `word_score` INT(11) NULL DEFAULT NULL,
-- MODIFY COLUMN `ppt_score` INT(11) NULL DEFAULT NULL,
-- MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `accounts` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
-- ============================================================================

View File

@@ -0,0 +1,98 @@
-- ============================================================================
-- 026 两位小数评分支持:分数列 DECIMAL(6,1) → DECIMAL(6,2)
--
-- 背景(2026-09-28 测试人员反馈)
-- 需求:中职 / 高职「综合大题」的人工评分需精确到**小数点后两位**(如 8.25)。
-- 现状:025 已把得分列由 INT(11) 放宽为 DECIMAL(6,1),但仍只有 1 位小数
-- ⇒ 8.25 会被静默收敛为 8.3 / 8.2,测试人员的评分意图落不了库。
--
-- 范围(17 列,与 025 完全一致,仅精度 1 → 2 位)
-- 大题作答:big_question_answers.score_1 / score_2 / score_3 / total_score
-- 大题配置:big_questions.score_per_sub / total_score(随评分口径)
-- 选手成绩:contestants.exam_score / word_score / ppt_score / score
-- 账号分: accounts.score
-- 提交表: zhongzhi_submissions.score / gaozhi_submissions.score /
-- exam_submissions.score / benke_submissions.score /
-- jiaoshi_submissions.score / work_submissions.score
-- 不改: choice_questions.score / judge_questions.score
-- —— 客观题口径已固定每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION),
-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1。
--
-- 兼容性
-- DECIMAL(6,1) → DECIMAL(6,2) 是**放宽**:既有值原样保留(7.5 读出来仍是 7.5),
-- 读写语义不变。取值范围 [−9999.99, 9999.99],足够覆盖总分 / 单题分。
-- ⚠️ 应用侧必须同步(本次已改):
-- ① lib/decimal_score.js → DECIMAL(6,2)(否则 Sequelize 会把 8.25 按 (6,1) 截断)
-- ② service/bigQuestion.js 的逐问校验与合计收敛 = ×100 / 100
-- ③ service/admin.js 的复核汇总收敛 = ×100 / 100
-- ④ 前端 Admin.vue 评分输入 :precision="2" :step="0.25"
--
-- 执行(在 database/ 目录下):
-- mysql -uroot -p competition_db < migrations/026-scores-decimal-two-places.sql
-- 幂等:MODIFY 到同一类型可重复执行。
-- ============================================================================
-- ---------------------------------------------------------------- 大题作答
ALTER TABLE `big_question_answers`
MODIFY COLUMN `score_1` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 2 位小数)',
MODIFY COLUMN `score_2` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 2 位小数)',
MODIFY COLUMN `score_3` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 2 位小数)',
MODIFY COLUMN `total_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '大题合计得分(可含 2 位小数)';
-- ---------------------------------------------------------------- 大题配置
ALTER TABLE `big_questions`
MODIFY COLUMN `score_per_sub` DECIMAL(6,2) NOT NULL DEFAULT 10 COMMENT '每小题分值',
MODIFY COLUMN `total_score` DECIMAL(6,2) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数';
-- ---------------------------------------------------------------- 选手成绩
ALTER TABLE `contestants`
MODIFY COLUMN `exam_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '卷面考试成绩',
MODIFY COLUMN `word_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 2 位小数)',
MODIFY COLUMN `ppt_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 2 位小数)',
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)';
-- ---------------------------------------------------------------- 账号分(历史兼容)
ALTER TABLE `accounts`
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)';
-- ---------------------------------------------------------------- 提交表分项
ALTER TABLE `zhongzhi_submissions`
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '本题得分';
ALTER TABLE `gaozhi_submissions`
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '本题得分';
ALTER TABLE `exam_submissions`
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '卷面得分';
ALTER TABLE `benke_submissions`
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
ALTER TABLE `jiaoshi_submissions`
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
ALTER TABLE `work_submissions`
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
-- ============================================================================
-- 回滚(DECIMAL(6,2) → DECIMAL(6,1) 会**丢失**第 2 位小数,执行前须确认:
-- SELECT COUNT(*) FROM big_question_answers
-- WHERE score_1 <> ROUND(score_1,1) OR score_2 <> ROUND(score_2,1)
-- OR score_3 <> ROUND(score_3,1) OR total_score <> ROUND(total_score,1); -- 应为 0)
--
-- ALTER TABLE `big_question_answers`
-- MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL,
-- MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL,
-- MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL,
-- MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `big_questions`
-- MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10,
-- MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30;
-- ALTER TABLE `contestants`
-- MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL,
-- MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL,
-- MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL,
-- MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `accounts` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
-- ============================================================================

38
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@artus/core": "^2.2.4", "@artus/core": "^2.2.4",
"@eggjs/router": "^3.0.6", "@eggjs/router": "^3.0.6",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"bcryptjs": "^3.0.3", "bcrypt": "^6.0.0",
"cliui": "^9.0.1", "cliui": "^9.0.1",
"egg": "^3.34.0", "egg": "^3.34.0",
"egg-cors": "^2.2.4", "egg-cors": "^2.2.4",
@@ -1606,13 +1606,17 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": { "node_modules/bcrypt": {
"version": "3.0.3", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz", "resolved": "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"license": "BSD-3-Clause", "hasInstallScript": true,
"bin": { "dependencies": {
"bcrypt": "bin/bcrypt" "node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
} }
}, },
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
@@ -7748,6 +7752,14 @@
"lower-case": "^1.1.1" "lower-case": "^1.1.1"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.9.2",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7767,6 +7779,16 @@
"node": ">=10.5.0" "node": ">=10.5.0"
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-hex": { "node_modules/node-hex": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",

View File

@@ -14,7 +14,7 @@
"@artus/core": "^2.2.4", "@artus/core": "^2.2.4",
"@eggjs/router": "^3.0.6", "@eggjs/router": "^3.0.6",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"bcryptjs": "^3.0.3", "bcrypt": "^6.0.0",
"cliui": "^9.0.1", "cliui": "^9.0.1",
"egg": "^3.34.0", "egg": "^3.34.0",
"egg-cors": "^2.2.4", "egg-cors": "^2.2.4",

View File

@@ -12,7 +12,7 @@
* cd <生产 backend 目录> * cd <生产 backend 目录>
* node scripts/diagnose_upload_download.cjs * node scripts/diagnose_upload_download.cjs
* # 如需抽查数据库(可选): * # 如需抽查数据库(可选):
* DB_HOST=127.0.0.1 DB_USER=root DB_PASS=74191221 DB_NAME=competition_db \ * DB_HOST=127.0.0.1 DB_USER=root DB_PASS='<本机 DB 口令>' DB_NAME=competition_db \
* node scripts/diagnose_upload_download.cjs * node scripts/diagnose_upload_download.cjs
* *
* 不依赖 Egg 启动,纯 Node + fs 即可;DB 部分为可选(拿不到模块/连接失败则跳过)。 * 不依赖 Egg 启动,纯 Node + fs 即可;DB 部分为可选(拿不到模块/连接失败则跳过)。
@@ -114,7 +114,7 @@ console.log('--- 4. 数据库 submission.file_path 真实性抽查(可选)--
const dbCfg = { const dbCfg = {
host: process.env.DB_HOST || '127.0.0.1', host: process.env.DB_HOST || '127.0.0.1',
user: process.env.DB_USER || 'root', user: process.env.DB_USER || 'root',
password: process.env.DB_PASS || '74191221', password: process.env.DB_PASS,
database: process.env.DB_NAME || 'competition_db', database: process.env.DB_NAME || 'competition_db',
}; };

View File

@@ -1,8 +1,17 @@
const { Sequelize } = require('./node_modules/sequelize'); const { Sequelize } = require('./node_modules/sequelize');
const sequelize = new Sequelize('competition_db', 'root', '74191221', { // 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
host: 'localhost', port: 3306, dialect: 'mysql', // DB_PASSWORD='<本机 DB 口令>' node seed-carousel.js
logging: false const sequelize = new Sequelize(
}); process.env.DB_NAME || 'competition_db',
process.env.DB_USER || 'root',
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3306,
dialect: 'mysql',
logging: false,
}
);
(async () => { (async () => {
try { try {

View File

@@ -1,7 +1,12 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
(async () => { (async () => {
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
// DB_PASSWORD='<本机 DB 口令>' node show_tables.js
const c = await mysql.createConnection({ const c = await mysql.createConnection({
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db' host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'competition_db',
}); });
const [t] = await c.query('SHOW TABLES'); const [t] = await c.query('SHOW TABLES');
t.forEach(r => console.log(Object.values(r)[0])); t.forEach(r => console.log(Object.values(r)[0]));