Compare commits

..

43 Commits

Author SHA1 Message Date
7bca48369f fix(upload): 作品上传上限 100MB -> 150MB(R225)
现象:教师组反馈作品压缩包上传失败。生产日志证实为
nodejs.MultipartFileTooLargeError(今日 10 次:jiaoshi 8 次 / benke 2 次,
10:37~11:07 密集出现)。

根因:作品上传的唯一通道是 POST /api/upload/:group 单次流式整包
(分片通道前端已于 R43 删除,仅服务端保留),因此全组共用同一个
config.multipart.fileSize='100mb';而前端 uploadCombo.ZIP_MAX 长期承诺
200MB ⇒ 100~200MB 的 zip 被前端放行、被后端拒收。日志里的请求耗时
仅 322~665ms,是 nginx(proxy_request_buffering 默认 on)缓冲完整 body
后一次性转发、Node 在内网回环速度下读完并超限报错的时间,与「用户已
耗时数分钟上传完才看到失败」并不矛盾。

改动:
- config.multipart.fileSize: '100mb' -> '150mb',并在注释里标明这是
  所有 multipart 通道的唯一上限,须与 frontend uploadCombo.MAX_MB、
  nginx client_max_body_size 三处同步。
- app/controller/upload.js: docstring 里「最大 50MB」早已失效,
  改为指向配置项,避免再次漂移。

三层限额核对:frontend 150MB = backend 150MB (157286400 B)
< nginx client_max_body_size 200m (209715200 B),nginx 留出余量。

验证:生产 config 去 CR md5 与仓库一致(改动前 c9b997fd…),
仅本改动一项差异;重启后 6s 就绪,/api/health 返 db:"ok"。
2026-10-23 11:19:17 +08:00
c15b46a0f9 feat(score): 成绩排名表导出 R220(客观题分排名 + 三档底色 + 前80红框)+ 修公示表参赛单位
中职/高职按小组客观题分降序全量导出,一行 = 一个小组,6 列:
名次 / 小组编号 / 学校 / 参赛选手 / 人数 / 客观题分(不含大题分与总分)。

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

修复 buildPublicityXlsx 参赛单位取值(R220):
where:{group} -> account_uid IN (contestants.id)。
生产库 aggregation.group 中职/高职 161 行全 NULL,旧写法导出的公示表
「参赛单位名称」整列空白;改后非空率 100%。
2026-10-22 17:25:22 +08:00
78f34ea9f5 R194+R195: 小组维度作品列表 / 下载名带小组编号 / includeIdle 口径 / deleteNews 连带清理附件
- service/admin.js
  · getScores & getExamSubmissions 支持 includeIdle(默认隐藏「从未参赛」报名者),
    成绩导出与批量打包强制全量(includeIdle:true),页面开关不影响导出内容
  · 新增 getTeamFolders:小组编号口径统一走 resolveTeamLabels,⛔ 不直接展示 team_id
  · 单个/批量下载包内名统一走 buildTeamDownloadName(中职高职 <小组编号>-<姓名>-<原名>)
  · 🔴 deleteNews:原实现只 News.destroy 不删盘 ⇒ 每轮公示表测试留 4 个孤儿 xlsx。
    改为「先删库后删盘」并连带清理 attachment_url 附件;新增 _removeNewsFile 三重安全闸门
    (仅 /public/news/ 单段文件名 + 自产命名前缀 + path.resolve 不越界)与幂等语义,
    公示表覆盖写复用同一入口(白名单收窄到 news_file_publicity_)
- lib/team_folder.js:新增 buildTeamDownloadName + MAX_DOWNLOAD_NAME=150(防 Windows 255 上限)
- controller/admin.js:透传 includeIdle;batchDownload 接住 bizCode=EMPTY(业务态返 404 非 500);
  中职高职单个大题下载名改走 buildTeamDownloadName

验证:真接口探针 12/12(双清 + 幂等 + 三重闸门);变异 M1(不删盘)1 红、M2(去闸门)3 红,
还原后 12/12 复绿
2026-10-22 09:41:51 +08:00
0a380371ec feat(upload): 组内作品唯一 + 小组编号目录 + 按小组打包下载(R190)
需求(管理人员反馈):
① 本科组/教师组同样遵循「小组内只要有一人上传作品,其他人就不能再上传」;
② 作品按「小组编号」建目录存放并保留原始文件名,管理端下载时按小组打包。

后端落地:
- 组锁:新增 getTeamUploadState/assertTeamUploadAllowed,按 (group, teamUid) 取全组
  提交记录,**最早提交者为归属人**;其他人上传与分片合并两条路径都返 6006
  (data 带 teamLocked/teamOwnerName/teamNumber/folderName)。删除本人作品后自动解锁。
  ⛔ 必须先按 group 过滤再认 teamUid(team_uid 仅组内唯一)。
- 落盘:works/<group>/<小组编号>/<原始文件名>。目录名走新增 lib/team_folder.js
  (唯一 SoT:净化 Windows 非法字符/控制字符/首尾点/设备保留名,限长 60)。
  撞名三态:不存在→原名;同一聚合+同一 file_type→原名原子覆盖;否则 (2)(3)。
  DB file_path 仍为相对路径 ⇒ 存量平铺文件免迁移。
- 直传与分片合并收敛到同一套 prepareWorkTarget + commitWorkFile:先写同目录临时名
  (.upload_*/.merge_*),再 renameSync;失败一律 safeCleanup 后抛。
- status 接口新增 teamLocked/canUpload/teamOwnerName/isTeamOwner/teamFiles/memberCount。
- 管理端:works 列表返回 teamUid/teamNumber/teamFolder(走 resolveTeamLabels 既有口径);
  新增 GET /api/admin/team-zip/:group?teamUid= 单组打包(仅本科/教师,其余 400,包内平铺);
  batch-download 包内改为 <小组编号>/<原始文件名>。两者共用 _buildZip。

验证:真接口 45/45 + 变异 M1(locked=false ⇒ 15 条断言红,还原后复绿);
已收编为门禁第 58 步。
2026-10-21 15:20:42 +08:00
de719d8ec6 feat(admin): 时间配置加入值级乐观锁 + 并发冲突返回 4090(B②)
场景:两个管理员同时打开「时间设置」,甲先保存,乙手里的表单已过期,
乙再保存就会静默覆盖甲的结果 —— 此前 setTimes 直接 upsert,无任何检测。

- service/admin.js:setTimes(times, { expected }) 先**全量校验**、全部通过后才写
  (边校验边 upsert 会留下"部分生效"的半成品)。判据选值级比对而非时间戳:
  competition_times.updated_at 没有 ON UPDATE CURRENT_TIMESTAMP,其刷新依赖
  Sequelize 对 default 字面量的处理,语义不确定 ⇒ 建在它上面等于建在流沙上。
  比对前归一到秒级,避免毫秒尾差造成恒定假冲突。
- controller/admin.js:冲突返回 **200 + code=4090**(⛔ 5xx —— 按 500 返回会让前端
  拦截器提示"服务器错误",把"你刷新一下"掩盖成"系统挂了")。
- 不传 expectedTimes ⇒ 退回旧行为,不破坏既有调用方。
2026-10-21 13:44:31 +08:00
459f1adf6d feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
2026-10-20 11:06:29 +08:00
5392345078 feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复
成绩导入(R173 落地 → R174 改为三小问四列制)
- app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件)
  · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值
  · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE
  · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝
  · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号
- app/service/bigQuestion.js
  · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持")
  · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores
  · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores
- app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence)
- app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores
  · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载)

节点备份调度器(R168/R170)
- app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间
  · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻
  · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源)
- config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地
  (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导)
- app.js:启动构造单例 + hydrate 未到期计划

缺陷修复
- app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理
- app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount)
- app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注

测试
- .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js,
  使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
192b43bc67 feat(auth): R114 默认口令 SoT 缺口注释同步(已下沉为仓内常驻门禁)
passwordPolicy.js 头注原如实登记「尚无仓内机检」覆盖默认口令清单。
本轮 R114 已把断言下沉进仓(scripts/unit_password_policy.cjs 第 [8] 段 16 项
+ scripts/unit_password_policy_mutation.cjs 3 个新变异体),并接线为
scripts/ci-gates.sh 第 25 步 ⇒ 原注释失实,就地更正为「已收口」。

同时明确机检覆盖边界:机检覆盖「本清单自身的形状与判定行为」(非空 /
双通道命中 / 非默认不误伤);「本清单 <-> database/schema.sql <-> seeder 002」
三处的跨文件同值仍靠人工核对。
2026-10-14 11:30:41 +08:00
14a110eb5f docs(auth): R113-㉔ 哈希来源注释同步(schema.sql 已统一为 bcrypt)
㉔ 采纳 bcrypt:database/schema.sql 的初始口令由 SHA256(admin) 统一为 bcrypt(admin123) ⇒ 与 seeder 002 同值同形态(两条建库路径收敛)。本文件同步两处来源注释(⛔ 未删原文,保留可追溯性):
① isDefaultAdminHash 头注 —— 哈希通道来源改为「R113 前 schema.sql 无盐 SHA256;R113 起 schema.sql 与 seeder 002 统一为同一固定盐 bcrypt」;
② KNOWN_DEFAULT_ADMIN_HASHES 来源注释 —— SHA256 项改注为「历史值保留」(旧库/旧备份仍须拦截,且非默认口令不会命中)、bcrypt 项注明「两处已收敛为同值」。

验证:node --check ✔;单测 34/34;变异 3/3;R112 驱动回归 45/45;R113 判别力自证 9/9 + 5 变异全红(仓外 .workbuddy/tmp/r113/verify_r113_changes.cjs);verify_no_secrets HIGH 0/CAND 2(零新增);verify_tautology_guard PASS;verify_constant_shape_guard PASS;三文件 CR=0。
2026-10-14 11:18:08 +08:00
4f78995f1c fix(auth): 默认口令守卫改为哈希值判据(双通道 + 下沉单源)
R112-㉒:原守卫是「口令字面」判据(DEFAULT_ADMIN_PASSWORDS.includes),任何未枚举的默认口令均绕过。改为双通道并下沉单源:① 哈希通道 isDefaultAdminHash(admin.password) —— 与输入无关,字面比对已发布默认哈希(SHA256('admin') + 固定盐 bcrypt)并以 bcrypt.compareSync 遍历候选口令,覆盖「同口令、不同盐」的重新播种;② 口令通道沿用原逻辑。清单与哈希集下沉至 lib/passwordPolicy.js 成为唯一真相源(controller 删本地声明)。bcrypt 延迟加载 + try/catch ⇒ 无 bcrypt 时降级为仅字面比对且不抛异常。

验证:node --check x2;单测 34/34;变异 3/3 红;仓外判别力自证 45/45 + 4 变异全红;verify_no_secrets HIGH 0/CAND 2(零新增);verify_tautology_guard PASS;verify_constant_shape_guard PASS;6 文件 CR=0。连带失真同步:守卫行 387->394 ⇒ schema.sql:218 + docs/modules.md:581。
2026-10-14 11:06:23 +08:00
ae436c01ca fix(auth): seeder 002 改种 admins + 默认口令守卫补 'admin'(候选⑳/㉑)
候选⑳:002-seed-admin.js 原 bulkInsert 到 accounts 表(row 含 role/name_cn/salt
等列),但登录链路读 admins 表(service/auth.js:101 findAdmin() → model/admin.js:49
tableName='admins')⇒ 原写法属死写入,种下的行永不被登录读取。改为写入 admins 并按
其真实列契约(id/username/password/created_at/updated_at)重写 row;down() 同步改
bulkDelete('admins', {username:'admin'})。前置已全仓 grep 确认 accounts.role='admin'
零消费点(后端 4 处 Account 用法均按 id_number/uid 定位;role==='admin' 5 处判定全
作用于 JWT payload / 前端本地态)。

候选㉑:DEFAULT_ADMIN_PASSWORDS 原为 ['admin123','admin123456'],不含 'admin',而
schema.sql 建库路径的初始口令恰为 admin ⇒ 生产未改密时该口令可绕过 4005 守卫
(verifyPassword 兼容 SHA256 ⇒ 可直接登录)。已补入 'admin'。结构性缺口(口令字面
判据 ⇒ 未枚举的默认口令均绕过)未消除,登记为 BACKLOG 候选㉒。

同源引用同步(就地更正、零删除):schema.sql:213-218 注释、docs/modules.md:581
注释(含行号引用 378→387)。

验证:node --check 通过;R111 判别力自证 22/22 PASS + 4 项变异全红;
verify_no_secrets HIGH 0 / CAND 2(与基线一致 ⇒ 零新增);verify_tautology_guard
PASS;verify_constant_shape_guard PASS。
2026-10-14 10:47:51 +08:00
6d3fbd62b4 security(seeders): 初始管理员 seeder 支持 ADMIN_INIT_PASSWORD 覆盖(R110-B5①b)
- 002-seed-admin.js:口令改为「内置 bcrypt 哈希默认 + ADMIN_INIT_PASSWORD 环境变量覆盖」;
  提供环境变量时以 bcrypt.hashSync(值,10) 重算哈希、salt 取哈希前 29 字符(bcrypt 盐前缀)。
- 默认分支保持原哈希不变 ⇒ 既有开发/演示链路(admin123)零回归。
- 本文件⛔不落盘任何明文口令(铁律 25);凭据扫描 CAND 计数 2 → 2(零新增,可见性无损)。
2026-10-14 10:22:53 +08:00
706ff2507e feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108)
此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后
改不了也删不掉。本提交新增受控编辑通道。

service.updateContestant(id, payload):
  · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 /
    password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略
    (静默忽略会让管理员误以为改成功)
  · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝;
    校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中
    即有 4 条校验位不符,硬拦截会造成合法数据改不动)
  · 唯一性冲突 => 409
  · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录
    账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支:
    测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算
  · 同值提交(含仅大小写差异)不发 UPDATE
  · 审计日志证号脱敏、口令只记「是否重算」

controller.updateContestant:映射 400/404/409。
router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 =
非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。

判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。
行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570)
=> 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
f87ed306d2 fix(import): 将身份证号判据单一源 lib/idCard.js 纳入版本控制(R107 遗漏)
该模块是 GB 11643-1999 校验的**判据单一源**,被下列位置同源引用:
  · backend/app/service/admin.js  require('../lib/idCard')(导入通道 + R108 编辑接口)
  · scripts/preflight_import_check.py  —— ★ 逐字对齐 WEIGHTS / CHECK_CODES
  · scripts/verify_update_contestant.mjs (P4 证号同源判据)

此前从未纳入版本控制(untracked),若只推送已跟踪文件,线上 / 新克隆将缺该模块,
导致「导入前置校验」与「选手信息编辑接口」同时 ReferenceError。
本提交仅纳入该文件,不含 R108 其余未提交改动。
2026-10-12 16:55:20 +08:00
76ac1c7b6b security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9)
共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。
本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」,
但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。

D6 · APP_KEYS 派生兜底(原 config.default.js:157)
  原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
      config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
  「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开
  ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口:
    · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys()
      (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底)
    · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值)
    · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移)

D7 · 生产弱口令兜底(原 config.default.js:176)
  原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
  而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产
  实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 /
  显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。
  修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的
  app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。

D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217)
  原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把
  localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、
  不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。
  且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。
  修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。
  配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 ——
  「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。

D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv)
  非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留
  JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有
  **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界
  实际被打通,属**安全属性**缺陷。
  修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由
  scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret()
  同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端
  验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。

行为等价性(生产逐字不变,已逐项实测):
  · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变
  · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达
  · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变

验证:
  · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产
    a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null
  · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功,
    /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS)
  · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401**
  · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200
  · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17);
    verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00
cf91f6ba34 fix(download): 哨兵旁路改走 Content-Disposition 的 size= 扩展参数(R103 收口)
背景(R103-3 生产实测已定因):
  X-Accel-Redirect 分支下真正吐字节的是 nginx,Node 侧无从得知 nginx 是否吐对
  了内容(内部 location 缺失时会吐前端 index.html)。原实现靠自定义响应头
  X-File-Size 把「服务端 stat 的真实字节数」透出给前端比对,但双通道对照实测
  (.workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js)证伪了原注释所称
  「nginx 会保留上游自定义响应头」:
    · 直连后端 :8023 ⇒ 204 空体,响应带 x-file-size: 19009
    · 经 nginx :80  ⇒ 200 / 19009 字节(与磁盘逐字节一致),而 x-file-size **缺失**
    · 对照 /api/health 经 nginx ⇒ 自定义头 x-readtime **保留**
  ⇒ 丢弃只发生在 X-Accel 内部重定向这一条路径。
  影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。

修法:
  把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`(实测
  Content-Disposition 能穿透 X-Accel),前端按 x-file-size → CD size= 顺序回落。
  两通道同源同值;任一缺失只降级不误伤。Node 流分支(absolutePath)不加旁路 ——
  其响应走 nginx 常规转发,自定义头本就保留。

验证(2026-10-12 生产部署后实测):
  · 本地判别力自证 verify_cd_size_channel.js:pass=12 fail=0
    (含 C1 变异测试:修复前旧 CD 形态解不出 ⇒ 判据确判别力)
  · 生产判决点 r106_prod_sentinel.js:pass=11 fail=0
    ARM A(直连) CD 含 size=19009;ARM B(经 nginx) CD 的 size=19009 穿透成功;
    B2 端到端 sha256 与磁盘逐字节一致;C1 对照组佐证丢弃仅限 X-Accel 通道。
2026-10-12 11:05:47 +08:00
89b9b2b0da security(config): 移除内置 JWT 密钥明文兜底,收敛到唯一解析入口(R105-D1)
问题(R104 卫生清理 H2 分类的唯二受版本控制命中):
  config.default.js 原为
      const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs';
      secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET
  该常量为**已入库、任何人可读**的明文,且与 scripts/.creds.local.json 的
  jwt_fallback_secret 同值 ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token
  冒充任意身份」。它与仓库既有纪律(_creds.py / _db.cjs / 本文件:459 对
  DB_PASSWORD 的「缺失即阻断」)不一致 —— 同族问题各自实现必然漂移。

修法:
  · config/config.default.js
      - 删除 DEFAULT_JWT_SECRET 常量与 `||` 兜底,改调唯一解析入口;
      - 生产安全守卫中同步移除 `jwt.secret === DEFAULT_JWT_SECRET` 比对分支
        (不再存在可比的兜底值;缺失时解析期即抛错)。
  · 新增 scripts/_secret.cjs(与 _db.cjs / _creds.py 同一纪律)
      ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret
      → ③ 明确失败(⛔ 刻意不提供明文兜底)

行为等价性:生产经 systemd EnvironmentFile、本地经 loadDeployEnv 从
deploy/.env.production 注入 JWT_SECRET,正常运行路径逐字不变;仅消除了
「密钥缺失时静默使用公开值」这一分支。

验证:
  · 后端真实重启 ⇒ 配置加载成功,生效 jwt.secret 指纹与注入值一致;
  · 端到端判别力(本地 7001 与生产 8023 各一次):新密钥签发 ⇒ 200,
    旧密钥签发 ⇒ 401(auth.js jwt.verify 分支);
  · scripts/verify_no_secrets.py:HIGH 0,判别力自证 17/17;
  · scripts/verify_script_health.py:H1–H4 PASS。
2026-10-12 10:26:03 +08:00
3b4d422e36 fix(download): X-Accel-Redirect 头值须 URL 编码(修复中文名下载 500)
承接 c94d8fe:上一提交把中文名从 400「非法文件路径」放行后,暴露出
同一族 bug 的第二处漏网 —— sendFile.js:98 把含中文的 relativePath
直接拼进 X-Accel-Redirect 头值,Node res.setHeader 抛
ERR_INVALID_CHAR ⇒ 500(生产实测,common-error.log 为铁证)。

该分支仅在 NODE_ENV=production(X-Accel 开启)下命中,本地 dev 走
Node 流分支 ⇒ 永远测不出,典型「本地绿、生产红」陷阱。

修法:内部路径逐段 encodeURIComponent(保留 / 分隔符)得到纯 ASCII
的 internalUri;nginx 按解码后 URI 匹配 internal location,alias 落到
磁盘真实中文文件名。

验证:X-Accel 头编码判别力自证 PASS=7/FAIL=0(旧写法必抛
ERR_INVALID_CHAR;新写法头全 ASCII、逐段解码 == 原路径);
经 nginx 生产端到端 PASS=7/FAIL=0(中文名 200、bytes==磁盘 size、
下载内容 sha256 == 源文件 sha256、RFC5987 头正确);
直连 8023 对照 204/0 字节(反证字节确由 nginx 吐出)。
2026-10-12 09:34:32 +08:00
c94d8fe3ff fix(download): sendFile 路径校验支持中文文件名(修复 400 非法文件路径)
根因:app/utils/sendFile.js:36 的路径穿越防护用全 ASCII 白名单
/^[A-Za-z0-9_.\/-]+$/,把合法中文文件名(如 测试.xlsx)误判为非法
路径 ⇒ 400「非法文件路径」。该正则与同文件 :88 的 RFC 5987
(filename*=UTF-8''…)中文名支持自相矛盾 —— 中文名本是设计内支持。

影响面:sendFile 是统一文件出口,materials(备赛资料,磁盘保留原始
中文/全角名)/ exam(Word 模板)/ admin(作品·导出)三通道,其中
materials 重度受影响(选手侧直接可见);works 通道磁盘名 ASCII 化
(work_<ts>_<rand>.<ext>)不受影响。

修法:把安全判据从「字符集白名单」换成「危险形态黑名单(控制字符/
反斜杠/绝对路径/.. 段)+ path.resolve 归一化后前缀校验(必须落在
app/public 内)」双层防护,不误伤 Unicode,且防穿越更硬。

验证:端到端(真后端 7001 + 真 DB)PASS=9/FAIL=0(中文名 200、
字节==磁盘 size、X-File-Size 哨兵一致);sendFile 守卫判别力自证
PASS=9/FAIL=0(5 类危险形态全 400、3 类合法 Unicode 放行);
既有门禁 verify_upload_download.py 16/16 通过。
2026-10-12 09:28:44 +08:00
95ad1adca4 fix(deps): xlsx → @e965/xlsx@0.20.3(消除两处已知 high 供应链漏洞)
背景:npm 上的 xlsx 永久冻结在 0.18.5(SheetJS 已停止向 npm 发布),该版本含
CVE-2023-30533(原型污染,0.19.3 修复)与 CVE-2024-22363(ReDoS,0.20.2 修复)两处 high,
而 npm 上**不存在**修复版本——官方修复版只发自家 CDN。

选型判据=CDN 可达性:本机实测 cdn.sheetjs.com 不可达(curl rc=35 schannel 吊销离线;
--ssl-no-revoke 后 rc=28 超时),采用官方 CDN tarball 会把「依赖能否装上」变成部署单点。
故改用 npm 上忠实转发官方 CDN 构建的 @e965/xlsx(0.20.3,含上述两处修复、零依赖、
API 与 xlsx 全等)。未选 exceljs:其 API 完全不同,11 个调用点需全量重写且写入变异步,
风险直接落在承载生产导入路径的代码上。

- package.json:xlsx@^0.18.0 → @e965/xlsx@^0.20.3
- app/service/admin.js(成绩导出 / 客观题模板 / 报名表导入 / 客观题导入)
  app/service/bigQuestion.js(综合大题模板 / 综合大题导入):仅改 require 包名,
  业务逻辑零改动(共 6 条路径 / 11 个调用点)
- package-lock.json:移除 xlsx 及其 7 个传递依赖(cfb/codepage/ssf/wmf/word/adler-32/frac),
  净 −7 包

验证:API 面 + 写读往返 PASS;差分测试 24/24(含畸形尺寸样本与 201 行模板);
真实 HTTP 回归 6 条路径 PASS(写 3/3 均 PK 头、读 3/3 正效应且落库计数吻合);
跨库往返 6/6;npm audit 中 xlsx 命中【无】(backend high 23 → 22)。
2026-10-10 15:15:37 +08:00
29ce734ffa fix(admin): getTeams「已激活」口径修正为「至少一名成员激活」
R90续三:需求方确认该筛选的设计目的是「确认小组是否已有人激活可以参赛」,
故 partial(部分激活)应归入已激活侧查看,旧口径「全员激活」与之不符。

- 行字段 activated:activatedCount === memberCount → activatedCount > 0
- 新增 allActivated 字段保留严格「全员激活」语义(不丢信息)
- summary 新增筛选后三态分布 filteredActivated / filteredPartial / filteredInactive
- 筛选逻辑零改动:语义收敛在行字段 activated 上,筛选仍按该字段判定
- 同步修正方法头 JSDoc 与筛选/summary 段注释

预期副作用:选中「已有成员激活」时 filteredMemberActivated 恒等于全集
memberActivated(inactive 组贡献 0)⇒ 原先「选手列表激活 4 人 / 小组视图 3 人」
的差额(= partial 组内已激活成员)不再出现。

生产已部署并复验:线上 33 项断言 ALL_PASS,activated=true 由 2 组变为 3 组
(2 全员 + 1 部分)、成员激活 3 → 4;服务器侧 MySQL 只读交叉复核一致。
2026-10-09 15:53:20 +08:00
26a29b2214 feat(admin): 选手列表补齐「小组编号/所属学校」,小组视图汇总改双口径
R90 人工测试反馈三问题修复(方案 1 + 方案 3-A):

- getAccounts:新增 teamUid / teamId / teamNumber / schoolName
  · 与 getTeams 同源口径(小组键 = 组别 group + 队伍号 teamUid;
    学校取自 aggregation.organization_cn)
  · 2 条批量 SQL(Aggregation / Team 各一次)替代逐行查询,避免 N+1
  · 既有字段一律不变
- getTeams:summary 新增 memberActivatedInPartial / filteredTotal /
  filteredMemberTotal / filteredMemberActivated
  · 既有 6 个汇总字段语义保持「筛选前全集」不变
  · 同步修正 JSDoc 中陈旧的 allSummary 描述

生产已部署并复验:线上接口 63 项断言全部 PASS,
且与服务器侧 MySQL 只读交叉复核一致。
2026-10-09 15:27:37 +08:00
c101b15e2e feat(admin): 新增小组激活状态清单接口 /api/admin/teams
- router:GET /api/admin/teams(adminAuth;固定路径段,与既有路由无通配冲突)

- controller:teams() 整体透传 group/activated/includeTest(防前端筛选静默失效)

- service:getTeams(),键 = 组别+队伍号;成员并集 aggregation.team_uid ∪ contestants.team_id;学校取 aggregation.organization_cn;激活 = 全组成员均已激活;附 summary 与 schoolConflict

- model/aggregation:补声明运行库既有列 organization_cn / group(此前模型漏声明 ⇒ 查询被当未知列)

线上验证:R86 已部署上线,公网 /api/admin/teams 返回 401(鉴权在位)
2026-10-09 09:33:05 +08:00
481de69e3d fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示
现象(人工测试反馈,已取证):
  测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后,
  正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的
  作答数据(70 题 / 38 分),而王乐乐本人从未作答。

根因:
  各提交表的 aggregation_uid 写入键不统一 ——
    - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id
    - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid)
  历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集**
  (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。
  而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒
  「某人的真实聚合 uid == 同组另一人的 contestant.id」时,
  前者会把后者的作答/作品记录「借」过来展示。
  生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。

治法:新增唯一真相源 app/lib/aggregationKey.js
  - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid
  - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用
    (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性)
  - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY
    (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据
  - 6 处归集点全部改走该模块:
      service/admin.js  getExamSubmissions / getScores(考试分支) / getScores(作品分支)
                        getContestantScore / _reviewWorks
      controller/score.js  _resolveTeamScoreSource(考生端,与 getContestantScore 同函数)

生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600):
  旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283
  守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES
  观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例)
  吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤**

验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值)
  夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者)
  变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象
  变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红
  ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留

部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致;
  MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --;
  路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
a471487092 fix(admin): 修复选手管理「激活状态」「注册日期」筛选被静默丢弃
现象(人工测试反馈):选手管理页「激活状态」下拉选择后列表无变化。

根因 —— 两层都在丢参且零报错:
  - controller/admin.js 只解构 group/keyword/page/pageSize,
    前端已发出的 activated 从未透传给 service
  - service/admin.js 即使收到也未解构 activated,where 中根本没有该条件
  - 同族缺陷:前端还发了 startDate/endDate(注册日期范围),
    controller 同样未转发 => 该筛选同样失效(此前无人报告)

修复:
  - controller:将 activated/startDate/endDate 整体透传给 service
  - service:解构 activated,并对 BOOLEAN 列做白名单归一化
    前端 el-select 传的是字符串 'true'/'false',直接入 where 会被
    MySQL 隐式转为 0,导致「已激活」错查出「未激活」(比不筛选更难察觉);
    白名单之外的值(含 undefined/'')一律视为未指定忽略,
    避免畸形入参被当作 false 而静默缩窄结果集

验证(scripts/verify_accounts_filter.py,9/9 PASS,真实 HTTP + 独立 DB 真值):
  A1 无筛选 total=26 == DB 26
  A2 activated=true  -> 10 == DB 已激活数
  A3 activated=false -> 16
  A4 分区性 10 + 16 == 26(关键判别器)
  D1 日期限定 DB 实际存在那天 -> 20 == DB 当日
  D2 负向对照(范围早于最早记录)-> 0
  变异自证:拆掉 where.activated 分支重跑 -> A2/A2b/A3/A3b/A4 全红 EXIT=1,
  失败信息精确复现原现象(total=26 != 10);还原后 diff 空、md5 一致、无标记残留
  同族扫描:全后端 ctx.query 仅 11 处,逐点核对 => 仅 getAccounts 一对丢参

部署(生产 back-end):
  线上 md5 与本地逐文件一致(controller ad0e60f8 / service 93eb3dbb)
  MainPID 3341710 -> 3343212;8023 监听正常;err 级日志 -- No entries --
  路由探针:health 200 / admin-login 错误口令 HTTP200+code4004 / 不存在路由 404 /
            未授权 accounts 401
2026-10-08 11:22:18 +08:00
15c9ab6f91 fix(big-question): 小问改为与后台一致的「选填」,模板说明补充常见疑问
去掉 Excel 导入里「三个小问必须全部填写」的硬拦截:后台手工录入的弹窗标签写明
「可留空」,且 validateQuestionPayload 对小问无非空校验 ⇒ 导入比手工更严会让测试人员
困惑「手工能录、Excel 被拒」。现行为与后台一致,「建议三问写全」改为在模板说明中提示。

模板「填写说明」新增【常见疑问】5 问(29 行 → 46 行),直接回答:排序/状态填 0
是否有误、为何建议状态 0、导入后是否会一直不参与考试、题干与题干HTML 填哪个、小问能否留空。
「排序」补上「数值越小越靠前」(此前说明缺失,与后台弹窗标签不一致);
「状态」补上「1=启用(参与随机抽题)· 0=停用」的完整语义与启用操作路径。
2026-10-08 10:23:49 +08:00
154338e5dd feat(big-question): 综合大题 Excel 模板下载 + 批量导入(R73)
新增 GET /api/admin/big-questions/:group/template —— 生成自解释模板:
数据 sheet 排第 1(导入侧按 SheetNames[0] 解析,是承重契约)+「填写说明」sheet,
含列名含义/状态建议/常见被拒原因,让测试人员下载即会用,不依赖外部文档。

新增 POST /api/admin/big-questions/:group/import-excel —— 逐行调用
service.bigQuestion.createQuestion,标题长度/三小问/分值/泄题检测(detectAnswerLeak)
全部自动继承,与手工录入同一入口同一校验;批量导入绝不成为绕过防呆的后门。
组别以 URL :group 为准,忽略表格里的组别列;同组内标题重复即跳过并报错;
状态缺省 0(停用),批量录入的题未经人工校对不直接进抽题池。

客观题模板 buildQuestionTemplate 同步加「填写说明」sheet:数据 sheet 仍排第 1、
原 8 列不变,故 importQuestionsFromExcel 的 SheetNames[0] 解析不受影响(已离线验证)。
路由顺序硬约束:含具体段的 template / import-excel 必须排在通配段 /:id 之前。
2026-10-08 10:08:41 +08:00
d44d466a90 fix(exam): 考试组已交卷禁止再进入与重复提交(4006 门控) 2026-09-30 15:46:43 +08:00
a2e24aba2a feat(auth): 口令强度策略(弱口令黑名单 / 账号派生值 / 长度上限)
背景(R61 实测):浏览器密码管理器在「设置密码」页预填 123456,考生若直接提交,该口令即成为其账号真实口令;而后端此前仅由 validate 校验「长度 >= 6」,弱口令完全合法。R62 已从前端修掉预填,本次补齐后端这道闸门。

- app/lib/passwordPolicy.js + password_blacklist.json:口令策略唯一事实来源。黑名单刻意做成纯 JSON,供 Node 运行时与运维脚本共用,避免两份清单漂移。

- controller/auth.js:activate() 与 resetPassword() 接入策略(错误码 2001 / 7001),位置在 validate 之后、短信校验之前(纯本地计算、无副作用、快速失败)。

- validate/index.js:补 max:20,与前端 el-form 规则对齐(此前只有前端限制长度上限)。

- scripts/_db.cjs:DB 配置解析唯一入口;scan_weak_password_accounts.cjs:存量弱口令只读扫描器;verify_password_policy_api.cjs:真接口验证(含「强口令通过」正向对照臂,防假绿)。
2026-09-30 11:56:35 +08:00
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
79 changed files with 14405 additions and 781 deletions

20
.gitignore vendored
View File

@@ -23,6 +23,15 @@ run/
.env*
!.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/
@@ -46,6 +55,13 @@ app/public/carousel/
app/public/tmp/
app/public/exports/
# Tests (non-required; code runs without them) — 上传目的=运行必须,test 不入库
test/
# Tests —— 上传目的=运行必须:`test/` 下的夹具 / 临时物 / 子目录**不入库**。
# 例外(P1 ㉚a · R134 候选 ㊴ 修复):`test/` 顶层的 `*.test.js` 最小单测集**必须入库** ——
# `scripts/ci-gates.sh` 第 26 步(裸 mocha)直跑它;若被忽略则「本地绿 ≠ 可复现绿」
# (干净检出上该步拿不到文件 ⇒ 必红)。
# 注:此处必须用 `test/*` 而非 `test/` —— 目录被整体排除后 git 不再进入该目录,
# 其内文件无法用 `!` 反忽略(R134 已实测:`test/` + `!test/lib.test.js` 无效)。
# 改用 `test/*` 后子目录(如 `test/sub/`)与其它文件仍被忽略,政策不破。
test/*
!test/*.test.js
test_dir/

View File

@@ -1,7 +1,12 @@
const mysql = require('mysql2/promise');
(async () => {
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
// DB_PASSWORD='<本机 DB 口令>' node add_word_cols.js
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 {
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');
(async () => {
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
// DB_PASSWORD='<本机 DB 口令>' node add_wx_link.js
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 {
await c.query("ALTER TABLE news ADD COLUMN wx_link VARCHAR(500) NULL DEFAULT NULL COMMENT '微信公众号文章链接' AFTER content");

67
app.js
View File

@@ -61,27 +61,80 @@ class AppBootHook {
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
);
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):egg-cluster 按 os.cpus() 起多 worker,
// 总连接数 ≈ worker 数 × pool.max。若超过 MySQL max_connections(默认 151),
// 高峰期会偶发 "connection acquire timeout" / "too many connections"。
// 启动期只做 WARN(不阻断启动),提醒运维调大 DB 或调小 DB_POOL_MAX。
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。
// 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire
// timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。
//
// 🔴 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 {
const os = require('os');
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 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;
if (estimated > dbMax) {
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 {
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) {
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
}
// -----------------------------------------------------------------
// 节点备份调度器:进程启动 + hydrate 未到期的三节点计划
// -----------------------------------------------------------------
// 需求:考试开始前 30 分钟 / 考试结束后 10 分钟 / 成绩发布后 触发生产库备份。
// 实现:app/lib/nodeBackupScheduler.js(进程内 setTimeout,无额外常驻进程 ——
// 用户裁定①「巡检脚本会占用额外内存」的替代方案)。
// ⛔ 非生产环境调度器构造时即 enabled=false ⇒ 这里只在生产实际排计划。
try {
const { NodeBackupScheduler } = require('./app/lib/nodeBackupScheduler');
const scheduler = new NodeBackupScheduler(this.app);
// 挂到 app 上:service/controller 通过 app.nodeBackupScheduler 访问(单例)
this.app.nodeBackupScheduler = scheduler;
if (scheduler.enabled) {
const rows = await this.app.model.CompetitionTime.findAll({
attributes: [ 'group', 'start_time', 'end_time' ],
raw: true,
});
await scheduler.hydrate(rows);
}
} catch (e) {
this.app.logger.error('[AppBoot][NodeBackup] 调度器初始化失败(不影响主服务):' + (e && e.message));
}
}
}

File diff suppressed because it is too large Load Diff

118
app/controller/adminUser.js Normal file
View File

@@ -0,0 +1,118 @@
'use strict';
/**
* 管理员账号管理控制器(R178 · C 层)
*
* 统一错误约定(与项目其它控制器一致):
* - 业务错误 = HTTP 200 + `code !== 0`(helper.error 不改 HTTP 状态)
* - service 通过 `bizCode` 表达期望错误码,这里原样翻译
* ⛔ 不要把业务错误包成 500(前端会当成系统故障,用户看到「服务器错误」)
*/
const Controller = require('egg').Controller;
const adminPermissions = require('../lib/adminPermissions');
class AdminUserController extends Controller {
/** 把 service 抛出的 bizCode 翻译成业务响应;非业务异常交给上层 catch */
_fail(err, fallbackMessage) {
const { ctx } = this;
if (err && err.bizCode) {
ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message);
return;
}
ctx.logger.error(`${fallbackMessage}:`, err);
ctx.body = ctx.helper.error(500, fallbackMessage);
}
/**
* 当前登录管理员的权限信息(自助,无需业务权限)
* GET /api/admin/me
*
* 前端用途:① 渲染侧边菜单;② 权限被他人改动后,下次进页面能感知并提示。
* 顺带返回权限点目录(catalog),避免前端硬编码一份权限清单(那是第二真相源)。
*/
async me() {
const { ctx } = this;
const admin = ctx.state.currentAdmin;
if (!admin) {
ctx.body = ctx.helper.error(401, '未登录或登录状态已失效');
return;
}
ctx.body = ctx.helper.success({
id: admin.id,
username: admin.username,
isSuper: Number(admin.is_super) === 1,
permissions: admin.permissions || [],
catalog: adminPermissions.PERMISSIONS,
}, '获取当前管理员信息成功');
}
/** GET /api/admin/admin-users */
async list() {
const { ctx, service } = this;
try {
const list = await service.adminUser.list();
ctx.body = ctx.helper.success({ list, total: list.length }, '获取管理员列表成功');
} catch (err) {
this._fail(err, '获取管理员列表失败');
}
}
/** POST /api/admin/admin-users */
async create() {
const { ctx, service } = this;
try {
const result = await service.adminUser.create(ctx.request.body || {});
ctx.body = ctx.helper.success(result, '管理员账号已创建');
} catch (err) {
this._fail(err, '创建管理员失败');
}
}
/** PUT /api/admin/admin-users/:id/permissions */
async updatePermissions() {
const { ctx, service } = this;
try {
const result = await service.adminUser.updatePermissions(ctx.params.id, ctx.request.body || {});
ctx.body = ctx.helper.success(result, '权限已更新');
} catch (err) {
this._fail(err, '更新权限失败');
}
}
/** PUT /api/admin/admin-users/:id/password */
async resetPassword() {
const { ctx, service } = this;
try {
const result = await service.adminUser.resetPassword(ctx.params.id, ctx.request.body || {});
ctx.body = ctx.helper.success(result, '口令已重置,该账号需重新登录');
} catch (err) {
this._fail(err, '重置口令失败');
}
}
/** PUT /api/admin/admin-users/:id/status */
async setStatus() {
const { ctx, service } = this;
try {
const result = await service.adminUser.setStatus(ctx.params.id, ctx.request.body || {});
const msg = Number(result.status) === 1 ? '账号已启用' : '账号已禁用,其在线会话已失效';
ctx.body = ctx.helper.success(result, msg);
} catch (err) {
this._fail(err, '修改账号状态失败');
}
}
/** DELETE /api/admin/admin-users/:id */
async remove() {
const { ctx, service } = this;
try {
const result = await service.adminUser.remove(ctx.params.id);
ctx.body = ctx.helper.success(result, '管理员账号已删除');
} catch (err) {
this._fail(err, '删除管理员失败');
}
}
}
module.exports = AdminUserController;

View File

@@ -3,6 +3,11 @@
const Controller = require('egg').Controller;
const svgCaptcha = require('svg-captcha');
const VALIDATION_RULES = require('../validate');
// 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。
// ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。
const passwordPolicy = require('../lib/passwordPolicy');
// R178:管理端权限点解析(与 middleware/auth.js 同一真相源)
const adminPermissions = require('../lib/adminPermissions');
/**
* 认证控制器 - AuthController
@@ -190,7 +195,7 @@ class AuthController extends Controller {
* 1. 校验身份证号、手机号、短信验证码
* 2. 通过身份证号查找选手
* 3. 检查账号是否已激活
* 4. SHA256 哈希密码,更新 activated=1
* 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1
* 5. 清除 session 中的短信验证码
*/
async activate() {
@@ -206,6 +211,21 @@ class AuthController extends Controller {
return;
}
// 口令强度校验(R63 · C1)
// 背景:此前仅由 validate 校验「长度 ≥ 6」,`123456` 完全合法 —— R61 测试人员
// 实测「浏览器密码管理器预填 123456,直接提交后即成为账号口令」。
// 位置(validate 之后 / 短信码之前)的理由:
// · validate 只管「必填 + 格式」,强度是业务规则,分层后两者失败文案互不覆盖;
// · 纯本地计算、无副作用,可快速失败,不消耗也不依赖 session 中的验证码。
// 错误码 2001 = 口令强度不足(activate 段编码;resetPassword 段用 7001)。
// ⚠️ 日志只记命中的规则名,绝不记录口令明文或身份证号。
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number, phone });
if (!pwdCheck.ok) {
ctx.logger.info(`[口令策略] 激活口令被拒:rule=${pwdCheck.rule}`);
ctx.body = ctx.helper.error(2001, pwdCheck.reason);
return;
}
// 校验短信验证码
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
ctx.body = ctx.helper.error(2005, '短信验证码错误');
@@ -263,7 +283,8 @@ class AuthController extends Controller {
* 流程:
* 1. 通过手机号查找选手
* 2. 检查账号是否激活
* 3. SHA256(输入密码) 与数据库密码比对
* 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希,
* 并在验证成功后透明升级为 bcrypt)
* 4. 生成 JWT Token,返回用户信息和 Token
*/
async login() {
@@ -309,9 +330,9 @@ class AuthController extends Controller {
role: 'contestant',
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
// 返回用户信息(排除密码)
const userData = contestant.toJSON();
delete userData.password;
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口:
// 剔除密码 + 按「成绩发布门控」决定是否携带 score(铁律15 全通道封堵,2026-09-21)
const userData = await service.auth.toClientUser(contestant);
ctx.body = ctx.helper.success({
token,
@@ -324,7 +345,7 @@ class AuthController extends Controller {
*
* 流程:
* 1. 通过用户名从 admins 表查找管理员
* 2. 密码比对(SHA256)
* 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希)
* 3. 生成管理员 JWT Token
*/
async adminLogin() {
@@ -353,11 +374,37 @@ class AuthController extends Controller {
return;
}
// P2-3 默认口令守卫:schema.sql 内置默认管理员(admin/admin123),
// 🔴 R178 禁用检查:必须在口令校验**之后**。
// 若放在校验之前,「账号已禁用」与「密码错误」的差别会让未授权者探测出
// 某个用户名真实存在且已被停用。校验通过后才告知状态,不额外泄露信息。
if (Number(admin.status) !== 1) {
ctx.logger.warn(`[安全] 已禁用的管理员 ${admin.username} 尝试登录,已拒绝`);
ctx.body = ctx.helper.error(4006, '该管理员账号已被禁用,请联系最高管理员');
return;
}
// P2-3 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js),
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
const DEFAULT_ADMIN_PASSWORDS = ['admin123', 'admin123456'];
const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password);
//
// ⚠️ 【R111 更正 · 实测】原注释写「schema.sql 内置默认管理员(admin/admin123)」,两处失实:
// ① 归属错 —— `admin123` 是 **seeder 002** 的 bcrypt 值;`schema.sql:214` 的字面哈希实测 = SHA256(`admin`)。
// ② 列表漏项 —— 原列表 `['admin123','admin123456']` **不含 `admin`**,而 `schema.sql` 建库路径的初始
// 口令恰为 `admin` ⇒ 生产若未改密,用 `admin/admin` 登录**不会**触发 4005(`verifyPassword` 兼容
// SHA256 ⇒ 可直接登录成功)⇒ 恰是本守卫要防的场景却不设防。已补入 `'admin'`。
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑱/㉑。
// ⚠️ 结构性缺口**未消除**:本守卫是「口令字面」判据 ⇒ 任何未枚举的默认口令均绕过;
// 彻底治法应改为「哈希值判据」(DB 值 ∈ 已知默认哈希集)。见候选㉑。
// ✅ 【R112 已消除 · 候选㉒】上述「口令字面」单通道已升级为**双通道**,并把清单下沉为**单源**:
// ① 口令通道:`DEFAULT_ADMIN_PASSWORDS.includes(password)`(原逻辑原样保留);
// ② 哈希通道:`isDefaultAdminHash(admin.password)` —— **与用户输入无关**,直接判 DB 存储值
// 是否为已发布的默认哈希(无盐 SHA256 + 固定盐 bcrypt),并对候选口令做 bcrypt 遍历,
// 以覆盖「同口令、**不同盐**」的重新播种场景(见 `002-seed-admin.js` 的 ADMIN_INIT_PASSWORD 分支)。
// ⇒ 上面那条「未枚举即绕过」的绕过面已消除;仅剩「新增内置凭据未登记」这一**显式维护点**
// (清单已下沉至 `lib/passwordPolicy.js` 模块头 ⚠️,⛔ 勿在本控制器内再写第二份)。
const usingDefaultPassword =
passwordPolicy.isDefaultAdminHash(admin.password) ||
passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password);
const isProd = this.app.config.isProduction;
if (usingDefaultPassword && isProd) {
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
@@ -374,14 +421,33 @@ class AuthController extends Controller {
const token = ctx.helper.generateToken({
id: admin.id,
role: 'admin',
// 🔴 R178 令牌版本:middleware/auth.js 每次请求都与库中 token_version 比对,
// 不一致即 401。改密 / 禁用 / 改权限时服务层自增本值 ⇒ 旧令牌立刻作废。
// 存量令牌里没有 tv,读取时按 0 处理(与初值一致),升级不会踢掉在线会话。
tv: Number(admin.token_version || 0),
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
// 登录痕迹(best-effort):审计要能回答「谁在什么时候从哪登录」。
// 失败不影响登录本身 —— 记录登录时间不是登录的必要条件。
try {
await admin.update({
last_login_at: new Date(),
last_login_ip: String(ctx.ip || '').slice(0, 64) || null,
});
} catch (recordErr) {
ctx.logger.warn('[adminLogin] 记录登录信息失败:', recordErr && recordErr.message);
}
ctx.body = ctx.helper.success({
token,
user: {
id: admin.id,
username: admin.username,
role: 'admin',
// 前端据 isSuper 决定「管理员账号管理」入口与最高管理员的只读展示
isSuper: Number(admin.is_super) === 1,
// 权限点数组:前端据此过滤侧边菜单与按钮。后端仍逐请求校验,前端过滤只是体验层。
permissions: adminPermissions.parsePermissions(admin.permissions),
},
// 使用默认口令时向前端透出改密提醒标记
mustChangePassword: usingDefaultPassword,
@@ -392,7 +458,7 @@ class AuthController extends Controller {
* 获取当前登录用户信息(需认证)
*/
async currentUser() {
const { ctx } = this;
const { ctx, service } = this;
// 从 JWT token 中获取用户信息
const token = ctx.headers.authorization
@@ -426,8 +492,9 @@ class AuthController extends Controller {
return;
}
const userData = contestant.toJSON();
delete userData.password;
// 与 login 共用同一脱敏出口(同源族:两条 auth 通道口径必须一致,
// 且此处原为全字段 toJSON —— 会连带下发 exam_score/word_score/ppt_score)
const userData = await service.auth.toClientUser(contestant);
ctx.body = ctx.helper.success(userData, '获取用户信息成功');
}
@@ -498,6 +565,18 @@ class AuthController extends Controller {
return;
}
// 口令强度校验(R63 · C1)—— 与 activate() 共用同一策略(lib/passwordPolicy.js),
// ⚠️ 禁止在本处另行实现:一旦「忘记密码」通道放宽,弱口令缺口会从另一条路重新打开
// (这正是本模块必须单源的原因)。
// 错误码 7001 = 口令强度不足(resetPassword 段编码;activate 段用 2001)。
// 本入口请求体无 phone 字段(见 validate.resetPassword),故仅传 idNumber 做派生值比对。
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number });
if (!pwdCheck.ok) {
ctx.logger.info(`[口令策略] 重置口令被拒:rule=${pwdCheck.rule}`);
ctx.body = ctx.helper.error(7001, pwdCheck.reason);
return;
}
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
ctx.body = ctx.helper.error(7004, '短信验证码错误');
return;
@@ -544,7 +623,7 @@ class AuthController extends Controller {
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
*/
async refresh() {
const { ctx } = this;
const { ctx, service } = this;
const authorization = ctx.headers.authorization || '';
const token = authorization.replace(/^Bearer\s+/i, '') || null;
@@ -597,8 +676,9 @@ class AuthController extends Controller {
: { 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 userData = account.toJSON();
delete userData.password;
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口
// (与 login / currentUser 同源:禁止各自直接 toJSON,否则成绩未发布时 score 会从本通道漏出)
const userData = await service.auth.toClientUser(account);
ctx.body = ctx.helper.success({
token: newToken,

View File

@@ -216,6 +216,20 @@ class ExamController extends Controller {
return;
}
// 🔴 已正式交卷禁止再次进入(考试组一次性答题,测试反馈 #3)。
// 这里是「进入考试」的真实门控:题目经此下发,拒绝即等于无法进入答题界面,
// 前端按钮置灰只是 UX 层,绕过前端直调本接口同样被拦。
// hasSubmitted 仅在 exam_submissions.submit_time 落库后为 true ⇒
// 未交卷的意外退出(断网/断电)不受影响,仍可重进恢复作答。
// 仅约束考试组;本科/教师走作品提交,不在此限制。
if (group === 'zhongzhi' || group === 'gaozhi') {
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
if (alreadySubmitted) {
ctx.body = ctx.helper.error(4006, '您已提交试卷,无法再次进入考试');
return;
}
}
// 团队锁:同一小组只能一人进入正式考试(原子检查+占位,G1 修复)
const teamGate = await this._enterExamWithTeamLock(aggregation);
if (!teamGate.allowed) {
@@ -375,6 +389,12 @@ class ExamController extends Controller {
return;
}
// 🔴 交卷冻结(R178·B①):已正式交卷者不得再改写答案。
// 位置刻意在判分之前 —— 已冻结的答卷不该再做一次无意义的 autoScore。
// 与 service.saveAnswers 内的兜底调用是**同一个函数**(单一判据源),
// 不是两套条件;异常由下方 catch 按 bizCode 翻译成 4006。
await service.exam.assertNotSubmitted(aggregation.aggregation_uid);
// 参数校验
const rule = {
answers: { type: 'array', required: true, min: 1 },
@@ -438,6 +458,15 @@ class ExamController extends Controller {
savedCount: savedRecords.length,
}, '答案提交成功');
} catch (err) {
// 业务性拒绝(如「已交卷,答卷已冻结」)不该被伪装成 500:
// service 层用 bizCode 标记期望的错误码,这里原样翻译给前端。
if (err && err.bizCode) {
ctx.logger.warn(
`[submitAnswers] 业务拒绝 code=${err.bizCode} msg=${err.bizMessage || err.message}`
);
ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message);
return;
}
ctx.logger.error('提交答案失败:', err);
ctx.body = ctx.helper.error(500, '提交答案失败');
}
@@ -456,6 +485,17 @@ class ExamController extends Controller {
const { group } = ctx.params;
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();
if (!aggregation) {
@@ -609,6 +649,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 文件
* POST /api/exam/:group/submit-all
@@ -623,35 +810,106 @@ class ExamController extends Controller {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、
// 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库,
// 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。
// forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方
// 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。
//
// 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。
// 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体,
// 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。
// 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
// 验证考试是否活跃
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();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
const teamGate = await this._enterExamWithTeamLock(aggregation);
if (!teamGate.allowed) {
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
return;
// 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论)
//
// 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒
// (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**;
// 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 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;
}
}
// 参数校验
// 🔴 窗口内重复交卷拦截(考试组,测试反馈 #3)。
// 背景:下方窗口内分支会重新校验请求体答案并覆盖写库 ⇒ 已交卷者若可重进,
// 即可「改答案 → 再交卷」无损刷分。入口 questions() 已拦重进,此处再堵住
// 绕过前端直接重放 submit-all 的路径。
// ⚠️ 只作用于窗口打开时;窗口关闭后走上方结算通道(那是补写标记,不是重新作答)。
if (windowOpen && (group === 'zhongzhi' || group === 'gaozhi')) {
const alreadyDone = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
if (alreadyDone) {
ctx.body = ctx.helper.error(4006, '您已提交试卷,不能重复提交');
return;
}
}
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
// ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用,
// 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。
if (!settleOnly) {
const teamGate = await this._enterExamWithTeamLock(aggregation);
if (!teamGate.allowed) {
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
return;
}
}
// 参数校验(结算通道不接收请求体答案,故放宽为可空)
const rule = {
answers: { type: 'array', required: true, min: 1 },
answers: settleOnly
? { type: 'array', required: false }
: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
@@ -660,6 +918,34 @@ class ExamController extends Controller {
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;
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
@@ -735,6 +1021,14 @@ class ExamController extends Controller {
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
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(自动评分仅落库供管理后台阅卷与合分使用)。
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
ctx.body = ctx.helper.success({

View File

@@ -1,6 +1,7 @@
'use strict';
const Controller = require('egg').Controller;
const { resolveAggregationKeysForContestant } = require('../lib/aggregationKey');
/**
* 成绩查询控制器 - ScoreController
@@ -138,8 +139,10 @@ class ScoreController extends Controller {
where: { id: operatorId },
attributes: ['id', 'name', 'word_score', 'ppt_score', 'score', 'exam_score'],
});
const keys = [String(operatorId)];
if (operatorAggUid) keys.push(String(operatorAggUid));
// 归集键(单一真相源 lib/aggregationKey):
// 中职/高职 = contestant.id 为主键;兜底键(真实聚合 uid)仅在无歧义时启用,
// 避免「操作员真实聚合 uid 撞上同组他人 contestant.id」而读到他人(甚至他队)作答记录。
const keys = await resolveAggregationKeysForContestant(ctx, group, operatorId, operatorAggUid);
return { group, isExam, operatorId, operator, aggKeys: keys, viewerName: me.name };
}

View File

@@ -48,15 +48,48 @@ class UploadController extends Controller {
* 上传作品文件
* POST /api/upload/:group
*
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 150MB
* (🔴 R225:原注释写「最大 50MB」早已失效,实际上限只由
* config.multipart.fileSize 决定,当时为 '100mb',现为 '150mb'。
* 本注释不再复述数字,改为指向配置项,避免再次漂移。)
*/
async upload() {
const { ctx, service } = this;
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
let ioAcquired = false;
// 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。
// 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 ——
// 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。
let savedFileInfo = null;
let recordPersisted = false;
try {
const { group } = ctx.params;
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);
if (!deadlineCheck.valid) {
@@ -77,6 +110,25 @@ class UploadController extends Controller {
return;
}
// 🔴 R190 组内上传互斥:本科/教师组「小组中只要有一人上传,其他人就不能上传」,
// 替换权只归组内最早提交者(归属人)。⚠️ 必须放在 ctx.getFileStream() **之前** ——
// 被锁成员根本不该把几十 MB 请求体传上来再被拒(白耗入带宽 + 通道配额)。
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
if (!gate.ok) {
// 业务错误 = HTTP 200 + code≠0(helper.error 不改 HTTP 状态),
// 附加 data 供前端在按钮/提示里展示归属人,message 已含完整可读文案。
const body = ctx.helper.error(6006, gate.message);
body.data = {
teamLocked: true,
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
teamNumber: gate.state.teamNumber,
folderName: gate.state.folderName,
};
ctx.body = body;
return;
}
const teamFolder = gate.state.folderName;
// 获取上传的文件流
const stream = await ctx.getFileStream();
if (!stream) {
@@ -102,14 +154,18 @@ class UploadController extends Controller {
return;
}
// 保存文件
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
// 获取作品名称(流式模式字段在 stream.fields 中)
// 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。
// ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
// 文件类型:word / ppt(前端上传时透传)
const fileType = (stream.fields && stream.fields.file_type) || 'word';
// 保存文件:落点 = works/<group>/<小组编号>/<原始文件名>
const fileInfo = await service.upload.saveWorkFile(
stream, stream.filename, group, teamFolder,
{ fileType, aggregationUid: aggregation.aggregation_uid }
);
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
// 保存提交记录
const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid,
@@ -118,6 +174,8 @@ class UploadController extends Controller {
workName,
fileType
);
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({
id: record.id,
@@ -130,9 +188,25 @@ class UploadController extends Controller {
uploadTime: record.upload_time || new Date().toISOString(),
}, '文件上传成功');
} 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);
const msg = (err && err.message) ? err.message : '未知错误';
ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
// 否则配额只减不增,若干轮后上传通道**永久 429**。
if (ioAcquired) releaseFileIo();
}
}
@@ -142,6 +216,8 @@ class UploadController extends Controller {
*
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
*/
// @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并;
// 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。
async uploadChunk() {
const { ctx, app } = this;
@@ -214,6 +290,11 @@ class UploadController extends Controller {
*/
async mergeChunks() {
const { ctx, service, app } = this;
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
let mergedRelPath = null;
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
let mergedTmpPath = null;
let recordPersisted = false;
try {
const { group } = ctx.params;
@@ -239,6 +320,22 @@ class UploadController extends Controller {
return;
}
// 🔴 R190 组内上传互斥:与 upload() 同一个守卫(两条落盘路径都必须拦,
// 否则被锁成员改走分片合并即可绕过组内锁)。
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
if (!gate.ok) {
const body = ctx.helper.error(6006, gate.message);
body.data = {
teamLocked: true,
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
teamNumber: gate.state.teamNumber,
folderName: gate.state.folderName,
};
ctx.body = body;
return;
}
const teamFolder = gate.state.folderName;
// 检查分片目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
@@ -258,16 +355,20 @@ class UploadController extends Controller {
return;
}
// 构建目标文件路径
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const ext = path.extname(fileName);
const timestamp = Date.now();
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
const targetPath = path.join(uploadDir, safeFileName);
// 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = service.prepareWorkTarget,
// 与 saveWorkFile 同源,⛔ 不得在此另写一份随机命名)。
const target = await service.upload.prepareWorkTarget({
group,
folderName: teamFolder,
filename: fileName,
fileType: file_type || 'word',
aggregationUid: aggregation.aggregation_uid,
});
// 先合并到临时名,再原子 rename 到最终名 —— 避免管理员在合并过程中下载到半截文件
mergedTmpPath = path.join(
target.dir,
`.merge_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${path.extname(target.fileName)}`
);
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
@@ -280,8 +381,8 @@ class UploadController extends Controller {
}
try {
// 合并分片
const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath);
// 合并分片(落到临时名)
await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath);
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
@@ -289,16 +390,11 @@ class UploadController extends Controller {
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
// 构建相对路径
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
// 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径)
const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target);
mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用
// 保存提交记录
const fileInfo = {
filePath: relativePath,
fileName,
fileSize: mergeResult.fileSize,
md5: mergeResult.md5,
};
const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid,
group,
@@ -306,21 +402,31 @@ class UploadController extends Controller {
work_name || fileName,
file_type || 'word'
);
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({
id: record.id,
workName: record.work_name,
filePath: relativePath,
fileUrl: ctx.helper.fileUrl(relativePath),
fileName,
fileSize: mergeResult.fileSize,
md5: mergeResult.md5,
filePath: fileInfo.filePath,
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
fileName: fileInfo.fileName,
fileSize: fileInfo.fileSize,
md5: fileInfo.md5,
uploadTime: record.upload_time || new Date().toISOString(),
}, '分片合并成功');
} finally {
releaseFileIo();
}
} catch (err) {
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
if (mergedRelPath && !recordPersisted) {
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
}
// 合并中途失败 ⇒ 临时名文件也必须清掉(best-effort,绝不掩盖真实错误)
if (mergedTmpPath && !mergedRelPath) {
ctx.helper.safeCleanup(mergedTmpPath, { label: 'mergeChunks-failed' });
}
ctx.logger.error('分片合并失败:', err);
ctx.body = ctx.helper.error(500, '分片合并失败');
}
@@ -330,6 +436,8 @@ class UploadController extends Controller {
* 查询分片上传进度
* GET /api/upload/:group/chunk-status?fileId=xxx
*/
// @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync),
// 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。
async chunkStatus() {
const { ctx, app } = this;
@@ -385,16 +493,33 @@ class UploadController extends Controller {
return;
}
// 检查提交状态
const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group);
// 检查提交状态(本人)+ 组内互斥状态(R190)
const [submitted, teamState, deadlineCheck] = await Promise.all([
service.upload.hasSubmitted(aggregation.aggregation_uid, group),
service.upload.getTeamUploadState(aggregation, group),
service.upload.validateDeadline(group),
]);
// 检查截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
// 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败,
// 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。
const canUpload = deadlineCheck.valid && !teamState.locked;
ctx.body = ctx.helper.success({
submitted,
submitted, // 本人是否已上传
deadline: deadlineCheck.deadline,
canSubmit: deadlineCheck.valid,
canSubmit: canUpload, // 兼容旧字段:语义 = 现在能不能上传
canUpload,
deadlineValid: deadlineCheck.valid,
// ---- 小组维度 ----
teamNumber: teamState.teamNumber, // 人类可读小组编号(teams.team_number || team_id)
folderName: teamState.folderName, // 后端落盘用的目录名(前端只展示,不参与命名)
memberCount: teamState.memberCount,
teamSubmitted: teamState.hasAnySubmission, // 组内是否已有人上传
teamOwnerName: teamState.owner ? teamState.owner.name : null,
isTeamOwner: teamState.isOwner,
teamLocked: teamState.locked, // true = 组内别人已上传,本人不能再传
teamLockMessage: teamState.lockMessage,
teamFiles: teamState.teamFiles, // 组内已上传文件清单(含上传者)
}, '获取提交状态成功');
} catch (err) {
ctx.logger.error('查询提交状态失败:', err);

View File

@@ -3,7 +3,23 @@
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
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 轮,登录场景性能与安全的平衡点)
const BCRYPT_ROUNDS = 10;

288
app/lib/adminPermissions.js Normal file
View File

@@ -0,0 +1,288 @@
'use strict';
/**
* 管理端权限点与「路由 → 权限」映射(R178·C 层唯一真相源)
* ============================================================================
*
* 为什么需要这层:后端有 63 条 /api/admin/* 路由,原本全部只挂 adminAuth(role='admin')。
* 在本模块之前,「是管理员」等于「有全部权限」—— 无法做到「只给阅卷、不给发布」。
*
* 三条设计纪律(改动前必读):
*
* 1. **默认拒绝**:`resolvePermission()` 匹配不到任何规则时返回 null,
* 中间件对 null **直接 403**,而不是放行。
* ⇒ 新增路由若忘了登记规则,表现是「功能不可用」而非「静默成为人人可用的后门」。
*
* 2. **单一真相源**:路由的权限归属只在本文件声明,⛔ 不得在 router.js 逐条写权限参数
* (那会让 63 条路由各带一份权限字符串,与本表必然漂移,且无人能核对全集)。
* 全集对齐由门禁 `scripts/verify_admin_rbac.py` 双向断言:
* 「router.js 的 /api/admin 路由全集」== 「ROUTE_RULES 覆盖集」,两个方向差集都必须为空。
*
* 3. **多权限其一(anyOf)**:部分接口被两个菜单共用(如成绩复核页复用成绩列表),
* 用 `perms: ['scores','review']` 表达「具备任一即可」。这不会降低安全性 ——
* 任何一条都需被显式授予,只是避免「勾了复核却被 403」的误伤。
*
* 高危权限点单独成项(score.publish / data.reset / admins.manage),
* 目的是把「能看成绩」与「能发布成绩」分开 —— 这两件事的责任人通常不同。
*/
const MODULE = 'module';
const HIGH_RISK = 'highRisk';
/**
* 全部权限点。顺序即前端展示顺序。
* ⚠️ 新增权限点必须同步:① 本数组;② ROUTE_RULES 至少一条规则;③ 前端 `can()` 使用处。
*/
const PERMISSIONS = [
{ key: 'dashboard', label: '数据总览', group: MODULE, desc: '查看各组账号数 / 提交数 / 激活率' },
{ key: 'accounts', label: '选手管理', group: MODULE, desc: '查看选手、导入报名表、编辑选手信息、标记测试数据' },
{ key: 'questions', label: '试卷管理', group: MODULE, desc: '客观题与综合大题题库维护、Word 模板' },
{ key: 'materials', label: '备赛资料', group: MODULE, desc: '上传 / 删除备赛资料' },
{ key: 'submissions', label: '作品管理', group: MODULE, desc: '查看作品与答题记录、下载作品文件' },
{ key: 'times', label: '时间设置', group: MODULE, desc: '考试起止时间、抽题数量、节点备份状态' },
{ key: 'scores', label: '成绩管理', group: MODULE, desc: '阅卷打分、导出成绩、生成公示表(不含发布)' },
{ key: 'review', label: '成绩复核', group: MODULE, desc: '按选手查验分项得分' },
{ key: 'news', label: '新闻管理', group: MODULE, desc: '新闻增删改、置顶、附件上传、发布 / 撤回' },
{ key: 'carousel', label: '轮播管理', group: MODULE, desc: '首页轮播图增删改与排序' },
{ key: 'score.publish', label: '发布 / 撤回成绩', group: HIGH_RISK, desc: '控制考生端能否看到成绩,并自动生成公示草稿' },
{ key: 'data.reset', label: '重置测试数据 / 删除选手', group: HIGH_RISK, desc: '会真实删除数据的操作,仅授予负责人' },
{ key: 'admins.manage', label: '管理员账号管理', group: HIGH_RISK, desc: '新增 / 删除管理员、分配权限、重置口令、禁用' },
];
const PERMISSION_KEYS = PERMISSIONS.map(p => p.key);
const PERMISSION_KEY_SET = new Set(PERMISSION_KEYS);
/**
* 路径模式 → 正则。`:xxx` 段匹配任意单段(不含 `/`)。
* 其余字符按字面转义,避免 `/api/admin/big-questions/:group/:id` 里的 `-` `.` 被当元字符。
*/
function toRegExp(pattern) {
const src = pattern
.split('/')
.map((seg) => {
if (seg.startsWith(':')) return '[^/]+';
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
})
.join('/');
return new RegExp('^' + src + '$');
}
/**
* 路由规则表。**顺序敏感**:更具体的路径必须排在带 `:id` 的通配路径之前
* (如 `/news/order` 必须在 `/news/:id` 之前),否则会被后者抢先匹配。
*
* ⚠️ 每条规则的 perms 至少一个;`perms` 语义 = 「具备其中之一即可」。
*/
const ROUTE_TABLE = [
// ---------- 自助信息(登录即可,无需业务权限) ----------
// 前端用它得知「我有哪些权限」以渲染菜单,并感知权限是否被他人改动。
// ⚠️ perms 为空数组是本表唯一允许的「显式放行」写法,门禁会断言只有本行如此。
['GET', '/api/admin/me', []],
// ---------- 管理员账号管理(高危) ----------
['GET', '/api/admin/admin-users', ['admins.manage']],
['POST', '/api/admin/admin-users', ['admins.manage']],
['PUT', '/api/admin/admin-users/:id/permissions', ['admins.manage']],
['PUT', '/api/admin/admin-users/:id/password', ['admins.manage']],
['PUT', '/api/admin/admin-users/:id/status', ['admins.manage']],
['DELETE', '/api/admin/admin-users/:id', ['admins.manage']],
// ---------- 数据总览 ----------
['GET', '/api/admin/stats', ['dashboard']],
// ---------- 选手管理 ----------
['GET', '/api/admin/accounts', ['accounts']],
['GET', '/api/admin/teams', ['accounts']],
['POST', '/api/admin/import-excel', ['accounts']],
['POST', '/api/admin/contestants/backfill-teacher', ['accounts']],
['PUT', '/api/admin/contestants/:id', ['accounts']],
['POST', '/api/admin/toggle-test-flag', ['accounts']],
// 重置测试数据是真实删除,单列高危(不给「只做选手维护」的人)
['POST', '/api/admin/reset-test-data', ['data.reset']],
// ---------- 作品管理 ----------
// 成绩管理页的「考生答题记录」也读 submissions 接口 ⇒ 允许 scores 其一
['GET', '/api/admin/submissions/:group', ['submissions', 'scores']],
['GET', '/api/admin/submission-file/:group/:id', ['submissions']],
['GET', '/api/admin/submissions/:group/:id/word', ['submissions']],
['GET', '/api/admin/works/:group', ['submissions']],
['GET', '/api/admin/batch-download/:group', ['submissions']],
// R190 单小组作品压缩包:与批量下载同属「作品管理」能力,同权限即可
['GET', '/api/admin/team-zip/:group', ['submissions']],
// ---------- 成绩管理(阅卷/导出/公示表) ----------
['GET', '/api/admin/scores/:group', ['scores', 'review']],
['PUT', '/api/admin/scores/:group', ['scores']],
['GET', '/api/admin/export-csv/:group', ['scores']],
['GET', '/api/admin/export-excel/:group', ['scores']],
['GET', '/api/admin/export-publicity/:group', ['scores']],
// R220 成绩排名表:同属「成绩管理」导出能力,与 export-excel / export-publicity 同权限
['GET', '/api/admin/export-score-rank/:group', ['scores']],
['GET', '/api/admin/competition-item', ['scores']],
['PUT', '/api/admin/competition-item', ['scores']],
['GET', '/api/admin/big-answers/:group', ['scores', 'review']],
['GET', '/api/admin/big-answers/:group/export', ['scores']],
['GET', '/api/admin/big-answers/:group/import-template', ['scores']],
['POST', '/api/admin/big-answers/:group/import-preview', ['scores']],
['POST', '/api/admin/big-answers/:group/import-scores', ['scores']],
['PUT', '/api/admin/big-answers/:group/:uid/scores', ['scores']],
['GET', '/api/admin/publish-scores', ['scores', 'review']],
// 发布 / 撤回 = 高危:能看成绩 ≠ 能公布成绩
['PUT', '/api/admin/publish-scores', ['score.publish']],
// ---------- 时间设置 ----------
['GET', '/api/admin/times', ['times']],
['PUT', '/api/admin/times', ['times']],
['GET', '/api/admin/node-backup/status', ['times']],
// ---------- 试卷管理 ----------
['GET', '/api/admin/exam/:group/questions', ['questions']],
['PUT', '/api/admin/exam/:group/questions', ['questions']],
['POST', '/api/admin/exam/:group/questions/import-excel', ['questions']],
['GET', '/api/admin/exam/:group/questions/template', ['questions']],
['PUT', '/api/admin/exam/:group/word-template', ['questions']],
['GET', '/api/admin/exam/:group/word-template', ['questions']],
['DELETE', '/api/admin/exam/:group/word-template', ['questions']],
['GET', '/api/admin/big-questions/:group', ['questions']],
['POST', '/api/admin/big-questions/:group', ['questions']],
['GET', '/api/admin/big-questions/:group/export', ['questions']],
['GET', '/api/admin/big-questions/:group/template', ['questions']],
['POST', '/api/admin/big-questions/:group/import-excel', ['questions']],
['PUT', '/api/admin/big-questions/:group/:id', ['questions']],
['DELETE', '/api/admin/big-questions/:group/:id', ['questions']],
// ---------- 备赛资料 ----------
['GET', '/api/admin/materials/:group', ['materials']],
['POST', '/api/admin/materials/:group', ['materials']],
['DELETE', '/api/admin/materials/:group/:fileName', ['materials']],
// ---------- 新闻管理 ----------
// ⚠️ /news/order 必须排在 /news/:id 之前
['PUT', '/api/admin/news/order', ['news']],
['GET', '/api/admin/news', ['news']],
['POST', '/api/admin/news', ['news']],
['PUT', '/api/admin/news/:id', ['news']],
['DELETE', '/api/admin/news/:id', ['news']],
['POST', '/api/admin/upload/news-image', ['news']],
['POST', '/api/admin/upload/news-file', ['news']],
// ---------- 轮播管理 ----------
// ⚠️ /carousel/order 必须排在 /carousel/:id 之前
['PUT', '/api/admin/carousel/order', ['carousel']],
['GET', '/api/admin/carousel', ['carousel']],
['POST', '/api/admin/carousel', ['carousel']],
['PUT', '/api/admin/carousel/:id', ['carousel']],
['DELETE', '/api/admin/carousel/:id', ['carousel']],
['POST', '/api/admin/upload/image', ['carousel']],
// ---------- 成绩复核 ----------
['GET', '/api/admin/review/search', ['review']],
['GET', '/api/admin/review/score/:group/:id', ['review']],
].map(([method, pattern, perms]) => ({ method, pattern, perms, re: toRegExp(pattern) }));
/**
* 解析请求需要哪些权限(任一即可)。
*
* @param {string} method - HTTP 方法(大小写不敏感)
* @param {string} path - 请求路径(不含 query)
* @returns {string[]|null} 权限点数组;null = 未登记(调用方必须拒绝)
*/
function resolvePermission(method, path) {
const m = String(method || '').toUpperCase();
const p = String(path || '').split('?')[0];
for (const rule of ROUTE_TABLE) {
if (rule.method !== m) continue;
if (rule.re.test(p)) return rule.perms;
}
return null;
}
/**
* 解析数据库里的 permissions 列(JSON 数组文本)。
* 容错策略:解析失败 / 非数组 ⇒ 返回空数组(= 无权限),⛔ 绝不回落到「全权」。
*
* @param {string|null|undefined} raw
* @returns {string[]}
*/
function parsePermissions(raw) {
if (raw === null || raw === undefined || raw === '') return [];
let arr = raw;
if (typeof raw === 'string') {
try {
arr = JSON.parse(raw);
} catch (e) {
return [];
}
}
if (!Array.isArray(arr)) return [];
return arr.filter(k => typeof k === 'string' && PERMISSION_KEY_SET.has(k));
}
/**
* 规整待写入的权限点:只保留已登记的 key,去重,按 PERMISSIONS 顺序排列。
* ⛔ 必须过滤未知值 —— 否则前端传错一个字符串就会被存进库,
* 将来某个新权限点恰好同名时会「凭空获得权限」。
*
* @param {string[]} list
* @returns {string[]}
*/
function sanitizePermissions(list) {
if (!Array.isArray(list)) return [];
const set = new Set(list.filter(k => PERMISSION_KEY_SET.has(k)));
return PERMISSION_KEYS.filter(k => set.has(k));
}
/**
* 判断某管理员是否满足给定权限(任一命中即可)。
*
* ⚠️ 三种返回语义必须严格区分:
* required === null ⇒ 未登记路由(由 resolvePermission 返回,调用方必须拒绝)
* required === [] ⇒ **显式声明**「登录即可,无需业务权限」(如 /api/admin/me)
* required === ['a','b'] ⇒ 具备任一即可
*
* @param {{is_super?:number|boolean, permissions?:string[]|string}} admin
* @param {string[]|null} required
* @returns {boolean}
*/
function canAccess(admin, required) {
if (!admin) return false;
// 🔴 顺序不可换(R178 运行期判据 P3-2 抓到):**未登记路由对任何人都是拒绝**,
// 包括最高管理员。若把 is_super 判在前面,`canAccess(super, null)` 会返回 true ——
// 中间件虽然另有 `required === null ⇒ 403` 的前置分支,但那样「默认拒绝」就只活在
// 一处调用约定里:任何新调用方(例如前端按钮显隐的服务端预判)漏判即成后门。
// 此处作为第二道兜底,保证 null 永远不构成授权。
if (!Array.isArray(required)) return false;
if (Number(admin.is_super) === 1) return true;
// 空数组 = 显式放行(仅限登记为「登录即可」的接口)
if (required.length === 0) return true;
const owned = Array.isArray(admin.permissions)
? admin.permissions
: parsePermissions(admin.permissions);
return required.some(k => owned.includes(k));
}
/**
* 账号是否可被管理(改权限 / 重置口令 / 禁用 / 删除)。
* 最高管理员一律不可变动 —— 这是用户明确裁定的硬约束,集中在单点判断,
* ⛔ 不得在各接口里各写一遍 `id === 1`。
*
* @param {{is_super?:number|boolean}} admin
* @returns {boolean}
*/
function isManageable(admin) {
return !!admin && Number(admin.is_super) !== 1;
}
module.exports = {
PERMISSIONS,
PERMISSION_KEYS,
PERMISSION_KEY_SET,
ROUTE_TABLE,
resolvePermission,
parsePermissions,
sanitizePermissions,
canAccess,
isManageable,
MODULE,
HIGH_RISK,
};

152
app/lib/aggregationKey.js Normal file
View File

@@ -0,0 +1,152 @@
'use strict';
/**
* 归集键单一真相源 - aggregationKey
* ===================================================================
* 背景(2026-10-08 生产实测,人工测试反馈「测试账号的提交数据出现在正常
* 生产账号上」):
*
* 各组提交表的 aggregation_uid **写入键并不统一**:
* - 中职 / 高职:controller/exam.js 落库时写 `aggregation_uid = contestant.id`
* - 本科 / 教师:benke_submissions / jiaoshi_submissions 写
* `aggregation.aggregation_uid`(真实聚合 uid)
*
* 历史实现为兼容「两种写法并存」,对两侧都做**双键归集**——同时用
* `contestant.id` 与「真实 aggregation_uid」去匹配提交表。
*
* 但这两套编号来自**彼此独立的自增序列**,数值区间完全重叠。于是当
* 「某人的真实聚合 uid == 同组另一人的 contestant.id」时,前者会把后者的
* 作答记录 / 作品提交「借」过来展示 —— 生产实测共 **283 对**碰撞
* (gaozhi 239 / benke 43 / jiaoshi 1),已复现 1 例:
* 王乐乐(cid=600, 真实聚合 uid=621) 命中 吴巍荪(cid=621) 的 70 题 / 38 分。
*
* 治法(本模块):
* 1. **主键按组语义确定**,单一、无歧义:
* - 考试组(zhongzhi / gaozhi)主键 = contestant.id
* - 作品组(benke / jiaoshi)主键 = 真实 aggregation_uid
* 2. **兜底键沿用历史混杂写法,但必须通过歧义判定**才启用:
* - 不得等于同组任何人的主键(唯一的真实歧义来源:跨编号空间重叠)
* - (「兜底键被多人共用」结构上不可能:两套编号各自都是 PRIMARY KEY,
* 详见 buildAggregationKeyMap 内的不变量说明,故不设该分支)
* 未通过者一律丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性。
*
* 生产实测效果:283 个碰撞兜底被丢弃,317 个安全兜底保留,残留碰撞 **0**。
*
* ⚠️ 使用约束:本模块的歧义判定以 **同组** 为范围。调用方必须保证
* `contestants` 与查询的提交表同属一个 `group`(提交表按组物理分表,
* 跨组不会互相命中,故同组范围即充分)。
*/
/** 考试类组别(客观题 + 综合大题;提交表按 contestant.id 归集) */
const EXAM_GROUPS = ['zhongzhi', 'gaozhi'];
/** 是否为考试类组别 */
function isExamGroup(group) {
return EXAM_GROUPS.indexOf(group) !== -1;
}
function _blank(v) {
return v === null || v === undefined || v === '';
}
/**
* 主键:该组数据**实际写入**所用的键。
* @param {string} group
* @param {number|string} contestantId contestants.id
* @param {number|string|null} realAggUid aggregation.aggregation_uid
* @returns {string}
*/
function primaryKeyOf(group, contestantId, realAggUid) {
const cid = String(contestantId);
if (isExamGroup(group)) return cid;
// 作品组:主键是真实聚合 uid;缺失时退化为 cid(保持可用,不再引入第二个键)
return _blank(realAggUid) ? cid : String(realAggUid);
}
/**
* 兜底键:历史混杂写法所用键(与主键分属**相反**的编号空间)。
* @returns {string|null} null 表示无兜底键
*/
function fallbackKeyOf(group, contestantId, realAggUid) {
const cid = String(contestantId);
if (isExamGroup(group)) return _blank(realAggUid) ? null : String(realAggUid);
return cid;
}
/**
* 批量:为**同组**全部选手构建归集键。
* @param {string} group
* @param {Array<{id:number|string}>} contestants 同组选手列表
* @param {Object<string, number|string>} realAggByContestant contestant.id -> 真实 aggregation_uid
* @returns {Object<string, string[]>} contestant.id -> 归集键数组(首元素必为主键,且不重复)
*/
function buildAggregationKeyMap(group, contestants, realAggByContestant) {
const aggMap = realAggByContestant || {};
const list = Array.isArray(contestants) ? contestants : [];
const primarySet = new Set();
const plan = new Map();
for (const c of list) {
const cid = String(c.id);
const rag = aggMap[cid] !== undefined ? aggMap[cid] : aggMap[c.id];
const p = primaryKeyOf(group, cid, rag);
const f = fallbackKeyOf(group, cid, rag);
plan.set(cid, { p, f });
primarySet.add(p);
}
// ⚠️ 结构性不变量(已核对 DDL):`aggregation.aggregation_uid` 是 PRIMARY KEY(全局唯一),
// `contestants.id` 也是 PRIMARY KEY ⇒ 兜底键不可能被两名选手共用
// (考试组兜底 = 真实聚合 uid,作品组兜底 = cid,两者各自唯一)。
// 故**不需要**「兜底键被多人共用」的计数分支——那是永假的死判据。
// 唯一真实的歧义来源 = 跨编号空间重叠(兜底键撞上他人主键),下面一行即判据。
const result = {};
for (const [cid, { p, f }] of plan) {
const keys = [p];
if (f && f !== p && !primarySet.has(f)) keys.push(f);
result[cid] = keys;
}
return result;
}
/**
* 判定某个键是否**已被同组他人认领为主键**(单选手路径的兜底键歧义判定)。
*
* - 考试组:主键 = contestant.id ⇒ 查同组 contestants 是否存在该 id
* - 作品组:主键 = 真实 aggregation_uid ⇒ 查同组 aggregation 是否存在该 aggregation_uid
*
* 两处查询都命中主键/唯一列,`COUNT` ∈ {0,1},判据非退化(`>0` 即「有人认领」)。
*
* @returns {Promise<boolean>} true = 有歧义,兜底键必须丢弃
*/
async function isKeyClaimedInGroup(ctx, group, key) {
if (_blank(key)) return false;
const numeric = /^\d+$/.test(String(key)) ? Number(key) : String(key);
if (isExamGroup(group)) {
return (await ctx.model.Contestant.count({ where: { id: numeric, group } })) > 0;
}
return (await ctx.model.Aggregation.count({ where: { aggregation_uid: numeric, group } })) > 0;
}
/**
* 单选手:解析归集键(语义与 buildAggregationKeyMap 一致,歧义判定改为按需查库)。
* @returns {Promise<string[]>} 归集键数组(首元素必为主键)
*/
async function resolveAggregationKeysForContestant(ctx, group, contestantId, realAggUid) {
const cid = String(contestantId);
const p = primaryKeyOf(group, cid, realAggUid);
const f = fallbackKeyOf(group, cid, realAggUid);
const keys = [p];
if (f && f !== p && !(await isKeyClaimedInGroup(ctx, group, f))) keys.push(f);
return keys;
}
module.exports = {
EXAM_GROUPS,
isExamGroup,
primaryKeyOf,
fallbackKeyOf,
buildAggregationKeyMap,
isKeyClaimedInGroup,
resolveAggregationKeysForContestant,
};

Binary file not shown.

78
app/lib/auditLog.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
/**
* 管理员高危操作审计(R178)
* ============================================================================
*
* 三种写法被刻意排除:
* 1. **不在控制器里直接 create** —— 控制器有 60+ 个方法,散落写入必然漏;
* 统一走本函数,写入范围只由「谁调用它」决定,容易被审阅。
* 2. **不做全量写操作审计** —— 见 model/adminAuditLog.js 头注:约 30 个写接口,
* 日志量增长快且无清理策略;真正要回答的是「谁改了成绩 / 谁动了账号」。
* 3. **不阻断业务** —— 审计失败只 warn。日志表故障不该让「发布成绩」失败,
* 否则为可观测性牺牲了主流程可用性。
*
* ⚠️ detail 只放「变更前后的非敏感差异」。⛔ 绝不传口令、令牌、身份证号明文。
*/
/**
* 写入一条审计记录(best-effort,永不抛出)
*
* @param {Object} ctx - Egg 上下文(需 ctx.state.currentAdmin 已由 adminAuth 写入)
* @param {string} action - 动作标识,点号分层,如 'score.publish' / 'admin.create'
* @param {string} [target] - 操作对象,如组别、用户名、选手 ID
* @param {Object|string} [detail] - 补充信息(对象会被 JSON 序列化)
* @returns {Promise<void>}
*/
async function recordAudit(ctx, action, target, detail) {
try {
const admin = (ctx && ctx.state && ctx.state.currentAdmin) || null;
// 没有操作人就不写:宁可缺一条,也不要写出一条「admin_id=0」的假归属,
// 那会让人误以为系统账号做过操作。
if (!admin || !admin.id) {
ctx && ctx.logger && ctx.logger.warn(`[audit] 缺少 currentAdmin,跳过记录 action=${action}`);
return;
}
let detailText = null;
if (detail !== undefined && detail !== null) {
try {
detailText = typeof detail === 'string' ? detail : JSON.stringify(detail);
} catch (e) {
detailText = '[unserializable]';
}
if (detailText && detailText.length > 4000) detailText = detailText.slice(0, 4000) + '…';
}
await ctx.model.AdminAuditLog.create({
admin_id: admin.id,
admin_username: admin.username,
action: String(action || 'unknown').slice(0, 64),
target: target === undefined || target === null ? null : String(target).slice(0, 200),
detail: detailText,
ip: String(ctx.ip || '').slice(0, 64) || null,
created_at: new Date(),
});
} catch (err) {
// 审计失败不得影响业务:只记日志
if (ctx && ctx.logger) {
ctx.logger.warn(`[audit] 写入失败 action=${action}:`, err && err.message);
}
}
}
/** 审计动作常量(避免各处手写字符串导致同一动作被记成两个名字) */
const ACTIONS = {
SCORE_PUBLISH: 'score.publish',
SCORE_UNPUBLISH: 'score.unpublish',
DATA_RESET_TEST: 'data.resetTest',
CONTESTANT_DELETE: 'contestant.delete',
EXAM_UNSUBMIT: 'exam.unsubmit',
ADMIN_CREATE: 'admin.create',
ADMIN_PERMISSIONS: 'admin.permissions',
ADMIN_PASSWORD: 'admin.password',
ADMIN_STATUS: 'admin.status',
ADMIN_DELETE: 'admin.delete',
};
module.exports = { recordAudit, ACTIONS };

429
app/lib/bigScoreImport.js Normal file
View File

@@ -0,0 +1,429 @@
'use strict';
/**
* 大题成绩 Excel 导入 —— 纯判定层(R172 建,R174 改三小问四列制)
*
* 为什么单独抽一个 lib:
* 1) **可被 `--self-test` 直接自证**:本文件零依赖(不 require Egg / 不连库 / 不读文件),
* `scripts/verify_big_score_import.py` 可直接 `node -e "require(...)"` 拿到**真实实现**,
* 而不是在测试里另写一份「看起来一样」的副本(⛔ 副本自证 = 恒绿假自证)。
* 2) 判据与主流程**同源**:service/bigQuestion.js 只能调这里,不得内联第二套算法。
*
* 业务口径(用户 2026-10-19 / 2026-10-20 两次裁定):
* · Excel 为**四列**:`小组编号` + `第1小问` + `第2小问` + `第3小问`,四列都必填;
* 总分由系统**求和**,表中不再有「分数」列。
* · **任一小问留空 ⇒ 整行拒绝并报错**(用户明确裁定:留空按 0 会静默抹分)。
* · **缺任一小问列 ⇒ 拒绝整份文件**,并提示重新下载模板。
* · **旧版两列模板(小组编号 + 分数)不再支持**,整份拒绝并提示改用三小问模板。
* · **一个小组只会有一人参加考试**(真正抽题作答的那位),但成绩**组内共享**
* —— 因此只需把分写给「抽题者」,其余成员由 score.js `_resolveTeamScoreSource` 共享读取;
* · 已评分默认拒绝,需显式勾选「覆盖已评分」。
*
* ⚠️ R174 变更史(改动前必读):
* 旧实现只有 `scoreRaw` 一列总分,由 `splitScoreAcrossSubs()` **均分**到三小问。
* 用户指出「模板忘了改成三小问形式」,故:
* · 删除 `splitScoreAcrossSubs()`;
* · 新增 `scoreSubs()`(逐项取值 + 逐项校验,不再做均分);
* · 行状态 `SCORE_MISSING/SCORE_INVALID/SCORE_OUT_OF_RANGE` → `SUB_MISSING/SUB_INVALID/SUB_OUT_OF_RANGE`;
* · 文件级错误码新增 `LEGACY_TEMPLATE` / `SUB_COLUMNS_MISSING`。
* ⛔ 不得为「兼容老文件」保留均分分支 —— 用户已裁定「不再支持,只认三小问模板」。
*/
/** 小问数量(题制固定 3 问,与 BIG_QUESTION 域一致) */
const SUB_COUNT = 3;
/** 默认每小问分值(题库可配 score_per_sub,此处仅兜底) */
const DEFAULT_PER_SUB = 10;
/** 分数两位小数(与 DECIMAL(6,2) 同口径) */
const SCORE_SCALE = 100;
/** 表头别名(与实际 Excel 填写习惯对齐;⛔ 大小写与空格在匹配前已归一) */
const COLUMN_ALIAS = {
teamNumber: ['小组编号', '队伍编号', '团队编号', '小组号', '队号', 'team_number', 'teamnumber', 'team_id', 'teamid'],
sub1: [
'第1小问', '第1问', '小问1', '小问一', '第一小问', '1小问',
'score1', 'score_1', 'sub1', 'sub_score_1', '大题第1小问',
],
sub2: [
'第2小问', '第2问', '小问2', '小问二', '第二小问', '2小问',
'score2', 'score_2', 'sub2', 'sub_score_2', '大题第2小问',
],
sub3: [
'第3小问', '第3问', '小问3', '小问三', '第三小问', '3小问',
'score3', 'score_3', 'sub3', 'sub_score_3', '大题第3小问',
],
/**
* 旧版两列模板的「总分」列。
* ⚠️ 只用于**识别旧文件并给出可读拒绝**,绝不参与取值(用户裁定:不再支持旧模板)。
*/
legacyTotal: ['分数', '得分', '成绩', '总分', '大题得分', '大题总分', 'score', 'totalscore', 'total'],
};
const ROW_STATUS = {
OK: 'OK', // 可导入(该考生尚未评分)
OVERWRITE: 'OVERWRITE', // 可导入,但会覆盖既有评分 ⇒ 需显式勾选
EMPTY_ROW: 'EMPTY_ROW', // 整行空白 ⇒ 静默跳过,不计错误
TEAM_MISSING: 'TEAM_MISSING', // 小组编号列为空
SUB_MISSING: 'SUB_MISSING', // 至少一个小问留空 ⇒ 整行拒绝(不按 0 分处理)
SUB_INVALID: 'SUB_INVALID', // 小问不是数值 / 负数
SUB_OUT_OF_RANGE: 'SUB_OUT_OF_RANGE', // 某小问超过单价分值(perSub)
DUPLICATE_IN_FILE: 'DUPLICATE_IN_FILE', // 同一文件内该小组编号出现多次
TEAM_NOT_FOUND: 'TEAM_NOT_FOUND', // 全库都没有这个小组编号
TEAM_IN_OTHER_GROUP: 'TEAM_IN_OTHER_GROUP', // 小组编号存在,但属于别的组
NO_OPERATOR: 'NO_OPERATOR', // 组内无人抽取大题 ⇒ 无 rows 可评分
MULTI_OPERATOR: 'MULTI_OPERATOR', // 组内多人抽取大题 ⇒ 违反「一组一人参赛」,拒绝猜测
};
/** 判定为「阻断」的状态(不写库、计入错误) */
const BLOCKING_STATUS = [
ROW_STATUS.TEAM_MISSING,
ROW_STATUS.SUB_MISSING,
ROW_STATUS.SUB_INVALID,
ROW_STATUS.SUB_OUT_OF_RANGE,
ROW_STATUS.DUPLICATE_IN_FILE,
ROW_STATUS.TEAM_NOT_FOUND,
ROW_STATUS.TEAM_IN_OTHER_GROUP,
ROW_STATUS.NO_OPERATOR,
ROW_STATUS.MULTI_OPERATOR,
];
/** 文件级错误码(`parseScoreRows` 返回的 `code`) */
const FILE_CODE = {
EMPTY_FILE: 'EMPTY_FILE',
HEADER_NOT_FOUND: 'HEADER_NOT_FOUND',
LEGACY_TEMPLATE: 'LEGACY_TEMPLATE', // 只有「分数」一列 ⇒ 旧版两列模板
SUB_COLUMNS_MISSING: 'SUB_COLUMNS_MISSING', // 有小问列但不足三列
NO_DATA_ROW: 'NO_DATA_ROW',
};
/**
* 规范化「小组编号」单元格。
*
* Excel 常见污染:首尾空格、全角空格(U+3000)、不换行空格(U+00A0)、零宽字符(U+200B/200C/200D/FEFF)。
* 这些肉眼不可见,但会让「BRICS2026-ST-027-91001」与库中值不相等 ⇒ 全部行莫名「小组编号不存在」。
*
* ⛔ 不做大小写折叠:队号含字母,大小写是其字面的一部分,擅自 toUpperCase 会掩盖录入错误。
* @param {*} v
* @returns {string}
*/
function normalizeTeamNumber(v) {
if (v === null || v === undefined) return '';
return String(v)
.replace(/[\u3000\u00a0\u200b-\u200d\ufeff]/g, '')
.trim();
}
/** 表头单元格归一:去空白/下划线/全角空格后小写,仅用于别名匹配 */
function normalizeHeader(v) {
if (v === null || v === undefined) return '';
return String(v)
.replace(/[\s\u3000\u00a0_\-()()]/g, '')
.toLowerCase();
}
/**
* 按别名在同义词表里定位列下标。
* @param {Array<string>} headerRow
* @param {Array<string>} aliases
* @returns {number} 命中列下标,未命中 -1
*/
function findColumn(headerRow, aliases) {
const cells = (headerRow || []).map(normalizeHeader);
for (const alias of aliases) {
const want = normalizeHeader(alias);
const idx = cells.indexOf(want);
if (idx >= 0) return idx;
}
return -1;
}
/** 单元格是否「空」(null / undefined / 纯空白串三者都算空) */
function isBlank(v) {
return v === null || v === undefined || String(v).trim() === '';
}
/**
* 逐项取三小问得分并校验(**不再均分**)。
*
* 校验顺序(顺序即报错优先级,⛔ 不可调换):
* 1) 先扫全量「留空」⇒ 一次性报出**所有**空列(用户裁定:留空整行拒绝,不按 0 分处理);
* 2) 再逐项验「是否数值 / 是否负数」;
* 3) 再逐项验「是否超过单问上限 perSub」。
*
* 为什么「留空」优先于「数值非法」:
* 评卷人填到一半时最常见的形态就是「后两问还空着」,此时报「第 2、3 小问未填写」
* 比报「第 2 小问 xx 不是数值」更贴近真实原因。
*
* ⚠️ 为什么不给「留空按 0」的宽松模式:
* 用户 2026-10-20 明确裁定「整行拒绝并报错」——留空按 0 会**静默抹掉**已评分数,
* 且导入后三问合计与评卷人预期不一致时,无从判断是漏填还是真 0 分。
*
* @param {Array<*>} subRaws - 三小问原始单元格值,长度不足按空处理
* @param {number} [subCount=3]
* @param {number} [perSub=10]
* @returns {{ok:boolean, subs?:number[], total?:number, message?:string, status?:string}}
*/
function scoreSubs(subRaws, subCount, perSub) {
const n = Number.isFinite(Number(subCount)) && Number(subCount) > 0
? Math.floor(Number(subCount))
: SUB_COUNT;
const per = Number.isFinite(Number(perSub)) && Number(perSub) > 0
? Number(perSub)
: DEFAULT_PER_SUB;
const perCents = Math.round(per * SCORE_SCALE);
const list = Array.isArray(subRaws) ? subRaws : [];
const raws = [];
for (let i = 0; i < n; i++) raws.push(list[i]);
// 1) 留空优先:一次性报出所有空列
const emptyIdx = [];
raws.forEach((raw, i) => { if (isBlank(raw)) emptyIdx.push(i + 1); });
if (emptyIdx.length) {
return {
ok: false,
status: ROW_STATUS.SUB_MISSING,
message: `第 ${emptyIdx.join('、')} 小问未填写(三小问必须逐项填写,留空不会按 0 分处理)`,
};
}
// 2) / 3) 逐项验数值与上限
const cents = [];
for (let i = 0; i < n; i++) {
const raw = raws[i];
const v = Number(raw);
if (!Number.isFinite(v)) {
return {
ok: false,
status: ROW_STATUS.SUB_INVALID,
message: `第 ${i + 1} 小问「${raw}」不是数值`,
};
}
const c = Math.round(v * SCORE_SCALE);
if (c < 0) {
return {
ok: false,
status: ROW_STATUS.SUB_INVALID,
message: `第 ${i + 1} 小问不能为负数(填的是 ${(c / SCORE_SCALE).toFixed(2)})`,
};
}
if (c > perCents) {
return {
ok: false,
status: ROW_STATUS.SUB_OUT_OF_RANGE,
message: `第 ${i + 1} 小问 ${(c / SCORE_SCALE).toFixed(2)} 超过单问上限 ${(perCents / SCORE_SCALE).toFixed(2)} 分`,
};
}
cents.push(c);
}
const totalCents = cents.reduce((s, c) => s + c, 0);
return {
ok: true,
subs: cents.map(c => c / SCORE_SCALE),
total: totalCents / SCORE_SCALE,
message: '',
};
}
/**
* 单行判定(纯函数,零 IO)。
*
* 入参是**预查好的上下文**,判定逻辑与主流程共用同一份代码:
* · foundInGroup / foundInOtherGroup 由 service 查 teams 得到;
* · operatorCount = 该小组内「有 big_question_answers 行」的考生数;
* · currentTotal = 该考生现有大题总分(null/undefined ⇒ 未评分)。
*
* ⚠️ 判定顺序即优先级:先排「表格自身问题」,再排「编码/归属问题」,最后才是「覆盖确认」。
* 顺序不能乱:否则「已评分但小问越界」会被报成 OVERWRITE,前端勾选覆盖后照样写不进去。
*
* @param {Object} input
* @param {*} input.teamNumber 小组编号单元格原值
* @param {Array<*>} input.subRaws 三小问原始单元格值
* @returns {{status:string, message:string, subs?:number[], total?:number}}
*/
function classifyScoreRow(input) {
const it = input || {};
const teamNumber = normalizeTeamNumber(it.teamNumber);
const subRaws = Array.isArray(it.subRaws) ? it.subRaws : [];
const allSubsBlank = subRaws.every(isBlank);
// 1) 整行空白 ⇒ 静默跳过(模板示例后的空行常见)
if (!teamNumber && allSubsBlank) {
return { status: ROW_STATUS.EMPTY_ROW, message: '' };
}
// 2) 小组编号为空
if (!teamNumber) {
return { status: ROW_STATUS.TEAM_MISSING, message: '小组编号为空' };
}
// 3) 本文件内重复
if (Number(it.dupCount) > 1) {
return {
status: ROW_STATUS.DUPLICATE_IN_FILE,
message: `小组编号「${teamNumber}」在本文件中出现 ${it.dupCount} 次,无法判断以哪行为准 ⇒ 全部拒绝`,
};
}
// 4) 三小问本身非法(留空 / 非数值 / 负数 / 越界)
const subs = scoreSubs(subRaws, it.subCount, it.perSub);
if (!subs.ok) {
return { status: subs.status, message: subs.message };
}
// 5) 小组编号归属
if (!it.foundInGroup) {
if (it.foundInOtherGroup) {
return {
status: ROW_STATUS.TEAM_IN_OTHER_GROUP,
// ⚠️ otherGroupLabel 已含「组」字(GROUP_LABELS 的值形如「高职组」)⇒ 这里不得再拼「组」
message: `小组编号「${teamNumber}」属于${it.otherGroupLabel || '其他组别'},请在对应组别页签导入`,
};
}
return {
status: ROW_STATUS.TEAM_NOT_FOUND,
message: `小组编号「${teamNumber}」在本组不存在(请核对空格 / 是否为纯数字编号被 Excel 转成数值)`,
};
}
// 6) 参赛者(抽题者)唯一性
const opCount = Number(it.operatorCount) || 0;
if (opCount === 0) {
return {
status: ROW_STATUS.NO_OPERATOR,
message: '该小组尚无人抽取综合大题,无法录入成绩(考试结束后再导入)',
};
}
if (opCount > 1) {
return {
status: ROW_STATUS.MULTI_OPERATOR,
message: `该小组有 ${opCount} 人抽取了大题,与「一组一人参赛」不符 ⇒ 拒绝猜测,请先人工核对`,
};
}
// 7) 覆盖确认
const hasScore = !isBlank(it.currentTotal);
if (hasScore) {
return {
status: ROW_STATUS.OVERWRITE,
message: `该考生已评分(${Number(it.currentTotal).toFixed(2)} 分),需勾选「覆盖已评分」才会写入`,
subs: subs.subs,
total: subs.total,
};
}
return { status: ROW_STATUS.OK, message: '', subs: subs.subs, total: subs.total };
}
/**
* 把 sheet 的 AOA 解析成规范化行(纯函数)。
*
* 表头契约:**同一行**须同时命中「小组编号」列 + 三个小问列。
* 缺列时的错误码必须可区分,否则用户只看到「表头未找到」,不知道是自己删了列还是用了旧模板:
* · 只有「分数」一列(无任何小问列)⇒ `LEGACY_TEMPLATE`(提示重新下载三小问模板)
* · 有小问列但不足三列 ⇒ `SUB_COLUMNS_MISSING`(点名缺第几列)
* · 两者都认不出 ⇒ `HEADER_NOT_FOUND`
*
* @param {Array<Array>} aoa - 含表头的二维数组(`sheet_to_json(sheet, { header: 1 })`)
* @returns {{ok:boolean, code?:string, message?:string, rows?:Array}}
*/
function parseScoreRows(aoa) {
const grid = Array.isArray(aoa) ? aoa : [];
if (!grid.length) {
return {
ok: false,
code: FILE_CODE.EMPTY_FILE,
message: 'Excel 内容为空(首行须为表头,第 1 页为「成绩导入」)',
};
}
// 表头可能不在第 1 行(用户常先写标题)⇒ 在前 5 行里找同时命中「编号 + 三小问」的
let headerRowIdx = -1;
let teamCol = -1;
let subCols = null;
/** 部分命中的证据,用于给出可区分的拒绝理由 */
let partial = null;
for (let i = 0; i < Math.min(grid.length, 5); i++) {
const line = grid[i] || [];
const t = findColumn(line, COLUMN_ALIAS.teamNumber);
if (t < 0) continue;
const found = [
findColumn(line, COLUMN_ALIAS.sub1),
findColumn(line, COLUMN_ALIAS.sub2),
findColumn(line, COLUMN_ALIAS.sub3),
];
const hit = found.filter(x => x >= 0).length;
if (hit === SUB_COUNT) {
headerRowIdx = i;
teamCol = t;
subCols = found;
break;
}
if (!partial) {
if (hit > 0) {
partial = { kind: 'subs', missing: [1, 2, 3].filter(k => found[k - 1] < 0) };
} else if (findColumn(line, COLUMN_ALIAS.legacyTotal) >= 0) {
partial = { kind: 'legacy' };
}
}
}
if (headerRowIdx < 0) {
if (partial && partial.kind === 'legacy') {
return {
ok: false,
code: FILE_CODE.LEGACY_TEMPLATE,
message: '检测到旧版「小组编号 + 分数」两列模板,该模板已不再支持。'
+ '请在管理端重新下载「成绩导入模板」,按「小组编号 + 第1/2/3小问」四列填写后重试',
};
}
if (partial && partial.kind === 'subs') {
return {
ok: false,
code: FILE_CODE.SUB_COLUMNS_MISSING,
message: `表头缺少「第 ${partial.missing.join('、')} 小问」列。`
+ '三小问必须逐列填写(缺列会导致该小问成绩被静默丢失)⇒ 整份文件已拒绝,'
+ '请重新下载模板后重试',
};
}
return {
ok: false,
code: FILE_CODE.HEADER_NOT_FOUND,
message: '未找到表头:同一行须同时存在「小组编号」列与「第1小问」「第2小问」「第3小问」三列(请先下载模板)',
};
}
const rows = [];
const seen = {};
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const line = grid[i] || [];
const teamNumber = normalizeTeamNumber(line[teamCol]);
const subRaws = subCols.map(c => line[c]);
const rowIndex = i + 1; // Excel 行号(1 基)
rows.push({ row: rowIndex, teamNumber, subRaws });
if (teamNumber) {
const k = teamNumber;
seen[k] = (seen[k] || 0) + 1;
}
}
for (const r of rows) {
r.dupCount = r.teamNumber ? (seen[r.teamNumber] || 0) : 0;
}
if (!rows.length) {
return { ok: false, code: FILE_CODE.NO_DATA_ROW, message: '表头之后没有数据行' };
}
return { ok: true, rows };
}
module.exports = {
SUB_COUNT,
DEFAULT_PER_SUB,
SCORE_SCALE,
COLUMN_ALIAS,
ROW_STATUS,
BLOCKING_STATUS,
FILE_CODE,
isBlank,
normalizeTeamNumber,
normalizeHeader,
findColumn,
scoreSubs,
classifyScoreRow,
parseScoreRows,
};

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);
};
};

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

@@ -0,0 +1,490 @@
'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, size, color } 或 { br: true }
* color 为 6 位十六进制(可带 `#` 前缀),如 'FF0000' / '#FF0000';非法值忽略。
*/
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';
/**
* 规范化 run 的颜色值。
*
* ⛔ 只接受 6 位十六进制(可带 `#`),其余一律返回 '':
* OOXML 的 `w:val` 若被填入 `red` / `#F00` / 任意字符串,Word 会拒绝打开整个文档,
* 而调用方(导出服务)无法感知 —— 表现为「导出的 docx 打不开」,排查成本极高。
*
* @param {*} v
* @returns {string} 6 位大写十六进制或 ''
*/
function normalizeColor(v) {
if (v === null || v === undefined) return '';
const s = String(v).trim().replace(/^#/, '');
return /^[0-9a-fA-F]{6}$/.test(s) ? s.toUpperCase() : '';
}
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"/>');
// ⚠️ 顺序硬约束:CT_RPr 是 sequence,`w:color` 必须排在 rFonts 之后、`w:sz` 之前。
// 顺序错乱(如放到 sz 之后)Word 同样会判文档损坏 —— 与「pPr 必须先于 runs」同理。
const color = normalizeColor(run.color);
if (color) parts.push(`<w:color w:val="${color}"/>`);
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,
// 导出内部件便于单测(`scripts/verify_big_score_import.py` 会断言 color 渲染与序列位置)
_internal: { parseHtml, buildDocumentXml, rPrOf, normalizeColor, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
};

103
app/lib/idCard.js Normal file
View File

@@ -0,0 +1,103 @@
'use strict';
/**
* 身份证号判据(导入通道的单一判据源)
*
* ⚠️ 同源约束:本模块的判定口径必须与下列两处保持一致,任一处变更须同步其余两处:
* 1. `scripts/preflight_import_check.py`(导入前离线校验器,CLI)
* 2. `backend/app/model/contestant.js`(id_number: STRING(18) + validate.len [18,18])
*
* 口径来源(2026-10-12 依据生产事故校准):
* · 报名表「已审核(含增补)」导入时,第 188 / 1012 / 1421 行因身份证号被写成
* 19 / 17 / 20 位,被 Sequelize 的 len 校验拦下,但错误信息只有
* `Validation len on id_number failed` —— 管理员在页面上完全看不到是哪几行、是谁。
* · 故把「长度必须 18 位」前置为**可读错误**(与原行为等价:原本也进不了库,只是报错难懂)。
*
* 分级原则(避免误伤真实数据):
* · 长度 ≠ 18 → error:绝不会写入成功(模型层硬约束),前置拦截并给出人话原因。
* · 18 位但含非法字符 → warn :模型只查长度、不查字符集,原值可入库 ⇒ 只提示不阻断。
* · 18 位但校验位不符 → warn :生产库现存 753 条中即有 4 条校验位不符(真实在册考生),
* 若硬拦截会造成合法考生漏导 ⇒ 只提示不阻断。
*/
const ID_LEN = 18;
const ID_CHARSET_RE = /^\d{17}[\dX]$/;
// GB 11643-1999 校验位加权因子与余数映射
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const CHECK_CODES = '10X98765432';
/**
* 身份证号归一化:去首尾空白 + 末位 x 统一为大写 X。
* @param {*} raw - 原始值(可能为 undefined / number / string)
* @returns {string} 归一化后的字符串(空值返回 '')
*/
function normalizeIdNumber(raw) {
if (raw === undefined || raw === null) return '';
return String(raw).trim().toUpperCase();
}
/**
* 计算 18 位身份证号第 18 位(校验位)。
* @param {string} s - 至少 17 位的数字串(已归一化)
* @returns {string} 期望的校验位('0'-'9' / 'X');入参不足或含非数字时返回 ''
*/
function expectedCheckCode(s) {
if (typeof s !== 'string' || s.length < 17) return '';
const body = s.slice(0, 17);
if (!/^\d{17}$/.test(body)) return '';
let sum = 0;
for (let i = 0; i < 17; i++) sum += Number(body[i]) * WEIGHTS[i];
return CHECK_CODES[sum % 11];
}
/**
* 检验一个身份证号是否可用于导入,并给出人话级原因。
*
* @param {*} raw - 原始单元格值
* @returns {{ importable: boolean, level: 'error'|'warn'|null, message: string, value: string }}
* · importable=false ⇒ 该行**不可能写入成功**,调用方应跳过(level 恒为 'error')
* · importable=true 且 level='warn' ⇒ 可导入,但需人工复核(message 说明原因)
* · importable=true 且 level=null ⇒ 无异常
*/
function inspectIdNumber(raw) {
const value = normalizeIdNumber(raw);
if (!value) {
return { importable: false, level: 'error', message: '身份证号为空', value };
}
if (value.length !== ID_LEN) {
return {
importable: false,
level: 'error',
message: `身份证号应为 ${ID_LEN} 位,实际 ${value.length} 位(${value})`,
value,
};
}
if (!ID_CHARSET_RE.test(value)) {
return {
importable: true,
level: 'warn',
message: `身份证号含非法字符(${value}),已按原值导入,请人工核对`,
value,
};
}
const expect = expectedCheckCode(value);
if (expect && expect !== value[ID_LEN - 1]) {
return {
importable: true,
level: 'warn',
message: `身份证号校验位不符(末位应为 ${expect},实际为 ${value[ID_LEN - 1]}),已按原值导入,请人工核对`,
value,
};
}
return { importable: true, level: null, message: '', value };
}
module.exports = {
ID_LEN,
ID_CHARSET_RE,
normalizeIdNumber,
expectedCheckCode,
inspectIdNumber,
};

View File

@@ -0,0 +1,844 @@
'use strict';
/**
* 节点备份调度器(Node 内置定时器)—— 考试开始前 / 考试结束后 / 成绩发布后
* ============================================================================
* 需求来源(用户 2026-10-16提出):「系统在考试开始前,考试结束后,成绩发布后
* 这三个节点应该执行数据备份」。
*
* ============================ 为什么不用巡检脚本 ============================
* 用户裁定:巡检脚本(cron 每分钟扫状态跃迁)会占用额外内存 ⇒ 改用**后端钩子**。
* 本模块就是该钩子的落点:**进程内 Node 定时器**,不新增常驻进程、不新增轮询。
* 额外内存开销 = 最多 8 个 setTimeout 句柄(4 组 × 2 节点),可忽略。
*
* ============================ 三节点的时间口径 ============================
* ① before-start :考试开始前 30 分钟(缓冲,留出 dump 落盘时间)
* ② after-end :考试结束后 10 分钟(缓冲,等最后一批提交落库)
* ③ score-published:成绩发布**瞬间**触发(发布态本身不可逆,见下方「撤销」说明)
*
* ⚠️ 「撤销发布」不作为节点(用户裁定):撤销发生时备份到的是**已经发布过的数据**,
* 对「回退到未发布态」这个诉求毫无意义 ⇒ 只在 publish === true 时触发。
*
* ============================ 幂等:为什么用「计划键」而不是「组别」 ============================
* 定时器只活在内存里,进程重启即丢失 ⇒ 必须在 `didReady` 重新 hydrate。
* 若 hydrate 时无条件按组别重排,会出现「重启一次 = 重复备份一次」。
* 故幂等键 = `${group}:${node}:${fireAtISO}` —— **时间点本身就是身份**:
* · 管理员把考试时间改成新值 ⇒ fireAt 变了 ⇒ 是新计划,理应再备一次 ✅
* · 进程重启 hydrate 出同一 fireAt ⇒ 键相同 ⇒ 覆盖同一计划,不重复 ❌→ ✅ 不触发
* 另有一层持久化去重:`system_configs` 的 `node_backup_fired_<key>`
* (哈希后落键),用于「重启后 fireAt 已在过去」的场景下仍不重复备份。
*
* ============================ 🔴 生产隔离铁律 ============================
* 本模块**只在生产环境启用**(`config.isProduction === true`)。
* 非生产一律 `enabled = false` 且 `schedule()` 直接返回空 —— 因为备份目标是
* **生产库**(用户裁定②),本地/测试环境触发它等于让本机进程去 dump 生产库。
* 这与 config.default.js 里 `DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', ...]`
* 是同一条纪律的延伸:非生产进程**不得持有生产连接凭据**。
*
* ============================ 备份执行 ============================
* 不自己实现 dump ⇒ **复用** `scripts/backup_db.py`(同一份 mysqldump + gzip +
* 完整性校验 + 保留期治理逻辑,避免两套备份实现的校验口径漂移)。
* 通过 `child_process.spawn` 异步执行,**绝不阻塞业务请求**:
* · setTimes 写完库立即返回,备份在后台跑
* · publishScores 同理
* 失败只落日志 + 状态文件 + 告警日志,**绝不 throw 到调用方** ——
* 备份失败不能把「设置考试时间」「发布成绩」这两个管理动作搞挂。
*
* ============================ 🔴 多 worker 必须处理 ============================
* 🔴 R168 修正(生产取证):生产 `EGG_WORKERS=2`(deploy/.env.production:77)⇒ Egg 起
* **2 个 app worker 进程**,而 `didReady` 在**每个 worker 里各跑一次** ⇒ 原实现每个
* worker 各建一个调度器、各自 hydrate 出同一批计划 ⇒ **到点两个进程各 spawn 一次
* mysqldump**(重复备份 + 双倍 IO + 竞态写同一目录)。
* ⚠️ 本地 dev 单 worker(egg-bin 强制)⇒ 测试完全看不出来 —— 又一个
* 「测试环境掩盖生产缺陷」的实例,故判据必须显式模拟多 worker。
* 同理 `setTimes` 只落到「处理该请求的那一个 worker」⇒ 另一个 worker 的旧计划
* **不会被清除** ⇒ 改期后残留定时器仍会在旧时刻误触发一次。
*
* 两道防线(缺一不可):
* ① **只在 master 进程排程**(`isMaster` 门禁)—— 从根上消除重复源头;
* ② **跨进程文件锁**(`_fire` 内)—— 兜底:worker 数配置漂移 / 时序异常时
* 也不会并发 dump。
* ============================================================================
*/
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const crypto = require('crypto');
// ---- 三节点常量 ----
const NODE_BEFORE_START = 'before-start';
const NODE_AFTER_END = 'after-end';
const NODE_SCORE_PUBLISHED = 'score-published';
/**
* 计划键:节点身份 = 组别 + 节点 + 触发时刻
* @param {string} group 组别
* @param {string} node 节点常量
* @param {Date} fireAt 触发时刻
* @returns {string}
*/
function planKey(group, node, fireAt) {
return `${group}:${node}:${new Date(fireAt).toISOString()}`;
}
// ===========================================================================
// 结果台账(P1「生产备份静默失败防护」的落地)
// ===========================================================================
// 🔴 为什么必须有台账,而不是「只写 logger」:
// 台账 P1 的原始形态就是「失败只落日志,没人看 ⇒ 直到需要恢复时才发现备份
// 根本不存在」。logger 是**运维异步消费**的通道,管理后台读不到它。
// 故这里额外维护一份 **append-only JSONL**(`node-backup-history.jsonl`),
// 管理端状态接口直接读它 ⇒ 失败在设置页立刻可见,不必 ssh 翻日志。
//
// 为什么用 JSONL 而不是一个 status.json:
// · 生产 EGG_WORKERS=2,多进程并发写同一状态文件必定互相覆盖(读-改-写竞态);
// · 单行 append + O_APPEND 是原子写(< PIPE_BUF),多进程安全;
// · 天然保留**历史**,才能判「漏备」「连续失败」这类需要时间序列的结论。
// 体量估算:4 组 × 2 节点 + 少量即时节点 ≈ 每天数十行(<10KB/天),
// 90 天后仍 <1MB ⇒ 暂不做轮转(轮转需 mv 归档,等真的有量再说)。
const HISTORY_FILE_NAME = 'node-backup-history.jsonl';
/** 台账文件路径 */
function historyPath(logDir) {
return path.join(logDir, HISTORY_FILE_NAME);
}
/**
* 读取台账尾部 N 条(**最新在前**)
*
* ⚠️ 单行损坏(进程在 append 中途被 kill)只跳过该行,绝不让整个读取失败 ——
* 台账读不出来等于把可见性做回到「只能翻日志」。
* @param {string} logDir
* @param {number} limit
* @returns {Array<object>}
*/
function readHistory(logDir, limit) {
const n = Math.max(1, Math.min(Number(limit) || 50, 500));
let text = '';
try {
text = fs.readFileSync(historyPath(logDir), 'utf8');
} catch (e) {
return []; // 文件不存在 = 从未触发过,是正常状态而非错误
}
const lines = String(text).split('\n');
const out = [];
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
const s = lines[i].trim();
if (!s) continue;
try { out.push(JSON.parse(s)); } catch (e) { /* 半行/损坏行跳过 */ }
}
return out;
}
/**
* 列出备份产物(按 mtime 倒序)
* @param {string} outDir
* @param {number} limit
*/
function listArtifacts(outDir, limit) {
const n = Math.max(1, Math.min(Number(limit) || 10, 100));
const items = [];
try {
for (const name of fs.readdirSync(outDir)) {
if (!/\.(sql|gz|tgz|zip)$/i.test(name)) continue;
try {
const st = fs.statSync(path.join(outDir, name));
if (!st.isFile()) continue;
items.push({
name,
sizeMB: Math.round((st.size / 1048576) * 100) / 100,
mtime: new Date(st.mtimeMs).toISOString(),
});
} catch (e) { /* 并发被清理 ⇒ 跳过 */ }
}
} catch (e) {
return [];
}
items.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : 0));
return items.slice(0, n);
}
/**
* 健康判定(**纯函数**:不读磁盘、不看时钟以外的外部状态 ⇒ 可直接单测)
*
* 判据设计原则:每条都对应一种「备份实际没做,但界面上看不出来」的真实故障形态,
* 而不是泛泛的「有错就红」:
* H1 PROD_DISABLED 生产环境调度器没启用 ⇒ 三节点备份永远不会执行
* H2 LAST_RUN_FAILED 最近一次有结果执行是失败(含连续失败)⇒ 需人工介入
* H3 MISSED_RUN 计划已到点超宽限期,却既无成功也无失败也无跳过记录
* ⇒ 定时器丢了(进程重启/hydrate 漏了/kill -9),最危险
* H4 NO_ARTIFACT 台账说成功,但备份目录里一个产物都没有 ⇒ 假成功
* H5 NO_PLAN 已启用却没有任何待触发计划 ⇒ 提示(可能是善意状态)
*
* ⚠️ 非生产环境不报 H1:本地/测试本来就不该排生产备份,报红会造成告警疲劳
* (与 backup_daily.sh「不比对比报警」同一条纪律)。
*
* @param {object} input
* @param {boolean} input.enabled
* @param {boolean} input.isProduction
* @param {Array} input.plans [{ group, node, fireAt, key }]
* @param {Array} input.history readHistory() 的结果(最新在前)
* @param {number} input.artifactCount
* @param {number} [input.now]
* @returns {{level:string,reasons:Array,stats:object}}
*/
function evaluateHealth(input) {
const now = Number.isFinite(Number(input && input.now)) ? Number(input.now) : Date.now();
const GRACE_MS = 10 * 60 * 1000; // 到点后 10 分钟内不判漏(dump 可能正在跑)
const enabled = !!(input && input.enabled === true);
const isProduction = !!(input && input.isProduction === true);
const plans = (input && Array.isArray(input.plans)) ? input.plans : [];
const history = (input && Array.isArray(input.history)) ? input.history : [];
const artifactCount = Number(input && input.artifactCount) || 0;
const reasons = [];
const add = (level, code, message) => reasons.push({ level, code, message });
// ---- H1 生产未启用 ----
if (isProduction && !enabled) {
add('error', 'PROD_DISABLED', '生产环境节点备份调度器未启用:考试前/考试后/发布后三节点均不会执行');
}
// ---- H2 最近有结果的执行是失败 ----
let consecutiveFails = 0;
let lastFailMsg = '';
for (const h of history) {
if (!h || typeof h !== 'object') continue;
if (h.event === 'fail') {
consecutiveFails++;
if (!lastFailMsg) lastFailMsg = h.message || h.stage || '';
} else if (h.event === 'success') {
break; // 有成功兜底 ⇒ 连续失败链断裂
}
// start / plan / lock-skip / invalid-skip 不参与连续失败计数
}
if (consecutiveFails > 0) {
add('error', 'LAST_RUN_FAILED',
`最近 ${consecutiveFails} 次节点备份执行失败${lastFailMsg ? '(' + lastFailMsg + ')' : ''} ⇒ 需人工介入`);
}
// ---- H3 已到点却无任何结果(漏备)----
const settled = new Set();
for (const h of history) {
if (!h || !h.key) continue;
if (h.event === 'success' || h.event === 'fail' || h.event === 'lock-skip' || h.event === 'invalid-skip') {
settled.add(h.key);
}
}
const seenPlan = new Set();
const missed = [];
for (const h of history) {
if (!h || h.event !== 'plan' || !h.key) continue;
if (seenPlan.has(h.key)) continue;
seenPlan.add(h.key);
const t = new Date(h.fireAt).getTime();
if (!Number.isFinite(t)) continue;
if (t + GRACE_MS > now) continue; // 还没到点 / 刚过点,仍在宽限期内
if (settled.has(h.key)) continue;
missed.push(h.key);
}
if (missed.length > 0) {
add('error', 'MISSED_RUN',
`有 ${missed.length} 个已到点计划既无成功也无失败记录(疑似漏备):${missed.slice(0, 3).join(' / ')}`);
}
// ---- H4 假成功 ----
if (history.some(h => h && h.event === 'success') && artifactCount === 0) {
add('error', 'NO_ARTIFACT', '台账记录过备份成功,但备份目录中没有任何产物(疑似假成功或产物被清理)');
}
// ---- H5 无待触发计划 ----
if (enabled && plans.length === 0) {
add('warn', 'NO_PLAN', '当前没有任何待触发计划:四组均未设置考试时间,或时间节点均已过期(重启后不会补备)');
}
const level = reasons.some(r => r.level === 'error') ? 'error'
: reasons.some(r => r.level === 'warn') ? 'warn' : 'ok';
return {
level,
reasons,
stats: {
consecutiveFails,
missedCount: missed.length,
artifactCount,
planCount: plans.length,
historyCount: history.length,
},
};
}
/**
* 计算某组某节点的触发时刻
*
* ⚠️ 口径纪律:
* · before-start = start_time − 30min,**不得**早于「现在」(管理员可能把开始
* 时间设成过去时间 ⇒ 减完仍在过去 ⇒ 必须钳到 now,否则会立刻触发一次
* 「已经开考」的备份,语义错误且浪费 IO)。
* · after-end = end_time + 10min,同样钳到 now 之后一点点(+1s),
* 保证 setTimeout 拿到的是**未来**时刻而不是恰好相等(相等时行为依赖实现)。
*
* 🔴 opts.clamp = false 时**不钳制**,返回数学上的真实触发时刻。
* 这不是可选优化,而是 hydrate 判据所必需:hydrate 必须能分辨
* 「这个节点本来就还没到」vs「这个节点的时间设置在過去、早就该跳过了」。
* 若 hydrate 也走钳制路径,过期节点会被伪造成 now+1s ⇒ 进程一重启就立刻
* 误触发一次备份(2026-10-16 由 C28 判据 J4.2 实测抓到,非理论风险)。
*
* @param {string} group
* @param {string} node
* @param {Date|null} baseTime start_time 或 end_time
* @param {object} opts
* @param {boolean} [opts.clamp=true] 是否把过去时刻钳到 now+1s
* @returns {Date|null} 触发时刻;不可调度时返回 null
*/
function computeFireAt(group, node, baseTime, opts) {
if (!baseTime) return null;
const t = new Date(baseTime).getTime();
if (!Number.isFinite(t)) return null;
const leadMs = node === NODE_BEFORE_START
? (opts.beforeStartBufferMin || 30) * 60 * 1000
: (opts.afterEndDelayMin || 10) * 60 * 1000;
const fire = node === NODE_BEFORE_START ? t - leadMs : t + leadMs;
// ⛔ clamp=false ⇒ 返回数学时刻,交由调用方(hydrate)自行判断过期
if (opts.clamp === false) return new Date(fire);
// 钳制:绝不让计划落在过去(否则 setTimeout 立刻 fire,语义错误)
const now = Date.now();
if (fire <= now) return new Date(now + 1000);
return new Date(fire);
}
/**
* 节点备份调度器(挂在 app 上,全进程单例)
*/
class NodeBackupScheduler {
/**
* @param {object} app Egg app
*/
constructor(app) {
this.app = app;
this.logger = app.logger;
this.config = (app.config && app.config.nodeBackup) || {};
// key → timeout 句柄
this.timers = new Map();
// ⛔ 非生产直接封死:不排任何计划、不spawn 任何进程
// ⚠️ fail-safe(不是 fail-open):配置段缺失 / 非对象 / enabled 未显式为 true
// 时一律判为**关闭**。曾写成 `enabled !== false` ⇒ 配置段整个缺失时算出
// true(fail-open),由 C28 判据 J3.6 实测抓到 —— 生产隔离绝不能靠默认值。
this.enabled = !!(
app.config &&
app.config.isProduction &&
this.config &&
typeof this.config === 'object' &&
this.config.enabled === true
);
// 🔴 多 worker:这里**不能**按 isMaster 门禁关停 —— Egg 的 `didReady` 运行在
// **app worker 进程**里,master 根本不执行 app.js 生命周期钩子
// (曾一度想用 `EGG_SERVER_ENV === 'master'` 门禁,实测会<E6B58B><E4BC9A>整个调度器
// 永远关闭 ⇒ 永不备份)。故改为「每个 worker 都排程」+ 触发时刻两道去重:
// ① `_acquireLock` 跨进程文件锁(原子 mkdir)⇒ 并发到点只有一个真正 dump;
// ② `_stillValid` 触发前回库核对当前时间配置 ⇒ 改期后其它 worker 的
// **残留旧定时器**在触发时发现计划已作废 ⇒ 直接放弃(不误备份)。
// 这样既不会重复备份,也不会漏备份,且对 worker 数配置漂移免疫。
if (!this.enabled) {
this.logger.info(
'[NodeBackup] 未启用(enabled=%s · isProduction=%s)—— 非生产环境禁止触发生产库备份',
String(this.config.enabled !== false),
String(!!(app.config && app.config.isProduction))
);
}
}
/**
* 排一个节点备份计划(幂等:同key 覆盖旧句柄)
*
* @param {object} p
* @param {string} p.group
* @param {string} p.node
* @param {Date|null} p.baseTime start_time / end_time
* @param {string} p.reason 人类可读原因(落日志)
* @returns {string|null} 计划键;未启用/不可调度返回 null
*/
schedule(p) {
if (!this.enabled) return null;
const { group, node, baseTime, reason } = p;
const fireAt = computeFireAt(group, node, baseTime, this.config);
if (!fireAt) {
this.logger.warn(
'[NodeBackup] 跳过排程(缺少有效时间):group=%s node=%s baseTime=%s',
group, node, String(baseTime)
);
return null;
}
const key = planKey(group, node, fireAt);
// 同 key 已在册 ⇒ 说明是 hydrate 撞车,不重复排
if (this.timers.has(key)) {
this.logger.info('[NodeBackup] 计划已存在,跳过重复排程:%s', key);
return key;
}
const delay = Math.max(0, fireAt.getTime() - Date.now());
// setTimeout 上限 2^31-1 ms(约 24.8 天);超出则分段等待(考试时间被设到
// 很远以后时会真实触发此分支 —— 不处理会让定时器立即 fire ⇒ 立即误备份)
const MAX_DELAY = 2147483647;
const arm = ms => {
const handle = setTimeout(async () => {
this.timers.delete(key);
if (ms <= MAX_DELAY) {
await this._fire({ group, node, fireAt, key, reason });
} else {
// 分段续等
const rest = ms - MAX_DELAY;
this._rearm(key, { group, node, fireAt, reason }, rest);
}
}, ms);
// ⛔ 定时器不应阻止进程退出(正常退出时无需等备份跑完)
if (handle.unref) handle.unref();
this.timers.set(key, handle);
};
arm(delay);
// 台账:记下「曾经排过这个计划」—— 这是 H3「漏备」判定的唯一依据。
// 若不记,重启后计划丢了就再也无从发现「该备却没备」。
this._record('plan', {
key, group, node, fireAt: fireAt.toISOString(), reason: reason || '-',
});
this.logger.info(
'[NodeBackup] 已排程:key=%s 触发=%s(%s 分钟后)原因=%s',
key,
fireAt.toISOString(),
Math.round(delay / 60000),
reason || '-'
);
return key;
}
/**
* 追加一条台账事件(管理后台可见性的数据源)
*
* ⛔ 台账写失败**绝不能**影响备份本身:备份是保命的,台账是观测。
* 故这里吞掉所有异常,只回落到 logger.warn 留痕。
*
* @param {string} event plan | start | success | fail | lock-skip | invalid-skip
* @param {object} meta 计划/执行元信息
* @param {object} [extra] 附加字段(rc / logFile / message / stage)
*/
_record(event, meta, extra) {
try {
const logDir = this.config && this.config.logDir;
if (!logDir) return;
fs.mkdirSync(logDir, { recursive: true });
const row = Object.assign({
ts: new Date().toISOString(),
event,
pid: process.pid,
}, meta || {}, extra || {});
fs.appendFileSync(historyPath(logDir), JSON.stringify(row) + '\n');
} catch (e) {
this.logger.warn('[NodeBackup] 台账写入失败(不影响备份):event=%s err=%s', event, e.message);
}
}
/**
* 管理端状态快照(供 GET /api/admin/node-backup/status)
*
* ⛔ 返回值**不含任何凭据**:只有路径、时间、计数、判定结论。
* envFile 只给路径不给内容(内容里有 DB 口令)。
*
* @param {number} [limit] 台账取多少条
* @returns {object}
*/
status(limit) {
const cfg = this.config || {};
const n = Math.max(1, Math.min(Number(limit) || 50, 200));
const isProduction = !!(this.app && this.app.config && this.app.config.isProduction);
const history = cfg.logDir ? readHistory(cfg.logDir, n) : [];
const plans = this.list()
.map(p => {
const t = new Date(p.fireAt).getTime();
return Object.assign({}, p, {
remainingSec: Number.isFinite(t) ? Math.max(0, Math.round((t - Date.now()) / 1000)) : null,
});
})
.sort((a, b) => (a.fireAt < b.fireAt ? -1 : a.fireAt > b.fireAt ? 1 : 0));
const artifacts = cfg.outDir ? listArtifacts(cfg.outDir, 10) : [];
const health = evaluateHealth({
enabled: this.enabled,
isProduction,
plans,
history,
artifactCount: artifacts.length,
});
return {
enabled: this.enabled,
isProduction,
buffer: {
beforeStartMin: cfg.beforeStartBufferMin || 30,
afterEndMin: cfg.afterEndDelayMin || 10,
},
keepDays: cfg.keepDays || 90,
plans,
history,
artifacts,
health,
paths: {
outDir: cfg.outDir || null,
logDir: cfg.logDir || null,
historyFile: cfg.logDir ? historyPath(cfg.logDir) : null,
script: cfg.scriptPath || null,
envFile: cfg.envFile || null,
},
};
}
/**
* 分段续等(>24.8 天的远期计划)
*/
_rearm(key, meta, restMs) {
const handle = setTimeout(() => {
this.timers.delete(key);
if (restMs <= 2147483647) {
this._fire(meta);
} else {
this._rearm(key, meta, restMs - 2147483647);
}
}, 2147483647);
if (handle.unref) handle.unref();
this.timers.set(key, handle);
}
/**
* 组别时间被修改/保存后重排该组的两个时间节点
* @param {Array<{group:string,start_time?:Date,end_time?:Date}>} rows
*/
rescheduleGroupTimes(rows) {
if (!this.enabled) return;
for (const r of rows || []) {
this.schedule({
group: r.group, node: NODE_BEFORE_START, baseTime: r.start_time,
reason: `考试开始前备份(管理员设置/修改了 ${r.group} 的开始时间)`,
});
this.schedule({
group: r.group, node: NODE_AFTER_END, baseTime: r.end_time,
reason: `考试结束后备份(管理员设置/修改了 ${r.group} 的结束时间)`,
});
}
}
/**
* 成绩发布节点:立即异步备份(不等待)
*
* @param {string|null} group 缺省 = 全部组
*/
async triggerScorePublished(group) {
if (!this.enabled) return;
const key = `publish:${group || 'ALL'}:${new Date().toISOString()}`;
this.logger.info('[NodeBackup] 触发节点备份:成绩已发布 group=%s key=%s', group || 'ALL', key);
await this._fire({
group: group || 'ALL', node: NODE_SCORE_PUBLISHED,
fireAt: new Date(), key, reason: '成绩发布后备份',
});
}
/**
* 进程启动时从 competition_times 重建未到期的计划
*
* ⛔ 只重建**尚未到点**的计划:已过点的 fireAt 若 hydrate 出来会立刻触发,
* 等于「重启一次 = 补备一次」,与幂等键设计相悖。
* @param {Array} rows competition_times 行
*/
async hydrate(rows) {
if (!this.enabled) return;
const now = Date.now();
let armed = 0;
let skipped = 0;
for (const r of rows || []) {
for (const node of [ NODE_BEFORE_START, NODE_AFTER_END ]) {
const base = node === NODE_BEFORE_START ? r.start_time : r.end_time;
if (!base) continue;
// 🔴 必须用 clamp=false 取**数学时刻**来判过期,不能用默认的钳制结果 ——
// 钳制会把过期时刻伪造成 now+1s,于是过期节点也会被排上,
// 结果是「进程一重启就立刻误备份一次」(C28 判据 J4.2 实测抓到)。
const raw = computeFireAt(r.group, node, base, Object.assign({}, this.config, { clamp: false }));
if (!raw) continue;
if (raw.getTime() <= now) { skipped++; continue; }
this.schedule({
group: r.group, node, baseTime: base,
reason: `进程启动重建计划(${r.group})`,
});
armed++;
}
}
this.logger.info(
'[NodeBackup] hydrate 完成:已排 %d 个未来计划,跳过 %d 个已过期节点(避免重启即误备份)',
armed, skipped
);
}
/**
* 列出当前在册计划(供管理端排障接口读取)
*
* 🔴 R171 修既有缺陷:原实现用 `k.split(/:(.*):(.*)/)` 解析,正则是贪婪的 ⇒
* `zhongzhi:before-start:2026-11-01T08:30:00.000Z` 会被拆成
* ['zhongzhi', 'before-start:2026-11-01T08:30', '00.000Z'],
* 即 node 串进时间、fireAt 只剩碎片。group/node 本身不含 ':',
* 而 ISO 时刻含 ':' ⇒ 只能「按前两个冒号切、剩余全部归还 fireAt」。
*/
list() {
return Array.from(this.timers.keys()).map(k => {
const parts = String(k).split(':');
const group = parts[0] || '';
const node = parts[1] || '';
const at = parts.slice(2).join(':');
return { key: k, group, node, fireAt: at };
});
}
/**
* 🔴 防线①:跨进程文件锁(原子 mkdir,与 scripts/backup_daily.sh 的锁同法)
*
* 为什么必须有:生产 EGG_WORKERS=2 ⇒ 两个 app worker 进程各自持有一份**相同**的
* 计划(Egg 的 app worker 是独立进程,内存定时器**不共享**)⇒ 到点时两个进程
* 会同时触发。文件锁保证同一时刻只有一个进程真正 dump。
*
* ⚠️ 锁目录用 `mv` 归档而非 `rm`(⛔ 全项目禁 rm)。
* ⚠️ 残留锁(进程被 kill -9)会让后续备份永久拒绝执行 ⇒ 故设 TTL:
* 超过 lockTtlSec(默认 2h,远大于一次 dump 的耗时)视为陈旧锁,可接管。
* @returns {string|null} 持有则返回锁路径,未持有返回 null
*/
_acquireLock(lockKey) {
const lockDir = path.join(this.config.logDir, '.lock-' + lockKey.replace(/[^\w.-]/g, '_'));
try {
fs.mkdirSync(lockDir); // 原子:并发下只有一个成功
} catch (e) {
if (e.code !== 'EEXIST') {
this.logger.error('[NodeBackup] 创建锁失败(%s):%s', lockDir, e.message);
return null;
}
// 已存在 ⇒ 判断是否陈旧(残留锁)
const metaFile = path.join(lockDir, 'meta.json');
let ageMs = 0;
try {
const st = fs.statSync(lockDir);
ageMs = Date.now() - st.mtimeMs;
} catch (statErr) {
return null; // 锁刚被别人释放/改名 ⇒ 本次保守放弃
}
const ttlMs = (this.config.lockTtlSec || 7200) * 1000;
if (ageMs < ttlMs) {
this.logger.warn(
'[NodeBackup] 已有进程持锁(age=%ds),本次跳过(多 worker 去重生效):%s',
Math.round(ageMs / 1000), lockDir
);
return null;
}
// 陈旧锁 ⇒ 改名归档后重试一次(⛔ 不用 rm)
const stale = lockDir + '.stale-' + Date.now();
try {
fs.renameSync(lockDir, stale);
this.logger.warn('[NodeBackup] 清理陈旧锁(age=%ds)→ %s', Math.round(ageMs / 1000), stale);
} catch (renErr) {
return null;
}
try {
fs.mkdirSync(lockDir);
} catch (e2) {
return null; // 又被别人抢先 ⇒ 让给它
}
}
try {
fs.writeFileSync(path.join(lockDir, 'meta.json'),
JSON.stringify({ pid: process.pid, started: new Date().toISOString(), key: lockKey }));
} catch (e) { /* 锁本身已拿到,元信息写失败不阻塞 */ }
return lockDir;
}
/**
* 🔴 防线②:触发前回库核对计划是否仍然有效
*
* 解决的问题:`setTimes` 请求只落到「处理它的那一个 worker」⇒ 其它 worker 里
* 的**旧定时器不会被清除**。若管理员把考试时间改了,别的 worker 仍会在旧时刻
* 触发一次备份(备份内容是新配置下的库,触发时刻却是错的)。
* 修法:触发时按「当前 DB 里的时间」重算计划键;与本进程的旧键不符 ⇒ 已作废,放弃。
* ⛔ 对 score-published 节点**不做**此核对:它是即时事件、无时间基线可比。
*/
async _stillValid(meta) {
if (meta.node === NODE_SCORE_PUBLISHED) return true;
const col = meta.node === NODE_BEFORE_START ? 'start_time' : 'end_time';
try {
const row = await this.app.model.CompetitionTime.findOne({
where: { group: meta.group },
attributes: [ 'start_time', 'end_time' ],
raw: true,
});
if (!row || !row[col]) return false;
const current = computeFireAt(meta.group, meta.node, row[col],
Object.assign({}, this.config, { clamp: false }));
const currentKey = planKey(meta.group, meta.node, current);
if (currentKey !== meta.key) {
this.logger.info(
'[NodeBackup] 计划已作废(时间配置已被修改),放弃触发:旧=%s 新=%s', meta.key, currentKey
);
return false;
}
return true;
} catch (e) {
// 🔴 核对失败**不放行**:宁可漏一次备份,也不误备份(误备份会覆盖正确语义)。
// 漏备份可由下一节点/每日备份兜底。
this.logger.error('[NodeBackup] 计划有效性核对失败 ⇒ 放弃本次触发(防误备份):%s', e.message);
return false;
}
}
/**
* 实际执行备份(异步、不抛错)
*/
async _fire(meta) {
const { group, node, key, reason } = meta;
// ---- 防线②:计划是否仍然有效 ----
if (!(await this._stillValid(meta))) {
// 计划已作废(管理员改过时间)⇒ 必须记一笔:否则 H3 会把它误判成「漏备」
this._record('invalid-skip', { key, group, node, reason: '计划已作废(时间配置已被修改)' });
return;
}
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const logDir = this.config.logDir;
const outDir = this.config.outDir;
try {
fs.mkdirSync(logDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
} catch (e) {
this.logger.error('[NodeBackup] 建目录失败,放弃本次备份:%s', e.message);
// 目录建不出来 ⇒ 台账也写不进去,只能靠 logger;管理端会由 H3「漏备」暴露
return;
}
// ---- 防线①:跨进程锁(多 worker 去重)----
const lockDir = this._acquireLock(node === NODE_SCORE_PUBLISHED ? `publish-${group}` : key);
if (!lockDir) {
// 另一个 worker 正在做同一件事 ⇒ 记 lock-skip,H3 才不判成漏备
this._record('lock-skip', { key, group, node, reason: '另一进程持锁(多 worker 去重)' });
return;
}
const releaseLock = () => {
try {
fs.renameSync(lockDir, lockDir + '.done-' + Date.now());
} catch (e) { /* ignore */ }
};
const logFile = path.join(logDir, `node-backup-${stamp}.log`);
const args = [
this.config.scriptPath,
'--env-file', this.config.envFile,
'--out', outDir,
'--keep-days', String(this.config.keepDays || 90),
];
if (this.config.mysqldump) args.push('--mysqldump', this.config.mysqldump);
const env = Object.assign({}, process.env);
if (this.config.pythonPath) env.PYTHONPATH = this.config.pythonPath;
if (this.config.python) env.PYTHON_BIN = this.config.python;
const header = [
`===== 节点备份 ${stamp} =====`,
`节点=${node} 组别=${group} 计划键=${key}`,
`原因=${reason || '-'}`,
`命令=python ${args.join(' ')}`,
'',
].join('\n');
const out = fs.openSync(logFile, 'a');
try {
fs.writeSync(out, header);
} finally {
fs.closeSync(out);
}
this.logger.info('[NodeBackup] 开始备份 → %s(日志:%s)', outDir, logFile);
this._record('start', {
key, group, node, logFile,
reason: reason || '-',
cmd: `python ${args.join(' ')}`,
});
const child = spawn('python', args, { env, cwd: this.config.repoRoot });
const fd = fs.openSync(logFile, 'a');
child.stdout.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 日志失败不阻塞备份 */ } });
child.stderr.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 同上 */ } });
// ⚠️ spawn 的 'error' 与 'close' 可能**同时**触发(如 ENOENT)⇒ 用 settled 保证
// 只落一条结果,否则台账会出现「一次执行 = 一个 fail + 一个 success」的假象。
let settled = false;
const finish = (event, extra) => {
if (settled) return;
settled = true;
this._record(event, { key, group, node, logFile }, extra);
};
child.on('error', err => {
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
releaseLock();
// 🔴 备份失败必须**响亮**:这正是台账 P1「生产备份静默失败」的成因 ——
// 旧实现失败只落文件没人看 ⇒ 直到需要恢复时才发现备份根本不存在。
// R171:除 logger 外再落台账 ⇒ 管理后台「时间设置」页可直接看到红灯。
this.logger.error(
'[NodeBackup] 🔴 节点备份启动失败 group=%s node=%s key=%s err=%s(日志:%s)',
group, node, key, err.message, logFile
);
finish('fail', { stage: 'spawn', message: err.message });
});
child.on('close', code => {
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
releaseLock(); // ⚠️ 必须在 close 后释放,否则同 key 的重排会被自己的锁挡住
if (code === 0) {
this.logger.info(
'[NodeBackup] ✅ 节点备份成功 group=%s node=%s key=%s 日志=%s', group, node, key, logFile
);
finish('success', { rc: 0 });
} else {
this.logger.error(
'[NodeBackup] 🔴 节点备份失败(rc=%s)group=%s node=%s key=%s ⇒ 需人工介入 日志=%s',
code, group, node, key, logFile
);
finish('fail', { stage: 'exit', rc: code, message: `备份脚本退出码 ${code}` });
}
});
}
}
module.exports = {
NodeBackupScheduler,
NODE_BEFORE_START,
NODE_AFTER_END,
NODE_SCORE_PUBLISHED,
planKey,
computeFireAt,
// 台账 / 可见性(纯函数,便于 C28 判据独立验证)
HISTORY_FILE_NAME,
historyPath,
readHistory,
listArtifacts,
evaluateHealth,
};

329
app/lib/passwordPolicy.js Normal file
View File

@@ -0,0 +1,329 @@
'use strict';
/**
* 口令强度策略 —— 全站**唯一事实来源(SoT)**
* ============================================================
*
* 建立背景(2026-09-30 · R63)
* ------------------------------------------------------------
* R61 测试人员反馈:短信验证后进入「设置密码」页,输入框**已被浏览器密码管理器
* 预填 `123456`**。R62 已从前端修掉两条根因(激活页不清残留登录态 + 密码框未声明
* `autocomplete` 语义),但那是**前端便利性**层面的修复 —— 它只能阻止"自动填",
* 不能阻止"考生自己真的输入 123456 并提交"。实测后端此前只校验「长度 ≥ 6」
* (`app/validate/index.js` 的 activate / resetPassword),`123456` 完全合法
* ⇒ 弱口令仍是**后端可接受**的状态。本模块补上这一层。
*
* 为什么必须只有一个实现
* ------------------------------------------------------------
* 口令校验天然会散落到多个入口(激活 / 忘记密码 / 未来新增的改密),一旦各自
* 实现,必然出现「A 入口拦、B 入口放」的缺口(与项目里 `toClientUser` 单出口、
* `OBJECTIVE_SCORE_PER_QUESTION` 单源同一类教训)。因此:
* ⚠️ **任何新增的口令写入入口,必须调用本模块 `checkPassword()`,禁止自行判断。**
*
* 当前已接入(2/2):
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
* · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令)
*
* 另有一项**非强度**职责也收在本模块(保持「管理员默认凭据」口径单源,见文件末段):
* · `app/controller/auth.js` → `adminLogin()` 的「默认口令守卫」读取本模块的
* `DEFAULT_ADMIN_PASSWORDS` / `isDefaultAdminHash()` —— R112 · 候选㉒ 下沉。
*
* 不适用范围(重要,勿误改)
* ------------------------------------------------------------
* 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过:
* · `config.default.js` → `testDefaultPassword`(默认 `123456`):
* 测试数据导入(`service/admin.js:240`)与 `resetTestData`(`admin.js:1582`)
* 用它生成哈希写入 `test_flag=1` 的账号,供测试人员免激活重测。
* · 生产数据导入(`test_flag=0`):初始口令为「身份证后 6 位」的 SHA256
* (`service/admin.js:384`),且 `activated=false` —— 必须走激活流程,
* 激活时由考生自填口令**覆盖**该初始值。
* ⇒ 因此本策略的引入**不会破坏**测试数据便利性,也不会影响生产导入。
* 唯一的行为变化:测试人员激活测试账号时不能再把口令设成 `123456` 这类弱口令
* (这正是本次想要的效果,换个口令即可)。
*
* 黑名单为何放在独立 JSON 文件
* ------------------------------------------------------------
* `password_blacklist.json` 是纯数据,供两侧共用,避免出现两份会漂移的清单:
* · 本模块(Node / Egg 运行时)
* · `scripts/` 下的运维脚本(Python,用于扫描存量账号是否命中弱口令)
* 加词只改 JSON,两侧同时生效。
*
* @module lib/passwordPolicy
*/
const BLACKLIST = require('./password_blacklist.json');
/** 口令长度下限(与 `app/validate/index.js` 的 `min` 保持一致) */
const MIN_LENGTH = 6;
/**
* 口令长度上限。
*
* ⚠️ 此前**只有前端**限制 20 位(`Activate.vue` / `ForgotPassword.vue` 的
* `max: 20` 规则),后端 `validate` 无上限 ⇒ 直接调接口可写入超长口令。
* 本值使两侧口径一致;`app/validate/index.js` 同步补 `max: 20`。
*
* 选择 20 而非 72(bcrypt 有效上限)的原因:超长口令在考生端几乎只会来自
* 粘贴误操作,限制上限可减少「以为自己设了长口令、实际记不住」的求助。
*/
const MAX_LENGTH = 20;
/** 面向考生的统一提示(**不泄露**内部规则细节,仅给可执行指引) */
const STRENGTH_HINT = '密码强度不足,请使用 6-20 位且不易被猜到的组合(建议字母+数字+符号,避免连续或重复字符)';
/** 黑名单集合(小写归一化;比对时不区分大小写) */
const BLACKLIST_SET = new Set(BLACKLIST.map(p => String(p).trim().toLowerCase()));
/**
* 归一化:去首尾空白 + 小写。
*
* 说明:去空白是**双保险** —— `ctx.validate` 已用 `trim: true` 处理过一次,
* 但本模块也会被非 HTTP 场景(脚本 / 单测)直接调用,不能假设上游已清理。
*
* @param {*} v
* @returns {string}
*/
function normalize(v) {
return String(v === null || v === undefined ? '' : v).trim().toLowerCase();
}
/**
* 判断是否「纯重复字符」——如 `aaaaaa` / `@@@@@@` / `111111`。
*
* 用正则 `^(.)\1*$` 一次判定:首字符捕获后必须整体重复。
* 注意 `.` 在 JS 中不匹配换行符,口令含换行会自然落到"否",无副作用。
*
* @param {string} s 已归一化
* @returns {boolean}
*/
function isRepeatedChar(s) {
return s.length >= MIN_LENGTH && /^(.)\1*$/.test(s);
}
/**
* 判断是否「整串连续序列」——如 `123456`(递增)/ `654321`(递减)/
* `abcdef`(字母递增)/ `fedcba`(字母递减)。
*
* 判据:每个相邻字符的字符码点差**恒为 +1 或恒为 -1**。
* 刻意只判「整串」:`a1b2c3` / `abcd1234` 这类混合串**不**触发本规则
* (它们的风险由黑名单显式覆盖),避免误伤正常口令。
*
* @param {string} s 已归一化
* @returns {boolean}
*/
function isSequential(s) {
if (s.length < MIN_LENGTH) return false;
let up = true;
let down = true;
for (let i = 1; i < s.length; i++) {
const diff = s.charCodeAt(i) - s.charCodeAt(i - 1);
if (diff !== 1) up = false;
if (diff !== -1) down = false;
if (!up && !down) return false;
}
return up || down;
}
/**
* 校验口令强度。
*
* 规则(按执行顺序,命中即返回,**首个命中项**决定 reason):
* 1. 非空且为字符串
* 2. 长度在 [MIN_LENGTH, MAX_LENGTH] 区间
* 3. 不含空白字符(防止「看起来 8 位、实际含空格」被前端 min 误判通过)
* 4. 不在静态黑名单(大小写不敏感)
* 5. 非纯重复字符
* 6. 非整串连续序列
* 7. 不等于**账号自身的可猜派生值**:完整身份证号 / 身份证号后 6 位 / 手机号
* —— 生产账号的初始口令正是「身份证后 6 位」的 SHA256(`service/admin.js:384`),
* 考生激活时若直接沿用该值,等于口令从未变更,必须拒绝。
*
* @param {string} password - 明文口令
* @param {{idNumber?: string, phone?: string}} [context] - 账号上下文(用于派生值比对)
* @returns {{ok: boolean, rule: string, reason: string}}
* ok=false 时 `rule` 为命中的规则标识(供门禁/日志精确断言,**不下发前端**),
* `reason` 为面向考生的中文文案(可直接返回给前端)。
*/
function checkPassword(password, context = {}) {
const raw = String(password === null || password === undefined ? '' : password);
if (raw.length === 0) {
return { ok: false, rule: 'empty', reason: '请输入密码' };
}
const normalized = normalize(raw);
if (normalized.length < MIN_LENGTH) {
return { ok: false, rule: 'too_short', reason: `密码长度不能少于 ${MIN_LENGTH} 位` };
}
if (normalized.length > MAX_LENGTH) {
return { ok: false, rule: 'too_long', reason: `密码长度不能超过 ${MAX_LENGTH} 位` };
}
if (/\s/.test(raw)) {
return { ok: false, rule: 'contains_space', reason: '密码不能包含空格' };
}
if (BLACKLIST_SET.has(normalized)) {
return { ok: false, rule: 'blacklist', reason: STRENGTH_HINT };
}
if (isRepeatedChar(normalized)) {
return { ok: false, rule: 'repeated_char', reason: STRENGTH_HINT };
}
if (isSequential(normalized)) {
return { ok: false, rule: 'sequential', reason: STRENGTH_HINT };
}
// 派生值比对:口令不得等于账号自身可猜出的标识
const idNumber = normalize(context.idNumber);
const phone = normalize(context.phone);
if (idNumber) {
if (normalized === idNumber) {
return { ok: false, rule: 'equals_id_number', reason: '密码不能与身份证号相同' };
}
if (idNumber.length >= MIN_LENGTH && normalized === idNumber.slice(-MIN_LENGTH)) {
return { ok: false, rule: 'equals_id_suffix', reason: '密码不能使用身份证号后 6 位' };
}
}
if (phone && normalized === phone) {
return { ok: false, rule: 'equals_phone', reason: '密码不能与手机号相同' };
}
return { ok: true, rule: 'ok', reason: '' };
}
/**
* 解析「测试数据默认口令」—— **唯一入口**(R106-D7,2026-10-12)。
*
* 背景:`config.testDefaultPassword` 原为无条件 `process.env.TEST_DEFAULT_PASSWORD || '123456'`,
* 而 `deploy/.env.production` 的 `TEST_DEFAULT_PASSWORD` 一直是**注释状态** ⇒ 生产环境实际
* 取到的就是弱口令 `'123456'`。一旦生产走到「批量写默认口令」的路径(重置测试数据 / 显式
* `testFlag=true` 的 Excel 导入),一批账号会被设为 `'123456'` —— 这是**静默弱口令**,
* 而不是"注入链路断了"的明确失败,与 `_secret.cjs` / `DB_PASSWORD` 的既有纪律不一致
* (R104 结论:同族问题各自实现必然漂移)。
*
* 现约定:生产(`config.isProduction`)未显式注入 ⇒ `config.testDefaultPassword` 为 `null`
* ⇒ 本函数**明确失败**并给出可行动指引(⛔ 不静默回退)。测试环境逐字不变(仍 `'123456'`)。
*
* ⚠️ 与 `controller.admin.resetTestData` 里 `process.env.NODE_ENV === 'production'` 的
* 403 守卫**口径不同**(后者只看 NODE_ENV,本函数看 `config.isProduction`,即
* `APP_ENV`/`appInfo.env`)⇒ 两者互为补充:守卫拦"明确的生产",本函数兜住
* "`APP_ENV=production` 但 NODE_ENV 未设"这种分叉情形。
*
* @param {object} appConfig Egg `app.config`
* @returns {string} 生效的测试默认口令
*/
function requireTestPassword(appConfig) {
const pw = appConfig && appConfig.testDefaultPassword;
if (pw) return pw;
throw new Error(
'[安全阻断] 生产环境未配置 TEST_DEFAULT_PASSWORD —— 拒绝把账号批量设为「默认口令」。\n' +
' 该操作会写入 config.testDefaultPassword 的 bcrypt 哈希;若静默回退到内置弱口令\n' +
" (原行为 '123456'),等价于在生产上批量设置弱口令。请任选其一:\n" +
' ① 确实需要:在 deploy/.env.production 显式设置 TEST_DEFAULT_PASSWORD 后重启\n' +
' (推荐 —— 让「生产要设默认口令」成为一个显式决定);\n' +
' ② 若本就不该在生产重置测试数据,请在带 test_flag 的测试库执行。'
);
}
/**
* ── 内置默认管理员凭据(登录守卫的唯一真相源)──────────────────────
*
* 背景(BACKLOG 候选㉒ · R112)
* ------------------------------------------------------------
* `controller/auth.js` 的「默认口令守卫」原为**口令字面**判据(用户输入的明文
* 是否命中一张硬编码清单)。该判据有两个结构缺口:
* ① 清单**散落在 controller 内** —— 与 `database/schema.sql`、`backend/database/seeders/002-seed-admin.js`
* 各持一份口径,属「多真相源」,任一处改动都不会带动其余(R111 血证:守卫曾漏 `admin`);
* ② 只覆盖**已枚举**的默认口令 —— 任何未列出的内置默认口令(或清单被改动)即**静默绕过**。
*
* 治法:把「默认凭据」下沉到本模块(口令策略 SoT)成为**单一真相源**,并补一条
* **与用户输入无关**的判据 —— 直接看 DB 里存的哈希是否即已发布的默认哈希。
*
* ⚠️ bcrypt 的盐是**随机**的:同一口令每次哈希结果都不同 ⇒ ⛔ 不能只比对哈希字符串。
* 故本模块提供**两条互补通道**,由调用方 OR 合并(见 `controller/auth.js adminLogin`):
* · 哈希通道 `isDefaultAdminHash(dbHash)` —— 覆盖「DB 里就是内置的那串哈希」
* (R113 前 `schema.sql` 的无盐 SHA256;R113 起 `schema.sql` 与 `seeder 002`
* 统一为**同一固定盐 bcrypt**),**与输入无关**;
* · 口令通道 `DEFAULT_ADMIN_PASSWORDS.includes(明文)` —— 覆盖「同口令、不同盐」
* 的**重新播种**场景(登录成功的明文必等于该默认口令,见 `002-seed-admin.js` 的
* `ADMIN_INIT_PASSWORD` 分支会以随机盐重算哈希)。
* 二者合并即覆盖「**已知**默认口令的任意存储形态」。
* ⚠️ 未列入清单的默认口令仍是缺口 ⇒ 新增内置账号时**必须同步登记到下方清单**
* (这是**刻意的显式维护点**,避免静默逃逸)。
* ✅ R114 已收口(本条曾登记的「尚无仓内机检」缺口):断言已**下沉进仓**为常驻门禁 ——
* `scripts/unit_password_policy.cjs` 第 [8] 段 + `scripts/unit_password_policy_mutation.cjs`
* (+4 组变异体),接线为 `scripts/ci-gates.sh` 第 **25 步**(两条 `run_gate` 均阻断)⇒
* 本清单被**清空 / 字面改写 / 判定整体禁用**时,电池即**自动报红**(判别力由变异测试自证)。
* ⚠️ 机检覆盖的是「本清单**自身的形状与判定行为**」(非空 · 双通道命中 · 非默认不误伤);
* 「本清单 ↔ `database/schema.sql` ↔ seeder 002」三处的**跨文件同值**仍靠**人工核对**。
*/
/** 内置默认管理员口令(**唯一真相源**;登录守卫与文档口径均以此为准) */
const DEFAULT_ADMIN_PASSWORDS = ['admin', 'admin123', 'admin123456'];
/**
* 已发布默认管理员凭据的**字面哈希**(固定值,可精确比对)。
* · `8c69…4918`:SHA256(无盐) —— **R113 前** `database/schema.sql` 建库 INSERT 的产物;
* 自 **R113(候选㉔)** 起该文件已统一为下方 bcrypt ⇒ 本项作为**历史值保留**,
* 用于拦截仍存该哈希的旧库 / 旧备份(⛔ 不删:保留即安全 —— 非默认口令不会命中)。
* · `$2b$10$Nc6…co9.`:bcrypt(cost=10, 固定盐) —— 来源**两处已收敛为同值**:
* `database/schema.sql` 的建库 INSERT + `backend/database/seeders/002-seed-admin.js`。
*/
const KNOWN_DEFAULT_ADMIN_HASHES = [
'8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918',
'$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
];
/** 字面哈希集合(小写归一化;bcrypt 前缀 `$2B$` 亦见于部分工具) */
const KNOWN_DEFAULT_ADMIN_HASH_SET =
new Set(KNOWN_DEFAULT_ADMIN_HASHES.map(h => h.toLowerCase()));
/**
* 判定「DB 中存储的凭据哈希」是否为**已发布的默认值**(与用户输入无关)。
*
* 两级判定:
* ① 字面命中 `KNOWN_DEFAULT_ADMIN_HASHES`(覆盖无盐 SHA256 与固定盐 bcrypt 两种存量形态);
* ② 逐个候选默认口令做 bcrypt 校验(覆盖「同口令、**不同盐**」的重新播种 bcrypt 值 ——
* 这正是「不能只比哈希字符串」的原因)。
*
* ⚠️ 降级语义:`bcrypt` 采用**延迟加载**。本模块会被脚本 / 离线上下文(如变异测试写到
* 同目录的临时副本)加载,若该上下文取不到 `bcrypt`,则退化为「仅字面比对」(② 不可用),
* 且**不抛异常**(守卫降级属可接受,⛔ 不得因此阻断进程启动)。
*
* @param {string} dbHash - `admins.password` 的存储值
* @returns {boolean} 命中即 true
*/
function isDefaultAdminHash(dbHash) {
if (!dbHash) return false;
const h = String(dbHash).trim();
if (!h) return false;
if (KNOWN_DEFAULT_ADMIN_HASH_SET.has(h.toLowerCase())) return true;
let bcrypt = null;
try {
bcrypt = require('bcrypt');
} catch (e) {
return false; // 无 bcrypt ⇒ 退化为「仅字面比对」
}
for (const pw of DEFAULT_ADMIN_PASSWORDS) {
try {
if (bcrypt.compareSync(pw, h)) return true;
} catch (e) {
// 非法 bcrypt 形态(如 SHA256 十六进制串)⇒ 跳过,继续下一候选
}
}
return false;
}
module.exports = {
checkPassword,
requireTestPassword,
MIN_LENGTH,
MAX_LENGTH,
STRENGTH_HINT,
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
BLACKLIST,
// 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段)
DEFAULT_ADMIN_PASSWORDS,
KNOWN_DEFAULT_ADMIN_HASHES,
isDefaultAdminHash,
};

View File

@@ -0,0 +1,86 @@
[
"123456",
"1234567",
"12345678",
"123456789",
"1234567890",
"654321",
"0987654321",
"111111",
"000000",
"222222",
"333333",
"444444",
"555555",
"666666",
"777777",
"888888",
"999999",
"123123",
"112233",
"121212",
"123321",
"159357",
"5201314",
"1314520",
"666888",
"168168",
"a123456",
"123456a",
"123456789a",
"password",
"passw0rd",
"password1",
"password123",
"p@ssw0rd",
"admin",
"admin123",
"admin888",
"administrator",
"root",
"root123",
"qwerty",
"qwertyuiop",
"qwerty123",
"abc123",
"abcd1234",
"abcabc",
"aa123456",
"iloveyou",
"letmein",
"welcome",
"monkey",
"dragon",
"master",
"sunshine",
"princess",
"football",
"baseball",
"superman",
"hello123",
"test123",
"test1234",
"zxcvbnm",
"asdfgh",
"asdfghjkl",
"qazwsx",
"1qaz2wsx",
"1qazxsw2",
"qwe123",
"qweasd",
"q1w2e3",
"pass123",
"p123456",
"123abc",
"abcd123",
"a1b2c3",
"beidou",
"beidouin",
"brskills",
"competition",
"competition123",
"contest",
"contest2026",
"hwasmart",
"hwasmart123"
]

559
app/lib/publicitySheet.js Normal file
View File

@@ -0,0 +1,559 @@
'use strict';
/**
* 成绩公示表导出引擎 —— 纯函数层(R176)
*
* 目标:以赛事官方参考模板(`lib/assets/publicity-template.xlsx`)为**基底**生成
* xlsx,只填数据、不改版式,人工后续补「获奖等级」即可直接用。
*
* 🔴 为什么不用 `@e965/xlsx` 直接改模板再写回(实测结论,2026-10-19):
* 读入模板 → 写一个单元格 → 写回,`sheet1.xml` 里的 `s=` 样式引用数
* 从 **214 → 0**、`styles.xml` 从 22773 → 1546 字节,合并单元格还在但
* 边框/字号/列宽全部丢失。该库 CE 版**不写出样式**,`cellStyles: true`
* 只影响读、不影响写。
* ⇒ 唯一能保住版式的做法 = 只替换 `xl/worksheets/sheet1.xml` 这一个 zip 条目,
* 其余条目**原始压缩字节照抄**(不解压不重压 ⇒ 连 CRC 都不用重算)。
*
* 🔴 零依赖约束:只用 Node 内置 `zlib`,CRC32 自己实现。这样 `--self-test`
* 可以在零 DB / 零后端 / 零网络的条件下直接 require 本文件跑矩阵。
*
* 🔴 数据行数可以超过模板预留的 27 行:模板数据区是 row 3..29,两条签字行在 row 30
* (裁判长)与 row 33(仲裁长)。新表按「新数据末行 + 1」与「新数据末行 + 4」
* 的相对偏移重排两条签字行(N=27 时正好复现 row 30 / row 33)。
* ⚠️ 备注块合并区 `H3:J8` 固定不动(H:J 列,与 A:G 的数据列不冲突),
* 但必须保证至少生成 6 个数据行,否则备注框的下边框会落空。
*/
const zlib = require('zlib');
// ==================== 常量(模板结构的单一描述源) ====================
/** 模板第 2 行的 7 个表头,仅用于自证断言与文档;实际写值不依赖它 */
const TEMPLATE_HEADERS = ['序号', '参赛单位名称', '参赛选手', '指导教师', '成绩排名', '总分数', '获奖等级'];
/** 数据区起止行(1 基,含端点)。row 2 = 表头,row 3 起才是数据 */
const DATA_FIRST_ROW = 3;
/**
* 🔴 数据区实际末行是 29,**不是 30**。
* 模板的 row 30 是「裁判长签字:」行(A30 / E30 各一个),row 33 是「仲裁长签字:」行。
* 把 30 当数据行会直接抹掉裁判长签字 —— 2026-10-19 实测踩过这个坑。
*/
const DATA_LAST_ROW = 29;
/** 签字行原始行号(模板里两条:裁判长在 30,仲裁长在 33) */
const SIGN_ROWS = [30, 33];
/** 模板预留的数据行数(row 3..29 = 27 行;模板里 row 29 没有 row 元素,不影响计数口径) */
const RESERVED_DATA_ROWS = DATA_LAST_ROW - DATA_FIRST_ROW + 1;
/** 末条签字行行号 */
const TEMPLATE_TAIL_ROW = SIGN_ROWS[SIGN_ROWS.length - 1];
/** 签字行相对「数据区末行」的偏移(33 - 29 = 4) */
const TAIL_OFFSET = TEMPLATE_TAIL_ROW - DATA_LAST_ROW;
/** 备注块 H3:J8 要求的最小数据行数(8 - 3 + 1 = 6) */
const MIN_DATA_ROWS_FOR_NOTE = 6;
/** 数据列 A..G */
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
/** 备注块所在列(只补空样式单元格,撑住合并区边框) */
const NOTE_COLS = ['H', 'I', 'J'];
/** 模板标题里等待替换的两段占位文本 */
const TITLE_PLACEHOLDER_ITEM = '***赛项(选拔赛/国内决赛)比赛结果公示表';
const TITLE_PLACEHOLDER_CODE = '赛项编号:';
// ==================== CRC32 / zip 读写 ====================
let CRC_TABLE = null;
function crcTable() {
if (CRC_TABLE) return CRC_TABLE;
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
t[n] = c;
}
CRC_TABLE = t;
return t;
}
/** 标准 CRC-32(IEEE 802.3),返回无符号 32 位 */
function crc32(buf) {
const t = crcTable();
let c = -1;
for (let i = 0; i < buf.length; i++) {
c = t[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
}
return (c ^ -1) >>> 0;
}
/** 从尾部找 End Of Central Directory。找不到返回 -1 */
function findEOCD(buf) {
const min = Math.max(0, buf.length - 65557);
for (let i = buf.length - 22; i >= min; i--) {
if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) return i;
}
return -1;
}
/**
* 读 zip,返回每个条目的**原始压缩字节**(不解压)。
* 中央目录里的 compSize / crc 总是准确的(即使 local header 用了 data descriptor),
* 所以定位数据区一律以中央目录为准。
*/
function readZip(buf) {
if (!Buffer.isBuffer(buf) || buf.length < 22) throw new Error('不是有效的 zip/xlsx(内容过短)');
const eocd = findEOCD(buf);
if (eocd < 0) throw new Error('不是有效的 zip/xlsx(找不到 EOCD)');
const count = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16);
if (off >= buf.length) throw new Error('中央目录偏移越界');
const entries = [];
for (let i = 0; i < count; i++) {
if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) {
throw new Error(`中央目录第 ${i} 项签名不符 @${off}`);
}
const method = buf.readUInt16LE(off + 10);
const crc = buf.readUInt32LE(off + 16);
const compSize = buf.readUInt32LE(off + 20);
const uncompSize = buf.readUInt32LE(off + 24);
const nameLen = buf.readUInt16LE(off + 28);
const extraLen = buf.readUInt16LE(off + 30);
const commentLen = buf.readUInt16LE(off + 32);
const localOff = buf.readUInt32LE(off + 42);
const name = buf.slice(off + 46, off + 46 + nameLen).toString('utf8');
if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) {
throw new Error(`条目 ${name} 的本地头损坏`);
}
const lnLen = buf.readUInt16LE(localOff + 26);
const leLen = buf.readUInt16LE(localOff + 28);
const dataStart = localOff + 30 + lnLen + leLen;
if (dataStart + compSize > buf.length) throw new Error(`条目 ${name} 的数据区越界`);
entries.push({
name,
method,
crc,
compSize,
uncompSize,
data: buf.slice(dataStart, dataStart + compSize),
});
off += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** 把条目按原压缩字节重新打包成 zip(不重压缩 ⇒ CRC 无需重算) */
function writeZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const e of entries) {
const nameBuf = Buffer.from(e.name, 'utf8');
const crc = e.crc >>> 0;
const compSize = e.data.length;
const uncompSize = e.uncompSize;
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4); // version needed to extract
lh.writeUInt16LE(0x0800, 6); // general purpose flag: UTF-8 名称
lh.writeUInt16LE(e.method, 8);
lh.writeUInt16LE(0, 10); // last mod time(不关心,固定 0)
lh.writeUInt16LE(0, 12); // last mod date
lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(compSize, 18);
lh.writeUInt32LE(uncompSize, 22);
lh.writeUInt16LE(nameBuf.length, 26);
lh.writeUInt16LE(0, 28); // extra len
locals.push(lh, nameBuf, e.data);
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0);
ch.writeUInt16LE(20, 4); // version made by
ch.writeUInt16LE(20, 6); // version needed
ch.writeUInt16LE(0x0800, 8);
ch.writeUInt16LE(e.method, 10);
ch.writeUInt16LE(0, 12);
ch.writeUInt16LE(0, 14);
ch.writeUInt32LE(crc, 16);
ch.writeUInt32LE(compSize, 20);
ch.writeUInt32LE(uncompSize, 24);
ch.writeUInt16LE(nameBuf.length, 28);
ch.writeUInt16LE(0, 30); // extra
ch.writeUInt16LE(0, 32); // comment
ch.writeUInt16LE(0, 34); // disk number
ch.writeUInt16LE(0, 36); // internal attrs
ch.writeUInt32LE(0, 38); // external attrs
ch.writeUInt32LE(offset, 42);
centrals.push(ch, nameBuf);
offset += lh.length + nameBuf.length + e.data.length;
}
const centralStart = offset;
const centralBuf = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(centralStart, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([Buffer.concat(locals), centralBuf, eocd]);
}
/** 解压单个条目为 utf8 文本(method 0=store / 8=deflate) */
function entryText(entry) {
if (!entry) throw new Error('条目不存在');
const raw = entry.method === 0 ? entry.data : zlib.inflateRawSync(entry.data);
return raw.toString('utf8');
}
/** 用 utf8 文本替换某条目(重新 deflate + 重算 CRC) */
function replaceEntryText(entries, name, text) {
const raw = Buffer.from(text, 'utf8');
const deflated = zlib.deflateRawSync(raw);
let hit = 0;
const out = entries.map(e => {
if (e.name !== name) return e;
hit++;
return {
name: e.name,
method: 8,
crc: crc32(raw),
compSize: deflated.length,
uncompSize: raw.length,
data: deflated,
};
});
if (hit !== 1) throw new Error(`条目 ${name} 命中 ${hit} 次(应恰好 1 次)`);
return out;
}
// ==================== XML 片段工具 ====================
const XML_ESCAPE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
function esc(s) {
return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, c => XML_ESCAPE[c]);
}
const ROW_RE = /<row\b[^>]*\/>|<row\b[^>]*>[\s\S]*?<\/row>/g;
const CELL_RE = /<c\b[^>]*\/>|<c\b[^>]*>[\s\S]*?<\/c>/g;
function rowNumber(rowXml) {
const m = /<row\b[^>]*\br="(\d+)"/.exec(rowXml);
return m ? parseInt(m[1], 10) : -1;
}
/** 从一行 XML 里取 { 列字母: s 值 } */
function rowStyles(rowXml) {
const out = {};
const cells = rowXml.match(CELL_RE) || [];
for (const c of cells) {
const rm = /\br="([A-Z]+)\d+"/.exec(c);
if (!rm) continue;
const sm = /\bs="(\d+)"/.exec(c);
if (sm) out[rm[1]] = sm[1];
}
return out;
}
/** 生成一个数据单元格(数值 / 文本 / 空) */
function cellXml(col, row, style, value) {
const ref = col + row;
const sAttr = style === undefined ? '' : ` s="${style}"`;
if (value === null || value === undefined || value === '') {
return `<c r="${ref}"${sAttr}/>`;
}
if (typeof value === 'number' && isFinite(value)) {
return `<c r="${ref}"${sAttr}><v>${value}</v></c>`;
}
// 用 inlineStr 而不是 sharedStrings:新引入的字符串不用再去维护共享表下标
return `<c r="${ref}"${sAttr} t="inlineStr"><is><t xml:space="preserve">${esc(value)}</t></is></c>`;
}
/** 选「最常见的数据行样式」作为新行模板,避免拿模板里被手工改过的那一行当标准 */
function pickDataRowStyles(dataRows) {
const counted = new Map();
for (const r of dataRows) {
const st = rowStyles(r.xml);
const sig = DATA_COLS.map(c => st[c] === undefined ? '-' : st[c]).join(',');
const cur = counted.get(sig);
if (cur) cur.n++;
else counted.set(sig, { n: 1, styles: st });
}
let best = null;
for (const [, v] of counted) {
if (!best || v.n > best.n) best = v;
}
return best ? best.styles : { A: '6', B: '7', C: '7', D: '8', E: '6', F: '6', G: '6' };
}
/**
* 生成新的 sheetData。
*
* @param {string} sheetXml 原 sheet1.xml
* @param {Array<Object>} rows buildPublicityRows 的产物
* @returns {{ xml: string, dataRows: number, tailRow: number, styles: Object }}
*/
function patchSheetData(sheetXml, rows) {
const sheetDataMatch = /<sheetData\b[^>]*>([\s\S]*?)<\/sheetData>/.exec(sheetXml);
if (!sheetDataMatch) throw new Error('模板缺少 <sheetData>');
const allRows = sheetDataMatch[1].match(ROW_RE) || [];
const head = [];
const data = [];
const tail = [];
for (const r of allRows) {
const n = rowNumber(r);
if (n <= 2) head.push(r);
else if (n >= SIGN_ROWS[0]) tail.push(r);
else data.push({ n, xml: r });
}
if (head.length !== 2) throw new Error(`模板表头行应为 2 行,实际 ${head.length}`);
if (data.length === 0) throw new Error('模板没有数据行可作样式参照');
// 🔴 必须同时守住两条签字行:只保留一条等于把「裁判长签字」或「仲裁长签字」丢了
if (tail.length !== SIGN_ROWS.length) {
throw new Error(`模板签字行应为 ${SIGN_ROWS.length} 条(row ${SIGN_ROWS.join(' / ')}),实际 ${tail.length} 条`);
}
const styles = pickDataRowStyles(data);
// 备注块 H3:J8 的空单元格样式:直接取模板数据行里 H/I/J 的 s(row 3 有备注文本,取 row 4 起)
const noteStyleFrom = {};
for (const d of data) {
if (d.n <= 8) {
const st = rowStyles(d.xml);
for (const c of NOTE_COLS) {
if (st[c] !== undefined && noteStyleFrom[c] === undefined) noteStyleFrom[c] = st[c];
}
}
}
const count = Math.max(rows.length, MIN_DATA_ROWS_FOR_NOTE);
const out = [];
for (const h of head) out.push(h);
for (let i = 0; i < count; i++) {
const r = DATA_FIRST_ROW + i;
const row = rows[i] || null;
const cells = [
cellXml('A', r, styles.A, row ? row.seq : null),
cellXml('B', r, styles.B, row ? row.org : null),
cellXml('C', r, styles.C, row ? row.players : null),
cellXml('D', r, styles.D, row ? row.teachers : null),
cellXml('E', r, styles.E, row ? row.rank : null),
cellXml('F', r, styles.F, row ? row.score : null),
// 获奖等级:用户裁定「空置人工填入」⇒ 永远不写值
cellXml('G', r, styles.G, null),
];
// 备注合并区 H3:J8 需要这些单元格存在才能撑住边框
if (r <= 8) {
for (const c of NOTE_COLS) cells.push(cellXml(c, r, noteStyleFrom[c], null));
}
out.push(`<row r="${r}" customHeight="1" spans="1:7">${cells.join('')}</row>`);
}
const delta = (count + (DATA_FIRST_ROW - 1)) - DATA_LAST_ROW; // 新数据末行 - 旧数据末行
const shiftRow = (xml, add) => {
if (add === 0) return xml;
return xml.replace(/(<row\b[^>]*\br=")(\d+)(")/, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3)
.replace(/(<c\b[^>]*\br="[A-Z]+)(\d+)(")/g, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3);
};
for (const t of tail) out.push(shiftRow(t, delta));
const newSheetData = `<sheetData>${out.join('')}</sheetData>`;
let xml = sheetXml.replace(sheetDataMatch[0], newSheetData);
// dimension 同步(否则 Excel 认为表格只到旧末行,滚动区/打印区会截断)
const tailRow = TEMPLATE_TAIL_ROW + delta;
xml = xml.replace(/<dimension\b[^>]*\bref="[^"]*"/, `<dimension ref="A1:J${tailRow}"`);
return { xml, dataRows: count, tailRow, styles };
}
/**
* 替换标题里的赛项名与赛项编号。
*
* 标题是 A1 一个单元格里的三行文本(存在 sharedStrings 第 0 项),
* 所以改 sharedStrings 比改 sheet1 更准(A1 是 t="s" 引用,改 sheet 只能整体替换)。
*
* @param {string} xml sharedStrings.xml 原文
* @param {{itemName?: string, itemCode?: string}} meta
* @returns {{ xml: string, replacedItem: boolean, replacedCode: boolean }}
*/
function patchSharedStringsTitle(xml, meta) {
const itemName = meta && meta.itemName ? String(meta.itemName).trim() : '';
const itemCode = meta && meta.itemCode ? String(meta.itemCode).trim() : '';
let replacedItem = false;
let replacedCode = false;
// 定位第 1 个 <si>(标题)
const siMatch = /<si>([\s\S]*?)<\/si>/.exec(xml);
if (!siMatch) throw new Error('sharedStrings 里没有 <si>');
let inner = siMatch[1];
if (itemName && inner.indexOf(TITLE_PLACEHOLDER_ITEM) >= 0) {
// 赛项名替换掉「***」,保留「(选拔赛/国内决赛)比赛结果公示表」的公文后缀
inner = inner.replace(TITLE_PLACEHOLDER_ITEM, `${esc(itemName)}(选拔赛/国内决赛)比赛结果公示表`);
replacedItem = true;
}
if (itemCode && inner.indexOf(TITLE_PLACEHOLDER_CODE) >= 0) {
// 编号为空时不追加,避免留下一个孤零零的「赛项编号:」
inner = inner.replace(TITLE_PLACEHOLDER_CODE, `${TITLE_PLACEHOLDER_CODE}${esc(itemCode)}`);
replacedCode = true;
}
if (!replacedItem && !replacedCode) return { xml, replacedItem, replacedCode };
const newSi = `<si>${inner}</si>`;
return { xml: xml.replace(siMatch[0], newSi), replacedItem, replacedCode };
}
/**
* 完整流程:模板字节 + 数据行 + 标题信息 → 新 xlsx 字节。
* @param {Buffer} templateBuf
* @param {Array<Object>} rows
* @param {{itemName?: string, itemCode?: string}} [meta]
*/
function buildXlsx(templateBuf, rows, meta) {
const entries = readZip(templateBuf);
const sheetEntry = entries.find(e => e.name === 'xl/worksheets/sheet1.xml');
if (!sheetEntry) throw new Error('模板缺少 xl/worksheets/sheet1.xml');
const sheetXml = entryText(sheetEntry);
const patched = patchSheetData(sheetXml, rows);
let next = replaceEntryText(entries, 'xl/worksheets/sheet1.xml', patched.xml);
const ssEntry = next.find(e => e.name === 'xl/sharedStrings.xml');
let titleResult = { replacedItem: false, replacedCode: false };
if (ssEntry) {
const ssXml = entryText(ssEntry);
const r = patchSharedStringsTitle(ssXml, meta || {});
titleResult = { replacedItem: r.replacedItem, replacedCode: r.replacedCode };
if (r.replacedItem || r.replacedCode) {
next = replaceEntryText(next, 'xl/sharedStrings.xml', r.xml);
}
}
return {
buffer: writeZip(next),
dataRows: patched.dataRows,
tailRow: patched.tailRow,
title: titleResult,
styles: patched.styles,
};
}
// ==================== 数据构造(纯函数) ====================
/** 分数归一:null/''/NaN → null;字符串数字 → number */
function toScore(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return isFinite(n) ? Math.round(n * 100) / 100 : null;
}
/**
* 把「小组」数组排成公示表的行。
*
* 一行 = 一个小组(用户裁定:一组一人参赛、成绩全组共享 ⇒ 同一分数不该占多行)。
* 排序:有分的按分数**由高到低**,未评分的排最后;同分按团队编号升序保证稳定。
* 获奖等级不在此处产生(永远留空)。
*
* @param {Array<{teamUid?:number|string, teamId?:string, org?:string,
* players?:string[]|string, teachers?:string[]|string, score?:number|string}>} teams
* @returns {Array<{seq:number, org:string, players:string, teachers:string, rank:(number|null), score:(number|null), teamUid:any, teamId:string}>}
*/
function buildPublicityRows(teams) {
const list = Array.isArray(teams) ? teams : [];
const norm = list.map(t => {
const players = Array.isArray(t.players) ? t.players : (t.players ? [t.players] : []);
const teachers = Array.isArray(t.teachers) ? t.teachers : (t.teachers ? [t.teachers] : []);
return {
teamUid: t.teamUid === undefined ? null : t.teamUid,
teamId: t.teamId === undefined || t.teamId === null ? '' : String(t.teamId),
org: t.org === undefined || t.org === null ? '' : String(t.org),
players: uniqKeepOrder(players).join('、'),
teachers: uniqKeepOrder(teachers).join('、'),
score: toScore(t.score),
};
});
norm.sort((a, b) => {
const sa = a.score === null ? -Infinity : a.score;
const sb = b.score === null ? -Infinity : b.score;
if (sb !== sa) return sb - sa;
// 未评分的两个也按编号定序,避免顺序随查询波动
if (a.teamId < b.teamId) return -1;
if (a.teamId > b.teamId) return 1;
return 0;
});
let rank = 0;
return norm.map((t, i) => {
// 未评分不参与排名(否则会出现「0 分并列第 N」的假排名)
if (t.score !== null) rank++;
return {
seq: i + 1,
org: t.org,
players: t.players,
teachers: t.teachers,
rank: t.score === null ? null : rank,
score: t.score,
teamUid: t.teamUid,
teamId: t.teamId,
};
});
}
/** 去重保序(同组同名只留一份) */
function uniqKeepOrder(arr) {
const seen = new Set();
const out = [];
for (const x of arr) {
const s = String(x === null || x === undefined ? '' : x).trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
module.exports = {
TEMPLATE_HEADERS,
DATA_FIRST_ROW,
DATA_LAST_ROW,
SIGN_ROWS,
RESERVED_DATA_ROWS,
TEMPLATE_TAIL_ROW,
TAIL_OFFSET,
MIN_DATA_ROWS_FOR_NOTE,
DATA_COLS,
TITLE_PLACEHOLDER_ITEM,
TITLE_PLACEHOLDER_CODE,
crc32,
findEOCD,
readZip,
writeZip,
entryText,
replaceEntryText,
rowNumber,
rowStyles,
cellXml,
pickDataRowStyles,
patchSheetData,
patchSharedStringsTitle,
buildXlsx,
toScore,
buildPublicityRows,
uniqKeepOrder,
};

685
app/lib/scoreRankSheet.js Normal file
View File

@@ -0,0 +1,685 @@
'use strict';
/**
* 成绩排名表导出引擎(R220)
*
* 目标:中职组 / 高职组考试结束后,按小组客观题分降序全量导出排名表,
* 整行按分数区间上底色,表尾给出三区间的小组数统计。
*
* 🔴 为什么要自己在 zip 层动 styles.xml:
* `@e965/xlsx`(SheetJS CE)写 xlsx 时**完全丢弃样式**(连 `s=` 属性都不写),
* 实测 2026-10-19:模板 214 个样式引用写回后变 0、styles.xml 从 22773 → 1546 字节。
* ⇒ 唯一能上色的做法 = 先用该库产出一个合法的 xlsx 骨架,
* 再解压 → 改 `xl/styles.xml`(追加 fills + cellXfs)→ 重建 `sheetData`(写 `s=`)
* → 按原始压缩字节重新打包。
*
* 🔴 零新依赖:zip 读写与 XML 片段工具全部复用 `lib/publicitySheet.js`
* (该模块只用 Node 内置 zlib + 自实现 CRC32)⇒ 生产上线**不需要 npm install**,
* 也就不必为此停机。
*
* 🔴 全量重建 sheetData 而不是「只补 s 属性」:该库把字符串写成 `t="str"` + `<v>文本</v>`
* (这是「公式字符串」语义,无 `<f>` 时并不规范)。既然要逐行改样式,
* 索性用 `cellXml()` 以 `inlineStr` 重建每一行 ⇒ 顺带消掉这个隐患,
* 也保证「整行」A..H 每格都存在(该库会跳过空值单元格,底色会断成小块)。
*
* 🔴 分数口径:**必须由调用方传入实时口径**(客观题分 + 大题分)。
* `contestants.score` 只有管理员「成绩管理」里保存过分数后才有值,
* 考试刚结束恒为 0 —— 直接读它会导出「页面 78 分、表格 0 分」的假表。
*/
const XLSX = require('@e965/xlsx');
const {
readZip,
writeZip,
entryText,
replaceEntryText,
cellXml,
} = require('./publicitySheet');
// ==================== 常量 ====================
/**
* 表头(6 列,顺序即写入顺序)。
*
* 🔴 R220-C 用户裁定:本表**按客观题分筛选**,故「大题分」「总分」两列不出现。
* 删掉总分列有个强连带约束:底色/名次若仍按总分算,用户看不到总分就无法
* 解释某行为什么是绿的 ⇒ 排序键、并列判据、区间判定**三者必须同为客观题分**。
*/
const HEADERS = ['名次', '小组编号', '学校', '参赛选手', '人数', '客观题分'];
/** 6 个数据列 */
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F'];
/** 列宽(字符数) */
const COL_WIDTHS = [
{ wch: 7 }, // 名次
{ wch: 18 }, // 小组编号
{ wch: 34 }, // 学校
{ wch: 34 }, // 参赛选手
{ wch: 7 }, // 人数
{ wch: 11 }, // 客观题分
];
/** 默认统计口径:前 N 名 */
const DEFAULT_TOP_N = 80;
/**
* 客观题满分(R220-D 用户补充的事实)。
*
* ⚠️ 它**只用于说明行文字**,不参与任何判定:
* 区间阈值仍是用户裁定的 60 / 50(70 分制下 60 分 = 86%,属高分段)。
* ⛔ 不要因为满分变化就自动缩放阈值 —— 那是用户裁定过的业务规则。
* 若某次考试的满分不是 70,由调用方传 fullScore 覆盖即可。
*/
const DEFAULT_FULL_SCORE = 70;
/**
* 三个分数区间的**单一判定源**。
* 顺序即优先级:先 ≥60,再 ≥50,其余归红。
* 边界按用户裁定:≥60 绿 / 50–59.99 黄 / <50 红。
*
* 🔴 判定输入是**客观题分**(examScore),不是总分(R220-C 用户裁定)。
* 阈值本身未变,只是被量的对象换了 —— 因为表格里已无总分列。
*/
const BANDS = [
{ key: 'green', label: '≥60 分(绿)', min: 60, rgb: 'FFC6EFCE' },
{ key: 'yellow', label: '50–59.99 分(黄)', min: 50, rgb: 'FFFFEB9C' },
{ key: 'red', label: '<50 分(红)', min: -Infinity, rgb: 'FFFFC7CE' },
];
/** 表头底色(灰) */
const HEADER_RGB = 'FFD9D9D9';
/** 前 N 名「红框」的线色(R220-D) */
const RED_BORDER_RGB = 'FFFF0000';
// ==================== 纯函数层 ====================
/** 转数字,非法值归 0 */
function toNum(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/** 保留两位小数,消除浮点噪声(如 59.99999999) */
function round2(v) {
return Math.round(toNum(v) * 100) / 100;
}
/**
* 分数区间判定(唯一入口)。
* @param {number} score 小组**客观题分**(examScore)
* @returns {'green'|'yellow'|'red'}
*/
function scoreBand(score) {
const n = toNum(score);
for (const b of BANDS) {
if (n >= b.min) return b.key;
}
return 'red';
}
/**
* 名次编排:按**客观题分**(examScore)降序,同分并列(1、2、2、4)。
*
* 🔴 排序键 = 客观题分,与「区间底色判定」同源(R220-C 用户裁定)。
* ⛔ 不要改成 total:表格里已无总分列,按总分排序会让名次与可见分数矛盾
* (出现「客观题 90 分排第 5、客观题 70 分排第 1」这种用户无法理解的结果)。
*
* 并列组内的先后顺序用「人数升序 → 小组编号升序」定,纯为了结果可复现,
* 不影响名次数值本身。
*
* 注:返回项里仍保留 wordScore / total 字段供内部排查与既有断言使用,
* 但 **不写入表格**(HEADERS 只有 6 列)。
*
* @param {Array<Object>} teams 每项 {teamId, org, players, memberCount, examScore, wordScore, total}
* @returns {Array<Object>} 追加了 rank 字段的新数组(不改入参)
*/
function buildRankRows(teams) {
const list = (teams || []).map(t => ({
teamId: t && t.teamId != null ? String(t.teamId) : '',
org: t && t.org != null ? String(t.org) : '',
players: t && t.players != null ? String(t.players) : '',
memberCount: Math.trunc(toNum(t && t.memberCount)),
examScore: round2(t && t.examScore),
wordScore: round2(t && t.wordScore),
total: round2(t && t.total),
}));
list.sort((a, b) => {
if (b.examScore !== a.examScore) return b.examScore - a.examScore;
if (a.memberCount !== b.memberCount) return a.memberCount - b.memberCount;
return a.teamId < b.teamId ? -1 : (a.teamId > b.teamId ? 1 : 0);
});
let prevScore = null;
let prevRank = 0;
for (let i = 0; i < list.length; i++) {
const cur = list[i].examScore;
if (prevScore !== null && cur === prevScore) {
list[i].rank = prevRank; // 并列:沿用上一个名次
} else {
list[i].rank = i + 1; // 跳号:1、2、2、4
prevScore = cur;
prevRank = list[i].rank;
}
}
return list;
}
/**
* 三区间小组数统计,两套口径(全量 / 全量排名前 N)。
*
* 🔴 「前 N 名」= 全部小组降序排序后**连续**取前 N 个(跨区间截取),
* ⛔ 不是「名次 ≤ N」,⛔ 也不是「每个区间各取前 N 个」。
* 保证该列合计恒 = min(总数, N),与「筛选出排名靠前的 80 个小组」的口径一致。
* selfCheck() 里有对应断言,误实现成区间各取会在自检阶段直接抛错。
*
* @param {Array<Object>} rows buildRankRows 的产物
* @param {number} topN 默认 80
*/
function buildStats(rows, topN) {
const n = Math.max(0, Math.trunc(toNum(topN == null ? DEFAULT_TOP_N : topN)));
const all = rows || [];
const top = all.slice(0, n);
const countOf = arr => {
const c = { green: 0, yellow: 0, red: 0 };
// 区间判定与排序键同源:客观题分(R220-C)
for (const r of arr) c[scoreBand(r.examScore)]++;
return c;
};
return {
topN: n,
total: all.length,
topTotal: top.length,
all: countOf(all),
top: countOf(top),
};
}
/**
* 统计块下方的口径说明文字。
*
* 🔴 为什么需要它(R220 用户反馈):当小组总数 ≤ topN 时,「全量小组」与
* 「前N名(总排名)」两列数字必然完全相同 —— 用户看到 7 / 7 会合理怀疑
* 「是不是每个区间各取了 7 个」。列名再清楚也压不住这种猜疑,
* 干脆用一行文字把口径写死,同时把「为何两列一样」当场解释掉。
*/
function buildScopeNote(stats, fullScore) {
const n = stats.topN;
const full = fullScore == null ? DEFAULT_FULL_SCORE : fullScore;
const head = `说明:本表按客观题分(满分 ${full} 分)降序排列,名次、区间底色与红框范围均以客观题分为准;`;
const boxed = Math.min(n, stats.total);
const tail = `表格中前 ${boxed} 行已用红色外框圈出(跨区间连续,行间不画横线,以便看成一个整体)。`;
if (stats.total <= n) {
return `${head}本次共 ${stats.total} 个小组,未超过 ${n} 个,故「前${n}名(总排名)」列等于全量,两列数字相同属正常。`
+ `前${n}名 = 全部小组按客观题分降序排列后连续取前 ${n} 个,不是各分数区间分别取前 ${n} 个。${tail}`;
}
return `${head}「前${n}名(总排名)」= 全部 ${stats.total} 个小组按客观题分降序排列后连续取前 ${n} 个,可跨分数区间截取;`
+ `不是各分数区间分别取前 ${n} 个(若按区间各取,合计会远超 ${n})。${tail}`;
}
// ==================== styles.xml 手术 ====================
/** 取某个元素块内的直接子元素个数(按标签名首段统计) */
function countChildren(inner, tag) {
const re = new RegExp(`<${tag}\\b`, 'g');
return (inner.match(re) || []).length;
}
/**
* 在 styles.xml 末尾追加「表头灰底 + 三档底色 + 前 N 名红框」的
* fills / fonts / borders / cellXfs。
*
* ⚠️ 不假定基线 count 的具体数值:一律按**实际子元素个数**取索引并回写 count,
* 这样即使将来 @e965/xlsx 版本升级改了默认 fills 数量也不会把索引错位。
*
* 🔴 红框为什么要 4 个 border + 12 个 xf(R220-D):
* 用户要「前 80 个小组被一个红框整体圈住」,而不是每行各画一个框。
* 单格边框只能表达「这条边上有没有线」,所以整体外框得靠**按行位置分工**拼出来:
* 首行 → 上边 + 左右;中间行 → 只有左右;末行 → 下边 + 左右。
* 每行还有 3 种底色,故 3 底色 × 4 位置(含「只有 1 行」的四边全画)= 12 个 xf。
* 若改成「每行四边全画」,相邻行之间的红线会叠成红色网格,视觉上不再是「框选」。
*
* @param {string} xml styles.xml 原文
* @returns {{ xml: string, style: Object }} style 含 header/green/yellow/red 与 box[位置][颜色]
*/
function patchStyles(xml) {
const fillsRe = /<fills\b([^>]*)>([\s\S]*?)<\/fills>/;
const fontsRe = /<fonts\b([^>]*)>([\s\S]*?)<\/fonts>/;
const bordersRe = /<borders\b([^>]*)>([\s\S]*?)<\/borders>/;
const xfsRe = /<cellXfs\b([^>]*)>([\s\S]*?)<\/cellXfs>/;
const fillsM = fillsRe.exec(xml);
const fontsM = fontsRe.exec(xml);
const bordersM = bordersRe.exec(xml);
const xfsM = xfsRe.exec(xml);
if (!fillsM) throw new Error('styles.xml 缺少 <fills>');
if (!fontsM) throw new Error('styles.xml 缺少 <fonts>');
if (!bordersM) throw new Error('styles.xml 缺少 <borders>');
if (!xfsM) throw new Error('styles.xml 缺少 <cellXfs>');
const baseFillCount = countChildren(fillsM[2], 'fill');
const baseFontCount = countChildren(fontsM[2], 'font');
const baseBorderCount = countChildren(bordersM[2], 'border');
const baseXfCount = countChildren(xfsM[2], 'xf');
if (baseFillCount < 2) throw new Error(`styles.xml fills 基线异常(${baseFillCount})`);
if (baseFontCount < 1) throw new Error(`styles.xml fonts 基线异常(${baseFontCount})`);
if (baseXfCount < 1) throw new Error(`styles.xml cellXfs 基线异常(${baseXfCount})`);
// ---- fills:表头灰 + 三档底色 ----
const solid = rgb => `<fill><patternFill patternType="solid"><fgColor rgb="${rgb}"/><bgColor indexed="64"/></patternFill></fill>`;
const addedFills = [HEADER_RGB, ...BANDS.map(b => b.rgb)].map(solid).join('');
const fHeader = baseFillCount;
const fBand = {};
BANDS.forEach((b, i) => { fBand[b.key] = baseFillCount + 1 + i; });
let out = xml.replace(fillsM[0], () =>
`<fills count="${baseFillCount + 1 + BANDS.length}">${fillsM[2]}${addedFills}</fills>`);
// ---- fonts:表头加粗 ----
const boldFontId = baseFontCount;
const boldFont = '<font><b/><sz val="12"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font>';
out = out.replace(fontsM[0], () =>
`<fonts count="${baseFontCount + 1}">${fontsM[2]}${boldFont}</fonts>`);
// ---- borders:4 种红框(顺序必须是 left/right/top/bottom/diagonal)----
const side = name => `<${name} style="medium"><color rgb="${RED_BORDER_RGB}"/></${name}>`;
const empty = name => `<${name}/>`;
const borderXml = (top, bottom) =>
`<border>${side('left')}${side('right')}${top ? side('top') : empty('top')}${bottom ? side('bottom') : empty('bottom')}<diagonal/></border>`;
// 位置 → [是否有上边, 是否有下边]
const BOX_POS = {
single: [true, true], // 前 N 名只有 1 行时,这一个格子四边都要有
top: [true, false],
mid: [false, false],
bot: [false, true],
};
const BOX_KEYS = ['single', 'top', 'mid', 'bot'];
const addedBorders = BOX_KEYS.map(k => borderXml(BOX_POS[k][0], BOX_POS[k][1])).join('');
const bId = {};
BOX_KEYS.forEach((k, i) => { bId[k] = baseBorderCount + i; });
out = out.replace(bordersM[0], () =>
`<borders count="${baseBorderCount + BOX_KEYS.length}">${bordersM[2]}${addedBorders}</borders>`);
// ---- cellXfs ----
const xf = (fillId, opt) => {
const o = opt || {};
const borderId = o.borderId == null ? 0 : o.borderId;
const attrs = [
'numFmtId="0"',
`fontId="${o.bold ? boldFontId : 0}"`,
`fillId="${fillId}"`,
`borderId="${borderId}"`,
'xfId="0"',
];
if (fillId !== 0) attrs.push('applyFill="1"');
if (o.bold) attrs.push('applyFont="1"');
if (borderId !== 0) attrs.push('applyBorder="1"');
return `<xf ${attrs.join(' ')}/>`;
};
const addedXfs = [xf(fHeader, { bold: true })];
// 纯底色(不在前 N 名里的行)
BANDS.forEach(b => addedXfs.push(xf(fBand[b.key])));
// 前 N 名:底色 × 位置
const xfIndex = {};
BOX_KEYS.forEach(k => {
xfIndex[k] = {};
BANDS.forEach(b => {
xfIndex[k][b.key] = baseXfCount + addedXfs.length;
addedXfs.push(xf(fBand[b.key], { borderId: bId[k] }));
});
});
const style = {
header: baseXfCount,
box: xfIndex,
};
BANDS.forEach(b => { style[b.key] = baseXfCount + 1 + BANDS.indexOf(b); });
out = out.replace(xfsM[0], () =>
`<cellXfs count="${baseXfCount + addedXfs.length}">${xfsM[2]}${addedXfs.join('')}</cellXfs>`);
return { xml: out, style };
}
// ==================== sheetData 重建 ====================
function rowXml(r, cells) {
return `<row r="${r}">${cells.join('')}</row>`;
}
/**
* 生成完整的 sheetData(含表头行、数据行、空行、统计块)。
*
* 行号布局:
* 1 表头
* 2 .. 1+N 数据(N = 小组数;N=0 时数据区为空)
* 2+N 空行
* 3+N 统计表头(区间统计 / 全量小组 / 前 K 名(总排名))
* 4+N .. 6+N 三个区间
* 7+N 合计
* 8+N 口径说明(一行文字,解释「前 K 名」是全量跨区间截取)
*
* @returns {{ xml:string, lastRow:number }}
*/
function buildSheetData(rows, stats, style, fullScore) {
const out = [];
const R = k => k;
// 表头
out.push(rowXml(R(1), HEADERS.map((h, i) => cellXml(DATA_COLS[i], 1, style.header, h))));
// 数据行(6 列:名次/小组编号/学校/参赛选手/人数/客观题分)
// 🔴 底色取值按 examScore 判定 —— 与排序键同源,否则「分数高低」与「颜色」矛盾
// 🔴 前 N 行额外套红框:红框范围与统计块「前N名」列**同一口径**(排序后前 N 行),
// 保证「框住的」和「统计里算的」永远一致,不会一个框 80 行、一个算 80 组。
const boxCount = Math.min(stats.topN, rows.length);
for (let i = 0; i < rows.length; i++) {
const r = i + 2;
const row = rows[i];
const band = scoreBand(row.examScore);
let s;
if (i < boxCount) {
// 首行画上边、末行画下边、中间只画左右 ⇒ 拼成一个整体外框(行间无横线)
const pos = boxCount === 1 ? 'single' : (i === 0 ? 'top' : (i === boxCount - 1 ? 'bot' : 'mid'));
s = style.box[pos][band];
} else {
s = style[band];
}
out.push(rowXml(r, [
cellXml('A', r, s, row.rank),
cellXml('B', r, s, row.teamId),
cellXml('C', r, s, row.org),
cellXml('D', r, s, row.players),
cellXml('E', r, s, row.memberCount),
cellXml('F', r, s, row.examScore),
]));
}
const n = rows.length;
const blankRow = n + 2;
const statHeadRow = n + 3;
// 统计块表头
// 🔴 列标题曾写「其中前80名」,被用户读成「各个区间其中前80名」(R220 反馈)。
// 实际口径是**全量排名**前 N,故列名改为「前N名(总排名)」消除歧义。
out.push(rowXml(statHeadRow, [
cellXml('A', statHeadRow, style.header, '区间统计'),
cellXml('B', statHeadRow, style.header, '全量小组'),
cellXml('C', statHeadRow, style.header, `前${stats.topN}名(总排名)`),
]));
// 三个区间(A 列带该区间的底色,作为图例)
BANDS.forEach((b, i) => {
const r = statHeadRow + 1 + i;
out.push(rowXml(r, [
cellXml('A', r, style[b.key], b.label),
cellXml('B', r, undefined, stats.all[b.key]),
cellXml('C', r, undefined, stats.top[b.key]),
]));
});
// 合计
const sumRow = statHeadRow + 1 + BANDS.length;
out.push(rowXml(sumRow, [
cellXml('A', sumRow, style.header, '合计'),
cellXml('B', sumRow, undefined, stats.total),
cellXml('C', sumRow, undefined, stats.topTotal),
]));
// 口径说明行(无底色,A 列文字自然溢出到右侧空列)
const noteRow = sumRow + 1;
out.push(rowXml(noteRow, [
cellXml('A', noteRow, undefined, buildScopeNote(stats, fullScore)),
]));
return { xml: `<sheetData>${out.join('')}</sheetData>`, lastRow: noteRow, blankRow };
}
// ==================== 组装 ====================
/** 造一个最小合法 xlsx 骨架(表头一行的空表),后续全部靠 zip 层替换 */
function buildBaseBuffer(sheetName) {
const ws = XLSX.utils.aoa_to_sheet([HEADERS.slice()]);
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || 'Sheet1').slice(0, 31));
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
/** 从 zip 条目里取某文件文本 */
function textOf(entries, name) {
const e = entries.find(x => x.name === name);
if (!e) throw new Error(`xlsx 缺少条目 ${name}`);
return entryText(e);
}
/**
* 主入口:生成成绩排名表 xlsx。
*
* @param {Object} opt
* @param {Array<Object>} opt.teams 原始小组数组(未经排序)
* @param {string} [opt.sheetName] 工作表名
* @param {number} [opt.topN=80] 统计口径里的「前 N 名」
* @returns {{ buffer: Buffer, colored: boolean, rows: Array<Object>, stats: Object, lastRow: number }}
* colored=false 表示上色链路失败已自动降级为无色版
*/
function buildXlsx(opt) {
const options = opt || {};
const rows = buildRankRows(options.teams);
const topN = options.topN == null ? DEFAULT_TOP_N : options.topN;
const fullScore = options.fullScore == null ? DEFAULT_FULL_SCORE : options.fullScore;
const stats = buildStats(rows, topN);
const sheetName = options.sheetName || '成绩排名';
const base = buildBaseBuffer(sheetName);
// ---- 第一路:带底色 ----
try {
const entries = readZip(base);
const stylesPatched = patchStyles(textOf(entries, 'xl/styles.xml'));
let out = replaceEntryText(entries, 'xl/styles.xml', stylesPatched.xml);
const built = buildSheetData(rows, stats, stylesPatched.style, fullScore);
let sheetXml = textOf(out, 'xl/worksheets/sheet1.xml');
const sdRe = /<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/;
if (!sdRe.test(sheetXml)) throw new Error('sheet1.xml 缺少 <sheetData>');
sheetXml = sheetXml.replace(sdRe, built.xml);
// dimension 必须同步,否则 Excel 的滚动区/打印区按旧范围截断
sheetXml = sheetXml.replace(
/<dimension\b[^>]*\bref="[^"]*"/,
`<dimension ref="A1:${DATA_COLS[DATA_COLS.length - 1]}${built.lastRow}"`
);
// 骨架带出来的 ignoredErrors 指向 A1:H1,范围已失真,直接删掉避免误报
sheetXml = sheetXml.replace(/<ignoredErrors\b[^>]*\/>/g, '')
.replace(/<ignoredErrors\b[^>]*>[\s\S]*?<\/ignoredErrors>/g, '');
out = replaceEntryText(out, 'xl/worksheets/sheet1.xml', sheetXml);
const buffer = writeZip(out);
selfCheck(buffer, rows, stats, true);
return { buffer, colored: true, rows, stats, lastRow: built.lastRow };
} catch (err) {
// ---- 第二路:降级无色 ----
const buffer = buildPlain(rows, stats, sheetName, fullScore);
selfCheck(buffer, rows, stats, false);
return {
buffer,
colored: false,
rows,
stats,
lastRow: rows.length + 8,
degradeReason: err && err.message ? err.message : String(err),
};
}
}
/** 降级版:不上任何底色,但数据/表头/统计块与带色版完全一致 */
function buildPlain(rows, stats, sheetName, fullScore) {
const aoa = [HEADERS.slice()];
for (const row of rows) {
aoa.push([row.rank, row.teamId, row.org, row.players, row.memberCount, row.examScore]);
}
aoa.push([]);
aoa.push(['区间统计', '全量小组', `前${stats.topN}名(总排名)`]);
for (const b of BANDS) aoa.push([b.label, stats.all[b.key], stats.top[b.key]]);
aoa.push(['合计', stats.total, stats.topTotal]);
aoa.push([buildScopeNote(stats, fullScore)]);
const ws = XLSX.utils.aoa_to_sheet(aoa);
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || '成绩排名').slice(0, 31));
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
/**
* 自检:回读产物,确认「能读通 + 行数对 + 首行值对 +(带色版)styles 里真有底色」。
* 任一条不满足就抛错,由上层降级。
*/
function selfCheck(buffer, rows, stats, expectColor) {
const wb = XLSX.read(buffer, { type: 'buffer' });
const name = wb.SheetNames[0];
const ws = wb.Sheets[name];
if (!ws) throw new Error('自检失败:产物无工作表');
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: true });
if (!aoa.length) throw new Error('自检失败:产物为空');
const head = aoa[0] || [];
for (let i = 0; i < HEADERS.length; i++) {
if (String(head[i] == null ? '' : head[i]) !== HEADERS[i]) {
throw new Error(`自检失败:表头第 ${i + 1} 列应为「${HEADERS[i]}」,实为「${head[i]}」`);
}
}
// 数据区行数
const dataRows = aoa.slice(1, 1 + rows.length);
if (dataRows.length !== rows.length) {
throw new Error(`自检失败:数据行数 ${dataRows.length} ≠ 期望 ${rows.length}`);
}
// 首行关键值抽查(名次 / 小组编号 / 客观题分)
if (rows.length) {
const first = dataRows[0] || [];
if (String(first[0]) !== String(rows[0].rank)) throw new Error('自检失败:首行名次不符');
if (String(first[1]) !== String(rows[0].teamId)) throw new Error('自检失败:首行小组编号不符');
if (Number(first[5]) !== Number(rows[0].examScore)) throw new Error('自检失败:首行客观题分不符');
}
// 🔴 列数锁死:本表只出 6 列,「大题分」「总分」不得回流(R220-C 用户裁定)
if (HEADERS.length !== 6) throw new Error(`自检失败:表头列数应为 6,实为 ${HEADERS.length}`);
for (let i = 6; i < head.length; i++) {
if (String(head[i] == null ? '' : head[i]).trim() !== '') {
throw new Error(`自检失败:第 ${i + 1} 列应不存在,实为「${head[i]}」`);
}
}
// 排序/名次必须由客观题分驱动:逐行校验「客观题分不得逆序」
for (let i = 1; i < rows.length; i++) {
if (rows[i - 1].examScore < rows[i].examScore) {
throw new Error(`自检失败:第 ${i} 行客观题分逆序(${rows[i - 1].examScore} < ${rows[i].examScore})`);
}
}
// 统计块
const statHead = aoa[1 + rows.length + 1] || [];
if (String(statHead[0] || '') !== '区间统计') throw new Error('自检失败:统计块表头缺失');
const expectC = `前${stats.topN}名(总排名)`;
if (String(statHead[2] || '') !== expectC) {
throw new Error(`自检失败:统计块 C 列表头应为「${expectC}」,实为「${statHead[2]}」`);
}
// 🔴 口径自证:「前N名」列合计必须 = min(总数, N)。
// 若被误实现成「各分数区间分别取前 N 个」,总数 > N 时该值会超 N ⇒ 此处直接红。
const expectTopTotal = Math.min(stats.total, stats.topN);
if (stats.topTotal !== expectTopTotal) {
throw new Error(
`自检失败:前${stats.topN}名合计 ${stats.topTotal} ≠ min(总数 ${stats.total}, ${stats.topN})`
);
}
const topSum = stats.top.green + stats.top.yellow + stats.top.red;
if (topSum !== stats.topTotal) {
throw new Error(`自检失败:三区间前N名之和 ${topSum} ≠ 合计 ${stats.topTotal}`);
}
for (const b of BANDS) {
if (stats.top[b.key] > stats.all[b.key]) {
throw new Error(`自检失败:${b.label} 的「前N名」数 ${stats.top[b.key]} 超过全量数 ${stats.all[b.key]}`);
}
}
if (expectColor) {
const entries = readZip(buffer);
const styles = textOf(entries, 'xl/styles.xml');
for (const b of BANDS) {
if (styles.indexOf(b.rgb) < 0) throw new Error(`自检失败:styles.xml 缺底色 ${b.rgb}`);
}
const sheetXml = textOf(entries, 'xl/worksheets/sheet1.xml');
if (!/\bs="\d+"/.test(sheetXml)) throw new Error('自检失败:sheet1.xml 无任何样式引用');
// ---- 红框校验(R220-D):从产物反推,不依赖内存里的 style 索引 ----
// 先找「含红色 + medium 线宽」的 border 定义,再看哪些 xf 引用了它们,
// 最后逐行核对「前 min(topN, 行数) 行必须是红框 xf,其余行必须不是」。
const bordersBlock = /<borders\b[^>]*>([\s\S]*?)<\/borders>/.exec(styles);
const borderList = bordersBlock ? (bordersBlock[1].match(/<border\b[\s\S]*?<\/border>/g) || []) : [];
const redBorderIds = new Set();
borderList.forEach((b, i) => {
if (b.indexOf(RED_BORDER_RGB) >= 0 && /style="medium"/.test(b)) redBorderIds.add(i);
});
if (redBorderIds.size === 0) throw new Error('自检失败:styles.xml 里没有红色粗边框定义');
const xfsBlock = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(styles);
const xfList = xfsBlock ? (xfsBlock[1].match(/<xf\b[^>]*\/>/g) || []) : [];
const boxXfIds = new Set();
xfList.forEach((x, i) => {
const mm = /borderId="(\d+)"/.exec(x);
if (mm && redBorderIds.has(Number(mm[1]))) boxXfIds.add(i);
});
if (boxXfIds.size === 0) throw new Error('自检失败:没有 cellXfs 引用红色边框');
const expectBox = Math.min(stats.topN, rows.length);
const styleAt = k => {
const rm = new RegExp(`<row r="${k}">([\\s\\S]*?)</row>`).exec(sheetXml);
const sm = rm ? /\bs="(\d+)"/.exec(rm[1]) : null;
return sm ? Number(sm[1]) : -1;
};
for (let k = 0; k < rows.length; k++) {
const sx = styleAt(k + 2);
const hasBox = boxXfIds.has(sx);
const want = k < expectBox;
if (hasBox !== want) {
throw new Error(
`自检失败:第 ${k + 2} 行红框归属错误(应为「${want ? '有框' : '无框'}」,`
+ `实为「${hasBox ? '有框' : '无框'}」,s=${sx},前 ${expectBox} 行应有框)`
);
}
}
}
return { rows: aoa.length, sheetName: name };
}
module.exports = {
HEADERS,
DATA_COLS,
COL_WIDTHS,
DEFAULT_TOP_N,
DEFAULT_FULL_SCORE,
BANDS,
HEADER_RGB,
RED_BORDER_RGB,
toNum,
round2,
scoreBand,
buildRankRows,
buildStats,
buildScopeNote,
patchStyles,
buildSheetData,
buildXlsx,
buildPlain,
selfCheck,
};

287
app/lib/teacherBackfill.js Normal file
View File

@@ -0,0 +1,287 @@
'use strict';
/**
* 指导教师回填 —— 纯判定层(R176)
*
* 背景:`contestants` 原本没有任何「指导教师」承载字段,而成绩公示表
* (`7-2026一带一路暨金砖大赛成绩公示表-参考模版.xlsx`)第 4 列必须填写。
* 报名表里其实有指导教师行,但导入流程按业务规则只导入「参赛选手」身份
* (`service/admin.js` 的 IMPORT_IDENTITY),教师行被计入 `ignored` 丢弃。
*
* 本模块只负责**从报名表二维数组里把指导教师找出来、按小组归并**,
* 不碰数据库、不碰文件、不依赖 Egg。落库由 `service/admin.js#backfillTeachers` 完成。
*
* 🔴 为什么不直接复用既有导入:`importContestants` 对已存在的身份证号一律 skip
* (`result.skipped++`),所以「重导一次」不会更新任何字段。回填必须是独立通路。
*
* 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号(实测本科组也有
* team_uid=1),只按 teamId 归并会把别的组的教师挂到本组头上。
*/
/** 教师身份白名单(归一化:去掉所有空白字符后比对) */
const TEACHER_IDENTITIES = [
'指导教师',
'指导老师',
'指导专家',
'指导教师(专家)',
'指导教师(专家)',
'指导教师(教练)',
'指导教师(教练)',
'教练',
'导师',
];
/** 组别别名 → 系统 key。⚠️ 名字带 TEACHER_ 前缀,避免与 service/admin.js 的 GROUP_ALIAS 撞名 */
const TEACHER_GROUP_ALIAS = {
中职: 'zhongzhi',
中职组: 'zhongzhi',
高职: 'gaozhi',
高职组: 'gaozhi',
本科: 'benke',
本科组: 'benke',
教师: 'jiaoshi',
教师组: 'jiaoshi',
zhongzhi: 'zhongzhi',
gaozhi: 'gaozhi',
benke: 'benke',
jiaoshi: 'jiaoshi',
};
/** 报名表列名别名(与导入契约同源,但这里只取回填必需的 4 列) */
const TEACHER_COLUMN_ALIAS = {
name: ['姓名', '全名', 'name'],
teamId: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
group: ['组别', 'group'],
identity: ['身份', 'identity'],
};
const ROW_STATUS = {
TEACHER: 'TEACHER',
NOT_TEACHER: 'NOT_TEACHER',
TEACHER_NO_TEAM: 'TEACHER_NO_TEAM',
TEACHER_NO_NAME: 'TEACHER_NO_NAME',
TEACHER_NO_GROUP: 'TEACHER_NO_GROUP',
BLANK_ROW: 'BLANK_ROW',
};
/**
* 去掉所有空白字符(含全角空格、不换行空格、零宽字符)。
* ⛔ 不能只 trim:上游表格里「指导 教师」「指导教师」混用很常见,
* 只 trim 会把前者判成非教师身份而整行丢掉。
*/
function squash(v) {
if (v === null || v === undefined) return '';
return String(v)
.replace(/[\u3000\u00a0\u200b\u200c\u200d\ufeff]/g, '')
.replace(/\s+/g, '');
}
/** 组别归一;无法识别时返回空串(调用方据此判定"组别缺失") */
function normalizeGroup(v) {
const s = squash(v);
if (!s) return '';
return TEACHER_GROUP_ALIAS[s] || TEACHER_GROUP_ALIAS[s.toLowerCase()] || '';
}
/** 是否教师身份。归一化后精确命中白名单才算,⛔ 不做包含匹配(避免「非指导教师」被误判) */
function isTeacherIdentity(v) {
const s = squash(v);
if (!s) return false;
return TEACHER_IDENTITIES.indexOf(s) >= 0;
}
/** 队伍编号归一:只 trim 首尾空白与零宽字符,⛔ 不折叠大小写(编号大小写不敏感会串组) */
function normalizeTeamId(v) {
if (v === null || v === undefined) return '';
return String(v).replace(/[\u200b\u200c\u200d\ufeff]/g, '').replace(/^[\s\u3000\u00a0]+|[\s\u3000\u00a0]+$/g, '');
}
/** 在给定行里按别名找列下标,返回 -1 表示未命中 */
function findColumn(row, aliases) {
if (!Array.isArray(row)) return -1;
for (let i = 0; i < row.length; i++) {
const cell = squash(row[i]);
if (!cell) continue;
for (const a of aliases) {
if (cell === squash(a)) return i;
}
}
return -1;
}
/**
* 解析报名表 AOA,提取「小组 → 指导教师姓名列表」。
*
* @param {Array<Array>} aoa 二维数组(xlsx sheet_to_json header:1 的产物)
* @param {Object} [opts]
* @param {number} [opts.maxHeaderScan=10] 表头最多向下扫描的行数
* @returns {{
* ok: boolean, code: string, message: string,
* teams: Array<{group: string, teamId: string, names: string[], rowNumbers: number[]}>,
* stats: Object, errors: Array<Object>
* }}
*/
function extractTeachers(aoa, opts) {
const maxHeaderScan = (opts && opts.maxHeaderScan) || 10;
const empty = { teams: [], stats: { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }, errors: [] };
if (!Array.isArray(aoa) || aoa.length === 0) {
return Object.assign({ ok: false, code: 'EMPTY_FILE', message: '文件内容为空' }, empty);
}
// ---- 1) 定位表头 ----
let headerRowIdx = -1;
let colName = -1;
let colTeam = -1;
let colGroup = -1;
let colIdentity = -1;
for (let i = 0; i < Math.min(aoa.length, maxHeaderScan); i++) {
const r = aoa[i];
const cn = findColumn(r, TEACHER_COLUMN_ALIAS.name);
const ci = findColumn(r, TEACHER_COLUMN_ALIAS.identity);
if (cn >= 0 && ci >= 0) {
headerRowIdx = i;
colName = cn;
colIdentity = ci;
colTeam = findColumn(r, TEACHER_COLUMN_ALIAS.teamId);
colGroup = findColumn(r, TEACHER_COLUMN_ALIAS.group);
break;
}
}
if (headerRowIdx < 0) {
return Object.assign({
ok: false,
code: 'HEADER_NOT_FOUND',
message: '前 10 行内没有同时包含「姓名」与「身份」的表头,请确认上传的是报名表',
}, empty);
}
if (colTeam < 0) {
return Object.assign({
ok: false,
code: 'TEAM_COLUMN_MISSING',
message: '报名表缺少「队伍编号」列,无法把指导教师归到具体小组',
}, empty);
}
if (colGroup < 0) {
return Object.assign({
ok: false,
code: 'GROUP_COLUMN_MISSING',
message: '报名表缺少「组别」列,无法限定同组(team_uid 跨组重号,不能只按编号归并)',
}, empty);
}
// ---- 2) 逐行收集 ----
const stats = { rows: 0, teachers: 0, notTeachers: 0, blank: 0 };
const errors = [];
// key = group + '\u0000' + teamId,用不可见分隔符避免与编号里的字符冲突
const bucket = new Map();
for (let i = headerRowIdx + 1; i < aoa.length; i++) {
const r = aoa[i] || [];
stats.rows++;
const rowNumber = i + 1; // 1 基,与 Excel 行号一致
const nameRaw = colName >= 0 ? r[colName] : '';
const identityRaw = colIdentity >= 0 ? r[colIdentity] : '';
const allBlank = r.every(c => squash(c) === '');
if (allBlank) {
stats.blank++;
continue;
}
if (!isTeacherIdentity(identityRaw)) {
stats.notTeachers++;
continue;
}
const name = String(nameRaw === null || nameRaw === undefined ? '' : nameRaw).trim();
if (!name) {
stats.teachers++;
errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_NAME, message: '身份为导师但姓名为空,已跳过' });
continue;
}
const group = normalizeGroup(colGroup >= 0 ? r[colGroup] : '');
if (!group) {
stats.teachers++;
errors.push({
row: rowNumber,
status: ROW_STATUS.TEACHER_NO_GROUP,
message: `指导教师「${name}」所在行组别为空或无法识别,已跳过(缺少 group 无法安全归组)`,
});
continue;
}
const teamId = normalizeTeamId(colTeam >= 0 ? r[colTeam] : '');
if (!teamId) {
stats.teachers++;
errors.push({
row: rowNumber,
status: ROW_STATUS.TEACHER_NO_TEAM,
message: `指导教师「${name}」所在行队伍编号为空,已跳过(无法定位到具体小组)`,
});
continue;
}
stats.teachers++;
const key = `${group}\u0000${teamId}`;
let item = bucket.get(key);
if (!item) {
item = { group, teamId, names: [], rowNumbers: [] };
bucket.set(key, item);
}
if (item.names.indexOf(name) < 0) item.names.push(name);
item.rowNumbers.push(rowNumber);
}
const teams = Array.from(bucket.values());
if (teams.length === 0) {
return Object.assign({
ok: false,
code: 'NO_TEACHER_FOUND',
message: stats.teachers === 0
? '报名表里没有身份为「指导教师」的行(请确认「身份」列的写法)'
: '识别到指导教师行,但都缺少组别或队伍编号,无法归组',
}, { stats, errors });
}
return Object.assign({ ok: true, code: 'OK', message: `识别到 ${teams.length} 个小组的指导教师` }, { teams, stats, errors });
}
/**
* 把同一小组的多个指导教师姓名合成一个展示串。
* ⛔ 去重保序:报名表里同一教师可能重复出现(同一人被登记两次),
* 直接 join 会得到「张三、张三」。
*/
function joinNames(names) {
if (!Array.isArray(names)) return '';
const seen = new Set();
const out = [];
for (const n of names) {
const s = String(n === null || n === undefined ? '' : n).trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out.join('、');
}
/** 1 基数组是报名表原始行号,直接透出(调用方拼提示用) */
function formatRowNumbers(nums) {
if (!Array.isArray(nums) || nums.length === 0) return '';
return nums.join('、');
}
module.exports = {
TEACHER_IDENTITIES,
TEACHER_GROUP_ALIAS,
TEACHER_COLUMN_ALIAS,
ROW_STATUS,
squash,
normalizeGroup,
normalizeTeamId,
isTeacherIdentity,
findColumn,
extractTeachers,
joinNames,
formatRowNumbers,
};

191
app/lib/team_folder.js Normal file
View File

@@ -0,0 +1,191 @@
'use strict';
/**
* 小组作品目录命名 —— **单一 SoT**
*
* 【业务来源】R190 管理人员反馈:
* 「参赛小组上传作品时由于他们的作品分为两个文件且会自行命名,所以进行评分时
* 两个文件分开下载经常出现混乱,现在提出了想法,当参赛小组上传作品时,后台
* 自动生成一个以他们小组编号命名的文件夹,将他们的作品存放在这个文件夹中。」
*
* 【本模块的唯一职责】把「一个小组」映射成一个**安全、可读、稳定**的目录名。
* · 上传落盘(service/upload.js)与管理端打包(service/admin.js)**必须**都调这里
* —— 否则「上传写进 A 目录、下载从 B 目录找」的错位会在上线后立刻炸。
* · 前端**不参与**命名,只展示后端回传的 folderName / teamNumber。
*
* 🔴 与既有口径的关系(⛔ 不得另立第二套):
* 小组编号的展示值沿用 `service/admin.js#resolveTeamLabels` 的
* `teams.team_number || teams.team_id`;本模块只负责把它**变成合法目录名**,
* 不重新定义「哪个字段算编号」。
*
* 🔴 为什么必须净化:目录名直接参与 `fs.mkdir` / `path.join`。
* `team_number` 来自报名表导入,历史上出现过带 `/` `\` 的值会被当成路径分隔符
* ⇒ 文件落到意料之外的目录(甚至穿越到 works 之外)。故:
* · 替换 Windows 非法字符 `\ / : * ? " < > |` 与控制字符
* · 去掉首尾空白与首尾点(Windows 不允许尾点,且 `..` 是穿越向量)
* · 限长 60 字符(避免超过单段 255 字节上限,也给中文留余量)
*/
/** 单段目录名/文件名主体的最大长度(字符数,非字节) */
const MAX_SEGMENT = 60;
/** 非法字符:Windows 保留字符 + 控制字符 */
// eslint-disable-next-line no-control-regex
const ILLEGAL_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
/** Windows 设备保留名(不区分大小写,含扩展名前的部分) */
const RESERVED_NAMES = new Set([
'con', 'prn', 'aux', 'nul',
'com0', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
'lpt0', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
]);
/**
* 净化单个路径段(目录名或文件名主体)。
*
* @param {*} raw - 原始值(任意类型,null/undefined 视为空)
* @param {string} [fallback] - 净化后为空时的兜底值
* @returns {string} 安全的单段名称
*/
function sanitizeSegment(raw, fallback = '未命名') {
let s = String(raw === null || raw === undefined ? '' : raw);
s = s.replace(ILLEGAL_CHARS, '_');
// 折叠空白(含全角空格转半角后折叠),去掉首尾空白
s = s.replace(/\u3000/g, ' ').replace(/\s+/g, ' ').trim();
// 去掉首部点(`.` / `..` / `...foo` 都会被误判)与尾部点
s = s.replace(/^\.+/, '').replace(/\.+$/, '').trim();
if (s.length > MAX_SEGMENT) s = s.slice(0, MAX_SEGMENT).trim();
if (!s) return fallback;
// 保留名兜底:`CON` → `CON_`
if (RESERVED_NAMES.has(s.toLowerCase())) return s + '_';
return s;
}
/**
* 净化一个**文件名**(保留扩展名,主体走 sanitizeSegment)。
*
* @param {*} raw - 原始文件名(可能带路径)
* @param {string} [fallbackStem] - 主体为空时的兜底
* @param {string} [fallbackExt] - 无扩展名时的兜底扩展名(含点,如 '.pdf')
* @returns {string} 安全文件名
*/
function sanitizeFileName(raw, fallbackStem = '作品', fallbackExt = '') {
const original = String(raw === null || raw === undefined ? '' : raw);
// 只取 basename:同时切开 `/` 与 `\`(path.basename 在 Linux 上不切 `\`)
const base = original.replace(/^.*[\\/]/, '');
const dot = base.lastIndexOf('.');
const hasExt = dot > 0 && dot < base.length - 1;
const rawStem = hasExt ? base.slice(0, dot) : base;
const rawExt = hasExt ? base.slice(dot + 1) : '';
// 扩展名只允许字母数字,最长 10 位;出现异常字符即视为无扩展名
const ext = /^[A-Za-z0-9]{1,10}$/.test(rawExt) ? '.' + rawExt.toLowerCase() : fallbackExt;
const stem = sanitizeSegment(rawStem, fallbackStem);
return stem + ext;
}
/**
* 解析「小组作品目录名」—— 上传落盘与管理端打包共用的唯一入口。
*
* 三态优先级(与 `resolveTeamLabels` 的 teamNumber 口径对齐):
* ① `teamNumber`(= `teams.team_number || teams.team_id`,人类可读编号,如 BRICS2026-ST-027-94001)
* ② 无编号但有队伍归属 ⇒ `未编组_<teamUid>`(同队成员仍归到同一目录)
* ③ 连队伍归属都没有(单人参赛 / 历史脏数据)⇒ `未编组_<aggregationUid>`(每人独立,防撞车)
*
* @param {Object} p
* @param {?string} p.teamNumber - 人类可读小组编号
* @param {?number} p.teamUid - 队伍号(组内唯一)
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底)
* @returns {string} 安全的目录名(保证非空、无路径分隔符)
*/
function resolveTeamFolderName({ teamNumber, teamUid, aggregationUid } = {}) {
const label = teamNumber === null || teamNumber === undefined ? '' : String(teamNumber).trim();
if (label) return sanitizeSegment(label, '未编组');
if (teamUid !== null && teamUid !== undefined && String(teamUid).trim() !== '') {
return sanitizeSegment(`未编组_${teamUid}`, '未编组');
}
if (aggregationUid !== null && aggregationUid !== undefined && String(aggregationUid).trim() !== '') {
return sanitizeSegment(`未编组_${aggregationUid}`, '未编组');
}
return '未编组';
}
/**
* 拼接「小组作品目录」的相对路径(相对 `app/public`,正斜杠)。
*
* @param {string} group - 组别
* @param {string} folderName - resolveTeamFolderName 的返回值
* @returns {string} 如 `works/benke/BRICS2026-ST-027-94001`
*/
function teamFolderRelative(group, folderName) {
return ['works', sanitizeSegment(group, 'unknown'), sanitizeSegment(folderName, '未编组')]
.join('/');
}
/**
* 下载文件名主体(不含扩展名)的字符上限。
*
* 🔴 为什么要有上限:下载名 = 小组编号(≤60) + 姓名(≤60) + 原文件名(≤120),
* 三者直接拼接可到 240 字符。Windows 资源管理器对单文件名上限 255 **字符**
* (NTFS 255 UTF-16 单元),240 已贴边,再加上浏览器自动加的 `(1)` 后缀就会失败。
* 故统一收敛到 150,保证「编号 + 姓名 + 关键文件名」都能看见,又不触系统上限。
*/
const MAX_DOWNLOAD_NAME = 150;
/**
* 拼接「管理端下载文件名」—— 中职/高职大题(Word)单个下载与批量 ZIP 包内名共用。
*
* 格式:`<小组编号>-<姓名>-<原文件名>`(R195 需求:小组编号放最前)
* 例:`BRICS2026-ST-027-94001-张三-大题作答.docx`
*
* 🔴 单一口径:小组编号三态(teamNumber → `未编组_<teamUid>` → `未编组_<aggUid>`)
* 完全复用 resolveTeamFolderName,与本组上传落盘目录名**必然一致**
* ⇒ 管理员把下载文件解压回工作目录时,文件名前缀可直接对上作品目录。
* ⛔ 不得在 controller / service 各拼一套(尤其是「有队伍但 teams 表缺元信息」这一档,
* 两处拼法分叉后会出现「同一个人下载出两个不一样的前缀」)。
*
* @param {Object} p
* @param {?string} p.teamNumber - teams.team_number || teams.team_id
* @param {?number} p.teamUid - 队伍号(组内唯一)
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底,防多人同名前缀重号)
* @param {?string} p.personName - 选手姓名(缺省则跳过该段)
* @param {?string} p.originalName - 原文件名(选手上传名)
* @param {string} [p.fallbackStem] - 原文件名为空时的兜底主体
* @returns {string} 安全文件名(非空、无路径分隔符、长度受限)
*/
function buildTeamDownloadName({
teamNumber,
teamUid,
aggregationUid,
personName,
originalName,
fallbackStem = '大题作答',
} = {}) {
const prefix = resolveTeamFolderName({ teamNumber, teamUid, aggregationUid });
const person = personName === null || personName === undefined
? ''
: sanitizeSegment(personName, '');
const base = sanitizeFileName(originalName, fallbackStem);
// 拆扩展名:截断只能砍主体,⛔ 不能把 `.docx` 砍掉(砍掉后浏览器会存成未知类型)
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : '';
const head = [prefix, person].filter(Boolean).join('-');
// 预留 1 个字符给分隔符 '-'
const room = MAX_DOWNLOAD_NAME - head.length - ext.length - 1;
const finalStem = stem.length > room ? stem.slice(0, Math.max(1, room)).trim() : stem;
return `${head}-${finalStem}${ext}`;
}
module.exports = {
MAX_SEGMENT,
MAX_DOWNLOAD_NAME,
sanitizeSegment,
sanitizeFileName,
resolveTeamFolderName,
teamFolderRelative,
buildTeamDownloadName,
};

View File

@@ -1,6 +1,8 @@
'use strict';
const jwt = require('jsonwebtoken');
// R178:管理端权限判定(单一真相源,见 lib/adminPermissions.js)
const adminPermissions = require('../lib/adminPermissions');
/**
* JWT 认证中间件
@@ -81,6 +83,77 @@ module.exports = (options = {}, app) => {
};
return;
}
// ==================== R178 多管理员 + 权限(RBAC) ====================
//
// 🔴 为什么挂在这里,而不是新写一个全局中间件:
// Egg 的全局中间件早于 router 级中间件执行,此时 ctx.state.currentUser 还不存在;
// 若改成「逐条路由追加 adminPermission」,63 条 /api/admin 路由每一条都要改一次
// —— 漏一条就是后门,且新增路由默认无保护。
// adminOnly 分支已经挂在**每一条**管理端路由上,是唯一能保证零遗漏的位置。
//
// 🔴 为什么每次请求都查库(而不是把权限塞进 JWT):
// 权限进 JWT 后,改权限 / 禁用 / 重置口令在令牌过期前都不生效(本项目令牌 7 天)。
// 禁用某人却要等 7 天,等于没有禁用。单行主键查询的成本可以忽略。
//
// 三道判定(顺序不可换,否则错误信息会互相掩盖):
// 1) 账号仍存在 —— 被删除 ⇒ 401
// 2) status 启用 + 令牌版本一致 —— 被禁用 ⇒ 401;改密/禁用后旧令牌 ⇒ 401
// (⚠️ 两处都用 401 而非 403:账号停用/口令变更意味着「认证主体不再被接受」,
// 前端应回登录页;403 会被误解成「权限不足」。)
// 3) 路由已登记 + 权限命中 —— 未登记 ⇒ 403(默认拒绝,不是放行)
const admin = await ctx.model.Admin.findOne({
where: { id: ctx.state.currentUser.id },
attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'],
raw: true,
});
if (!admin) {
ctx.status = 401;
ctx.body = { code: 401, message: '管理员账号不存在或已被删除,请重新登录' };
return;
}
if (Number(admin.status) !== 1) {
// 🔴 用 401 而不是 403:账号被停用意味着「认证主体不再被接受」,
// 前端应把用户送回登录页。若返回 403,前端会理解为「权限不够」,
// 提示「无权限执行此操作」,与实际原因(账号已停用)不符。
ctx.status = 401;
ctx.body = { code: 401, message: '该管理员账号已被禁用,请联系最高管理员' };
return;
}
// 令牌版本:改密 / 禁用 / 改权限时由服务层自增,旧令牌立即失效。
// 存量令牌无 tv 字段 ⇒ 视作 0,与初值一致,避免升级瞬间把在线的管理员全部踢下线。
const tokenVersion = Number(decoded.tv || 0);
if (Number(admin.token_version) !== tokenVersion) {
ctx.status = 401;
ctx.body = { code: 401, message: '登录状态已失效(口令或权限已变更),请重新登录' };
return;
}
admin.permissions = adminPermissions.parsePermissions(admin.permissions);
ctx.state.currentAdmin = admin;
const required = adminPermissions.resolvePermission(ctx.method, ctx.path);
if (required === null) {
// 未登记 = 配置缺陷。默认拒绝并**大声记日志**,否则新路由会静默变成
// 「任何管理员都能调」的后门,而要等到出事才会被发现。
ctx.logger.error(
`[RBAC] 未登记权限策略的管理端路由: ${ctx.method} ${ctx.path} —— 已按默认拒绝处理,请补 lib/adminPermissions.js 的 ROUTE_TABLE`
);
ctx.status = 403;
ctx.body = {
code: 403,
message: '该接口未登记权限策略,已拒绝访问(请联系系统管理员)',
};
return;
}
if (!adminPermissions.canAccess(admin, required)) {
ctx.logger.warn(
`[RBAC] 拒绝访问 admin=${admin.username}(${admin.id}) ${ctx.method} ${ctx.path} 需要=${required.join('|')}`
);
ctx.status = 403;
ctx.body = { code: 403, message: '无权限执行此操作,请联系最高管理员分配权限' };
return;
}
}
// 继续执行后续中间件和控制器

View File

@@ -18,7 +18,29 @@
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
// 双保险,使单 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 = [
/^\/api\/upload\/[^/]+$/i,
/^\/api\/upload\/[^/]+\/chunk$/i,
/^\/api\/upload\/[^/]+\/merge$/i,
/^\/api\/exam\/[^/]+\/word$/i,
@@ -26,19 +48,172 @@ const HEAVY_PATTERNS = [
/^\/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) => {
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) {
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();
}
if (active >= MAX) {
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
ctx.status = 429;
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
return;
}

View File

@@ -10,6 +10,9 @@
module.exports = app => {
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', {
// 主键,自增
uid: {
@@ -66,7 +69,7 @@ module.exports = app => {
unique: true,
comment: '身份证号',
},
// 登录密码(SHA256 哈希存储)
// 登录密码(bcrypt 加盐哈希存储;兼容存量 SHA256 旧哈希,登录成功后透明升级)
password: {
type: STRING(100),
allowNull: false,
@@ -128,12 +131,7 @@ module.exports = app => {
comment: '账号激活状态',
},
// 总分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '账号总分',
},
score: decimalScore('score', '账号总分', { defaultValue: null }),
// 记录创建时间
created_at: {
type: DATE,

View File

@@ -7,7 +7,7 @@
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const { STRING, INTEGER, DATE, TEXT, TINYINT } = app.Sequelize;
const Admin = app.model.define('admin', {
// 主键 ID,自增
@@ -30,6 +30,51 @@ module.exports = app => {
allowNull: false,
comment: '管理员密码',
},
// 🔴 R178 最高管理员标记:唯一、不可改权限 / 不可禁用 / 不可删除 / 不可重置口令。
// 判据只认本字段(不认 username,避免改名绕过;也不认 id,避免误伤后续自增行)。
is_super: {
type: TINYINT,
allowNull: false,
defaultValue: 0,
comment: '最高管理员:1=不可变动(唯一)',
},
// 权限点 JSON 数组(见 lib/adminPermissions.js 的 PERMISSIONS)。
// NULL / 空数组 = 无任何业务权限;is_super=1 的账号忽略本列(恒全权)。
permissions: {
type: TEXT,
allowNull: true,
comment: '权限点 JSON 数组',
},
// 1=启用 0=禁用。禁用后:禁止登录,且已签发的令牌在下一次请求即被拒。
status: {
type: TINYINT,
allowNull: false,
defaultValue: 1,
comment: '1=启用 0=禁用',
},
// 令牌版本:改密 / 禁用 / 改权限时自增 ⇒ 旧 JWT 中的 tv 与之不匹配即失效。
// 这是「改密后旧会话立即下线」的唯一判据(本项目无会话表)。
token_version: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '令牌版本(改密/禁用时自增,使旧 JWT 失效)',
},
last_login_at: {
type: DATE,
allowNull: true,
comment: '最近登录时间',
},
last_login_ip: {
type: STRING(64),
allowNull: true,
comment: '最近登录 IP',
},
remark: {
type: STRING(200),
allowNull: true,
comment: '备注(姓名 / 岗位)',
},
// 记录创建时间
created_at: {
type: DATE,

View File

@@ -0,0 +1,72 @@
'use strict';
/**
* 管理员高危操作审计日志 - AdminAuditLog
*
* 只记录**高危写操作**(不是全量写操作):
* 发布/撤回成绩、重置测试数据、删除选手、撤回交卷、
* 管理员账号的增 / 改权限 / 重置口令 / 禁用 / 删除。
*
* 为什么不做全量审计:本项目日志无集中采集,全量写接口约 30 个,
* 日志量增长快且缺少清理策略;而「多管理员下谁改了成绩 / 谁动了账号」
* 才是真正需要回答的问题。故按「能改变比赛结果或权限边界的动作」划定范围。
*
* ⚠️ 写入必须 best-effort:审计失败不得阻断业务(否则日志表故障会连累发布成绩)。
* 统一走 service.admin.recordAudit(),⛔ 不要在控制器里直接 create。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT, BIGINT } = app.Sequelize;
const AdminAuditLog = app.model.define('adminAuditLog', {
id: {
type: BIGINT,
primaryKey: true,
autoIncrement: true,
comment: '日志唯一标识',
},
admin_id: {
type: INTEGER,
allowNull: false,
comment: '操作人管理员 ID',
},
// 冗余存用户名:账号被删除后仍能回答「当时是谁操作的」
admin_username: {
type: STRING(50),
allowNull: false,
comment: '操作人用户名(冗余,防账号删除后不可追溯)',
},
action: {
type: STRING(64),
allowNull: false,
comment: '动作标识,如 score.publish / admin.create',
},
target: {
type: STRING(200),
allowNull: true,
comment: '操作对象(组别 / 账号名 / 选手 ID)',
},
detail: {
type: TEXT,
allowNull: true,
comment: '补充信息(JSON 文本)',
},
ip: {
type: STRING(64),
allowNull: true,
comment: '来源 IP',
},
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '发生时间',
},
}, {
tableName: 'admin_audit_logs',
timestamps: false,
underscored: true,
});
return AdminAuditLog;
};

View File

@@ -78,6 +78,25 @@ module.exports = app => {
defaultValue: null,
comment: '驳回原因',
},
// 所属单位 / 学校名称(报名表原始来源,R84 续六新增声明)
// ⚠️ 该列在运行库中**一直存在**(`SHOW COLUMNS FROM aggregation` 可见),
// 只是模型漏声明 ⇒ 任何 `attributes: ['organization_cn']` 查询都会
// 被 Sequelize 当作未知列。此处补齐仅为让模型与运行库一致,
// 声明为 allowNull:true,不改变既有写入路径的语义。
organization_cn: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: '所属单位/学校名称',
},
// 参赛组别(zhongzhi/gaozhi/benke/jiaoshi)—— 同为运行库既有列,模型漏声明。
// ⚠️ `group` 是 MySQL 保留字,Sequelize 会自动加反引号;手写 SQL 时须自行反引号。
group: {
type: STRING(20),
allowNull: true,
defaultValue: null,
comment: '参赛组别',
},
// 记录创建时间
created_at: {
type: DATE,
@@ -114,9 +133,13 @@ module.exports = app => {
// 关联定义
Aggregation.associate = function() {
// ⚠️ R176:CompetitionInfo 的运行库结构是 key-value(主键 id),
// 不是原来的三列结构 ⇒ targetKey 必须是 `id`,否则 Sequelize 解析
// `competition_uid` 这个属性时会找不到定义。
// `aggregation.competition_uid` 存的是数字(实测恒为 1),对应 competition_info.id。
app.model.Aggregation.belongsTo(app.model.CompetitionInfo, {
foreignKey: 'competition_uid',
targetKey: 'competition_uid',
targetKey: 'id',
as: 'competition',
});
app.model.Aggregation.belongsTo(app.model.Team, {

View File

@@ -10,6 +10,9 @@
module.exports = app => {
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', {
// 主键 ID,自增
id: {
@@ -71,12 +74,7 @@ module.exports = app => {
comment: '上传时间',
},
// 评分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '评分',
},
score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
// 记录创建时间
created_at: {
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

@@ -3,56 +3,66 @@
/**
* 竞赛信息模型 - CompetitionInfo
*
* 存储竞赛的基本信息,包括竞赛名称和代码。
* 表名:competition_info
*
* 🔴 2026-10-19(R176)修正:原模型声明的是三列结构
* (`competition_uid` / `competition_name` / `competition_code`),
* 但**运行库实际是 key-value 结构**:
* `id` / `info_key` / `info_value` / `created_at` / `updated_at`(实测 SHOW CREATE TABLE)
* 模型与表不一致的后果:
* ① `findOne()` 默认按模型声明选列 ⇒ `Unknown column 'competition_uid'` 直接 500;
* ② `create()` 会带上 `competition_name`(allowNull:false)⇒ 必然失败。
* 旧代码之所以没暴露,是因为唯一的使用点(导入时取 competition_uid)
* 包在 try/catch 里,失败后静默回落到默认值 1 —— 而 `info_key='competition_uid'`
* 的值恰好也是 1,所以"看起来一直是对的"。
*
* ⚠️ 因此本表**不能按列访问**,必须走 `info_key` 取值。
* 公示表要用的两个键是 `competition_item_name` / `competition_item_code`。
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const { STRING, INTEGER, TEXT, DATE } = app.Sequelize;
const CompetitionInfo = app.model.define('competition_info', {
// 主键,自增
competition_uid: {
// 主键,自增(运行库真实主键是 id,不是 competition_uid)
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '竞赛唯一标识',
comment: '自增主键',
},
// 竞赛名称
competition_name: {
type: STRING(200),
allowNull: false,
comment: '竞赛名称',
},
// 竞赛代码(唯一标识码)
competition_code: {
// 配置键名(如 competition_name / competition_code / competition_item_name)
// ⚠️ 运行库**没有**给 info_key 建唯一索引 ⇒ 写侧必须"先查再写",不能依赖 upsert
info_key: {
type: STRING(50),
allowNull: false,
unique: true,
comment: '竞赛代码',
comment: '配置键',
},
// 配置值(统一按字符串存,数字也以文本形式落库)
info_value: {
type: TEXT,
allowNull: true,
comment: '配置值',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
allowNull: true,
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
allowNull: true,
comment: '更新时间',
},
}, {
tableName: 'competition_info',
timestamps: false,
underscored: true,
// 索引仅作 Sequelize 元数据声明(sync 时用);⛔ 本项目的表由 SQL 手工维护,
// 不要指望这里加 unique 会同步到运行库
indexes: [
{
unique: true,
fields: ['competition_code'],
fields: ['info_key'],
},
],
});

View File

@@ -10,6 +10,9 @@
module.exports = app => {
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', {
// 主键 ID,自增
id: {
@@ -64,32 +67,22 @@ module.exports = app => {
comment: '账号激活状态',
},
// 总分(管理员打分后更新,NULL 表示尚未评分)
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '选手总分(exam_score + word_score)',
},
score: decimalScore('score', '选手总分(exam_score + word_score)'),
// 卷面分(选择题+判断题自动计算)
exam_score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '卷面考试成绩',
},
// Word题分数(管理员手动打分)
word_score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: 'Word题得分',
},
exam_score: decimalScore('exam_score', '卷面考试成绩'),
// 综合大题 / Word 题分数(管理员手动打分,可含 2 位小数)
word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
// PPT分数(本科/教师组管理员手动打分)
ppt_score: {
type: INTEGER,
ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 2 位小数)'),
// 指导教师姓名(R176)。来源 = 报名表里 identity 为「指导教师」的行,
// 按 (group, 队伍编号) 归到同组选手身上;同组多人用「、」连接后冗余存一份。
// ⚠️ 语义上是**组级属性**,冗余到每位组员是为了让成绩公示表能直接按行取用,
// 不必每次再回查 aggregation。回填入口见 service/admin.js#backfillTeachers。
teacher_name: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: 'PPT得分(本科/教师组)',
comment: '指导教师姓名(同组多人用、分隔)',
},
// 所属团队 ID(外键关联 teams 表)
team_id: {

View File

@@ -11,6 +11,9 @@
module.exports = app => {
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', {
// 主键 ID,自增
id: {
@@ -64,12 +67,7 @@ module.exports = app => {
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
},
// 得分(管理员评分后更新,NULL 表示尚未评分)
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '试卷得分',
},
score: decimalScore('score', '试卷得分', { defaultValue: null }),
// 创建时间
created_at: {
type: DATE,

View File

@@ -10,6 +10,9 @@
module.exports = app => {
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', {
// 主键 ID,自增
id: {
@@ -54,12 +57,7 @@ module.exports = app => {
comment: '是否判为正确',
},
// 得分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '得分',
},
score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
// Word 文件存储路径
word_file_path: {
type: STRING(500),

View File

@@ -10,6 +10,9 @@
module.exports = app => {
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', {
// 主键 ID,自增
id: {
@@ -71,12 +74,7 @@ module.exports = app => {
comment: '上传时间',
},
// 评分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '评分',
},
score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
// 记录创建时间
created_at: {
type: DATE,

View File

@@ -79,6 +79,30 @@ module.exports = app => {
defaultValue: null,
comment: '标签,多个标签用逗号分隔',
},
// 草稿标记(R176)。成绩发布时会自动生成一条「成绩公示」预览新闻,
// 需人工编辑确认后才可对外发布 ⇒ 草稿期间必须前台不可见。
// 🔴 news.list / findById 的公开查询必须显式过滤 is_draft=0(默认值 0 保证历史 15 条不受影响)。
is_draft: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '草稿标记:0=已发布, 1=草稿(前台不可见)',
},
// 附件(R176)。用于成绩公示表的文件公示:管理员上传 xlsx/pdf 后,
// 详情页渲染成独立下载按钮,不依赖正文富文本里手写链接
// (手写链接在人工编辑正文时极易被误删)。
attachment_url: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '附件下载地址(相对路径 /public/news/xxx)',
},
attachment_name: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: '附件显示文件名(下载按钮文案)',
},
// 记录创建时间
created_at: {
type: DATE,

View File

@@ -11,6 +11,9 @@
module.exports = app => {
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', {
// 主键 ID,自增
id: {
@@ -63,12 +66,7 @@ module.exports = app => {
comment: '提交时间',
},
// 得分(管理员评分后更新,NULL 表示尚未评分)
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '作品得分',
},
score: decimalScore('score', '作品得分', { defaultValue: null }),
// 创建时间
created_at: {
type: DATE,

View File

@@ -10,6 +10,9 @@
module.exports = app => {
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', {
// 主键 ID,自增
id: {
@@ -54,12 +57,7 @@ module.exports = app => {
comment: '是否判为正确',
},
// 得分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '得分',
},
score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
// Word 文件存储路径
word_file_path: {
type: STRING(500),

View File

View File

View File

@@ -26,6 +26,21 @@ module.exports = app => {
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
// 交卷限流(2026-09-21 并发压测结论)
//
// 🔴 只挂在 submit-all 这**单条**路由上,绝不整段挂 /api/exam:
// 逐题保存 submit-answers 是「每答一题一次请求」(一场约 59 次),若整段挂
// exam:30/分钟,正常考生在答题过程中就会被自己的保存请求打爆(429),
// 直接造成丢答案 —— 这比不限流更糟。
//
// 🔴 必须排在 auth **之后**:rateLimit 的限流键优先取 ctx.state.currentUser.uid
// (见 middleware/rateLimit.js getClientId),而 currentUser 由 auth 写入。
// 若不排在 auth 之后,键会退化为 ip:<ctx.ip> —— 考场/学校普遍共用出口 IP,
// 会把整间考场的考生一起限流拒掉,属灾难级误伤。
//
// 阈值取 LIMITS.exam = 30 次/60 秒(正常交卷 1 次 + 前端自动重试数次,余量充足)。
const examSubmitLimit = middleware.rateLimit({ level: 'exam' });
// ==================== 公开接口(无需认证) ====================
// 健康检查(容器探针 / 负载均衡探活,无需认证)
@@ -72,7 +87,14 @@ module.exports = app => {
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll);
// 交卷:全局闸门(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);
// ==================== 文件上传(需认证) ====================
@@ -88,24 +110,75 @@ module.exports = app => {
// ==================== 管理后台(需管理员认证) ====================
// ---------- R178 多管理员 + 权限 ----------
// 这些路由与下面所有 /api/admin/* 一样只挂 adminAuth:
// 权限判定在 middleware/auth.js 的 adminOnly 分支内完成(单一真相源
// lib/adminPermissions.js 的 ROUTE_TABLE,未登记路由默认拒绝)。
// ⛔ 不要在这里逐条写权限参数——那会与映射表形成第二套真相源。
router.get('/api/admin/me', adminAuth, controller.adminUser.me);
router.get('/api/admin/admin-users', adminAuth, controller.adminUser.list);
router.post('/api/admin/admin-users', adminAuth, controller.adminUser.create);
router.put('/api/admin/admin-users/:id/permissions', adminAuth, controller.adminUser.updatePermissions);
router.put('/api/admin/admin-users/:id/password', adminAuth, controller.adminUser.resetPassword);
router.put('/api/admin/admin-users/:id/status', adminAuth, controller.adminUser.setStatus);
router.delete('/api/admin/admin-users/:id', adminAuth, controller.adminUser.remove);
// 数据统计
router.get('/api/admin/stats', adminAuth, controller.admin.stats);
// 账号管理
router.get('/api/admin/accounts', adminAuth, controller.admin.accounts);
// 小组(团队)激活状态清单(R84 续六):查看已激活 / 未激活小组,并携带所属学校名称。
// ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。
router.get('/api/admin/teams', adminAuth, controller.admin.teams);
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel);
// 指导教师回填(R176):报名表 → contestants.teacher_name。
// 🔴 与 import-excel 是**两条不同通路**:导入对已存在身份证号一律 skip,
// 无法用于"重导一次补字段";本接口只写 teacher_name,不碰账号/队伍/成绩。
// ⚠️ 路径段固定(backfill-teacher 为字面量),且与下方 PUT /:id 方法不同,无匹配冲突。
// 破坏面定级:非破坏性(白名单单列写入、按组限定、可重复执行且幂等)
router.post('/api/admin/contestants/backfill-teacher', adminAuth, controller.admin.backfillTeachers);
// 选手信息编辑(R108):让「导入之后就改不了」的历史限制成为过去 —— 身份证号 / 姓名 /
// 手机号可事后纠正(典型场景:报名表证号录入错误,人工核实后修正)。
// ⚠️ 路径段固定(/contestants/:id),与既有路由无匹配冲突;PUT 方法在 admin 段内唯一。
// 破坏面定级:非破坏性(白名单字段编辑、无批量清除语义、改动可逆且带脱敏审计日志)
// ⇒ 不登记进 DESTRUCTIVE_ENDPOINTS.json;登记册口径见该文件 note。
router.put('/api/admin/contestants/:id', adminAuth, controller.admin.updateContestant);
// 提交管理
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);
router.get('/api/admin/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
// R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable)
router.get('/api/admin/export-excel/:group', adminAuth, controller.admin.exportExcel);
// 成绩公示表导出(R176):按大赛参考模板生成 xlsx 半成品(获奖等级留空待人工填)。
// ⚠️ 与 export-excel 是两个不同产物:export-excel 是**内部核查表**(含身份证号/手机号),
// 本接口是**对外公示表**(不含任何身份证号/手机号),⛔ 两者不可互相替代。
router.get('/api/admin/export-publicity/:group', adminAuth, controller.admin.exportPublicity);
// 成绩排名表导出(R220):中职/高职按小组**客观题分**降序**全量**导出,整行按客观题分区间上底色。
// ⚠️ 第三个产物,⛔ 不可与上面两个互换:export-excel = 内部核查表(含身份证号/手机号)、
// export-publicity = 对外公示表(模板半成品、获奖等级留空)、
// 本接口 = 排名速览表(一小组一行、含区间底色与区间统计、无任何敏感字段)。
router.get('/api/admin/export-score-rank/:group', adminAuth, controller.admin.exportScoreRank);
// 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。
// ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。
router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo);
router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo);
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
// 单小组作品压缩包(R190):zip 本身即小组目录,包内平铺该组文件。
// ⚠️ 必须注册在 `/api/admin/works/:group` 之前不是硬要求(路径段数不同,不冲突),
// 但保持「同一业务相邻登记」,避免后来者以为是两条无关通道。
router.get('/api/admin/team-zip/:group', adminAuth, controller.admin.teamZip);
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
// 时间设置
router.get('/api/admin/times', adminAuth, controller.admin.getTimes);
router.put('/api/admin/times', adminAuth, controller.admin.setTimes);
// 节点备份调度器状态(P1「生产备份静默失败」可见性):只读,不触发备份。
// ⚠️ 路由段 node-backup 与 /api/admin/* 其余段无前缀冲突;GET 方法在此段唯一。
router.get('/api/admin/node-backup/status', adminAuth, controller.admin.getNodeBackupStatus);
// 分数管理
router.get('/api/admin/scores/:group', adminAuth, controller.admin.getScores);
router.put('/api/admin/scores/:group', adminAuth, controller.admin.setScores);
@@ -119,6 +192,31 @@ module.exports = app => {
router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate);
router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate);
// 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分
router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions);
router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion);
// Word 导出:?ids=1,2,3 批量;缺省 = 该组全部。1 道 → .docx;多道 → .zip(内含多个 .docx)
// ⚠️ 必须注册在 `:group/:id` 之前:Egg 按注册顺序匹配,否则一旦将来新增
// `GET /:group/:id`,`export` 就会被当作 :id 吞掉(实测 2026-09-23 复核时
// 本行曾被放在 :group/:id 之后,当时因 :group/:id 仅注册 PUT/DELETE 而未暴露问题)
router.get('/api/admin/big-questions/:group/export', adminAuth, controller.admin.exportBigQuestions);
// ⚠️ 注册顺序是硬约束:Egg 按注册序匹配,下面两条含**具体段**(template / import-excel),
// 必须排在通配段 `/:id` 之前,否则会被它抢走(GET .../template → id='template')。
router.get('/api/admin/big-questions/:group/template', adminAuth, controller.admin.downloadBigQuestionTemplate);
router.post('/api/admin/big-questions/:group/import-excel', adminAuth, controller.admin.importBigQuestionsExcel);
router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion);
router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion);
router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
// 考生答卷导出(R28):3 段路径,与上方 2 段列表、下方 PUT /:uid/scores 均不冲突
router.get('/api/admin/big-answers/:group/export', adminAuth, controller.admin.exportBigAnswers);
// 成绩 Excel 导入(R172):两段式(预览零写入 → 确认导入),均为 3 段路径,与 PUT /:uid/scores 不冲突。
// ⚠️ 具体段(import-template / import-preview / import-scores)不得与将来的 `/:uid` 通配段冲突 ⇒
// 保持与 export 同等注册位置(在 PUT 之前)。
router.get('/api/admin/big-answers/:group/import-template', adminAuth, controller.admin.downloadBigScoreTemplate);
router.post('/api/admin/big-answers/:group/import-preview', adminAuth, controller.admin.previewBigScoreImport);
router.post('/api/admin/big-answers/:group/import-scores', adminAuth, controller.admin.importBigScores);
router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores);
// 备赛资料管理
router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials);
router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial);
@@ -144,6 +242,9 @@ module.exports = app => {
// 图片上传(轮播图等)
router.post('/api/admin/upload/image', adminAuth, controller.admin.uploadImage);
router.post('/api/admin/upload/news-image', adminAuth, controller.admin.uploadNewsImage);
// 新闻附件上传(R176):成绩公示表以附件形式挂在新闻里,而不是把链接写进正文
// (正文会被人工作编辑,手写链接极易被删)。与 news-image 同目录、同访问前缀。
router.post('/api/admin/upload/news-file', adminAuth, controller.admin.uploadNewsFile);
// 下载考生答案 Word 文件
router.get('/api/admin/submissions/:group/:id/word', adminAuth, controller.admin.downloadWord);
@@ -152,6 +253,14 @@ module.exports = app => {
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData);
// ==================== 成绩复核(需管理员认证) ====================
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// ⚠️ 本组会回传客观题正确答案,务必保持 adminAuth —— 选手端一旦可达即为成绩泄露。
// 注册顺序:两条路径段数不同(3 段 / 5 段),不存在通配段互相吞并的问题;
// 仍按「固定段在前」的惯例把 search 放前面。
router.get('/api/admin/review/search', adminAuth, controller.admin.reviewSearch);
router.get('/api/admin/review/score/:group/:id', adminAuth, controller.admin.contestantScore);
// ==================== 成绩查询(需认证) ====================
router.get('/api/score', auth, controller.score.index);

File diff suppressed because it is too large Load Diff

270
app/service/adminUser.js Normal file
View File

@@ -0,0 +1,270 @@
'use strict';
/**
* 管理员账号管理(R178 · C 层)
* ============================================================================
*
* 职责:多管理员账号的增 / 改权限 / 重置口令 / 禁用启用 / 删除,以及高危操作审计。
*
* 三条不可协商的边界:
*
* 1. **最高管理员不可变动**(用户裁定):`is_super=1` 的账号不允许改权限、重置口令、
* 禁用、删除。判断集中在 `lib/adminPermissions.isManageable()`,⛔ 不得在各方法里
* 各写一遍 `id === 1` —— 那样改一处漏一处,且将来账号 id 变化就失效。
*
* 2. **口令策略单源**:一律走 `lib/passwordPolicy.checkPassword()`,
* ⛔ 不在这里写第二套长度/黑名单规则(第 25 步门禁会比对两套规则是否漂移)。
* 生产环境另外拒绝「已知默认口令」,与 adminLogin 的守卫同源。
*
* 3. **令牌失效靠自增**:改密 / 禁用时 `token_version += 1`。
* middleware/auth.js 每请求比对,旧令牌立即 401。本项目没有会话表,
* 这是唯一的「强制下线」手段。
*/
const Service = require('egg').Service;
const passwordPolicy = require('../lib/passwordPolicy');
const adminPermissions = require('../lib/adminPermissions');
const { recordAudit, ACTIONS } = require('../lib/auditLog');
/** 用户名规则:字母数字与 _ . - ,3~50 位。⛔ 不允许中文与空格,避免与登录校验规则不一致。 */
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,50}$/;
const REMARK_MAX = 200;
/** 业务错误:由 controller 翻译成 { code, message },不直接改 HTTP 状态。 */
function bizError(code, message) {
const err = new Error(message);
err.bizCode = code;
err.bizMessage = message;
return err;
}
class AdminUserService extends Service {
/**
* 管理员列表(脱敏:绝不返回 password)
* 排序:最高管理员置顶,其余按 id 升序 —— 与「主账号永远第一行」的视觉预期一致。
*/
async list() {
const { ctx } = this;
const rows = await ctx.model.Admin.findAll({
attributes: [
'id', 'username', 'is_super', 'permissions', 'status',
'last_login_at', 'last_login_ip', 'remark', 'created_at', 'updated_at',
],
order: [['is_super', 'DESC'], ['id', 'ASC']],
raw: true,
});
return rows.map(r => ({
id: r.id,
username: r.username,
isSuper: Number(r.is_super) === 1,
permissions: adminPermissions.parsePermissions(r.permissions),
status: Number(r.status) === 1 ? 1 : 0,
lastLoginAt: r.last_login_at || null,
lastLoginIp: r.last_login_ip || null,
remark: r.remark || '',
createdAt: r.created_at || null,
// 前端据此把「改权限/重置口令/禁用/删除」四个按钮整体置灰
manageable: adminPermissions.isManageable(r),
}));
}
/**
* 取一个**可被管理**的目标账号。
* @throws bizCode 400 不存在 / 403 最高管理员不可变动
*/
async _loadManageable(id) {
const { ctx } = this;
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw bizError(400, '管理员 ID 不合法');
}
const admin = await ctx.model.Admin.findOne({
where: { id: numericId },
attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'],
raw: true,
});
if (!admin) throw bizError(400, '该管理员账号不存在');
if (!adminPermissions.isManageable(admin)) {
throw bizError(403, '最高管理员账号不可修改(权限 / 口令 / 状态 / 删除均被禁止)');
}
return admin;
}
/**
* 新增管理员
*
* @param {{username:string, password:string, permissions:string[], remark?:string}} data
*/
async create(data) {
const { ctx } = this;
const username = String((data && data.username) || '').trim();
const password = String((data && data.password) || '');
if (!USERNAME_RE.test(username)) {
throw bizError(400, '用户名需为 3-50 位字母、数字、下划线、点或短横线');
}
const pwdCheck = passwordPolicy.checkPassword(password, { username });
if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason);
// 与 adminLogin 的守卫同源:生产环境不允许把「已知默认口令」分配给新账号
if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) {
throw bizError(400, '生产环境禁止使用默认口令,请设置强密码');
}
const exists = await ctx.model.Admin.findOne({ where: { username }, attributes: ['id'] });
if (exists) throw bizError(400, '该用户名已存在');
const perms = adminPermissions.sanitizePermissions(data && data.permissions);
const remark = data && data.remark ? String(data.remark).slice(0, REMARK_MAX) : null;
let created;
try {
created = await ctx.model.Admin.create({
username,
password: await ctx.helper.hashPassword(password),
is_super: 0, // 新增账号永远不是最高管理员(最高管理员只能由初始化脚本产生)
permissions: JSON.stringify(perms),
status: 1,
token_version: 0,
remark,
created_at: new Date(),
updated_at: new Date(),
});
} catch (e) {
// 并发下 findOne 与 create 之间仍可能撞唯一索引
if (/ER_DUP_ENTRY/.test(String(e && e.message))) {
throw bizError(400, '该用户名已存在');
}
throw e;
}
await recordAudit(ctx, ACTIONS.ADMIN_CREATE, `admin:${username}`, {
id: created.id,
permissions: perms,
});
return { id: created.id, username, permissions: perms, remark: remark || '' };
}
/**
* 编辑权限(含备注)
*
* ⚠️ 不递增 token_version:权限是每请求查库判定,改完立即生效;
* 递增反而会把正在操作的人无谓踢下线。
*/
async updatePermissions(id, data) {
const { ctx } = this;
const target = await this._loadManageable(id);
const perms = adminPermissions.sanitizePermissions(data && data.permissions);
const patch = {
permissions: JSON.stringify(perms),
updated_at: new Date(),
};
if (data && data.remark !== undefined) {
patch.remark = data.remark ? String(data.remark).slice(0, REMARK_MAX) : null;
}
await ctx.model.Admin.update(patch, { where: { id: target.id } });
const before = adminPermissions.parsePermissions(target.permissions);
await recordAudit(ctx, ACTIONS.ADMIN_PERMISSIONS, `admin:${target.username}`, {
before,
after: perms,
added: perms.filter(p => !before.includes(p)),
removed: before.filter(p => !perms.includes(p)),
});
return { id: target.id, permissions: perms };
}
/**
* 重置口令(管理员代改,不要求旧口令)
* 递增 token_version ⇒ 目标账号所有在线会话立即失效。
*/
async resetPassword(id, data) {
const { ctx } = this;
const target = await this._loadManageable(id);
const password = String((data && data.password) || '');
const pwdCheck = passwordPolicy.checkPassword(password, { username: target.username });
if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason);
if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) {
throw bizError(400, '生产环境禁止使用默认口令,请设置强密码');
}
const nextVersion = Number(target.token_version || 0) + 1;
await ctx.model.Admin.update({
password: await ctx.helper.hashPassword(password),
token_version: nextVersion,
updated_at: new Date(),
}, { where: { id: target.id } });
// ⛔ 审计 detail 不含口令本身,只记事实
await recordAudit(ctx, ACTIONS.ADMIN_PASSWORD, `admin:${target.username}`, {
tokenVersion: nextVersion,
});
return { id: target.id, tokenVersion: nextVersion };
}
/**
* 禁用 / 启用
* 禁用时递增 token_version ⇒ 该账号已签发的令牌下次请求即 401。
* 启用不递增:重新启用后旧令牌早已因禁用期间的版本差异失效,无需再踢。
*/
async setStatus(id, data) {
const { ctx } = this;
const target = await this._loadManageable(id);
const status = Number(data && data.status);
if (status !== 0 && status !== 1) throw bizError(400, '状态值只能是 0(禁用)或 1(启用)');
// 不允许把自己禁用 —— 否则可能一次性把所有可用账号锁死
const me = ctx.state.currentAdmin;
if (me && Number(me.id) === Number(target.id) && status === 0) {
throw bizError(400, '不能禁用自己的账号');
}
const patch = { status, updated_at: new Date() };
if (status === 0) patch.token_version = Number(target.token_version || 0) + 1;
await ctx.model.Admin.update(patch, { where: { id: target.id } });
await recordAudit(ctx, ACTIONS.ADMIN_STATUS, `admin:${target.username}`, {
status,
tokenVersion: patch.token_version || target.token_version,
});
return { id: target.id, status };
}
/**
* 删除账号
* 不可删最高管理员(_loadManageable 已挡)、不可删自己。
*/
async remove(id) {
const { ctx } = this;
const target = await this._loadManageable(id);
const me = ctx.state.currentAdmin;
if (me && Number(me.id) === Number(target.id)) {
throw bizError(400, '不能删除自己的账号');
}
const affected = await ctx.model.Admin.destroy({ where: { id: target.id } });
if (affected !== 1) throw bizError(500, '删除失败:目标账号已不存在');
await recordAudit(ctx, ACTIONS.ADMIN_DELETE, `admin:${target.username}`, {
id: target.id,
lastPermissions: adminPermissions.parsePermissions(target.permissions),
});
return { id: target.id, deleted: true };
}
}
module.exports = AdminUserService;

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 的选手设置为已激活状态,并更新密码。
*
* @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 模型对象
*/
async activateAccount(id, password) {

1604
app/service/bigQuestion.js Normal file

File diff suppressed because it is too large Load Diff

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 }]
* @returns {Promise<Object>} { autoScore, total, details }
*/
@@ -263,24 +276,49 @@ class ExamService extends Service {
let autoScore = 0;
let total = 0;
// 第一步:按题型收集原始自增 id(去重后一次查出)。
const idsByType = { choice: [], judge: [] };
for (const item of answers) {
const result = await this.checkAnswer(
item.question_id || item.question_uid,
item.question_type,
item.answer_data
);
if (result.question) {
total += result.question.maxScore;
autoScore += result.score;
details.push({
question_id: item.question_id || item.question_uid,
question_type: item.question_type,
answer_data: item.answer_data,
is_correct: result.isCorrect,
score: result.score,
max_score: result.question.maxScore,
});
}
const type = item.question_type;
// 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。
// 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串
// 命中 Object 原型链而绕过题型判定。
if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue;
const rawId = this._extractRawId(item.question_id || item.question_uid);
if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId));
}
const questionMap = new Map(); // `${type}:${id}` → question 实例
for (const type of [ 'choice', 'judge' ]) {
const uniqueIds = Array.from(new Set(idsByType[type]));
if (!uniqueIds.length) continue;
const QuestionModel = this._getQuestionModel(type);
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 };
@@ -364,6 +402,53 @@ class ExamService extends Service {
return timeConfig ? timeConfig.word_template : null;
}
/**
* 保存答题记录(逐题存储)
* @param {number} aggregationUid - 聚合报名 ID
* @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }]
* @param {string} group - 组别
* @returns {Promise<Array>} 保存后的记录列表
*/
/**
* 交卷冻结守卫(R178·B①)——**唯一判据源**
*
* 缺陷背景:逐题保存此前只校验「考试窗口 isActive」,不校验是否已正式交卷;
* `submit-all` 的 4006 只挡整卷重交。于是「提前交卷 → 窗口关闭前」这段时间内,
* `POST /submit-answers` 仍可改写答案并重新判分 —— 即「已提交但分数还能变」。
* 典型触发并非恶意:考生开了两个标签页(或手机+电脑),交卷后另一页仍在自动保存。
*
* 判据:`exam_submissions.submit_time` 非空即视为已正式交卷(与选手端 hasSubmitted 同源)。
*
* 调用点(两处,共用本函数,⛔ 不得各写一套条件):
* ① controller.exam.submitAnswers —— 前置快速失败,避免对已冻结答卷做无谓判分;
* ② service.exam.saveAnswers —— 兜底,保证任何调用方(含未来新增)都无法绕过。
* ⚠️ 门禁 `verify_exam_freeze.py` 的变异体要求**两处同改**才允许变红,否则属前道抢答。
*
* 为什么不缓存:逐题保存约 59 次/场,主键单列查询为微秒级;
* 换来「交卷状态变更立即生效」,无需判断缓存失效时机。
*
* @param {number} aggregationUid - 聚合报名 ID(中职/高职即 contestant.id)
* @returns {Promise<void>} 未交卷则静默返回
* @throws {Error} bizCode=4006
*/
async assertNotSubmitted(aggregationUid) {
const { ctx } = this;
if (!aggregationUid) return;
const submitted = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregationUid },
attributes: ['contestant_id', 'submit_time'],
raw: true,
});
if (submitted && submitted.submit_time) {
const err = new Error('答卷已提交,不能再修改答案');
// bizCode 由 controller 翻译成用户可见的业务错误码(4006 与本项目既有「已提交」口径一致)
err.bizCode = 4006;
err.bizMessage = '您已交卷,答卷已冻结,不能再修改答案;如需重考请联系管理员';
throw err;
}
}
/**
* 保存答题记录(逐题存储)
* @param {number} aggregationUid - 聚合报名 ID
@@ -374,6 +459,12 @@ class ExamService extends Service {
async saveAnswers(aggregationUid, answers, group) {
const { ctx } = this;
// 交卷冻结兜底:所有写答案的路径都经此函数,故此处是本守卫的最后一道闸。
// 不会挡住交卷自身 —— submitAll 是「先 saveAnswers、后 ensureExamSubmission 写
// submit_time」,执行到本函数时 submit_time 仍为 NULL;整卷重交在更早的 4006 已返回;
// settleOnly 结算通道刻意不调用本函数。
await this.assertNotSubmitted(aggregationUid);
// 根据组别选择对应的提交模型
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
@@ -394,31 +485,46 @@ class ExamService extends Service {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await ctx.model.transaction(async (t) => {
for (const answer of answers) {
// 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束,
// 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求
// 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。
//
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
// 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行
// 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。
//
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize
// 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉
// returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
const rawQuestionId = this._extractRawId(answer.question_uid);
await SubmissionModel.upsert({
aggregation_uid: aggregationUid,
question_uid: rawQuestionId,
question_type: answer.question_type,
answer_data: answer.answer_data,
is_correct: answer.is_correct,
score: answer.score,
}, {
conflictFields: ['aggregation_uid', 'question_uid', 'question_type'],
transaction: t,
});
}
// 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert,
// 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级,
// 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生
// 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate +
// updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。
//
// 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate,
// Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列;
// 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表,
// 否则会用 NULL 覆盖考生已上传的作品文件信息。
//
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
// 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖
// (B13 修复:uid 全局唯一化引入的落库回归)。
//
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning,
// 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
const rows = answers.map((answer) => ({
aggregation_uid: aggregationUid,
question_uid: this._extractRawId(answer.question_uid),
question_type: answer.question_type,
answer_data: answer.answer_data,
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;
} catch (err) {

View File

@@ -37,7 +37,11 @@ class NewsService extends Service {
const offset = (currentPage - 1) * currentPageSize;
// 构建查询条件
const where = {};
const where = {
// 🔴 R176:草稿只允许后台可见。成绩发布时会自动生成一条「成绩公示」预览新闻,
// 人工编辑确认前绝不能出现在前台列表(否则等于提前公示未定稿成绩)。
is_draft: 0,
};
// 组别过滤:同时包含指定组别和通用(all)新闻
if (group) {
@@ -90,7 +94,8 @@ class NewsService extends Service {
// 并行查询各分组计数
await Promise.all(groups.map(async group => {
const where = {};
// 🔴 R176:计数口径必须与 list 一致,否则首页 Tab 显示「3 条」点进去只有 2 条
const where = { is_draft: 0 };
// 对于具体组别,同时包含该组别和 all 通用新闻
if (group !== 'all') {
where.group = { [Sequelize.Op.in]: [group, 'all'] };
@@ -110,9 +115,10 @@ class NewsService extends Service {
async findById(id) {
const { ctx } = this;
// 🔴 R176:草稿不可通过 id 直接访问(前台详情页按 id 拉取 ⇒ 只过滤列表等于没防住)
return await ctx.model.News.findOne({
where: { id },
attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'created_at', 'updated_at'],
where: { id, is_draft: 0 },
attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'attachment_url', 'attachment_name', 'created_at', 'updated_at'],
});
}

View File

@@ -4,6 +4,11 @@ const Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const {
sanitizeSegment,
sanitizeFileName,
resolveTeamFolderName,
} = require('../lib/team_folder');
/**
* 上传服务 - UploadService
@@ -73,68 +78,384 @@ class UploadService extends Service {
};
}
// ==================== 小组(队伍)上下文 ====================
/**
* 保存作品文件
* 解析当前提交者所属「小组」的上下文 —— 上传落盘与管理端打包共用的唯一解析入口。
*
* 🔴 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)。
* teamUid 解析优先级与 `service/admin.js#resolveTeamLabels` 完全同源:
* `aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底)
*
* @param {Object} aggregation - getUserAggregation() 的返回值(含 include team)
* @param {string} group - 组别
* @returns {Promise<{teamUid:?number, teamNumber:?string, folderName:string, contestant:?Object}>}
*/
async resolveTeamContext(aggregation, group) {
const { ctx } = this;
if (!aggregation) {
return { teamUid: null, teamNumber: null, folderName: resolveTeamFolderName({}), contestant: null };
}
// 选手档案:既用于 team_id 兜底,也用于取姓名做提示文案
const contestant = await ctx.model.Contestant.findByPk(aggregation.account_uid, {
attributes: ['id', 'name', 'group', 'team_id'],
raw: true,
});
let teamUid = aggregation.team_uid;
if (teamUid === null || teamUid === undefined) {
teamUid = (contestant && contestant.team_id !== null && contestant.team_id !== undefined)
? contestant.team_id
: null;
}
// 队伍元信息:优先用 include 进来的 team;缺失时按 (group, teamUid) 回查,
// 避免「有归属无元信息」时把编号退化成兜底名(那会让同队两人的目录名不一致)。
let team = aggregation.team || null;
if (!team && teamUid !== null && teamUid !== undefined) {
team = await ctx.model.Team.findOne({
where: { team_uid: teamUid, group_name: group },
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
}
const teamNumber = team ? (team.team_number || team.team_id || null) : null;
const folderName = resolveTeamFolderName({
teamNumber,
teamUid,
aggregationUid: aggregation.aggregation_uid,
});
return { teamUid, teamNumber, folderName, contestant };
}
/**
* 查询「组内上传状态」—— 本科/教师组的组内互斥判定唯一事实来源。
*
* 【业务规则(R190)】
* ① 同一参赛小组内,只要有任一成员上传过作品,其他成员就不能再上传;
* ② 「替换权」只归**组内最早提交者**(归属人),他在截止前可以随时替换;
* ③ 无队伍归属的独立参赛者不参与组内互斥(自己传自己的)。
*
* ⚠️ 判据必须按 (group, teamUid) 取**全部成员**再数提交记录 —— 旧实现
* `hasSubmitted()` 只数本人的 `aggregation_uid`,同组他人上传后自己仍可继续传,
* 正是本次要修的缺陷。
*
* @param {Object} aggregation - getUserAggregation() 的返回值
* @param {string} group - 组别
* @returns {Promise<Object>} 见下方 result 结构
*/
async getTeamUploadState(aggregation, group) {
const { ctx } = this;
const Op = ctx.model.Sequelize.Op;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const teamCtx = await this.resolveTeamContext(aggregation, group);
const selfAggUid = aggregation.aggregation_uid;
const result = {
teamUid: teamCtx.teamUid,
teamNumber: teamCtx.teamNumber,
folderName: teamCtx.folderName,
memberCount: 1,
hasAnySubmission: false,
owner: null, // { aggregationUid, contestantId, name }
isOwner: false,
locked: false, // true = 组内别人已上传,本人不能再传
lockMessage: null,
teamFiles: [], // [{ fileName, fileType, uploaderName, isMine }]
};
// ---- 无队伍归属:独立参赛,组内锁不成立 ----
if (teamCtx.teamUid === null || teamCtx.teamUid === undefined) {
const mine = await this.hasSubmitted(selfAggUid, group);
result.hasAnySubmission = mine;
result.isOwner = mine;
if (mine) {
result.owner = {
aggregationUid: selfAggUid,
contestantId: aggregation.account_uid,
name: (teamCtx.contestant && teamCtx.contestant.name) || null,
};
}
return result;
}
// ---- 取同组同队的全部成员 ----
// 🔴 必须先按组别过滤再认 teamUid:aggregation.team_uid 是全局序列的弱引用,
// 不加组别限定会把别的组里同号队伍的人算进来(跨组串组,历史上已踩过)。
const aggRows = await ctx.model.Aggregation.findAll({
where: { team_uid: teamCtx.teamUid },
attributes: ['aggregation_uid', 'account_uid'],
raw: true,
});
const candIds = aggRows.map(a => a.account_uid).filter(v => v !== null && v !== undefined);
const memberContestants = candIds.length
? await ctx.model.Contestant.findAll({
where: { id: { [Op.in]: candIds }, group },
attributes: ['id', 'name'],
raw: true,
})
: [];
const nameOf = new Map(memberContestants.map(c => [String(c.id), c.name]));
const groupMemberIds = new Set(memberContestants.map(c => String(c.id)));
const members = aggRows.filter(a => groupMemberIds.has(String(a.account_uid)));
result.memberCount = Math.max(members.length, 1);
// ---- 成员提交记录:按 aggregation_uid 取最早一条,再全局取最早者作归属人 ----
const memberAggUids = members.map(m => m.aggregation_uid);
let firstByAgg = new Map();
if (memberAggUids.length > 0) {
const subs = await SubmissionModel.findAll({
where: { aggregation_uid: { [Op.in]: memberAggUids } },
attributes: ['id', 'aggregation_uid', 'upload_time', 'file_name', 'file_type'],
raw: true,
});
for (const s of subs) {
const t = s.upload_time ? new Date(s.upload_time).getTime() : 0;
const cur = firstByAgg.get(String(s.aggregation_uid));
// 同一 aggregation 有多行(word/ppt)时取最早;同时刻用更小 id 保序,保证可复现
if (!cur || t < cur.t || (t === cur.t && s.id < cur.id)) {
firstByAgg.set(String(s.aggregation_uid), { t, id: s.id, aggUid: s.aggregation_uid });
}
// 组内已上传文件清单:让被锁成员**看得见**「组里已经有什么」,
// 而不是只收到一句「你不能传」。
result.teamFiles.push({
fileName: s.file_name || '',
fileType: s.file_type || 'word',
uploaderName: nameOf.get(String(
(members.find(m => String(m.aggregation_uid) === String(s.aggregation_uid)) || {}).account_uid
)) || null,
isMine: String(s.aggregation_uid) === String(selfAggUid),
});
}
// 稳定排序:word 在前、ppt 在后,便于前端固定展示顺序
result.teamFiles.sort((a, b) => String(a.fileType).localeCompare(String(b.fileType)));
}
result.hasAnySubmission = firstByAgg.size > 0;
let best = null;
for (const v of firstByAgg.values()) {
if (!best || v.t < best.t || (v.t === best.t && v.id < best.id)) best = v;
}
if (best) {
const ownerMember = members.find(m => String(m.aggregation_uid) === String(best.aggUid));
const ownerContestantId = ownerMember ? ownerMember.account_uid : null;
result.owner = {
aggregationUid: best.aggUid,
contestantId: ownerContestantId,
name: ownerContestantId !== null ? (nameOf.get(String(ownerContestantId)) || null) : null,
};
result.isOwner = String(best.aggUid) === String(selfAggUid);
result.locked = !result.isOwner;
if (result.locked) {
const who = result.owner.name || '同组其他成员';
result.lockMessage =
`您的小组已由「${who}」上传作品。为保持组内作品唯一,`
+ `只有「${who}」可在截止时间前替换文件,其他成员不能再上传。`
+ `如需调整,请联系「${who}」处理。`;
}
}
return result;
}
/**
* 组内互斥守卫 —— 上传/合并两条路径**都必须**先过这里。
*
* @param {Object} aggregation
* @param {string} group
* @returns {Promise<{ok:boolean, message:string, state:Object}>}
*/
async assertTeamUploadAllowed(aggregation, group) {
const state = await this.getTeamUploadState(aggregation, group);
if (state.locked) {
return { ok: false, message: state.lockMessage, state };
}
return { ok: true, message: '', state };
}
// ==================== 作品落盘 ====================
/**
* 确保小组作品目录存在并返回绝对路径。
*
* @param {string} group - 组别
* @param {string} folderName - 小组目录名
* @returns {string} 目录绝对路径
*/
ensureWorkDir(group, folderName) {
const folder = sanitizeSegment(folderName, '未编组');
const dir = path.join(this.app.baseDir, 'app/public/works', group, folder);
try {
// 无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT
fs.mkdirSync(dir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[ensureWorkDir] mkdir failed', { dir, message: mkErr.message });
throw mkErr;
}
return dir;
}
/**
* 计算作品落盘目标(目录 + 最终文件名)—— 上传与分片合并共用的**唯一**命名实现。
*
* 🔴 为什么必须收敛:改造前 `service/upload.js#saveWorkFile` 与
* `controller/upload.js#mergeChunks` 各写了一份 `work_<ts>_<rand><ext>` 随机命名,
* 两处一旦漂移就会出现「合并落 A、下载找 B」。现统一到本方法。
*
* 落点:`app/public/works/<group>/<小组编号>/<原始文件名>`
*
* @param {Object} p
* @param {string} p.group - 组别
* @param {string} p.folderName - 小组目录名(resolveTeamFolderName)
* @param {string} p.filename - 上传时的原始文件名
* @param {string} [p.fileType] - word / ppt
* @param {?number} [p.aggregationUid] - 提交者聚合 ID(同名文件归属判定用)
* @returns {Promise<{dir:string, folder:string, fileName:string, absPath:string, relPath:string}>}
*/
async prepareWorkTarget({ group, folderName, filename, fileType = 'word', aggregationUid = null }) {
const dir = this.ensureWorkDir(group, folderName);
const folder = sanitizeSegment(folderName, '未编组');
const fileName = await this._resolveTargetFileName({
dir, group, folder, filename, fileType, aggregationUid,
});
const relPath = ['works', group, folder, fileName].join('/');
return { dir, folder, fileName, absPath: path.join(dir, fileName), relPath };
}
/**
* 决定小组目录内的最终文件名。
*
* 【规则】保留上传时的原始文件名(用户裁定);仅在**确实会覆盖他人文件**时才加序号。
* ① 目标不存在 ⇒ 直接用原名
* ② 目标存在,且 DB 里同路径记录属于**同一聚合、同一 file_type** ⇒ 是同一次提交的新版本
* ⇒ 用原名(走原子 rename 覆盖,`saveSubmissionRecord` 会清理旧路径)
* ③ 目标存在但查不到记录 / 属于别人 / 属不同 file_type ⇒ 加 `(2)(3)…` 序号,绝不覆盖
*
* @private
*/
async _resolveTargetFileName({ dir, group, folder, filename, fileType, aggregationUid }) {
const { ctx } = this;
const baseName = sanitizeFileName(filename);
const abs = path.join(dir, baseName);
if (!fs.existsSync(abs)) return baseName;
const rel = ['works', group, folder, baseName].join('/');
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const row = await SubmissionModel.findOne({
where: { file_path: rel },
attributes: ['id', 'aggregation_uid', 'file_type'],
raw: true,
});
const sameLogicalFile = row
&& String(row.file_type || 'word') === String(fileType || 'word')
&& (aggregationUid === null || String(row.aggregation_uid) === String(aggregationUid));
if (sameLogicalFile) return baseName;
// 撞名且不属同一逻辑文件 ⇒ 加序号,保护已在使用的文件
const dot = baseName.lastIndexOf('.');
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
const ext = dot > 0 ? baseName.slice(dot) : '';
for (let i = 2; i <= 99; i += 1) {
const candidate = `${stem}(${i})${ext}`;
if (!fs.existsSync(path.join(dir, candidate))) return candidate;
}
// 极端兜底:用时间戳确保唯一(不抛错,宁可名字丑也不丢文件)
return `${stem}_${Date.now()}${ext}`;
}
/**
* 把临时文件原子地落到最终位置,并补齐 size / md5。
*
* 🔴 为什么要先写临时名再 rename:直接写最终文件时,若恰好有管理员在下载,
* 会读到**半截文件**(zip 里就是损坏的 PDF)。同目录 rename 在同一文件系统内
* 是原子操作,读方要么看到旧的完整文件、要么看到新的完整文件。
*
* @param {string} tmpPath - 临时文件绝对路径(必须与目标同目录)
* @param {Object} target - prepareWorkTarget() 的返回值
* @returns {Promise<{filePath:string, fileName:string, fileSize:number, md5:string}>}
*/
async commitWorkFile(tmpPath, target) {
try {
fs.renameSync(tmpPath, target.absPath);
} catch (renameErr) {
this.ctx.helper.safeCleanup(tmpPath, { label: 'commitWorkFile-tmp' });
throw renameErr;
}
const stat = fs.statSync(target.absPath);
const md5 = await this._calculateMd5(target.absPath);
return {
filePath: target.relPath,
fileName: target.fileName,
fileSize: stat.size,
md5,
};
}
/**
* 保存作品文件(单次流式上传)
*
* @param {ReadableStream} stream - 文件流
* @param {string} filename - 原始文件名
* @param {string} group - 组别
* @param {string} folderName - 小组目录名(resolveTeamFolderName 的返回值)
* @param {Object} [opts]
* @param {string} [opts.fileType] - word / ppt
* @param {?number} [opts.aggregationUid] - 提交者聚合 ID
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
*/
async saveWorkFile(stream, filename, group) {
const { app } = this;
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
try {
fs.mkdirSync(uploadDir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message });
throw mkErr;
}
// 构建安全文件名
const ext = path.extname(filename);
const timestamp = Date.now();
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
const filePath = path.join(uploadDir, safeFileName);
// 将文件流写入磁盘(用 pipe 自动处理背压,避免大文件内存暴涨 / 写入被截断)
const writeStream = fs.createWriteStream(filePath);
let fileSize = 0;
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
stream.on('data', chunk => { fileSize += chunk.length; });
writeStream.on('finish', resolve);
stream.on('error', reject);
writeStream.on('error', reject);
async saveWorkFile(stream, filename, group, folderName, opts = {}) {
const { fileType = 'word', aggregationUid = null } = opts;
const target = await this.prepareWorkTarget({
group, folderName, filename, fileType, aggregationUid,
});
// 🔴 落盘完整性校验(F17-B 防「假成功」):
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
const stat = fs.statSync(filePath);
if (stat.size !== fileSize) {
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处):
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
// 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 rename 原子
const tmpName = `.upload_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
+ path.extname(target.fileName);
const tmpPath = path.join(target.dir, tmpName);
const writeStream = fs.createWriteStream(tmpPath);
let fileSize = 0;
try {
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
stream.on('data', chunk => { fileSize += chunk.length; });
writeStream.on('finish', resolve);
stream.on('error', reject);
writeStream.on('error', reject);
});
// 🔴 落盘完整性校验(F17-B 防「假成功」):
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
const stat = fs.statSync(tmpPath);
if (stat.size !== fileSize) {
// 🔴 清理半截文件用 best-effort:裸 unlinkSync 一旦失败会抛出「删除失败」,
// 把下面那条真正有诊断价值的「文件写入不完整:期望 X 实际 Y」**顶掉**。
this.ctx.helper.safeCleanup(tmpPath, { label: 'partialFile' });
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
}
} catch (err) {
// 任何失败都不留半截临时文件(best-effort,不掩盖真实错误)
this.ctx.helper.safeCleanup(tmpPath, { label: 'saveWorkFile-failed' });
throw err;
}
// 计算文件 MD5
const md5 = await this._calculateMd5(filePath);
// 构建相对路径(相对于 app/public/)
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
return {
filePath: relativePath,
fileName: filename,
fileSize,
md5,
};
return await this.commitWorkFile(tmpPath, target);
}
/**
@@ -275,10 +596,34 @@ class UploadService extends Service {
};
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({
...payload,
score: null, // 重新上传后清除评分
});
if (stalePath) {
// best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果
const r = this.removePublicFile(stalePath, 'overwrite-stale');
// 🔴 R157:登记计数(⛔ 不抛错,保持 best-effort),使「旧文件清理失败」
// 在运行期可被读取,而不只沉在日志里。
this._noteDeleteResult(r, stalePath, 'overwrite-stale');
if (r !== 'removed') {
this.app.logger.warn(
`[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}`
);
}
}
return existing;
}
@@ -339,7 +684,7 @@ class UploadService extends Service {
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
*/
async deleteSubmission(aggregationUid, group, id) {
const { ctx, app } = this;
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
@@ -351,18 +696,106 @@ class UploadService extends Service {
if (!record) return false;
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
//
// 🔴 R157:删除结果**不再丢弃**。此前返回值被完全忽略 ⇒ 删除失败只进日志,
// 运行期无计数、无暴露,孤儿文件在服务侧完全不可见(只能靠离线扫描发现,
// 而离线扫描报出的孤儿无法回溯「是哪一次删除失败的」)。
// 修法:累计到**服务实例计数器 + 暴露 getter**,⛔ 不抛错(保持 best-effort 语义)。
if (record.file_path) {
const absPath = path.join(app.baseDir, 'app/public', record.file_path);
try {
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
} catch (e) {
ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message);
}
const r = this.removePublicFile(record.file_path, 'deleteSubmission');
this._noteDeleteResult(r, record.file_path, 'deleteSubmission');
}
await record.destroy();
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'} 删除结果(不抛错)
*/
/**
* 【R157】登记一次删除结果 —— 把「删除失败」从**只有日志**变成**可计数、可读取**。
*
* 【为什么不抛错】删除是副作用,失败不得污染主业务结果(best-effort 语义见
* extend/helper.js:safeCleanup)。但「不抛错」⛔ 不等于「不可见」——
* 此前 removed/skipped/missing 三态被调用方直接丢弃,导致孤儿文件在运行期
* 完全不可见。这里累计计数并暴露 `delStats` / `getDeleteStats()`,
* 供健康检查、探针与门禁读取。
*
* @param {'removed'|'missing'|'skipped'} r - removePublicFile 的返回值
* @param {string} rel - 相对路径(仅用于日志定位)
* @param {string} label - 场景名
* @returns {{removed:number,skipped:number,missing:number,failed:number}} 累计计数(快照)
*/
_noteDeleteResult(r, rel, label = 'removePublicFile') {
if (!this.delStats) {
this.delStats = { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null };
}
const s = this.delStats;
if (r === 'removed') s.removed += 1;
else if (r === 'skipped') { s.skipped += 1; s.failed += 1; s.lastErrorPath = rel; }
else if (r === 'missing') s.missing += 1;
if (s.failed > 0) {
const logger = (this.ctx && this.ctx.logger) || (this.app && this.app.logger);
if (logger && logger.warn) {
logger.warn(
`[delStats] 累计删除异常 ${s.failed} 次` +
`(removed=${s.removed} skipped=${s.skipped} missing=${s.missing})` +
` 最近失败路径=${s.lastErrorPath} ⇒ 疑似残留孤儿文件,` +
` 可用 scan_orphan_files.py 离线复核`
);
}
}
return { ...s };
}
/** 读取累计删除统计(健康检查 / 探针 / 门禁用;⛔ 不含任何副作用)。 */
getDeleteStats() {
return { ...(this.delStats || { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null }) };
}
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;

View File

@@ -32,13 +32,32 @@ async function sendFile(ctx, app, opts) {
if (absolutePath) {
absPath = absolutePath;
} else {
// 🔴 路径穿越防护:只允许 app/public 下的安全相对路径(字母/数字/_./- 且不含 ..)
if (!relativePath || !/^[A-Za-z0-9_.\/-]+$/.test(relativePath) || relativePath.includes('..')) {
// 🔴 路径穿越防护(2026-10-12 修复):改为「危险形态黑名单 + resolve 前缀校验」。
// 原实现是全 ASCII 白名单 /^[A-Za-z0-9_.\/-]+$/,会把合法中文文件名(如 `测试.xlsx`)
// 误判为非法路径 ⇒ materials(资料)/ exam(模板)/ admin(作品·导出)三个下载通道
// 全部对中文名返回 400「非法文件路径」(生产实测 bug)。中文文件名是设计内支持的
// (见下方 RFC 5987 分支),故白名单口径错误。
// ① 形态拦截:控制字符/NUL · 反斜杠 · 绝对路径(/ 开头或盘符) · `..` 路径段
const unsafeShape =
!relativePath ||
/[\u0000-\u001f\u007f\\]/.test(relativePath) ||
relativePath.startsWith('/') ||
/^[A-Za-z]:/.test(relativePath) ||
relativePath.split('/').includes('..');
if (unsafeShape) {
ctx.status = 400;
ctx.body = ctx.helper.error(400, '非法文件路径');
return;
}
// ② 归一化后前缀校验(最硬兜底):解析结果必须仍落在 app/public 之内,
// 即使 ① 被绕过也逃不出根目录(比字符白名单更可靠,且不误伤 Unicode 文件名)。
const publicRoot = path.resolve(app.baseDir, 'app/public');
absPath = path.resolve(publicRoot, relativePath);
if (absPath !== publicRoot && !absPath.startsWith(publicRoot + path.sep)) {
ctx.status = 400;
ctx.body = ctx.helper.error(400, '非法文件路径');
return;
}
absPath = path.join(app.baseDir, 'app/public', relativePath);
}
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
@@ -77,16 +96,37 @@ async function sendFile(ctx, app, opts) {
// 🔴 X-Accel-Redirect:把文件交给 nginx 内部 location 直接从磁盘流式吐出,
// Node 只发响应头、不再持有文件流 → 极端并发下几乎零占用 worker。
const internalPath = cfg.internalPrefix.replace(/\/+$/, '/') + relativePath.replace(/^\/+/, '');
// 🔴 X-Accel-Redirect 是 HTTP 头值,只允许 ASCII:中文/全角文件名必须**逐段 URL 编码**
// (保留 `/` 分隔符),否则 Node 的 res.setHeader 抛 ERR_INVALID_CHAR ⇒ 500。
// 2026-10-12 生产实测:修复 :36 校验放行中文名后,本行成为新的失败点(400 → 500)。
// nginx 按**解码后**的 URI 匹配 internal location,再由 alias 落到磁盘真实中文文件名。
const internalUri = '/' + internalPath.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
ctx.set('Content-Type', contentType);
// 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。
// X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与本头,不一致即拒落盘。
// nginx 的 X-Accel-Redirect 会保留上游自定义响应头,故此头可穿透到浏览器。
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与哨兵,不一致即拒落盘。
ctx.set('X-File-Size', String(fileStat.size));
// 🔴🔴 2026-10-12(R103)生产实测修正 —— 原注释「nginx 的 X-Accel-Redirect 会保留上游自定义
// 响应头,故此头可穿透到浏览器」**不成立**,已证伪:
// 双通道对照实测(探针 .workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js):
// · 直连后端 :8023 ⇒ 204 空体,但响应带 `x-file-size: 19009`(后端确实发到了线上)
// · 经 nginx :80 ⇒ 200 / 19009 字节(sha256 与磁盘逐字节一致,确由 nginx 吐出),
// 而 **`x-file-size` 缺失**
// · 对照 /api/health 经 nginx ⇒ 自定义头 `x-readtime` **保留**
// ⇒ 丢弃只发生在 **X-Accel 内部重定向**这条路径(非「nginx 一概丢自定义头」)。
// 影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。
// 修法:改走「nginx 会保留的通道」—— Content-Disposition 实测能穿透(ARM B 仍在),
// 故把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`。
// 前端按 `x-file-size` → CD `size=` 顺序回落(见 utils/download.js readDeclaredSize)。
// 两通道同源同值;任一缺失只降级不误伤。
// ⚠️ Node 流分支(absolutePath)无需此旁路:其响应走 nginx 常规转发,自定义头本就保留。
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
// 切勿再包 encodeURIComponent 造成双重编码。
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);
ctx.set('X-Accel-Redirect', internalPath);
// `size=` 追加在 filename* 之后:浏览器忽略未知 disposition 参数,不影响下载行为。
ctx.set('Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}; size=${fileStat.size}`);
// ⚠️ 必须用编码后的 internalUri(纯 ASCII),不可直接用含中文的 internalPath。
ctx.set('X-Accel-Redirect', internalUri);
ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存
ctx.status = 200;
ctx.body = null;

View File

@@ -10,8 +10,14 @@
* 规则说明:
* - type: 'string' + required: true + allowEmpty: false:字段必填且不可为空串/纯空白
* - trim: true:校验前去除首尾空白
* - min: 6:字符串最小长度(用于密码强度底线)
* - min / max:字符串长度区间(口令口径 6-20,与前端 el-form 规则保持一致)
* - message:为每个校验项提供中文错误文案,parameter 会在 errors[] 中携带对应 message
*
* ⚠️ 口令的**强度**(弱口令黑名单 / 纯重复字符 / 连续序列 / 账号派生值)**不在本文件**。
* 本文件只负责「必填 + 长度格式」这一层;强度规则由 app/lib/passwordPolicy.js
* 单源提供,调用点在 controller/auth.js 的 activate() 与 resetPassword()。
* 分层理由:同一份强度规则需被多个口令写入口共用,并须能被运维脚本复用
* (脚本要扫描存量账号是否命中弱口令),散落实现必然出现「一处拦、一处放」。
*/
const required = (msg) => ({
@@ -52,10 +58,12 @@ module.exports = {
allowEmpty: false,
trim: true,
min: 6,
max: 20,
message: {
required: '请输入密码',
empty: '请输入密码',
min: '密码长度不能少于 6 位',
max: '密码长度不能超过 20 位',
},
},
},
@@ -76,10 +84,12 @@ module.exports = {
allowEmpty: false,
trim: true,
min: 6,
max: 20,
message: {
required: '请输入密码',
empty: '请输入密码',
min: '密码长度不能少于 6 位',
max: '密码长度不能超过 20 位',
},
},
},

View File

@@ -10,14 +10,62 @@
// 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.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 无使用点),跳过仅为口径一致。
// 仍保留注入的键:EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
// ⚠️ 2026-10-12(R106-D9)修正:JWT_SECRET / APP_KEYS **不再**保留注入。
// 原判「本地无副作用」不成立 —— 本地会拿到**生产签名密钥** ⇒ 跨环境会话可互相冒充。
// 现已并入 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过,本地改由 scripts/.creds.local.json 供值。
const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_'];
// 🔴 2026-10-12(R106-D9):非生产环境额外跳过的**签名密钥精确键**。
// 此前注释写「仍保留注入的键:JWT_SECRET / APP_KEYS ... —— 本地无副作用」,该结论**不成立**:
// · 本地 dev 于是直接继承 deploy/.env.production 里的**生产签名密钥** ⇒ 本地进程
// 可签发**生产可接受**的 token(本地 token 能冒充生产用户,反之亦然),
// 两套环境的会话边界实际上是打通的 —— 这是**安全属性**缺陷,不是"无副作用";
// · 且它与 R105/R106 刚建立的纪律直接冲突:JWT_SECRET / APP_KEYS 已被要求
// "缺凭据明确失败"(_secret.cjs),却仍允许本地静默拿到生产值 ⇒ 掩蔽注入故障。
// 修法:与 DB_/REDIS_/ALIYUN_/SMS_ 同向收口,用**精确键**匹配(⛔ 不用前缀,
// 避免误伤 APP_KEYS_* 之类的无关键)。非生产侧改由 scripts/.creds.local.json 供值
// (_secret.cjs 的第 2 级),与 Python 侧 _fixtures.resolve_jwt_secret() 同源。
// 生产(isProdEnv=true)**不跳过** ⇒ 本项零影响(生产本就由 systemd 注入)。
const DEPLOY_ENV_LOCAL_SKIP_KEYS = new Set(['JWT_SECRET', 'APP_KEYS']);
function loadDeployEnv(appInfo) {
try {
const fs = require('fs');
const path = require('path');
const SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
const targets = [];
// 生产判定:appInfo.env 为权威(Egg getServerEnv 的结果),NODE_ENV 为兼容兜底。
// 两者皆不满足即视为非生产(本地/测试),采用**保守**方向跳过连接类键。
const isProdEnv =
process.env.NODE_ENV === 'production' ||
(appInfo && (appInfo.env === 'prod' || appInfo.env === 'production'));
// 1) 显式指定(最高优先级)
if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE);
@@ -39,6 +87,7 @@
}
let loadedFrom = null;
let skipped = [];
for (const file of targets) {
let text;
try {
@@ -55,9 +104,20 @@
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key && !SKIP_KEYS.has(key) && process.env[key] === undefined) {
process.env[key] = val;
if (!key || DEPLOY_ENV_SKIP_KEYS.has(key) || process.env[key] !== undefined) continue;
// 非生产环境:跳过连接类 / 凭据类 / 网关开关类键,避免「本地进程连到生产库」
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_PREFIXES.some((p) => key.startsWith(p))) {
skipped.push(key);
continue;
}
// 非生产环境:跳过**签名密钥精确键**(JWT_SECRET / APP_KEYS)—— 避免本地进程持有
// 生产签名密钥(跨环境会话可互相冒充),并让本地改由 scripts/.creds.local.json 供值,
// 与 Python 侧 _fixtures.resolve_jwt_secret() 同源。见 DEPLOY_ENV_LOCAL_SKIP_KEYS(R106-D9)。
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_KEYS.has(key)) {
skipped.push(key);
continue;
}
process.env[key] = val;
}
loadedFrom = file;
break; // 第一个存在的文件即为权威来源
@@ -68,14 +128,30 @@
} else {
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) {
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
}
})();
}
module.exports = (appInfo) => {
const config = (exports = {});
// 🔴 必须在下文任何 process.env.* 读取之前执行(原本是模块级 IIFE,见函数头注释)。
// 放在此处而非模块顶层,是为了拿到已确定的 appInfo.env 以区分生产/本地。
loadDeployEnv(appInfo);
const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod';
// -----------------------------------------------------------------
@@ -96,13 +172,28 @@ module.exports = (appInfo) => {
// 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的
// bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测
// (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。
// 【测试模拟值 '123456';如需变更可设环境变量 TEST_DEFAULT_PASSWORD】
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
// 🔴 2026-10-12(R106-D7):**生产环境取消弱口令兜底**。
// 原实现无条件 `|| '123456'`,而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD
// 一直是**注释状态**(该文件 L88)⇒ 生产实际取到的就是弱口令 '123456'。
// 一旦生产走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入),
// 一批账号会被设为 '123456' —— 这是**静默弱口令**,而非"注入链路断了"的明确失败,
// 与 _secret.cjs / DB_PASSWORD 的既有纪律不一致(R104 结论:同族问题各自实现必然漂移)。
// 现约定:生产(config.isProduction)未显式注入 ⇒ 置 null,由使用点
// app/lib/passwordPolicy.requireTestPassword() 明确失败(⛔ 不静默回退)。
// 测试环境语义**逐字不变**(仍 '123456',既有测试前置脚本与门禁不受影响)。
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD
|| (config.isProduction ? null : '123456');
// Cookie 签名密钥:优先读取环境变量 APP_KEYS,缺省时回退到内置值(仅供本地开发)
// 【测试模拟值,生产请通过环境变量 APP_KEYS 替换为随机字符串,否则启动被守卫阻断】
const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
// Cookie 签名密钥(Egg `config.keys`):唯一来源见 backend/scripts/_secret.cjs;⛔ 无兜底。
// 🔴 2026-10-12(R106-D6):原实现为
// const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
// config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
// —— 「派生兜底」同样是**可预测公开值**(由 appInfo.name 可推算),语义等价于
// 「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。与 JWT_SECRET(R105-D1)
// 同治法收敛到唯一解析入口:① env APP_KEYS → ② scripts/.creds.local.json:app_keys
// → ③ 明确失败(⛔ 刻意不提供明文/派生兜底)。
const { loadAppKeys } = require('../scripts/_secret.cjs');
config.keys = loadAppKeys();
// add your config here
config.middleware = ['concurrencyGuard'];
@@ -114,8 +205,46 @@ module.exports = (appInfo) => {
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
config.concurrencyGuard = {
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 数据库配置
@@ -123,12 +252,23 @@ module.exports = (appInfo) => {
config.sequelize = {
dialect: 'mysql',
database: process.env.DB_NAME || 'competition_db',
host: process.env.DB_HOST || 'localhost',
// 🔴 2026-10-12(R106-D8)修复存量缺陷:兜底 host 由字面量 'localhost' 改为 '127.0.0.1'。
// 根因(实测):Node ≥ 18 的 DNS 结果顺序默认 `verbatim`(不再强制 IPv4 优先),
// 而 'localhost' 在本机通常同时解析出 ::1 与 127.0.0.1 ⇒ 可能先连 IPv6 `::1`;
// 本机 MariaDB 默认 `bind-address` 只监听 IPv4 ⇒ 连接落到 ::1 后**建连超时
// (ETIMEDOUT)**,既不报 1045 也不报 ECONNREFUSED,现象是「后端启动卡在 DB
// 连接」——极易被误判为「服务没起来 / 端口被占」,而真正的故障点是地址族。
// 取证一致性问题:本仓 `scripts/_db.cjs:43` 的兜底早已是 '127.0.0.1',唯独此处
// 是 'localhost' ⇒ 同一子系统两套默认值,必然漂移。收敛到 IPv4 字面量。
// 影响面:生产 `.env.production` 本就显式注入 DB_HOST ⇒ 逐字不变;仅影响兜底路径。
host: process.env.DB_HOST || '127.0.0.1',
port: parseInt(process.env.DB_PORT, 10) || 3306,
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
// 【测试模拟值 '74191221',仅供本地/测试部署;生产请通过 DB_PASSWORD 环境变量注入真实密码】
password: process.env.DB_PASSWORD || '74191221',
// 🔴 口令不再内置明文兜底(2026-09-23 入库口令处置 P3):
// 生产 —— 必须由 .env.production 注入 DB_PASSWORD(缺失则由下方生产安全守卫阻断启动);
// 本地 —— 由 config/config.local.js(已 gitignore)深合并覆盖为真实本机口令。
password: process.env.DB_PASSWORD,
timezone: '+08:00',
define: {
underscored: true,
@@ -144,14 +284,21 @@ module.exports = (appInfo) => {
// 由上方 define.collate 负责。此处传 collate 会触发
// "Ignoring invalid configuration option passed to Connection: collate" 警告。
charset: 'utf8mb4',
// 🔴 DECIMAL 必须显式转 Number(2026-09-23,一位小数评分)
// mysql2 默认把 DECIMAL 读成**字符串**,会导致:① 前端出现
// "7.5" + 0 → "7.50" 之类的字符串拼接;② `n > per` 退化为字符串比较。
// 开启后 DECIMAL 以 Number 返回,与 INTEGER 行为一致;全库原无 DECIMAL 列
// (迁移 025 首次引入),故对既有功能零影响。
decimalNumbers: true,
},
pool: {
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker,
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151,
// 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。
// 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 2,
// 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 2 × 20 = 40,
// 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言
// 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
min: 2,
acquire: 45000,
@@ -188,7 +335,8 @@ module.exports = (appInfo) => {
client: {
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
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,
},
};
@@ -196,11 +344,19 @@ module.exports = (appInfo) => {
// -----------------------------------------------------------------
// JWT 鉴权配置
// -----------------------------------------------------------------
const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs';
// 🔴 2026-10-12(R105-D1,用户授权):移除内置明文兜底密钥。
// 原实现:`const DEFAULT_JWT_SECRET = '<明文串>'` + `process.env.JWT_SECRET || DEFAULT_JWT_SECRET`
// —— 密钥缺失时静默回退到一个**已入库、任何人可读**的常量,且该常量与
// scripts/.creds.local.json 的 jwt_fallback_secret 同值
// ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。
// 现收敛到唯一解析入口 `backend/scripts/_secret.cjs`(与 _db.cjs / _creds.py 同一纪律):
// ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败
// 行为等价性:生产与本地均由注入链路提供密钥(deploy/.env.production 的 JWT_SECRET
// 经 loadDeployEnv 注入 process.env)⇒ 正常运行路径逐字不变,仅消除了兜底分支。
const { loadJwtSecret } = require('../scripts/_secret.cjs');
config.jwt = {
// JWT 签名密钥:优先读取环境变量 JWT_SECRET,缺省时回退到内置值(仅供本地开发)
// 【测试模拟值,生产请通过环境变量 JWT_SECRET 替换为 64 位以上随机串,否则启动被守卫阻断】
secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET,
// JWT 签名密钥:唯一来源见 _secret.cjs 头部说明;⛔ 无明文兜底,缺失即明确失败
secret: loadJwtSecret(),
enable: true,
// Token 有效期(秒),默认 7 天
expiresIn: 7 * 24 * 60 * 60,
@@ -223,7 +379,16 @@ module.exports = (appInfo) => {
// -----------------------------------------------------------------
config.multipart = {
mode: 'stream',
fileSize: '100mb',
// 🔴 2026-10-23 R225:'100mb' -> '150mb'
// 缘由:教师组(jiaoshi)作品上传唯一通道 = POST /api/upload/:group 单次流式整包
// (分片通道前端已于 R43 删除),故全组共用这一个硬闸门。原值与前端
// uploadCombo.ZIP_MAX=200MB 长期不一致 ⇒ 100~200MB 的 zip 前端放行、后端拒收
// (MultipartFileTooLargeError),多位教师报障。
// ⚠️ 本值是**所有** multipart 通道的唯一上限,调整时须与下列三处同步:
// frontend/src/utils/uploadCombo.js (ZIP_MAX / OTHER_MAX)
// frontend/src/views/Competition.vue (同上常量 + 文案)
// deploy/competition_website.conf (client_max_body_size 200m,须 > 本值)
fileSize: '150mb',
whitelist: [
// 文档类
'.pdf',
@@ -343,14 +508,91 @@ module.exports = (appInfo) => {
maxDays: 14,
};
// -----------------------------------------------------------------
// 节点备份调度(考试开始前 / 考试结束后 / 成绩发布后)
// -----------------------------------------------------------------
// 需求来源:用户 2026-10-16「系统在考试开始前,考试结束后,成绩发布后这三个
// 节点应该执行数据备份」。触发层用**后端钩子 + Node 定时器**(用户裁定①:
// 巡检脚本会占额外内存),实现见 app/lib/nodeBackupScheduler.js。
//
// 🔴 生产隔离:备份目标是**生产库**(用户裁定②)⇒ 本段仅在生产环境生效,
// 非生产 enabled=false 且调度器构造时即封死。这是与上方
// DEPLOY_ENV_LOCAL_SKIP_PREFIXES=['DB_',...] 同一纪律的延伸:
// 非生产进程不得持有生产连接凭据、不得去 dump 生产库。
//
// ⛔ 与 scripts/backup_daily.sh 的分工:那个脚本被自身硬门禁锁死在**本地库**
// (端口须 ∈ {3306,3307,33060}、BACKUP_ENV_FILE 匹配 *production* 一律拒绝),
// 故本节点备份**不复用它的入口**,只复用它底层的 scripts/backup_db.py
// (同一份 mysqldump + gzip + 完整性校验 + 保留期治理,避免两套校验口径漂移)。
// ⛔ 绝不「放宽 backup_daily.sh 的门禁来复用」—— 那是把本地防线拆给生产用。
// 🔴 路径纪律:生产机的目录布局**与本地仓库不一致**,⛔ 不许用 `__dirname` 推导
// (R168 实测踩坑)。取证依据(2026-08-10 / 2026-09-24 生产实证):
// 后端 baseDir = /home/root/production/agent-code/back-end/competition-website
// (注意是 `back-end` 连字符,不是仓库里的 `backend`)
// 脚本目录 = /home/root/production/agent-code/scripts/ ← 与 deploy/ 同级
// 环境文件 = /home/root/production/agent-code/deploy/competition-website/.env.production
// (systemd EnvironmentFile,见 deploy/competition-backend.service)
// ⇒ `repoRoot = resolve(__dirname,'../..')` 在本地指向仓库根、在生产指向
// `.../agent-code/back-end` ⇒ `join(root,'deploy','competition-website','scripts')`
// 在生产解析成 `.../back-end/deploy/competition-website/scripts` =**不存在**。
// 解法 = 全部走环境变量、可显式覆盖,默认值只覆盖本地开发:
// BACKUP_SCRIPT / BACKUP_ENV_FILE / BACKUP_OUT_DIR / BACKUP_LOG_DIR。
// ⚠️ 这四项须在 deploy/.env.production 中显式声明(部署检查清单会逐条核对),
// 否则生产会落到下面的默认值 ⇒ **路径错 ⇒ 备份静默失败**(P1 级)。
const nbPath = require('path');
const localRepo = nbPath.resolve(__dirname, '..', '..');
config.nodeBackup = {
enabled: true,
// 触发缓冲(用户裁定①的「留出缓冲」口径)
beforeStartBufferMin: Number(process.env.BACKUP_BEFORE_START_MIN) || 30, // 考试开始前 30 分钟
afterEndDelayMin: Number(process.env.BACKUP_AFTER_END_MIN) || 10, // 考试结束后 10 分钟
// 🔴 备份脚本路径:⛔ 本地默认值在生产不存在 ⇒ 生产必须由 BACKUP_SCRIPT 覆盖
scriptPath: process.env.BACKUP_SCRIPT
|| nbPath.join(localRepo, 'scripts', 'backup_db.py'),
// 环境文件(生产库凭据来源)
envFile: process.env.BACKUP_ENV_FILE
|| nbPath.join(localRepo, 'deploy', '.env.production'),
// 节点备份独立目录:⛔ 不复用 backups/db —— 否则会被每日备份的 keep-days
// 清理误删(每日 14 天 vs 节点备份需长期留存以备「赛后复盘/申诉」回溯)
outDir: process.env.BACKUP_OUT_DIR
|| nbPath.join(localRepo, 'backups', 'db-nodes'),
logDir: process.env.BACKUP_LOG_DIR
|| nbPath.join(localRepo, 'logs', 'backup', 'nodes'),
keepDays: Number(process.env.BACKUP_KEEP_DAYS) || 90,
// 跨进程锁 TTL(秒):残留锁(kill -9)会永久挡住后续备份 ⇒ 需能接管。
// 取 2h:远大于一次 dump 的正常耗时(分钟级),又不会让崩溃残留锁永久生效。
lockTtlSec: Number(process.env.BACKUP_LOCK_TTL_SEC) || 7200,
// 服务器 Py 3.6.8 无 pymysql/openpyxl ⇒ 既有约定走 /opt/pytools/lib
pythonPath: process.env.BACKUP_PYTHON_PATH || '/opt/pytools/lib',
mysqldump: process.env.BACKUP_MYSQLDUMP || '',
// ⚠️ spawn 的 cwd:只影响相对路径解析,backup_db.py 内部路径均基于 __dirname
repoRoot: process.env.BACKUP_CWD || localRepo,
};
// 非生产强制关闭(即使误设 enabled=true 也不生效)
if (!config.isProduction) {
config.nodeBackup.enabled = false;
}
// -----------------------------------------------------------------
// 生产环境安全守卫:禁止使用内置默认密钥上线
// (由 APP_ENV=production 触发;测试环境允许使用模拟密钥)
// (由 APP_ENV=production 触发)
// -----------------------------------------------------------------
if (config.isProduction) {
const insecure = [];
if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET');
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
// 🔴 2026-10-12(R105-D1):JWT_SECRET 不再在此比对 —— 内置兜底常量已移除,
// 解析统一走 backend/scripts/_secret.cjs;密钥缺失时 loadJwtSecret() 在配置
// 加载期即抛错 ⇒ 任何环境都不可能"静默使用公开密钥",本守卫无需(也无法)
// 再做「是否等于内置值」的判断。
// 🔴 2026-10-12(R106-D6):APP_KEYS 同 JWT_SECRET —— 内置**派生兜底常量**
// (appInfo.name + '_competition_website_secret_key_2026')已移除,解析统一走
// backend/scripts/_secret.cjs;密钥缺失时 loadAppKeys() 在配置加载期即抛错
// ⇒ 任何环境都不可能"静默使用可预测的 Cookie 签名密钥",本守卫无需(也无法)
// 再做「是否等于内置值」的判断。
// 🔴 2026-09-23(入库口令处置 P3):口令缺失同样阻断。
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
// 故现网永不触发本分支;一旦触发说明注入链路断了,明确阻断优于连不上库)。
if (!config.sequelize.password) insecure.push('DB_PASSWORD');
if (insecure.length > 0) {
// 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动
throw new Error(

View File

@@ -1,7 +1,7 @@
{
{
"development": {
"username": "root",
"password": "74191221",
"password": "CHANGE_ME_local_db_password",
"database": "competition_db",
"host": "localhost",
"port": 3306,
@@ -24,7 +24,7 @@
},
"test": {
"username": "root",
"password": "74191221",
"password": "CHANGE_ME_local_db_password",
"database": "competition_db_test",
"host": "localhost",
"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;
-- ============================================================================

View File

@@ -1,36 +1,41 @@
'use strict';
// 初始管理员账号。
// 口令:下方内置 bcrypt 哈希仅为**开发/演示**默认值(对应公开弱口令,仅供本机初始化)。
// 正式初始化(生产等)时应通过环境变量 ADMIN_INIT_PASSWORD 注入真实口令 ——
// 本文件⛔不落盘任何明文口令(铁律 25)。
//
// ⚠️ 【R111 更正 · 实测】本 seeder 原先 `bulkInsert` 到 **`accounts`** 表(row 含
// role/name_cn/salt/id_number 等列);但登录链路读的是 **`admins`** 表
// (`service/auth.js:101 findAdmin()` → `model/admin.js:49 tableName='admins'`)
// ⇒ 原写法属**死写入**(种下的那行永远不会被登录读取)。
// 已改为写入 `admins`,并按该表真实列契约(`id/username/password/created_at/updated_at`,
// 见 `database/migrations/002-create-admins.js:17-46` 与 `database/schema.sql:20-29`)重写 row。
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑳。
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
await queryInterface.bulkInsert('accounts', [
{
role: 'admin',
name_cn: '系统管理员',
name_en: 'Administrator',
gender: null,
phone: null,
email: 'admin@competition.com',
id_number: '000000000000000000',
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
salt: '$2b$10$Nc6PCJItIwuOzpM22dTiAe',
position: '系统管理员',
major: null,
country: '中国',
region: null,
organization_cn: '赛事组委会',
organization_en: 'Competition Committee',
activated: 1,
score: null,
created_at: now,
updated_at: now,
},
], {});
// `admins` 表列契约:id(自增主键,勿传)/ username / password / created_at / updated_at。
// ⛔ 该表**无** salt / role / name_cn / id_number / activated / score 等列。
const row = {
username: 'admin',
// bcrypt(cost=10) 哈希 = 开发默认弱口令(公开值,仅供本机初始化;生产须经 ADMIN_INIT_PASSWORD 覆盖)。
// 60 字符 < `admins.password VARCHAR(100)` ⇒ 列宽容得下。
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
created_at: now,
updated_at: now,
};
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(明文只经过程环境,⛔ 不落盘)。
const initPwd = process.env.ADMIN_INIT_PASSWORD;
if (initPwd) {
row.password = require('bcrypt').hashSync(String(initPwd), 10);
}
await queryInterface.bulkInsert('admins', [row], {});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('accounts', {
id_number: '000000000000000000',
await queryInterface.bulkDelete('admins', {
username: 'admin',
}, {});
},
};

143
package-lock.json generated
View File

@@ -13,9 +13,10 @@
"@alicloud/openapi-client": "^0.4.15",
"@alicloud/tea-util": "^1.4.11",
"@artus/core": "^2.2.4",
"@e965/xlsx": "^0.20.3",
"@eggjs/router": "^3.0.6",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.3",
"bcrypt": "^6.0.0",
"cliui": "^9.0.1",
"egg": "^3.34.0",
"egg-cors": "^2.2.4",
@@ -29,7 +30,6 @@
"mz-modules": "^2.1.0",
"svg-captcha": "^1.4.0",
"tslib": "^2.8.1",
"xlsx": "^0.18.0",
"ylru": "^1.4.0"
},
"devDependencies": {
@@ -372,6 +372,18 @@
"xml2js": "^0.6.2"
}
},
"node_modules/@e965/xlsx": {
"version": "0.20.3",
"resolved": "https://registry.npmmirror.com/@e965/xlsx/-/xlsx-0.20.3.tgz",
"integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/@eggjs/ip": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@eggjs/ip/-/ip-2.1.0.tgz",
@@ -1212,15 +1224,6 @@
"node": ">= 16.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@@ -1606,13 +1609,17 @@
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/binary-extensions": {
@@ -1901,19 +1908,6 @@
"node": ">=6"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cfork": {
"version": "1.11.0",
"resolved": "https://registry.npmmirror.com/cfork/-/cfork-1.11.0.tgz",
@@ -2205,15 +2199,6 @@
"node": ">= 14.0.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/coffee": {
"version": "5.5.1",
"resolved": "https://registry.npmmirror.com/coffee/-/coffee-5.5.1.tgz",
@@ -4752,15 +4737,6 @@
"pause-stream": "~0.0.11"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
@@ -7748,6 +7724,14 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7767,6 +7751,16 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",
@@ -9596,18 +9590,6 @@
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmmirror.com/stack-trace/-/stack-trace-0.0.10.tgz",
@@ -10840,24 +10822,6 @@
"@types/node": "*"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wordwrapjs": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz",
@@ -11077,27 +11041,6 @@
"get-ready": "~1.0.0"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",

View File

@@ -14,7 +14,7 @@
"@artus/core": "^2.2.4",
"@eggjs/router": "^3.0.6",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.3",
"bcrypt": "^6.0.0",
"cliui": "^9.0.1",
"egg": "^3.34.0",
"egg-cors": "^2.2.4",
@@ -28,7 +28,7 @@
"mz-modules": "^2.1.0",
"svg-captcha": "^1.4.0",
"tslib": "^2.8.1",
"xlsx": "^0.18.0",
"@e965/xlsx": "^0.20.3",
"ylru": "^1.4.0"
},
"devDependencies": {

61
scripts/_db.cjs Normal file
View File

@@ -0,0 +1,61 @@
'use strict';
/**
* 后端运维脚本共用:数据库连接配置解析(**唯一入口**)
* ============================================================
*
* 为什么单独成文件
* ------------------------------------------------------------
* 任何"需要连库"的运维脚本(弱口令扫描 / 口令策略接口验证 / 后续新增)都必须
* 用同一套优先级与同一条凭据纪律。各自实现必然漂移 —— 例如某处"缺口令时兜底成
* 空串"就能静默连上无口令的库,而另一处明确失败,两者行为不一致却都"看起来正常"。
*
* 读取优先级(与 scripts/_creds.py 完全同源)
* ------------------------------------------------------------
* 1. 环境变量 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME(CI / 服务器注入优先)
* 2. 仓库根 `scripts/.creds.local.json`(本机开发,已被 .gitignore 排除)
* 3. 明确失败 —— ⛔ **刻意不提供明文兜底**:口令为空时直接抛错,
* 绝不静默尝试空口令连库(那会掩盖"凭据没注入"这一真实故障)。
*
* ⚠️ Python 侧对应实现见 `scripts/_creds.py`,两侧口径必须一致;
* 如需增删键,**两边同时改**。
*
* @returns {{host: string, port: number, user: string, password: string, database: string}}
*/
const fs = require('fs');
const path = require('path');
function loadDbConfig() {
const env = process.env;
let local = {};
// backend/scripts/ → 仓库根/scripts/.creds.local.json
const localFile = path.join(__dirname, '..', '..', 'scripts', '.creds.local.json');
try {
local = JSON.parse(fs.readFileSync(localFile, 'utf8'));
} catch (e) {
// 文件不存在是允许的(走环境变量);但**损坏**必须报错,不静默降级
if (e.code !== 'ENOENT') {
throw new Error(`凭据文件解析失败 ${localFile}: ${e.message}`);
}
}
const cfg = {
host: env.DB_HOST || local.db_host || '127.0.0.1',
port: parseInt(env.DB_PORT || local.db_port || 3306, 10),
user: env.DB_USER || local.db_user || 'root',
password: env.DB_PASSWORD || local.db_password || '',
database: env.DB_NAME || local.db_name || 'competition_db',
};
if (!cfg.password) {
throw new Error(
'未取到数据库口令。请任选其一:\n' +
' ① 设置环境变量 DB_PASSWORD\n' +
' ② 创建 scripts/.creds.local.json(形如 {"db_password":"..."})\n' +
'⛔ 本模块刻意不提供明文兜底:缺凭据时明确失败,不静默连库。'
);
}
return cfg;
}
module.exports = { loadDbConfig };

112
scripts/_secret.cjs Normal file
View File

@@ -0,0 +1,112 @@
'use strict';
/**
* 后端配置共用:**配置密钥解析(唯一入口)**
* ============================================================
* 当前覆盖两个同族密钥:
* · `JWT_SECRET` —— JWT 签名密钥(`loadJwtSecret`)
* · `APP_KEYS` —— Cookie 签名密钥(`loadAppKeys`,Egg `config.keys`)
*
* 为什么单独成文件(R105-D1 + R106-D6,2026-10-12)
* ------------------------------------------------------------
* 原实现(config.default.js 旧 L300/L304 与 L157/L158)各自带**可预测的兜底值**:
* const DEFAULT_JWT_SECRET = '<32 位明文串>';
* secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET
* const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
* config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS
* 两者都是「**已入库、任何人可读或可推算**」的值 ⇒ 等价于「密钥公开」:
* · JWT_SECRET 公开 ⇒ 任意人可伪造 token 冒充任意身份;
* · APP_KEYS 公开 ⇒ 任意人可伪造已签名 Cookie(session / 防篡改校验)。
* 且它们与仓库既有纪律直接冲突:
* · `scripts/_creds.py` / `backend/scripts/_db.cjs` 早已统一为「缺凭据明确失败」;
* · config.default.js 对 `DB_PASSWORD` 也已改为「缺失即阻断」。
* 同族问题若各自实现必然漂移(R104 的结论)⇒ 收敛到**唯一解析源**,不留兜底。
*
* 读取优先级(与 `_db.cjs` / `_creds.py` 同一纪律)
* ------------------------------------------------------------
* 1. 对应的**环境变量**(生产由 systemd `EnvironmentFile=` / 启动器注入;本地由
* config.default.js 顶部的 `loadDeployEnv()` 注入 —— 注意 **R106-D9 起
* `JWT_SECRET` / `APP_KEYS` 属"非生产跳过键"**,本地开发不再继承生产值)
* 2. 仓库根 `scripts/.creds.local.json` 的对应键(本机开发用;`.gitignore` 已排除;
* `jwt_fallback_secret` 同时是 `scripts/_fixtures.py` 的末级来源,两侧同源 ⇒
* 自签 token 与后端验签必然一致)
* 3. **明确失败** —— ⛔ **刻意不提供明文兜底**:抛错并给出可行动的修复指引。
* 理由:密钥缺失属"注入链路断了"的真实故障,明确阻断远优于静默用公开值签名。
*
* ⚠️ 与 Python 侧 `scripts/_creds.py`(`JWT_FALLBACK_SECRET` / `APP_KEYS`)口径必须一致;
* 如需增删键,两边同时改。
*/
const fs = require('fs');
const path = require('path');
const LOCAL_FILE = path.join(__dirname, '..', '..', 'scripts', '.creds.local.json');
function _loadLocal() {
try {
return JSON.parse(fs.readFileSync(LOCAL_FILE, 'utf8'));
} catch (e) {
if (e.code === 'ENOENT') return {}; // 文件不存在合法(走环境变量)
// 损坏必须报错,不静默降级
throw new Error(`凭据文件解析失败 ${LOCAL_FILE}: ${e.message}`);
}
}
/**
* 通用解析:env → `.creds.local.json` → 明确失败。
*
* @param {object} o
* @param {string} o.envKey 环境变量名(如 'JWT_SECRET')
* @param {string} o.localKey `.creds.local.json` 中的键名(如 'jwt_fallback_secret')
* @param {string} o.purpose 失败时的用途说明(如 '签名/校验登录态')
* @param {string} o.builtin 被移除的兜底物名称(如 '内置密钥')
* @param {string} o.whyWrong 该兜底值为何不可接受的后果(如 'JWT 可被任意伪造')
* @returns {string} 生效中的密钥
*/
function _loadSecret(o) {
const { envKey, localKey, purpose, builtin, whyWrong } = o;
const fromEnv = process.env[envKey];
if (fromEnv) return fromEnv;
const local = _loadLocal();
const fromFile = local[localKey];
if (fromFile) return fromFile;
throw new Error(
`[配置缺失] 未取到 ${envKey} —— 无法安全地${purpose}。请任选其一:\n` +
` ① 设置环境变量 ${envKey}(生产/CI 推荐,deploy/.env.production 已含该键);\n` +
` ② 在 ${LOCAL_FILE} 写入键 "${localKey}"(本机开发用,该文件已被 .gitignore 排除);\n` +
`⛔ 本模块刻意不提供明文兜底:原 config.default.js 的${builtin}已移除,\n` +
` 因为「可预测的公开密钥」等价于「${whyWrong}」。`
);
}
/**
* JWT 签名密钥。
* @returns {string}
*/
function loadJwtSecret() {
return _loadSecret({
envKey: 'JWT_SECRET',
localKey: 'jwt_fallback_secret',
purpose: '签名/校验登录态',
builtin: '内置密钥',
whyWrong: 'JWT 可被任意伪造',
});
}
/**
* Cookie 签名密钥(Egg `config.keys`)。
* @returns {string}
*/
function loadAppKeys() {
return _loadSecret({
envKey: 'APP_KEYS',
localKey: 'app_keys',
purpose: '签发/校验 Cookie 签名',
builtin: '内置兜底值',
whyWrong: 'Cookie 可被任意伪造',
});
}
module.exports = { loadJwtSecret, loadAppKeys };

View File

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

View File

@@ -0,0 +1,274 @@
#!/usr/bin/env node
'use strict';
/**
* 弱口令存量账号扫描器(R63 · C2)
* ============================================================
*
* 用途
* ------------------------------------------------------------
* R61 测试人员实测:进入「设置密码」页时浏览器密码管理器**预填 `123456`**,
* 若考生**直接提交**,该口令就成为其账号的真实口令。R62 已从前端修掉预填
* (激活页清残留登录态 + 密码框声明 autocomplete 语义),但**存量**账号里
* 可能已经存在这种口令。本脚本负责**精确识别**它们。
*
* ⛔ 本脚本是**只读**的
* ------------------------------------------------------------
* 全程只执行 `SELECT`。**不做任何 UPDATE / DELETE / INSERT**,也不删文件。
* 识别结果仅用于人工决策(是否重设口令、如何通知考生)。
* —— 口令哈希不可逆,唯一可靠的识别方式就是"拿候选口令逐条 bcrypt 比对"。
*
* 为什么用 Node 而不是 Python
* ------------------------------------------------------------
* 必须与后端**同一个 bcrypt 实现**(`backend/node_modules/bcrypt`,原生模块)比对,
* 避免跨语言实现差异带来的假阴性;且本机 Python 无 bcrypt 依赖(服务器端有
* `/opt/pytools/lib`,但"同一实现"比"能跑"更重要)。黑名单直接从后端策略的
* JSON 读取,**不复制一份**(复制必然漂移)。
*
* 判据设计(避免把"没扫到"误读成"没问题")
* ------------------------------------------------------------
* · 命中即短路:每行按候选顺序比对,一旦命中就停止(`123456` 排在最前,
* 它是本次风险的核心)。
* · 未激活账号默认跳过:其口令来自导入时的派生值(生产 = 身份证后 6 位 SHA256,
* 见 `app/service/admin.js:384`),**不是**考生自己设置的,扫描无意义。
* 如需连未激活一起看,加 `--all`。
* · 输出末尾打印**扫描覆盖面**(扫了多少行 / 比对多少次 / 跳过多少行),
* 让"零命中"成为一个有依据的结论,而不是空输出。
*
* 用法
* ------------------------------------------------------------
* # 本地库(凭据取自 scripts/.creds.local.json,或环境变量)
* node backend/scripts/scan_weak_password_accounts.cjs
*
* # 追加自定义候选口令
* node backend/scripts/scan_weak_password_accounts.cjs --extra MyPwd123
*
* # 连未激活账号一起扫
* node backend/scripts/scan_weak_password_accounts.cjs --all
*
* # 机器可读输出
* node backend/scripts/scan_weak_password_accounts.cjs --json
*
* 退出码
* 0 = 扫描完成(无论是否命中;命中数在输出中提示)
* 2 = 参数错误 / 连接失败
*/
const crypto = require('crypto');
const mysql = require('mysql2/promise');
const bcrypt = require('bcrypt');
/** 黑名单 —— 与后端策略共用同一份 JSON(单一事实来源,勿另存副本) */
const BLACKLIST = require('../app/lib/password_blacklist.json');
/** bcrypt 哈希识别(与 extend/helper.js 的 isBcryptHash 同口径) */
function isBcryptHash(h) {
return typeof h === 'string' && /^\$2[aby]\$/.test(h);
}
/** 存量 SHA256(无盐)哈希 */
function sha256(s) {
return crypto.createHash('sha256').update(s).digest('hex');
}
/**
* DB 配置解析 —— 与其它运维脚本共用同一入口。
* ⚠️ 不在此处另写一份:优先级与「缺口令即明确失败」的纪律必须全脚本一致。
*/
const { loadDbConfig } = require('./_db.cjs');
/** 身份证号脱敏:保留前 6 后 4 */
function maskIdNumber(v) {
const s = String(v || '');
if (s.length <= 10) return s ? s[0] + '***' : '';
return `${s.slice(0, 6)}****${s.slice(-4)}`;
}
/** 命令行参数解析(极简,无需依赖) */
function parseArgs(argv) {
const opts = { extra: [], all: false, json: false, quiet: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--all') opts.all = true;
else if (a === '--json') opts.json = true;
else if (a === '--quiet') opts.quiet = true;
else if (a === '--extra') opts.extra.push(argv[++i]);
else if (a === '--help' || a === '-h') opts.help = true;
else {
console.error(`未知参数:${a}(用 --help 查看用法)`);
process.exit(2);
}
}
return opts;
}
/**
* 逐行比对:命中即返回命中的候选口令,未命中返回 null。
*
* @param {object} row - 含 password 的账号行
* @param {string[]} candidates - 候选口令(已去重、已排序)
* @returns {Promise<string|null>}
*/
async function findWeakPassword(row, candidates) {
const hash = row.password;
if (!hash) return null;
const bcryptHash = isBcryptHash(hash);
for (const candidate of candidates) {
if (bcryptHash) {
// 慢路径(每条约 0.1-0.3s,取决于 cost)
// eslint-disable-next-line no-await-in-loop
if (await bcrypt.compare(candidate, hash)) return candidate;
} else {
// 存量 SHA256:瞬时
if (sha256(candidate) === hash) return candidate;
}
}
return null;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) {
console.log(`
弱口令存量账号扫描器(只读)
用法:
node backend/scripts/scan_weak_password_accounts.cjs [选项]
选项:
--extra <pwd> 追加自定义候选口令(可重复)
--all 连未激活账号一并扫描(默认只扫 activated=1)
--json 以 JSON 输出结果
--quiet 只输出汇总
--help 显示本帮助
`);
return 0;
}
// 候选口令:123456 优先(本次风险核心),其余按黑名单顺序;去重
const seen = new Set();
const candidates = [];
for (const p of ['123456', ...BLACKLIST, ...opts.extra]) {
const s = String(p);
if (!seen.has(s)) {
seen.add(s);
candidates.push(s);
}
}
const cfg = loadDbConfig();
const conn = await mysql.createConnection({
host: cfg.host,
port: cfg.port,
user: cfg.user,
password: cfg.password,
database: cfg.database,
});
try {
// ── 只读扫描 ────────────────────────────────────────
// 列名以 model 定义为准(contestants):id / name / id_number / phone / group /
// activated / test_flag / password。⛔ 不可跨表套用其它表的列名。
const sql = [
'SELECT id, name, id_number, phone, `group`, activated, test_flag, password',
'FROM contestants',
opts.all ? '' : 'WHERE activated = 1',
'ORDER BY id',
].filter(Boolean).join(' ');
const [rows] = await conn.query(sql);
const total = rows.length;
const skipped = rows.filter(r => !r.password).length;
let comparisons = 0;
const hits = [];
let bcryptRows = 0;
for (const row of rows) {
if (!row.password) continue;
if (isBcryptHash(row.password)) bcryptRows++;
comparisons += candidates.length;
// eslint-disable-next-line no-await-in-loop
const matched = await findWeakPassword(row, candidates);
if (matched) {
hits.push({
id: row.id,
name: row.name,
id_number: maskIdNumber(row.id_number),
group: row.group,
activated: row.activated,
test_flag: row.test_flag,
matched_password: matched,
hash_type: isBcryptHash(row.password) ? 'bcrypt' : 'sha256',
});
}
}
// ── 输出 ────────────────────────────────────────────
if (opts.json) {
console.log(JSON.stringify({
scanned: total,
skipped_no_password: skipped,
bcrypt_rows: bcryptRows,
candidates: candidates.length,
comparisons,
hits_count: hits.length,
hits,
}, null, 2));
} else {
console.log('');
console.log('=== 弱口令存量账号扫描(只读)===');
console.log(`数据库 : ${cfg.user}@${cfg.host}:${cfg.port}/${cfg.database}`);
console.log(`扫描范围 : ${opts.all ? '全部账号' : '仅 activated=1(已激活)'}`);
console.log(`账号行数 : ${total}(其中无口令 ${skipped} 行已跳过;bcrypt 哈希 ${bcryptRows} 行)`);
console.log(`候选口令 : ${candidates.length} 条 × ${total - skipped} 行 = ${comparisons} 次比对`);
console.log('');
if (hits.length === 0) {
console.log('✓ 零命中:没有任何账号的口令命中候选弱口令清单');
console.log(' (本结论的覆盖面见上方「账号行数 / 候选口令」两行,非空输出)');
} else {
console.log(`⚠️ 命中 ${hits.length} 个账号(口令为弱口令):`);
console.log('');
console.log(' id 组别 测试? 已激活 姓名 身份证(脱敏) 口令 哈希');
console.log(' ------ -------------- ------ ------ ---------------- -------------------- ----------- --------');
for (const h of hits) {
console.log(
' ' +
String(h.id).padEnd(7) +
String(h.group || '').padEnd(15) +
(h.test_flag ? '是 ' : '否 ') +
(h.activated ? '是 ' : '否 ') +
String(h.name || '').padEnd(17) +
String(h.id_number || '').padEnd(21) +
String(h.matched_password).padEnd(12) +
h.hash_type
);
}
console.log('');
console.log(' 判读提示(重要,勿误判):');
console.log(' · test_flag=1 且口令=123456:通常是**设计内的测试便利口令**');
console.log(' (config.default.js:153 testDefaultPassword;由 resetTestData / 测试数据导入直写库,');
console.log(' 不经口令策略),**不是**被浏览器预填污染的考生账号。');
console.log(' · 真正的风险信号 = **test_flag=0(正式考生)且口令命中弱口令清单**。');
console.log('');
console.log(' ⇒ 处置建议:重设其口令为随机强口令,并提示考生走「忘记密码」自行设置。');
console.log(' ⚠️ 不要直接把口令告知考生本人以外的人;本表含 PII,勿外发。');
}
console.log('');
}
return 0;
} finally {
await conn.end();
}
}
main()
.then(code => process.exit(code))
.catch(e => {
console.error('✗ 扫描失败:' + e.message);
process.exit(2);
});

View File

@@ -0,0 +1,422 @@
#!/usr/bin/env node
'use strict';
/**
* 口令策略 —— 真接口验证(R63 · C1)
* ============================================================
*
* 目的:在**真实 HTTP 链路**上验证口令强度策略生效(单测只证明纯函数正确,
* 不能证明它被正确挂到了接口上;控制器里的调用点缺失,单测照样全绿)。
*
* 判别力设计(**这是本脚本的核心价值**,勿简化)
* ------------------------------------------------------------
* 对同一账号、同一 `smsCode`、同一接口路径,先提交**弱口令**、再提交**强口令**,
* 要求:
* · 弱口令 → `code = 2001`(activate)/ `7001`(reset-password)
* · 强口令 → `code = 0`
* **两者必须同时成立**才计通过。理由:如果链路本身坏了(例如短信码校验失败),
* 两个请求会**同时**返回 2005/7004 —— 那样"弱口令被拒"是**假绿**(它其实是被
* 短信校验拦下的,与口令策略无关)。因此断言必须落在**具体的业务码**上,
* 且必须有"强口令通过"作为链路通畅的正向对照。
*
* 数据安全(重要)
* ------------------------------------------------------------
* 本脚本会**真实写库**(激活账号 / 改口令),因此:
* · 只操作**测试库**。脚本启动时若检测到 URL 指向非本机,要求显式 `--allow-remote`。
* · 写前对每个受影响账号做**快照**(activated + password 原值)。
* · `finally` 中**无条件恢复**,并用**第二条独立连接**复核恢复结果
* (不依赖同连接的可见性语义)。
*
* 用法
* ------------------------------------------------------------
* node backend/scripts/verify_password_policy_api.cjs
* API_PORT=7001 API_HOST=127.0.0.1 node backend/scripts/verify_password_policy_api.cjs
*
* 退出码
* 0 = 全部通过;1 = 存在 FAIL;2 = 环境错误(无可用账号 / 连不上后端)
*/
const http = require('http');
const mysql = require('mysql2/promise');
const bcrypt = require('bcrypt');
const { loadDbConfig } = require('./_db.cjs');
const API_HOST = process.env.API_HOST || '127.0.0.1';
const API_PORT = parseInt(process.env.API_PORT || '7001', 10);
/** 弱口令样本(覆盖策略的各条规则) */
const WEAK_SAMPLES = [
{ pwd: '123456', note: '黑名单命中(R61 实测的浏览器预填值)' },
{ pwd: 'aaaaaa', note: '纯重复字符' },
{ pwd: 'abcdef', note: '整串连续序列' },
{ pwd: 'password', note: '英文弱口令' },
];
/** 强口令样本(保证能通过策略) */
const STRONG = 'Zx9#kQ2m';
const STRONG2 = 'Qw8@zL4n';
// ── 断言 ────────────────────────────────────────────────────
let pass = 0;
const failures = [];
function ok(name, cond, detail) {
if (cond) {
pass++;
console.log(` ✓ ${name}`);
} else {
failures.push(`${name} —— ${detail}`);
console.log(` ✗ ${name} ${detail}`);
}
}
// ── 极简 HTTP 客户端(带 cookie 罐;Egg session 依赖 EGG_SESS cookie)──
class ApiClient {
constructor() {
this.cookie = '';
}
_mergeCookies(setCookie) {
if (!setCookie || !setCookie.length) return;
const jar = {};
for (const part of String(this.cookie).split('; ')) {
if (!part) continue;
const i = part.indexOf('=');
if (i > 0) jar[part.slice(0, i)] = part.slice(i + 1);
}
for (const sc of setCookie) {
const kv = String(sc).split(';')[0];
const i = kv.indexOf('=');
if (i > 0) jar[kv.slice(0, i)] = kv.slice(i + 1);
}
this.cookie = Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ');
}
request(method, path, body) {
return new Promise((resolve, reject) => {
const data = body === undefined ? null : JSON.stringify(body);
const headers = { 'Content-Type': 'application/json' };
if (data) headers['Content-Length'] = Buffer.byteLength(data);
if (this.cookie) headers.Cookie = this.cookie;
const req = http.request({ host: API_HOST, port: API_PORT, path, method, headers }, res => {
let buf = '';
res.setEncoding('utf8');
res.on('data', c => { buf += c; });
res.on('end', () => {
this._mergeCookies(res.headers['set-cookie']);
let json = null;
try { json = JSON.parse(buf); } catch (e) { /* 非 JSON(如 SVG / 500 页面) */ }
resolve({ status: res.statusCode, json, raw: buf });
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}
get(p) { return this.request('GET', p); }
post(p, body) { return this.request('POST', p, body); }
}
/**
* 解析图形验证码的点击顺序。
*
* `GET/POST /api/auth/captcha` 返回 `{chars: [...打乱], hint: '请依次点击:天 → 地 → 人 → 和'}`,
* 而服务端 session 存的是 `selected.join('')`(**未打乱**的顺序)。
* ⇒ 从 hint 里按 "→" 拆开再拼起来,恰好等于服务端存的正确答案。
* (这是测试环境可用的快速通道;生产前端靠用户点击。)
*
* @param {string} hint
* @returns {string|null}
*/
function parseClickOrder(hint) {
const m = String(hint || '').match(/请依次点击\s*[::]\s*(.+)$/);
if (!m) return null;
const parts = m[1].split(/\s*→\s*/).map(s => s.trim()).filter(Boolean);
if (!parts.length) return null;
return parts.join('');
}
/**
* 走完「图形验证码 → 短信验证码」两步,返回 smsCode。
* @param {ApiClient} client
* @param {string} idNumber 身份证号(sms 接口的 phone 参数实际填身份证号)
* @param {string} label 场景标签(用于断言命名)
*/
async function fetchSmsCode(client, idNumber, label, endpoint) {
const cap = await client.post('/api/auth/captcha', {});
const order = parseClickOrder(cap.json && cap.json.data && cap.json.data.hint);
if (!order) {
return { err: `无法解析图形验证码 hint(status=${cap.status})` };
}
const sms = await client.post(endpoint, { phone: idNumber, captcha: order, id_number: idNumber });
const j = sms.json || {};
if (j.code !== 0) {
return { err: `${label} 取码失败:code=${j.code} message=${j.message}` };
}
return {
smsCode: j.data && j.data.smsCode,
realPhone: j.data && j.data.phone,
hint: cap.json.data.hint,
};
}
async function main() {
console.log('');
console.log('=== 口令策略真接口验证(R63 · C1)===');
console.log(`后端: http://${API_HOST}:${API_PORT}`);
console.log('');
// 保险:只允许对本机后端做"会写库"的验证
if (API_HOST !== '127.0.0.1' && API_HOST !== 'localhost' && !process.argv.includes('--allow-remote')) {
console.log(`✗ 拒绝执行:API_HOST=${API_HOST} 非本机。本脚本会真实写库,`);
console.log(' 如确需对远端执行请显式加 --allow-remote(并确认那是测试库)。');
return 2;
}
const cfg = loadDbConfig();
const conn = await mysql.createConnection(cfg);
/** 受影响账号快照,用于 finally 恢复 */
const backups = [];
/**
* 退出码。
* ⚠️ 刻意**不用** `return expr`:`return` 的表达式在 finally **之前**求值,
* 而"账号恢复失败"是在 finally 内部才发现的 —— 用 return 会静默漏掉它。
*/
let exitCode = 1;
try {
// ── 健康检查 ────────────────────────────────────
const hc = new ApiClient();
const health = await hc.get('/api/health');
const healthOk = health.json && health.json.code === 0;
ok('后端可达且 /api/health code=0', healthOk, `status=${health.status} body=${health.raw.slice(0, 120)}`);
if (!healthOk) {
console.log(' ⇒ 后端不可用,后续用例无意义,直接结束');
return 2;
}
// ════════════════════════════════════════════════
// 通道 A:POST /api/auth/activate
// ════════════════════════════════════════════════
console.log('');
console.log('[通道 A] POST /api/auth/activate(短信激活,设置初始口令)');
const [pendingRows] = await conn.query(
"SELECT id, id_number, phone, activated, password FROM contestants " +
"WHERE activated = 0 AND phone IS NOT NULL AND phone <> '' ORDER BY id LIMIT 2"
);
const pending = pendingRows[0];
if (!pending) {
console.log(' ⚠️ 库中没有「未激活且有手机号」的账号,跳过通道 A');
console.log(' (如需验证,请先把某个测试账号置为 activated=0)');
} else {
backups.push({ id: pending.id, activated: pending.activated, password: pending.password, tag: 'A' });
const cA = new ApiClient();
const smsInfo = await fetchSmsCode(cA, pending.id_number, 'activate', '/api/auth/sms');
ok('A1 图形验证码 + 短信验证码链路可用', !smsInfo.err, smsInfo.err || '');
if (!smsInfo.err) {
ok('A2 测试环境回传 smsCode(非生产模式)', !!smsInfo.smsCode,
`data=${JSON.stringify(smsInfo).slice(0, 160)}`);
// A3-A6:弱口令样本逐一被拒,且必须是「策略码 2001」而非其它失败
for (const s of WEAK_SAMPLES) {
// eslint-disable-next-line no-await-in-loop
const r = await cA.post('/api/auth/activate', {
id_number: pending.id_number,
phone: smsInfo.realPhone,
smsCode: smsInfo.smsCode,
password: s.pwd,
});
const code = r.json && r.json.code;
ok(`A·弱口令被拒 code=2001 → ${JSON.stringify(s.pwd)}(${s.note})`,
code === 2001,
`实际 code=${code} message=${r.json && r.json.message}`);
}
// A7:21 位 —— 由 validate 的 max 拦下(400 参数校验失败),不是策略码
{
const longPwd = 'Zx9#kQ2mNp5$vLr8Tb1!a'; // 21 位
const r = await cA.post('/api/auth/activate', {
id_number: pending.id_number,
phone: smsInfo.realPhone,
smsCode: smsInfo.smsCode,
password: longPwd,
});
const code = r.json && r.json.code;
ok('A7 超长口令(21 位)被 validate 拒绝(code=400)', code === 400,
`实际 code=${code} message=${r.json && r.json.message}`);
}
// A8:强口令通过(正向对照 —— 证明链路本身通畅,否则前面的"被拒"是假绿)
{
const r = await cA.post('/api/auth/activate', {
id_number: pending.id_number,
phone: smsInfo.realPhone,
smsCode: smsInfo.smsCode,
password: STRONG,
});
const code = r.json && r.json.code;
ok(`A8 强口令通过(code=0)—— 正向对照,证明 A·弱口令被拒非假绿`, code === 0,
`实际 code=${code} message=${r.json && r.json.message}`);
}
// A9-A10:落库核对(真写进去了,且写的是强口令)
{
const [after] = await conn.query(
'SELECT activated, password FROM contestants WHERE id = ?', [pending.id]
);
const row = after[0] || {};
ok('A9 DB 已激活(activated=1)', Number(row.activated) === 1,
`实际 activated=${row.activated}`);
// eslint-disable-next-line no-await-in-loop
const matched = row.password ? await bcrypt.compare(STRONG, row.password) : false;
ok('A10 DB 中口令哈希与强口令匹配(bcrypt 比对)', matched,
'哈希与强口令不匹配,说明落库内容并非提交的强口令');
}
}
}
// ════════════════════════════════════════════════
// 通道 B:POST /api/auth/reset-password
// ════════════════════════════════════════════════
console.log('');
console.log('[通道 B] POST /api/auth/reset-password(忘记密码,重设口令)');
const target = pending || pendingRows[1];
if (!target) {
console.log(' ⚠️ 没有可用账号,跳过通道 B');
} else {
// 若该账号尚未在快照中(通道 A 跳过的情形),补一条
if (!backups.some(b => b.id === target.id)) {
const [rs] = await conn.query(
'SELECT id, id_number, phone, activated, password FROM contestants WHERE id = ?', [target.id]
);
if (rs[0]) backups.push({ id: rs[0].id, activated: rs[0].activated, password: rs[0].password, tag: 'B' });
}
// ⚠️ 必须**实时查库**判断激活态:`target` 是通道 A 开始前的内存快照,
// 不会随通道 A 的激活动作更新 —— 用它判断会得出"仍未激活"的错误结论
// (曾因此重复走一遍激活、多发一条短信,且日志内容与事实相反、误导排障)。
const [tRows] = await conn.query(
'SELECT activated FROM contestants WHERE id = ?', [target.id]
);
const tActivated = Number((tRows[0] || {}).activated) === 1;
if (!tActivated) {
// 通道 B 要求账号已激活(forgotPassword 会拦未激活账号,报 6004)
console.log(` ℹ️ 账号 id=${target.id} 当前未激活,先激活以便测试重置通道`);
const cAct = new ApiClient();
const si = await fetchSmsCode(cAct, target.id_number, 'pre-activate', '/api/auth/sms');
if (!si.err) {
// eslint-disable-next-line no-await-in-loop
await cAct.post('/api/auth/activate', {
id_number: target.id_number, phone: si.realPhone, smsCode: si.smsCode, password: STRONG,
});
}
}
const cB = new ApiClient();
const smsB = await fetchSmsCode(cB, target.id_number, 'forgot-password', '/api/auth/forgot-password');
ok('B1 忘记密码取码链路可用', !smsB.err, smsB.err || '');
if (!smsB.err) {
for (const s of WEAK_SAMPLES) {
// eslint-disable-next-line no-await-in-loop
const r = await cB.post('/api/auth/reset-password', {
id_number: target.id_number,
smsCode: smsB.smsCode,
password: s.pwd,
});
const code = r.json && r.json.code;
ok(`B·弱口令被拒 code=7001 → ${JSON.stringify(s.pwd)}(${s.note})`,
code === 7001,
`实际 code=${code} message=${r.json && r.json.message}`);
}
{
const r = await cB.post('/api/auth/reset-password', {
id_number: target.id_number,
smsCode: smsB.smsCode,
password: STRONG2,
});
const code = r.json && r.json.code;
ok('B2 强口令通过(code=0)—— 正向对照', code === 0,
`实际 code=${code} message=${r.json && r.json.message}`);
}
{
const [after] = await conn.query(
'SELECT password FROM contestants WHERE id = ?', [target.id]
);
const row = after[0] || {};
// eslint-disable-next-line no-await-in-loop
const matched = row.password ? await bcrypt.compare(STRONG2, row.password) : false;
ok('B3 DB 中口令哈希与新口令匹配(bcrypt 比对)', matched, '哈希与新口令不匹配');
}
}
}
exitCode = failures.length === 0 ? 0 : 1;
} finally {
// ── 无条件的账号恢复 ──────────────────────────────
console.log('');
console.log('[收尾] 恢复被本脚本改动的账号');
if (!backups.length) {
console.log(' ℹ️ 无账号被改动,无需恢复');
} else {
for (const b of backups) {
// eslint-disable-next-line no-await-in-loop
await conn.query(
'UPDATE contestants SET activated = ?, password = ? WHERE id = ?',
[b.activated, b.password, b.id]
);
}
// 用**第二条独立连接**复核(不依赖同连接可见性语义;项目 R27 同类教训)
const verifyConn = await mysql.createConnection(cfg);
try {
let allOk = true;
for (const b of backups) {
// eslint-disable-next-line no-await-in-loop
const [vr] = await verifyConn.query(
'SELECT activated, password FROM contestants WHERE id = ?', [b.id]
);
const row = vr[0] || {};
const same = Number(row.activated) === Number(b.activated)
&& String(row.password) === String(b.password);
if (!same) {
allOk = false;
failures.push(`恢复失败 id=${b.id}(通道 ${b.tag})`);
}
}
ok(`已恢复 ${backups.length} 个账号并独立复核一致`, allOk,
'存在未恢复一致的账号,请人工核查');
} finally {
await verifyConn.end();
}
}
console.log('');
console.log('────────────────────────────────────────');
if (failures.length === 0) {
console.log(`✓ 全部通过:${pass} 项`);
} else {
console.log(`✗ ${failures.length} 项失败 / 共 ${pass + failures.length} 项`);
for (const f of failures) console.log(` · ${f}`);
}
console.log('');
await conn.end();
// 恢复阶段新增的失败也必须反映到退出码(见 exitCode 处的注释)
if (failures.length > 0) exitCode = 1;
}
return exitCode;
}
main()
.then(code => process.exit(code))
.catch(e => {
console.error('✗ 执行异常:' + (e && e.stack || e));
process.exit(2);
});

View File

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

View File

@@ -1,7 +1,12 @@
const mysql = require('mysql2/promise');
(async () => {
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
// DB_PASSWORD='<本机 DB 口令>' node show_tables.js
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');
t.forEach(r => console.log(Object.values(r)[0]));

168
test/lib.test.js Normal file
View File

@@ -0,0 +1,168 @@
'use strict';
/**
* backend 最小可跑单测集(P1 ㉚a)
* ================================================================
* 背景:`backend/package.json` 声明了 `test-local: egg-bin test`,但 `backend/test/`
* 长期是**空目录** ⇒ 「配置存在、用例为零」——跑 `npm run test-local` 恒绿(0 个用例),
* 属 R95 根因①「最危险假绿族」。本文件填充**真实、无外部依赖(无 DB / 无网络 /
* 无 egg 启动)、有判别力**的最小单测集,使该 script 不再空转。
*
* 取材原则(为什么选这三个纯函数模块):
* ① 业务核心:aggregationKey = 铁律13/14 归集键 SoT;idCard = 导入通道判据 SoT;
* filename = R108g 上传落盘名清洗。
* ② **判别力**:三者的关键判据都有「退化后必然出错」的形态——
* · aggregationKey 若退回「无条件双键归集」(历史 bug)⇒ 跨编号空间重叠用例必红;
* · idCard 若校验位算法改动 ⇒ 已知公开有效号 `11010519491231002X` 用例必红;
* · filename 若漏控制字符 / 路径分隔符清洗 ⇒ 穿越与 ENOENT 用例必红。
* ③ 零副作用:全部为纯函数,不触盘、不连库。
*
* ⚠️ 运行:`cd backend && node_modules/.bin/egg-bin test`(或裸 mocha 直跑本文件)。
*/
const assert = require('assert');
const aggKey = require('../app/lib/aggregationKey');
const idCard = require('../app/lib/idCard');
const { sanitizeUploadFileName } = require('../app/utils/filename');
describe('app/lib/aggregationKey —— 归集键 SoT(铁律13/14)', () => {
it('考试组:主键 = contestant.id,兜底键 = 真实聚合 uid', () => {
assert.strictEqual(aggKey.primaryKeyOf('gaozhi', 5, 9), '5');
assert.strictEqual(aggKey.primaryKeyOf('zhongzhi', 5, 9), '5');
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, 9), '9');
});
it('考试组:缺真实聚合 uid ⇒ 无兜底键(null),而非拼一个假键', () => {
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, null), null);
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, ''), null);
});
it('作品组:主键 = 真实聚合 uid,兜底键 = cid', () => {
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, 9), '9');
assert.strictEqual(aggKey.fallbackKeyOf('benke', 5, 9), '5');
});
it('作品组:缺真实聚合 uid ⇒ 主键退化为 cid(不再引入第二个键)', () => {
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, null), '5');
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, ''), '5');
});
it('isExamGroup 只认 zhongzhi / gaozhi', () => {
assert.strictEqual(aggKey.isExamGroup('zhongzhi'), true);
assert.strictEqual(aggKey.isExamGroup('gaozhi'), true);
assert.strictEqual(aggKey.isExamGroup('benke'), false);
assert.strictEqual(aggKey.isExamGroup('jiaoshi'), false);
});
// ---- 判别力核心:跨编号空间重叠 ----
// 生产实测(2026-10-08)283 对碰撞的根因就是「无条件双键归集」。
it('buildAggregationKeyMap:兜底键撞上同组他人主键 ⇒ 必须被丢弃', () => {
// 考试组:cid=1 的真实聚合 uid = 2,而 cid=2 的主键恰为 '2' ⇒ 二者重叠
const contestants = [{ id: 1 }, { id: 2 }];
const realAgg = { 1: 2 };
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
assert.deepStrictEqual(map['1'], ['1'], 'cid=1 的兜底键 2 会命中 cid=2 ⇒ 必须丢弃');
assert.deepStrictEqual(map['2'], ['2']);
});
it('buildAggregationKeyMap:安全兜底键须保留(不因噎废食)', () => {
const contestants = [{ id: 1 }, { id: 2 }];
const realAgg = { 1: 7 }; // 7 未被任何人认领主键
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
assert.deepStrictEqual(map['1'], ['1', '7']);
assert.deepStrictEqual(map['2'], ['2']);
});
it('buildAggregationKeyMap:空输入 / 非数组不抛错', () => {
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', null, null), {});
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', [], {}), {});
});
it('buildAggregationKeyMap:首元素恒为主键且键不重复', () => {
const contestants = [{ id: 3 }];
const map = aggKey.buildAggregationKeyMap('benke', contestants, { 3: 3 }); // 兜底=cid=主键
assert.deepStrictEqual(map['3'], ['3'], '主键与兜底相等时不得重复');
});
});
describe('app/lib/idCard —— 导入通道判据 SoT', () => {
it('normalizeIdNumber:trim + 末位 x 归一为大写 X', () => {
assert.strictEqual(idCard.normalizeIdNumber(' 11010519491231002x '), '11010519491231002X');
assert.strictEqual(idCard.normalizeIdNumber(null), '');
assert.strictEqual(idCard.normalizeIdNumber(undefined), '');
});
it('expectedCheckCode:对 GB11643 公开样例号算出正确校验位(独立已知答案)', () => {
// `11010519491231002X` 为公开引用的有效样例;校验位算法若被改动此处必红。
assert.strictEqual(idCard.expectedCheckCode('11010519491231002'), 'X');
// 入参不足 17 位或含非数字 ⇒ 返回 ''
assert.strictEqual(idCard.expectedCheckCode('123'), '');
assert.strictEqual(idCard.expectedCheckCode('1101051949123100A'), '');
});
it('inspectIdNumber:合法号 ⇒ importable=true 且 level=null', () => {
const r = idCard.inspectIdNumber('11010519491231002X');
assert.strictEqual(r.importable, true);
assert.strictEqual(r.level, null);
});
it('inspectIdNumber:空 ⇒ error 且不可导入', () => {
const r = idCard.inspectIdNumber('');
assert.strictEqual(r.importable, false);
assert.strictEqual(r.level, 'error');
});
it('inspectIdNumber:长度≠18 ⇒ error 且不可导入(模型层硬约束前置为可读错误)', () => {
const r = idCard.inspectIdNumber('11010519491231002'); // 17 位
assert.strictEqual(r.importable, false);
assert.strictEqual(r.level, 'error');
assert.ok(r.message.includes('18'));
});
it('inspectIdNumber:18 位但校验位不符 ⇒ warn(不阻断,防误伤在册考生)', () => {
const r = idCard.inspectIdNumber('110105194912310020'); // 末位应为 X
assert.strictEqual(r.importable, true);
assert.strictEqual(r.level, 'warn');
assert.ok(r.message.includes('校验位'));
});
it('inspectIdNumber:18 位但含非法字符 ⇒ warn(模型只查长度)', () => {
const r = idCard.inspectIdNumber('1101051949123100AX');
assert.strictEqual(r.importable, true);
assert.strictEqual(r.level, 'warn');
});
});
describe('app/utils/filename —— 上传落盘名清洗(R108g)', () => {
it('去除控制字符(防 stat 失败 / ENOENT)', () => {
assert.strictEqual(sanitizeUploadFileName('a\x00b\x1fc.txt'), 'abc.txt');
});
it('路径分隔符替换为下划线(防目录穿越)', () => {
assert.strictEqual(sanitizeUploadFileName('a/b\\c.txt'), 'a_b_c.txt');
});
it('折叠多余空白并 trim(修「前导空格 + 乱码」类 ENOENT)', () => {
assert.strictEqual(sanitizeUploadFileName(' a b .txt'), 'a b .txt');
});
it('去除首尾点(防 .name / .. 类)', () => {
assert.strictEqual(sanitizeUploadFileName('.hidden'), 'hidden');
});
it('超长名保留扩展名并截断到上限(120)', () => {
const out = sanitizeUploadFileName('a'.repeat(200) + '.txt');
assert.strictEqual(out.length, 120);
assert.ok(out.endsWith('.txt'), '扩展名必须保留');
});
it('客户端预编码(%XX)先解码', () => {
assert.strictEqual(sanitizeUploadFileName('%E5%89%8D.txt'), '前.txt');
});
it('空值 / 非字符串 ⇒ 返回空串(调用方回退时间戳名)', () => {
assert.strictEqual(sanitizeUploadFileName(''), '');
assert.strictEqual(sanitizeUploadFileName(null), '');
assert.strictEqual(sanitizeUploadFileName(123), '');
});
});